diff --git a/cmd/soundtouch-web/main.go b/cmd/soundtouch-web/main.go index 1f9ea11..f7dcac6 100644 --- a/cmd/soundtouch-web/main.go +++ b/cmd/soundtouch-web/main.go @@ -4,6 +4,7 @@ package main import ( "context" "embed" + "fmt" "io/fs" "log" "net" @@ -37,7 +38,7 @@ func main() { }, &cli.StringFlag{ Name: "bind", - Usage: "Address (host or IP) for the HTTP listener; leave empty to listen on all interfaces", + Usage: "Address for the HTTP listener: host, IP, or local interface name (e.g. eth0). Leave empty to listen on all interfaces", EnvVars: []string{"BIND_ADDR"}, }, &cli.StringFlag{ @@ -48,7 +49,17 @@ func main() { }, Action: func(c *cli.Context) error { port := c.String("port") - bindAddr := resolveBindAddr(c.String("bind")) + rawBind := c.String("bind") + + bindAddr, err := resolveBindAddr(rawBind) + if err != nil { + log.Fatal(err) + } + + if rawBind != "" && bindAddr != rawBind { + log.Printf("Resolved --bind %q to %s", rawBind, bindAddr) + } + ifaceName := c.String("interface") addr := ":" + port @@ -102,17 +113,31 @@ func main() { } } -func resolveBindAddr(bindAddr string) string { +// resolveBindAddr returns the address to bind the HTTP listener to. +// +// If bindAddr names a local network interface, the interface's single IPv4 +// address is returned. When no IPv4 is present, the function falls back to the +// interface's single non-link-local IPv6 address (wrapped in brackets so it +// composes correctly with ":port"). Ambiguous interfaces (multiple addresses +// in the chosen family) or interfaces with no usable address produce an error, +// so misconfiguration surfaces immediately instead of becoming an obscure DNS +// lookup failure at listen time. +// +// If bindAddr is not an interface name — including the empty string, a host +// name, or a literal IP — it is returned unchanged. +func resolveBindAddr(bindAddr string) (string, error) { iface, err := net.InterfaceByName(bindAddr) if err != nil { - return bindAddr + return bindAddr, nil } addrs, err := iface.Addrs() if err != nil { - return bindAddr + return "", fmt.Errorf("--bind %q: failed to list addresses for interface: %w", bindAddr, err) } + var ipv4, ipv6 []net.IP + for _, addr := range addrs { var ip net.IP @@ -123,12 +148,31 @@ func resolveBindAddr(bindAddr string) string { ip = v.IP } - if ipv4 := ip.To4(); ipv4 != nil { - return ipv4.String() + if ip == nil { + continue + } + + if v4 := ip.To4(); v4 != nil { + ipv4 = append(ipv4, v4) + } else if !ip.IsLinkLocalUnicast() { + // Skip IPv6 link-local (fe80::); it requires a zone ID and + // can't be used as a plain "[ip]:port" listen address. + ipv6 = append(ipv6, ip) } } - return bindAddr + switch { + case len(ipv4) == 1: + return ipv4[0].String(), nil + case len(ipv4) > 1: + return "", fmt.Errorf("--bind %q: interface has multiple IPv4 addresses (%v); specify one directly", bindAddr, ipv4) + case len(ipv6) == 1: + return "[" + ipv6[0].String() + "]", nil + case len(ipv6) > 1: + return "", fmt.Errorf("--bind %q: interface has multiple IPv6 addresses (%v); specify one directly", bindAddr, ipv6) + default: + return "", fmt.Errorf("--bind %q: interface has no usable IPv4 or IPv6 address", bindAddr) + } } func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux { diff --git a/cmd/soundtouch-web/resolve_bind_addr_test.go b/cmd/soundtouch-web/resolve_bind_addr_test.go new file mode 100644 index 0000000..1dade52 --- /dev/null +++ b/cmd/soundtouch-web/resolve_bind_addr_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "net" + "strings" + "testing" +) + +func TestResolveBindAddr_PassThrough(t *testing.T) { + // Inputs that don't match any local interface name must be returned + // unchanged: empty string, hostnames, IPv4/IPv6 literals, and bogus + // strings the user might have typed. + tests := []string{ + "", + "localhost", + "127.0.0.1", + "192.168.1.5", + "::1", + "definitely-not-an-iface-xyz", + } + + for _, input := range tests { + t.Run(quoted(input), func(t *testing.T) { + got, err := resolveBindAddr(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got != input { + t.Errorf("got %q, want %q (input should pass through unchanged)", got, input) + } + }) + } +} + +func TestResolveBindAddr_LoopbackInterface(t *testing.T) { + loopback, expected, ok := findLoopbackWithSingleIPv4(t) + if !ok { + t.Skipf("no loopback interface with exactly one IPv4 address found") + } + + got, err := resolveBindAddr(loopback) + if err != nil { + t.Fatalf("unexpected error resolving %q: %v", loopback, err) + } + + if got != expected { + t.Errorf("got %q, want %q for loopback interface %q", got, expected, loopback) + } +} + +// findLoopbackWithSingleIPv4 returns the name of a loopback interface and the +// single IPv4 address attached to it. If the host has multiple loopback +// interfaces or the loopback has zero or several IPv4 addresses, it returns +// ok=false so the caller can skip the test rather than fail on an environment +// quirk. +func findLoopbackWithSingleIPv4(t *testing.T) (name, addr string, ok bool) { + t.Helper() + + ifaces, err := net.Interfaces() + if err != nil { + t.Fatalf("net.Interfaces: %v", err) + } + + for _, iface := range ifaces { + if iface.Flags&net.FlagLoopback == 0 { + continue + } + + addrs, addrErr := iface.Addrs() + if addrErr != nil { + continue + } + + var ipv4s []string + + for _, a := range addrs { + if ipnet, isIPNet := a.(*net.IPNet); isIPNet { + if v4 := ipnet.IP.To4(); v4 != nil { + ipv4s = append(ipv4s, v4.String()) + } + } + } + + if len(ipv4s) == 1 { + return iface.Name, ipv4s[0], true + } + } + + return "", "", false +} + +func quoted(s string) string { + if s == "" { + return "(empty)" + } + + return strings.ReplaceAll(s, "/", "_") +}