feat: outbound SIP registration (REGISTER) - #774
Open
skyzer wants to merge 3 commits into
Open
Conversation
Adds optional per-node SIP registration, so a deployment without a stable public address can accept inbound calls from a provider that routes to a registration binding, instead of needing a registering proxy in front of it. Configured under sip_registrations: registrar, credentials, requested expiry and a NAT keepalive interval. Registration is a prerequisite for inbound calls, not a path for them, so INVITEs still arrive on the existing inbound path and are authenticated and dispatched by trunk as before. Covers digest auth on 401 and 407, refresh ahead of expiry, 423 Min-Expires negotiation, un-REGISTER on shutdown, backoff on failure, rport on the Via so a registrar can answer the address it observes behind NAT, and an OPTIONS keepalive to hold that NAT mapping open between refreshes. Refs livekit#524 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vEyHdjPUV3BrxxN2m41pT
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #774 +/- ##
==========================================
+ Coverage 65.25% 66.90% +1.65%
==========================================
Files 51 42 -9
Lines 6588 8376 +1788
==========================================
+ Hits 4299 5604 +1305
- Misses 1915 2255 +340
- Partials 374 517 +143 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Label the registrations_active gauge with the address-of-record as well as the registrar, so several accounts on one provider do not share a series and mask each other. Never schedule a refresh sooner than half the granted lifetime. Subtracting a fixed margin alone made a lifetime just over that margin refresh far more often than a slightly shorter one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vEyHdjPUV3BrxxN2m41pT
Author
|
Thanks for the automated review — both findings were right and are fixed in 4eaf040:
|
A challenge that fails to parse no longer has its header value repeated in the error. It carries the registrar's nonce and opaque, and the retry loop writes that error to the log on every attempt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018vEyHdjPUV3BrxxN2m41pT
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
LiveKit SIP can only be an INVITE-only endpoint: a provider has to know a stable address to send inbound INVITEs to. Providers commonly offer a second inbound mode for endpoints that do not have one — the endpoint sends
REGISTER, and calls are delivered to the resulting binding. DIDWW, for example, offers it explicitly for a PBX or SBC that "has a dynamic IP address, operates behind NAT, or when you prefer registration-based routing". WithoutREGISTER, a self-hosted deployment on a dynamic IP, behind NAT, or on CGNAT cannot accept inbound calls at all.There is nothing partial to build on today: the registered method handlers in
pkg/sip/server.goareOnOptions/OnInvite/OnAck/OnBye/OnNotify/OnNoRoute, andpkg/config/config.gohas no registration keys.Why not just run a proxy
That is the workaround in #524, and it does work — Kamailio
uac_reg, OpenSIPSuac_registrant, or Asterisk PJSIP in front of LiveKit SIP on localhost. We run one downstream and have taken it end to end on a live PSTN call through a real NAT. It costs a second SIP element, with its own config, container and failure modes, whose only job is to send REGISTER — in front of a service that already owns the trunk state and already knows its own signaling address. It also rewrites the source of every inbound INVITE to the proxy, so LiveKit SIP no longer sees which carrier a call came from, and the NAT details below have to be solved again in the proxy's config language.Design
Config-file only. No
livekit/protocolchange, so this is reviewable and mergeable in one repo.I did consider putting this on
SIPOutboundTrunkInfo/SIPInboundTrunkInfo, which is where @lixuanqun's design comment on #524 proposes it. That is arguably the better product surface, but it is a cross-repo change, and it forces the multi-node question immediately: a trunk-level flag implies every node holding that trunk registers, so it needs owner election before it is safe. A registration is really a property of one node's connectivity to a provider rather than of a project's trunk record, so per-node config is both the smaller change and, I think, the more accurate model. The two compose — trunk-level fields can be layered on later without changing anything here. If you would rather have the protobuf version, I am glad to write that instead; I would just rather agree on it first.Registration is a prerequisite for inbound calls, not a path for them. Once the provider has a binding, its INVITEs arrive on the existing inbound path and are authenticated and dispatched by trunk exactly as they are today.
inbound.gois untouched.What it does
pkg/sip/register.goadds oneregistrantper configured account, started after the server is listening and stopped before the client closes.Expires: 0on shutdown, exponential backoff on failure.To.423 Interval Too Briefnegotiation, and the negotiated value is kept for later refreshes instead of being re-negotiated every time. (DIDWW answers 423 withMin-Expires: 600.)pkg/service;service.NewService's signature is unchanged.Two NAT details this has to get right, both learned the hard way downstream:
rport. Behind NAT our Via carries an address the registrar cannot reply to, so it has to ask the registrar to answer the source address and port it actually observed (RFC 3581). sipgo only adds a Via when the request has none, and the one it adds has norport, so the registrant builds its own. Without this, registration never completes at all — the registrar answers into the void. Thereceived/rportit echoes back is logged when it differs from the Contact we sent, which is the single most useful line for someone who is registered but not getting calls.Keepalive. Providers commonly enforce a registration interval far longer than a NAT UDP mapping survives — 600s against a mapping usually dropped after 30s of silence. Without traffic, the mapping the provider recorded stops being reachable and inbound calls stop, while the registration still looks healthy. That is a worse failure than not registering, so I did not want to leave it purely as an operator concern: a bounded OPTIONS keepalive (25s by default, configurable, negative to disable) holds the mapping open, and an unanswered one is logged. It keeps running while a refresh is failing, because that is exactly when the mapping the provider is still calling needs to be held.
REGISTER also has to leave from the signaling socket, since the provider delivers to the mapping the REGISTER created. sipgo reuses the server listener for outbound UDP, but only once that listener is registered with the transport layer; sending earlier binds a separate socket which is then pooled for the registrar address permanently. So
startRegistrationswaits for the listener and fails startup if it never appears.Registrations are per-node, and the README says to point one node at a given account.
livekit_sip_registrations_activeis exported per registrar, because losing a registration silently stops inbound calls.Tests
pkg/sip/register_test.goruns a real sipgo UAS as the registrar over UDP, and drives a realregistrantthrough a realsipgo.Clienton its own signaling socket. Covered: the digest handshake and that the digest URI is the Request-URI;rporton the Via and that the REGISTER leaves from the signaling port; a separateauth_username; the 401 and the 407 paths; a stale-nonce re-challenge; Min-Expires negotiation and its persistence across a refresh; refresh with a stable Call-ID and increasing CSeq; the granted expiry read from the returned Contact, the Expires header, or the shortest listed binding; un-REGISTER on shutdown, including a challenged one; keepalives continuing through a refresh failure and stopping once the binding lapses; a rejected password not looping; andStopaborting a REGISTER in flight rather than waiting out the transaction timeout.pkg/config/config_test.gocovers the config bounds.go test -race ./pkg/...passes.Validating against a real carrier
Offered rather than claimed: this Go code has been tested against a local UAS, not yet against a carrier. We have DIDWW trunks in both Static Endpoint and SIP Registration mode, and we have already taken the equivalent path end to end downstream through a real NAT — REGISTER out, the carrier delivering the INVITE to the NAT-mapped port rather than the Contact literal, the call answered, two-way audio published into the room. If this is worth pursuing, I will run that same validation against this branch and post the trace.
Addresses #524. Scope is outbound registration only; accepting REGISTER as a registrar is not part of this.