From 7a04a755ce04a20f16c7ba4f8541c4bce42a5b77 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 15 Jul 2026 17:30:18 +0300 Subject: [PATCH] feat: resolve LIVEKIT_NODE_IP_IFACE to set listen_ip at startup If LIVEKIT_NODE_IP_IFACE is set, resolve the first IPv4 address of that network interface and use it as conf.ListenIP for SIP signalling binding. This mirrors the LiveKit media server behaviour and is useful in ECS/Fargate/Kubernetes, where the container IP lives on a known interface (e.g. eth0) and must be bound explicitly rather than falling back to the 0.0.0.0 wildcard. The override is skipped when the env var is empty, or when listen_ip is already set to a specific (non-wildcard) address in config. Co-Authored-By: Claude --- cmd/livekit-sip/main.go | 76 ++++++++++++++++++++++++++++ cmd/livekit-sip/main_test.go | 96 ++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 cmd/livekit-sip/main_test.go diff --git a/cmd/livekit-sip/main.go b/cmd/livekit-sip/main.go index 7f3bd27a..6492256d 100644 --- a/cmd/livekit-sip/main.go +++ b/cmd/livekit-sip/main.go @@ -17,6 +17,7 @@ package main import ( "context" "fmt" + "net" "os" "os/signal" "syscall" @@ -70,6 +71,17 @@ func runService(ctx context.Context, c *cli.Command) error { if err != nil { return err } + + // Resolve LIVEKIT_NODE_IP_IFACE (if set) into conf.ListenIP before the + // service starts. This mirrors the LiveKit media server behaviour: the env + // var names the network interface whose IPv4 address should be used for SIP + // signalling binding. It is useful in ECS/Fargate/Kubernetes where the + // container IP lives on a known interface (e.g. eth0) and must be bound + // explicitly rather than falling back to the 0.0.0.0 wildcard. + if err = applyNodeIPIface(conf); err != nil { + return err + } + log := logger.GetLogger() if conf.JaegerURL != "" { jaeger.Configure(ctx, conf.JaegerURL, conf.ServiceName) @@ -124,6 +136,70 @@ func runService(ctx context.Context, c *cli.Command) error { return svc.Run() } +// applyNodeIPIface reads LIVEKIT_NODE_IP_IFACE and, if set, resolves the first +// IPv4 address of that interface into conf.ListenIP. +// +// The override is skipped when: +// - the env var is absent or empty, or +// - conf.ListenIP is already set to a specific (non-wildcard) address, +// meaning the operator made an explicit choice in the config file. +func applyNodeIPIface(conf *config.Config) error { + iface := os.Getenv("LIVEKIT_NODE_IP_IFACE") + if iface == "" { + return nil + } + + log := logger.GetLogger() + + // Respect an explicit listen_ip that isn't the wildcard. + if conf.ListenIP != "" && conf.ListenIP != "0.0.0.0" { + log.Infow("LIVEKIT_NODE_IP_IFACE set but listen_ip already configured, skipping interface resolution", + "iface", iface, + "listen_ip", conf.ListenIP, + ) + return nil + } + + ip, err := ipv4FromIface(iface) + if err != nil { + return fmt.Errorf("LIVEKIT_NODE_IP_IFACE=%s: %w", iface, err) + } + + log.Infow("resolved listen_ip from network interface", "iface", iface, "ip", ip) + conf.ListenIP = ip + return nil +} + +// ipv4FromIface returns the first IPv4 address assigned to the named interface. +func ipv4FromIface(name string) (string, error) { + ifc, err := net.InterfaceByName(name) + if err != nil { + return "", fmt.Errorf("interface %q not found: %w", name, err) + } + addrs, err := ifc.Addrs() + if err != nil { + return "", fmt.Errorf("cannot list addresses for %q: %w", name, err) + } + if ip, ok := firstIPv4(addrs); ok { + return ip, nil + } + return "", fmt.Errorf("no IPv4 address found on interface %q", name) +} + +// firstIPv4 returns the first IPv4 address in addrs, if any. +func firstIPv4(addrs []net.Addr) (string, bool) { + for _, addr := range addrs { + ipnet, ok := addr.(*net.IPNet) + if !ok { + continue + } + if ip4 := ipnet.IP.To4(); ip4 != nil { + return ip4.String(), true + } + } + return "", false +} + func getConfig(c *cli.Command, initialize bool) (*config.Config, error) { configFile := c.String("config") configBody := c.String("config-body") diff --git a/cmd/livekit-sip/main_test.go b/cmd/livekit-sip/main_test.go new file mode 100644 index 00000000..b85d5130 --- /dev/null +++ b/cmd/livekit-sip/main_test.go @@ -0,0 +1,96 @@ +// 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 ( + "net" + "testing" + + "github.com/livekit/sip/pkg/config" +) + +func TestFirstIPv4(t *testing.T) { + cases := []struct { + name string + addrs []net.Addr + want string + ok bool + }{ + { + name: "empty", + addrs: nil, + want: "", + ok: false, + }, + { + name: "ipv6 only", + addrs: []net.Addr{ + &net.IPNet{IP: net.ParseIP("fe80::1"), Mask: net.CIDRMask(64, 128)}, + }, + want: "", + ok: false, + }, + { + name: "picks first ipv4", + addrs: []net.Addr{ + &net.IPNet{IP: net.ParseIP("fe80::1"), Mask: net.CIDRMask(64, 128)}, + &net.IPNet{IP: net.ParseIP("10.0.0.5"), Mask: net.CIDRMask(24, 32)}, + &net.IPNet{IP: net.ParseIP("10.0.0.9"), Mask: net.CIDRMask(24, 32)}, + }, + want: "10.0.0.5", + ok: true, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := firstIPv4(c.addrs) + if got != c.want || ok != c.ok { + t.Fatalf("firstIPv4() = (%q, %v), want (%q, %v)", got, ok, c.want, c.ok) + } + }) + } +} + +func TestApplyNodeIPIface(t *testing.T) { + t.Run("env unset is a no-op", func(t *testing.T) { + t.Setenv("LIVEKIT_NODE_IP_IFACE", "") + conf := &config.Config{ListenIP: ""} + if err := applyNodeIPIface(conf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conf.ListenIP != "" { + t.Fatalf("ListenIP = %q, want empty", conf.ListenIP) + } + }) + + t.Run("explicit listen_ip is preserved", func(t *testing.T) { + t.Setenv("LIVEKIT_NODE_IP_IFACE", "eth0") + conf := &config.Config{ListenIP: "10.1.2.3"} + if err := applyNodeIPIface(conf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conf.ListenIP != "10.1.2.3" { + t.Fatalf("ListenIP = %q, want 10.1.2.3", conf.ListenIP) + } + }) + + t.Run("unknown interface errors", func(t *testing.T) { + t.Setenv("LIVEKIT_NODE_IP_IFACE", "definitely-not-an-iface-xyz") + conf := &config.Config{ListenIP: ""} + if err := applyNodeIPIface(conf); err == nil { + t.Fatal("expected error for unknown interface, got nil") + } + }) +}