Skip to content

Commit bd5fcff

Browse files
committed
feat: support multi-bind interfaces, UI client always on loopback
- ApiConfig.Listen (string) → Listens ([]string): one address per bind - resolveBindsToListens: accepts list of interface names or "all" · "all" → ["0.0.0.0:port"] (covers loopback) · other → always prepends 127.0.0.1 so loopback is guaranteed - api/server.Run: starts one http.Server per listen address, graceful multi-shutdown on context cancellation - UI client: always uses http://127.0.0.1:port — clean, no fallback logic - bind in config.yaml now accepts string or list (viper.GetStringSlice) - Tests updated accordingly https://claude.ai/code/session_01GKdjLHJH3n1CDut5j95H1u
1 parent b4392d9 commit bd5fcff

7 files changed

Lines changed: 148 additions & 77 deletions

File tree

api/server.go

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"net/http"
7+
"sync"
78
"time"
89

910
"github.com/b0bbywan/go-odio-api/backend"
@@ -33,29 +34,40 @@ func NewServer(cfg *config.ApiConfig, b *backend.Backend) *Server {
3334
}
3435

3536
func (s *Server) Run(ctx context.Context) error {
36-
srv := &http.Server{
37-
Addr: s.config.Listen,
38-
Handler: s.mux,
37+
servers := make([]*http.Server, len(s.config.Listens))
38+
for i, addr := range s.config.Listens {
39+
servers[i] = &http.Server{Addr: addr, Handler: s.mux}
3940
}
4041

41-
// Goroutine for signal handling
42+
// Shutdown all servers on context cancellation
4243
go func() {
4344
<-ctx.Done()
44-
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
45-
defer shutdownCancel()
46-
47-
if err := srv.Shutdown(shutdownCtx); err != nil {
48-
logger.Info("[api] Server shutdown error: %v", err)
45+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
46+
defer cancel()
47+
for _, srv := range servers {
48+
if err := srv.Shutdown(shutdownCtx); err != nil {
49+
logger.Info("[api] server %s shutdown error: %v", srv.Addr, err)
50+
}
4951
}
5052
}()
5153

52-
logger.Info("[api] http server running on %s", s.config.Listen)
53-
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
54-
return fmt.Errorf("Server error: %w", err)
54+
// Start one goroutine per listen address
55+
errCh := make(chan error, len(servers))
56+
var wg sync.WaitGroup
57+
for _, srv := range servers {
58+
wg.Add(1)
59+
go func(srv *http.Server) {
60+
defer wg.Done()
61+
logger.Info("[api] http server running on %s", srv.Addr)
62+
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
63+
errCh <- fmt.Errorf("server %s: %w", srv.Addr, err)
64+
}
65+
}(srv)
5566
}
5667

57-
return nil
58-
68+
wg.Wait()
69+
close(errCh)
70+
return <-errCh
5971
}
6072

6173
func (s *Server) register(b *backend.Backend) {
@@ -98,7 +110,7 @@ func (s *Server) register(b *backend.Backend) {
98110
}
99111

100112
func (s *Server) registerUIRoutes() {
101-
uiHandler := ui.NewHandler(s.config.Listen)
113+
uiHandler := ui.NewHandler(s.config.Port)
102114
uiHandler.RegisterRoutes(s.mux)
103115
logger.Info("[api] UI routes registered at /ui")
104116
}

api/server_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ func TestServerDisabled(t *testing.T) {
131131
cfg := &config.ApiConfig{
132132
Enabled: false,
133133
Port: 8018,
134-
Listen: "127.0.0.1:8018",
134+
Listens: []string{"127.0.0.1:8018"},
135135
}
136136

137137
backend := &backend.Backend{}
@@ -147,7 +147,7 @@ func TestServerEnabled(t *testing.T) {
147147
cfg := &config.ApiConfig{
148148
Enabled: true,
149149
Port: 8018,
150-
Listen: "127.0.0.1:8018",
150+
Listens: []string{"127.0.0.1:8018"},
151151
}
152152

153153
backend := &backend.Backend{}
@@ -168,7 +168,7 @@ func TestRoutesWithDisabledBackends(t *testing.T) {
168168
cfg := &config.ApiConfig{
169169
Enabled: true,
170170
Port: 8018,
171-
Listen: "127.0.0.1:8018",
171+
Listens: []string{"127.0.0.1:8018"},
172172
}
173173

174174
// Backend with all backends disabled (nil)
@@ -282,7 +282,7 @@ func TestRoutesWithEnabledSystemdBackend(t *testing.T) {
282282
cfg := &config.ApiConfig{
283283
Enabled: true,
284284
Port: 8018,
285-
Listen: "127.0.0.1:8018",
285+
Listens: []string{"127.0.0.1:8018"},
286286
}
287287

288288
// Create a mock systemd backend (we can't create a real one without D-Bus)
@@ -313,7 +313,7 @@ func TestNilBackendHandling(t *testing.T) {
313313
cfg := &config.ApiConfig{
314314
Enabled: true,
315315
Port: 8018,
316-
Listen: "127.0.0.1:8018",
316+
Listens: []string{"127.0.0.1:8018"},
317317
}
318318

319319
// Nil backend
@@ -339,7 +339,7 @@ func TestServerRouteAlwaysRegistered(t *testing.T) {
339339
cfg := &config.ApiConfig{
340340
Enabled: true,
341341
Port: 8018,
342-
Listen: "127.0.0.1:8018",
342+
Listens: []string{"127.0.0.1:8018"},
343343
}
344344

345345
// Backend with no sub-backends but should still have server info
@@ -365,7 +365,7 @@ func TestRouteMethodRestrictions(t *testing.T) {
365365
cfg := &config.ApiConfig{
366366
Enabled: true,
367367
Port: 8018,
368-
Listen: "127.0.0.1:8018",
368+
Listens: []string{"127.0.0.1:8018"},
369369
}
370370

371371
backend := &backend.Backend{}

config/config.go

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ type UIConfig struct {
3636

3737
type ApiConfig struct {
3838
Enabled bool
39-
Listen string
39+
Listens []string // one entry per bind address, always includes 127.0.0.1
4040
Port int
4141

4242
UI *UIConfig
@@ -88,10 +88,11 @@ func parseLogLevel(levelStr string) logger.Level {
8888
}
8989
}
9090

91-
// resolveBindToIP convertit bind (interface name ou "all") en IP pour l'API
92-
func resolveBindToIP(bind string) (string, error) {
93-
if bind == "all" {
94-
return "0.0.0.0", nil
91+
// resolveIfaceToIP returns the IPv4 address of a single named interface.
92+
// "lo" resolves to "127.0.0.1" without querying the OS interface list.
93+
func resolveIfaceToIP(bind string) (string, error) {
94+
if bind == "lo" {
95+
return "127.0.0.1", nil
9596
}
9697

9798
iface, err := net.InterfaceByName(bind)
@@ -115,21 +116,63 @@ func resolveBindToIP(bind string) (string, error) {
115116
return "", fmt.Errorf("no IPv4 on interface %s", bind)
116117
}
117118

118-
// getZeroconfInterfaces retourne les interfaces pour zeroconf
119-
func getZeroconfInterfaces(bind string) []net.Interface {
120-
if bind == "all" {
121-
return getAllActiveNonLoopback()
119+
// resolveBindsToListens converts a list of bind names to host:port listen addresses.
120+
// "all" expands to 0.0.0.0 (which covers loopback too).
121+
// Otherwise 127.0.0.1 is always prepended if not already present.
122+
func resolveBindsToListens(binds []string, port string) ([]string, error) {
123+
for _, b := range binds {
124+
if b == "all" {
125+
return []string{net.JoinHostPort("0.0.0.0", port)}, nil
126+
}
122127
}
123128

124-
iface, err := net.InterfaceByName(bind)
125-
if err != nil {
126-
logger.Warn("[config] interface %q not found: %v", bind, err)
127-
return nil
129+
seen := map[string]bool{}
130+
var addrs []string
131+
132+
for _, bind := range binds {
133+
ip, err := resolveIfaceToIP(bind)
134+
if err != nil {
135+
return nil, err
136+
}
137+
addr := net.JoinHostPort(ip, port)
138+
if !seen[addr] {
139+
seen[addr] = true
140+
addrs = append(addrs, addr)
141+
}
128142
}
129-
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
130-
return nil
143+
144+
loopback := net.JoinHostPort("127.0.0.1", port)
145+
if !seen[loopback] {
146+
addrs = append([]string{loopback}, addrs...)
147+
}
148+
149+
return addrs, nil
150+
}
151+
152+
// getZeroconfInterfaces returns the network interfaces on which mDNS should be announced.
153+
func getZeroconfInterfaces(binds []string) []net.Interface {
154+
for _, b := range binds {
155+
if b == "all" {
156+
return getAllActiveNonLoopback()
157+
}
131158
}
132-
return []net.Interface{*iface}
159+
160+
var result []net.Interface
161+
for _, bind := range binds {
162+
if bind == "lo" {
163+
continue
164+
}
165+
iface, err := net.InterfaceByName(bind)
166+
if err != nil {
167+
logger.Warn("[config] interface %q not found: %v", bind, err)
168+
continue
169+
}
170+
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
171+
continue
172+
}
173+
result = append(result, *iface)
174+
}
175+
return result
133176
}
134177

135178
// getAllActiveNonLoopback retourne toutes interfaces UP sauf loopback
@@ -229,8 +272,11 @@ func New(cfgFile *string) (*Config, error) {
229272
if port <= 0 || port > 65535 {
230273
return nil, fmt.Errorf("invalid port: %d", port)
231274
}
232-
bind := viper.GetString("bind")
233-
listenIP, err := resolveBindToIP(bind)
275+
276+
// bind accepts a single interface name or a list: "enp2s0", ["enp2s0","wlan0"], "all"
277+
binds := viper.GetStringSlice("bind")
278+
portStr := strconv.Itoa(port)
279+
listens, err := resolveBindsToListens(binds, portStr)
234280
if err != nil {
235281
return nil, err
236282
}
@@ -241,7 +287,7 @@ func New(cfgFile *string) (*Config, error) {
241287

242288
apiCfg := ApiConfig{
243289
Enabled: viper.GetBool("api.enabled"),
244-
Listen: net.JoinHostPort(listenIP, strconv.Itoa(port)),
290+
Listens: listens,
245291
Port: port,
246292
UI: &uiCfg,
247293
}
@@ -274,7 +320,7 @@ func New(cfgFile *string) (*Config, error) {
274320
Timeout: mprisTimeout,
275321
}
276322

277-
interfaces := getZeroconfInterfaces(bind)
323+
interfaces := getZeroconfInterfaces(binds)
278324
zerocfg := ZeroConfig{
279325
Enabled: viper.GetBool("zeroconf.enabled"),
280326
InstanceName: AppName,

config/config_test.go

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -435,31 +435,38 @@ func TestNew_DefaultBindLocalhost(t *testing.T) {
435435
t.Fatalf("New(nil) returned error: %v", err)
436436
}
437437

438-
// Should bind to localhost by default for security
439-
expectedListen := "127.0.0.1:8018"
440-
if cfg.Api.Listen != expectedListen {
441-
t.Errorf("Api.Listen = %q, want %q (localhost by default)", cfg.Api.Listen, expectedListen)
438+
// Should always include localhost for security
439+
loopback := "127.0.0.1:8018"
440+
found := false
441+
for _, l := range cfg.Api.Listens {
442+
if l == loopback {
443+
found = true
444+
break
445+
}
446+
}
447+
if !found {
448+
t.Errorf("Api.Listens = %v, want to contain %q (localhost by default)", cfg.Api.Listens, loopback)
442449
}
443450
}
444451

445452
func TestNew_CustomBindAddress(t *testing.T) {
446453
tests := []struct {
447-
name string
448-
bind string
449-
port int
450-
expectListen string
454+
name string
455+
bind string
456+
port int
457+
expectContain string // address that must appear in Listens
451458
}{
452459
{
453-
name: "explicit localhost",
454-
bind: "lo",
455-
port: 8080,
456-
expectListen: "127.0.0.1:8080",
460+
name: "explicit localhost",
461+
bind: "lo",
462+
port: 8080,
463+
expectContain: "127.0.0.1:8080",
457464
},
458465
{
459-
name: "all interfaces",
460-
bind: "all",
461-
port: 8018,
462-
expectListen: "0.0.0.0:8018",
466+
name: "all interfaces",
467+
bind: "all",
468+
port: 8018,
469+
expectContain: "0.0.0.0:8018",
463470
},
464471
}
465472

@@ -477,8 +484,15 @@ func TestNew_CustomBindAddress(t *testing.T) {
477484
t.Fatalf("New(nil) returned error: %v", err)
478485
}
479486

480-
if cfg.Api.Listen != tt.expectListen {
481-
t.Errorf("Api.Listen = %q, want %q", cfg.Api.Listen, tt.expectListen)
487+
found := false
488+
for _, l := range cfg.Api.Listens {
489+
if l == tt.expectContain {
490+
found = true
491+
break
492+
}
493+
}
494+
if !found {
495+
t.Errorf("Api.Listens = %v, want to contain %q", cfg.Api.Listens, tt.expectContain)
482496
}
483497
})
484498
}
@@ -591,9 +605,9 @@ func TestNew_SecurityDefaults(t *testing.T) {
591605
}{
592606
{
593607
name: "bind localhost",
594-
got: cfg.Api.Listen,
608+
got: cfg.Api.Listens[0],
595609
want: "127.0.0.1:8018",
596-
errorMsg: "API should bind to localhost by default",
610+
errorMsg: "API should include localhost first by default",
597611
},
598612
{
599613
name: "systemd disabled",
@@ -633,7 +647,7 @@ func TestNew_SecurityDefaults(t *testing.T) {
633647
// Tests for network interface helpers
634648
func TestGetZeroconfInterfaces_Localhost(t *testing.T) {
635649
// Localhost should return nil (no zeroconf on loopback)
636-
interfaces := getZeroconfInterfaces("lo")
650+
interfaces := getZeroconfInterfaces([]string{"lo"})
637651

638652
if interfaces != nil {
639653
t.Errorf("getZeroconfInterfaces(lo) = %v, want nil (no zeroconf on localhost)", interfaces)
@@ -642,7 +656,7 @@ func TestGetZeroconfInterfaces_Localhost(t *testing.T) {
642656

643657
func TestGetZeroconfInterfaces_AllInterfaces(t *testing.T) {
644658
// 0.0.0.0 should return all active non-loopback interfaces
645-
interfaces := getZeroconfInterfaces("all")
659+
interfaces := getZeroconfInterfaces([]string{"all"})
646660

647661
// Should call getAllActiveInterfaces() which filters loopback
648662
for _, iface := range interfaces {
@@ -665,7 +679,7 @@ func TestGetZeroconfInterfaces_InvalidIP(t *testing.T) {
665679

666680
for _, ip := range tests {
667681
t.Run(ip, func(t *testing.T) {
668-
interfaces := getZeroconfInterfaces(ip)
682+
interfaces := getZeroconfInterfaces([]string{ip})
669683

670684
// Invalid IPs should return nil (with warning logged)
671685
if interfaces != nil {
@@ -678,7 +692,7 @@ func TestGetZeroconfInterfaces_InvalidIP(t *testing.T) {
678692
func TestGetZeroconfInterfaces_NonexistentIP(t *testing.T) {
679693
// IP that's valid but doesn't exist on this machine
680694
nonexistentIP := "192.168.99.99"
681-
interfaces := getZeroconfInterfaces(nonexistentIP)
695+
interfaces := getZeroconfInterfaces([]string{nonexistentIP})
682696

683697
// Should return nil since no interface has this IP
684698
if interfaces != nil {

share/config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# bind accepts a single interface name, a list, or "all"
2+
# bind: lo # loopback only (default)
3+
# bind: [enp2s0, wlan0] # multiple interfaces
4+
# bind: all # all active interfaces (0.0.0.0)
15
bind: lo
26
logLevel: info
37

0 commit comments

Comments
 (0)