Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Currently, the following features are supported:
- Dialing Out (Sending INVITEs)
- Dialing In (Accepting INVITEs)
- Digest Authentication
- Registering with a provider (Sending REGISTERs)
- Touch Tone (Sending and Reading DTMF)

## Documentation
Expand Down Expand Up @@ -66,6 +67,40 @@ rtp_port: port to listen and send RTP traffic (default 10000-20000)

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.

### Registering with a provider

Providers usually deliver inbound calls to an address you give them, which requires the SIP
service to be reachable at a stable IP. Where that is not possible, most of them offer a
registration-based trunk instead: the service registers, and calls are delivered to the
resulting binding. Configure one entry per account:

```yaml
sip_registrations:
- registrar: sip:sip.provider.example # registrar address, or host[:port]
username: 1000 # address-of-record user
password: secret
# auth_username: 1000 # digest username, if issued separately
# domain: provider.example # AOR host, defaults to the registrar host
# expiry: 10m # requested lifetime; the registrar may grant less
# keepalive: 25s # OPTIONS interval to hold the NAT binding open
```

Each node registers its own Contact, so point one node at a given account. Registration only
tells the provider where to send calls: inbound INVITEs still arrive on the normal inbound path
and are authenticated and dispatched by trunk as usual.

The Contact is this node's signaling address, which behind NAT is a private one. That works
with providers that route to the source address they observe, which is what the REGISTER asks
them to do. If yours routes to the Contact instead, set `nat_1_to_1_ip` to the address it
should use.

`keepalive` matters because providers commonly refuse a registration interval short enough to
refresh a NAT mapping on its own, and a mapping that expires stops inbound calls while the
registration still looks healthy. Set it to a negative value to disable.

Because losing a registration silently stops inbound calls, each one is also exported as
`livekit_sip_registrations_active`, labeled by registrar.

### Using the SIP service

#### Creating Bridge and Dispatch Rule
Expand Down
1 change: 1 addition & 0 deletions cmd/livekit-sip/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func runService(ctx context.Context, c *cli.Command) error {
return err
}
svc := service.NewService(conf, log, sipsrv, sipsrv.Stop, sipsrv.ActiveCalls, psrpcClient, bus, mon)
svc.SetSIPServiceDrain(sipsrv.StopRegistrations)
sipsrv.SetHandler(svc)

if err = sipsrv.Start(); err != nil {
Expand Down
81 changes: 81 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,52 @@ const (
DefaultRTPDrainingDuration = 10 * time.Minute // hard cap on how long a port stays draining
)

const (
// DefaultSIPRegistrationExpiry is the registration lifetime requested from the registrar.
// The registrar may grant less, or demand more with 423 Interval Too Brief.
DefaultSIPRegistrationExpiry = 10 * time.Minute
// DefaultSIPRegistrationKeepalive is how often an OPTIONS request is sent to the registrar
// to keep the NAT binding open. NAT UDP mappings commonly expire after 30s, and providers
// often refuse a registration interval short enough to refresh the mapping on its own.
DefaultSIPRegistrationKeepalive = 25 * time.Second
// MinSIPRegistrationExpiry is the shortest registration lifetime we allow to be configured.
MinSIPRegistrationExpiry = 30 * time.Second
// MaxSIPRegistrationExpiry is the longest registration lifetime, both as configured and as
// a registrar can push us to with 423 Interval Too Brief.
MaxSIPRegistrationExpiry = 24 * time.Hour
// MinSIPRegistrationKeepalive is the shortest keepalive interval we allow to be configured.
MinSIPRegistrationKeepalive = 5 * time.Second
)

// SIPRegistrationConfig makes this node register with a SIP registrar (RFC 3261, section 10),
// so that a provider can deliver inbound calls to a node it cannot address by a static IP.
//
// Registrations are per-node: each node that has them configured registers its own Contact.
// Point exactly one node at a given registrar account unless the provider supports (and you
// want) parallel forking to several contacts.
type SIPRegistrationConfig struct {
// Registrar is the address of the registrar: "sip:host[:port][;transport=tcp]", or just
// "host[:port]". It becomes the Request-URI of the REGISTER, and the destination it is
// sent to.
Registrar string `yaml:"registrar"`
// Username is the user part of the address-of-record to register, and the digest username
// unless AuthUsername is set.
Username string `yaml:"username"`
// Password is the digest password. May be empty if the registrar does not challenge.
Password string `yaml:"password"`
// AuthUsername overrides the digest username, for providers that issue one separately
// from the address-of-record.
AuthUsername string `yaml:"auth_username"`
// Domain is the host part of the address-of-record. Defaults to the registrar host, which
// is what most providers expect.
Domain string `yaml:"domain"`
// Expiry is the registration lifetime to request. Defaults to DefaultSIPRegistrationExpiry.
Expiry time.Duration `yaml:"expiry"`
// Keepalive is how often to send OPTIONS to the registrar to keep a NAT binding open.
// Defaults to DefaultSIPRegistrationKeepalive. Set to a negative value to disable.
Keepalive time.Duration `yaml:"keepalive"`
}

type TLSCert struct {
CertFile string `yaml:"cert_file"`
KeyFile string `yaml:"key_file"`
Expand Down Expand Up @@ -103,6 +149,9 @@ type Config struct {
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

// SIPRegistrations makes this node register with the listed SIP registrars on startup.
SIPRegistrations []SIPRegistrationConfig `yaml:"sip_registrations"`

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"`
Expand Down Expand Up @@ -208,6 +257,9 @@ func (c *Config) Init() error {
if c.MaxCpuUtilization <= 0 || c.MaxCpuUtilization > 1 {
c.MaxCpuUtilization = 0.9
}
if err := c.initRegistrations(); err != nil {
return err
}

if err := c.InitLogger(); err != nil {
return err
Expand All @@ -224,6 +276,35 @@ func (c *Config) Init() error {
return nil
}

func (c *Config) initRegistrations() error {
for i := range c.SIPRegistrations {
r := &c.SIPRegistrations[i]
if r.Registrar == "" {
return fmt.Errorf("sip_registrations[%d]: registrar must be set", i)
}
if r.Username == "" {
return fmt.Errorf("sip_registrations[%d]: username must be set", i)
}
switch {
case r.Expiry == 0:
r.Expiry = DefaultSIPRegistrationExpiry
case r.Expiry < MinSIPRegistrationExpiry:
return fmt.Errorf("sip_registrations[%d]: expiry must be at least %s", i, MinSIPRegistrationExpiry)
case r.Expiry > MaxSIPRegistrationExpiry:
return fmt.Errorf("sip_registrations[%d]: expiry must be at most %s", i, MaxSIPRegistrationExpiry)
}
switch {
case r.Keepalive == 0:
r.Keepalive = DefaultSIPRegistrationKeepalive
case r.Keepalive < 0:
r.Keepalive = 0 // disabled
case r.Keepalive < MinSIPRegistrationKeepalive:
return fmt.Errorf("sip_registrations[%d]: keepalive must be at least %s, or negative to disable", i, MinSIPRegistrationKeepalive)
}
}
return nil
}

func (c *Config) InitLogger(values ...interface{}) error {
zl, err := logger.NewZapLogger(&c.Logging)
if err != nil {
Expand Down
59 changes: 59 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// 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"
"time"

"github.com/stretchr/testify/require"
)

func TestInitRegistrations(t *testing.T) {
newConf := func(regs ...SIPRegistrationConfig) *Config {
return &Config{SIPRegistrations: regs}
}

t.Run("defaults", func(t *testing.T) {
c := newConf(SIPRegistrationConfig{Registrar: "sip.example.com", Username: "alice"})
require.NoError(t, c.Init())
require.Equal(t, DefaultSIPRegistrationExpiry, c.SIPRegistrations[0].Expiry)
require.Equal(t, DefaultSIPRegistrationKeepalive, c.SIPRegistrations[0].Keepalive)
})
t.Run("keepalive disabled", func(t *testing.T) {
c := newConf(SIPRegistrationConfig{Registrar: "sip.example.com", Username: "alice", Keepalive: -1})
require.NoError(t, c.Init())
require.Zero(t, c.SIPRegistrations[0].Keepalive)
})
t.Run("registrar required", func(t *testing.T) {
require.Error(t, newConf(SIPRegistrationConfig{Username: "alice"}).Init())
})
t.Run("username required", func(t *testing.T) {
require.Error(t, newConf(SIPRegistrationConfig{Registrar: "sip.example.com"}).Init())
})
t.Run("expiry out of range", func(t *testing.T) {
require.Error(t, newConf(SIPRegistrationConfig{
Registrar: "sip.example.com", Username: "alice", Expiry: time.Second,
}).Init())
require.Error(t, newConf(SIPRegistrationConfig{
Registrar: "sip.example.com", Username: "alice", Expiry: 48 * time.Hour,
}).Init())
})
t.Run("keepalive too short", func(t *testing.T) {
require.Error(t, newConf(SIPRegistrationConfig{
Registrar: "sip.example.com", Username: "alice", Keepalive: time.Second,
}).Init())
})
}
12 changes: 12 additions & 0 deletions pkg/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import (

type sipServiceStopFunc func()
type sipServiceActiveCallsFunc func() sip.ActiveCalls
type sipServiceDrainFunc func()

type Service struct {
conf *config.Config
Expand All @@ -59,12 +60,19 @@ type Service struct {

sipServiceStop sipServiceStopFunc
sipServiceActiveCalls sipServiceActiveCallsFunc
sipServiceDrain sipServiceDrainFunc

mon *stats.Monitor
shutdown core.Fuse
killed atomic.Bool
}

// SetSIPServiceDrain registers a hook run once shutdown starts and before the service waits
// for active calls to finish, for work that should stop new calls from arriving.
func (s *Service) SetSIPServiceDrain(fn sipServiceDrainFunc) {
s.sipServiceDrain = fn
}

func NewService(
conf *config.Config, log logger.Logger, srv rpc.SIPInternalServerImpl, sipServiceStop sipServiceStopFunc,
sipServiceActiveCalls sipServiceActiveCallsFunc, cli rpc.IOInfoSIPClient, bus psrpc.MessageBus, mon *stats.Monitor,
Expand Down Expand Up @@ -185,6 +193,10 @@ func (s *Service) Run() error {
<-s.shutdown.Watch()
s.log.Infow("shutting down")
s.DeregisterCreateSIPParticipantTopic()
if s.sipServiceDrain != nil {
// Stop attracting new inbound calls before waiting for the current ones to finish.
s.sipServiceDrain()
}

if !s.killed.Load() {
shutdownTicker := time.NewTicker(5 * time.Second)
Expand Down
Loading