mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 03:26:15 +00:00
fix(web): harden --bind interface resolution and add tests
The previous implementation silently returned the literal interface name when the interface existed but had no IPv4 address (or when listing addresses failed). That reproduces the exact error from #264 ("listen tcp: lookup eth103 on ...: no such host") for users on IPv6-only or admin-down interfaces, so the fix only worked for the happy path. Return an explicit error for those cases and fatal in main with a message that identifies the offending --bind value. Add an IPv6 fallback (single non-link-local address, bracketed) and treat any ambiguity -- multiple IPv4 or multiple IPv6 addresses on the same interface -- as an error rather than picking one silently. Log when an interface name was resolved to an IP so the indirection is visible. Update the --bind flag help text to reflect the supported inputs. Add a test covering the pass-through cases (host, IP, empty, unknown name) and a portable loopback-interface test that skips cleanly when the loopback isn't in a single-IPv4 configuration. Refs #264 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
413ae74315
commit
206ef1f665
@@ -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 {
|
||||
|
||||
@@ -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, "/", "_")
|
||||
}
|
||||
Reference in New Issue
Block a user