Skip to content

Commit b203b32

Browse files
localai-botmudler
andauthored
feat(realtime): make WebRTC ICE candidates configurable (#10231)
The /v1/realtime WebRTC handler created the peer connection with a bare webrtc.Configuration and no SettingEngine, so pion gathered a host ICE candidate for every local interface. Under Docker host networking that includes bridge addresses (docker0/veth, 172.x) a remote browser cannot route to; the call establishes on a good pair and then drops once ICE consent freshness checks fail on the unreachable candidates. Add two opt-in knobs, applied via a pion SettingEngine: - LOCALAI_WEBRTC_NAT_1TO1_IPS: advertise these IPs as the host candidates (e.g. the host LAN IP) - LOCALAI_WEBRTC_ICE_INTERFACES: restrict ICE gathering to these interfaces Defaults are unchanged (empty => current all-interface behavior). Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 48a8ce9 commit b203b32

6 files changed

Lines changed: 144 additions & 12 deletions

File tree

core/cli/run.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ type RunCMD struct {
3030
ModelArgs []string `arg:"" optional:"" name:"models" help:"Model configuration URLs to load"`
3131

3232
ExternalBackends []string `env:"LOCALAI_EXTERNAL_BACKENDS,EXTERNAL_BACKENDS" help:"A list of external backends to load from gallery on boot" group:"backends"`
33+
WebRTCNAT1To1IPs []string `env:"LOCALAI_WEBRTC_NAT_1TO1_IPS,WEBRTC_NAT_1TO1_IPS" help:"IPs advertised as the host ICE candidates for /v1/realtime WebRTC instead of every local interface. Set to the reachable host/LAN IP when running under Docker host networking or NAT, where pion otherwise offers unreachable bridge addresses and the connection drops after ICE consent checks fail." group:"api"`
34+
WebRTCICEInterfaces []string `env:"LOCALAI_WEBRTC_ICE_INTERFACES,WEBRTC_ICE_INTERFACES" help:"Restrict /v1/realtime WebRTC ICE candidate gathering to these network interfaces (e.g. eth0), filtering out docker0/veth noise." group:"api"`
3335
BackendsPath string `env:"LOCALAI_BACKENDS_PATH,BACKENDS_PATH" type:"path" default:"${basepath}/backends" help:"Path containing backends used for inferencing" group:"backends"`
3436
BackendsSystemPath string `env:"LOCALAI_BACKENDS_SYSTEM_PATH,BACKEND_SYSTEM_PATH" type:"path" default:"/var/lib/local-ai/backends" help:"Path containing system backends used for inferencing" group:"backends"`
3537
ModelsPath string `env:"LOCALAI_MODELS_PATH,MODELS_PATH" type:"path" default:"${basepath}/models" help:"Path containing models used for inferencing" group:"storage"`
@@ -225,6 +227,8 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
225227
config.WithApiKeys(r.APIKeys),
226228
config.WithModelsURL(append(r.Models, r.ModelArgs...)...),
227229
config.WithExternalBackends(r.ExternalBackends...),
230+
config.WithWebRTCNAT1To1IPs(r.WebRTCNAT1To1IPs...),
231+
config.WithWebRTCICEInterfaces(r.WebRTCICEInterfaces...),
228232
config.WithOpaqueErrors(r.OpaqueErrors),
229233
config.WithEnforcedPredownloadScans(!r.DisablePredownloadScan),
230234
config.WithSubtleKeyComparison(r.UseSubtleKeyComparison),

core/config/application_config.go

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,19 @@ import (
1212
)
1313

1414
type ApplicationConfig struct {
15-
Context context.Context
16-
ConfigFile string
17-
SystemState *system.SystemState
18-
ExternalBackends []string
15+
Context context.Context
16+
ConfigFile string
17+
SystemState *system.SystemState
18+
ExternalBackends []string
19+
20+
// WebRTCNAT1To1IPs, when set, are advertised as the host ICE candidates for
21+
// /v1/realtime WebRTC instead of every local interface address. Needed when
22+
// the routable address differs from what pion gathers — e.g. Docker host
23+
// networking (where pion also offers unreachable bridge IPs) or NAT.
24+
WebRTCNAT1To1IPs []string
25+
// WebRTCICEInterfaces, when set, restricts ICE candidate gathering to these
26+
// network interfaces (e.g. eth0), filtering out docker0/veth noise.
27+
WebRTCICEInterfaces []string
1928
UploadLimitMB, Threads, ContextSize int
2029
F16 bool
2130
Debug bool
@@ -81,7 +90,6 @@ type ApplicationConfig struct {
8190
// file is mode 0600.
8291
MITMCADir string
8392

84-
8593
// PIIPatternOverrides applies persisted per-id deltas (action,
8694
// disabled) to the live redactor at startup. Loaded from
8795
// runtime_settings.json and applied right after pii.NewRedactor.
@@ -116,11 +124,11 @@ type ApplicationConfig struct {
116124
// --require-backend-integrity / LOCALAI_REQUIRE_BACKEND_INTEGRITY.
117125
RequireBackendIntegrity bool
118126

119-
SingleBackend bool // Deprecated: use MaxActiveBackends = 1 instead
120-
MaxActiveBackends int // Maximum number of active backends (0 = unlimited, 1 = single backend mode)
121-
WatchDogIdle bool
122-
WatchDogBusy bool
123-
WatchDog bool
127+
SingleBackend bool // Deprecated: use MaxActiveBackends = 1 instead
128+
MaxActiveBackends int // Maximum number of active backends (0 = unlimited, 1 = single backend mode)
129+
WatchDogIdle bool
130+
WatchDogBusy bool
131+
WatchDog bool
124132

125133
// Memory Reclaimer settings (works with GPU if available, otherwise RAM)
126134
MemoryReclaimerEnabled bool // Enable memory threshold monitoring
@@ -311,6 +319,18 @@ func WithExternalBackends(backends ...string) AppOption {
311319
}
312320
}
313321

322+
func WithWebRTCNAT1To1IPs(ips ...string) AppOption {
323+
return func(o *ApplicationConfig) {
324+
o.WebRTCNAT1To1IPs = ips
325+
}
326+
}
327+
328+
func WithWebRTCICEInterfaces(interfaces ...string) AppOption {
329+
return func(o *ApplicationConfig) {
330+
o.WebRTCICEInterfaces = interfaces
331+
}
332+
}
333+
314334
func WithMachineTag(tag string) AppOption {
315335
return func(o *ApplicationConfig) {
316336
o.MachineTag = tag
@@ -702,7 +722,6 @@ func WithMITMCADir(dir string) AppOption {
702722
}
703723
}
704724

705-
706725
func WithDynamicConfigDir(dynamicConfigsDir string) AppOption {
707726
return func(o *ApplicationConfig) {
708727
o.DynamicConfigsDir = dynamicConfigsDir

core/http/endpoints/openai/realtime_webrtc.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ func RealtimeCalls(application *application.Application) echo.HandlerFunc {
4848
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "codec registration failed"})
4949
}
5050

51-
api := webrtc.NewAPI(webrtc.WithMediaEngine(m))
51+
se := webRTCSettingEngine(application.ApplicationConfig())
52+
api := webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithSettingEngine(se))
5253

5354
pc, err := api.NewPeerConnection(webrtc.Configuration{})
5455
if err != nil {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package openai
2+
3+
import (
4+
"github.com/mudler/LocalAI/core/config"
5+
"github.com/mudler/xlog"
6+
"github.com/pion/webrtc/v4"
7+
)
8+
9+
// webRTCSettingEngine builds the pion SettingEngine for /v1/realtime WebRTC.
10+
//
11+
// With a default (empty) SettingEngine, pion gathers a host ICE candidate for
12+
// every local interface. Under Docker host networking that includes bridge
13+
// addresses (docker0/veth, 172.x) that a remote browser cannot route to; the
14+
// connection often establishes on a good pair and then drops once ICE consent
15+
// checks fail on the unreachable ones. The two opt-in knobs below let an
16+
// operator advertise only the reachable address.
17+
func webRTCSettingEngine(cfg *config.ApplicationConfig) webrtc.SettingEngine {
18+
s := webrtc.SettingEngine{}
19+
if cfg == nil {
20+
return s
21+
}
22+
if len(cfg.WebRTCNAT1To1IPs) > 0 {
23+
s.SetNAT1To1IPs(cfg.WebRTCNAT1To1IPs, webrtc.ICECandidateTypeHost)
24+
xlog.Debug("realtime webrtc: advertising NAT 1:1 host IPs", "ips", cfg.WebRTCNAT1To1IPs)
25+
}
26+
if filter := iceInterfaceFilter(cfg.WebRTCICEInterfaces); filter != nil {
27+
s.SetInterfaceFilter(filter)
28+
xlog.Debug("realtime webrtc: restricting ICE interfaces", "interfaces", cfg.WebRTCICEInterfaces)
29+
}
30+
return s
31+
}
32+
33+
// iceInterfaceFilter returns an interface allow-list predicate for pion, or nil
34+
// when no interfaces are configured (pion's default: gather from all).
35+
func iceInterfaceFilter(allowed []string) func(string) bool {
36+
if len(allowed) == 0 {
37+
return nil
38+
}
39+
set := make(map[string]struct{}, len(allowed))
40+
for _, name := range allowed {
41+
set[name] = struct{}{}
42+
}
43+
return func(iface string) bool {
44+
_, ok := set[iface]
45+
return ok
46+
}
47+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package openai
2+
3+
import (
4+
"github.com/mudler/LocalAI/core/config"
5+
. "github.com/onsi/ginkgo/v2"
6+
. "github.com/onsi/gomega"
7+
)
8+
9+
var _ = Describe("webRTC ICE settings", func() {
10+
Describe("iceInterfaceFilter", func() {
11+
It("returns nil when no interfaces are configured", func() {
12+
Expect(iceInterfaceFilter(nil)).To(BeNil())
13+
Expect(iceInterfaceFilter([]string{})).To(BeNil())
14+
})
15+
16+
It("admits only the configured interfaces", func() {
17+
f := iceInterfaceFilter([]string{"eth0", "wlan0"})
18+
Expect(f).NotTo(BeNil())
19+
Expect(f("eth0")).To(BeTrue())
20+
Expect(f("wlan0")).To(BeTrue())
21+
Expect(f("docker0")).To(BeFalse())
22+
Expect(f("veth123")).To(BeFalse())
23+
})
24+
})
25+
26+
Describe("webRTCSettingEngine", func() {
27+
It("does not panic on a nil config", func() {
28+
Expect(func() { webRTCSettingEngine(nil) }).NotTo(Panic())
29+
})
30+
31+
It("builds an engine with NAT 1:1 IPs and an interface filter configured", func() {
32+
cfg := &config.ApplicationConfig{
33+
WebRTCNAT1To1IPs: []string{"192.168.1.10"},
34+
WebRTCICEInterfaces: []string{"eth0"},
35+
}
36+
Expect(func() { webRTCSettingEngine(cfg) }).NotTo(Panic())
37+
})
38+
})
39+
})

docs/content/features/openai-realtime.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,28 @@ EXTERNAL_GRPC_BACKENDS=opus:/path/to/backend/go/opus/opus
7474

7575
The opus backend is loaded automatically when a WebRTC session starts. It does not require any model configuration file — just the backend binary.
7676

77+
#### WebRTC behind Docker host networking or NAT
78+
79+
By default pion gathers a host ICE candidate for every local interface. Under
80+
Docker **host networking** that includes bridge addresses (`docker0`/`veth`,
81+
`172.x`) that a remote browser cannot route to: the call typically connects on a
82+
good candidate and then drops a few seconds later when ICE consent checks fail on
83+
the unreachable ones. Two settings let you advertise only the reachable address:
84+
85+
```bash
86+
# Advertise these IPs as the host ICE candidates (e.g. the host's LAN IP)
87+
LOCALAI_WEBRTC_NAT_1TO1_IPS=192.168.1.10
88+
89+
# ...or restrict ICE gathering to specific interfaces
90+
LOCALAI_WEBRTC_ICE_INTERFACES=eth0
91+
```
92+
93+
{{% notice tip %}}
94+
For a browser on another LAN machine talking to LocalAI in a host-networked
95+
container, set `LOCALAI_WEBRTC_NAT_1TO1_IPS` to the host's LAN IP. This is the
96+
most reliable fix for WebRTC connections that establish and then drop.
97+
{{% /notice %}}
98+
7799
## Protocol
78100

79101
The API follows the OpenAI Realtime API protocol for handling sessions, audio buffers, and conversation items.

0 commit comments

Comments
 (0)