From e7b36543133385355d0f7e01f35c385c9905308d Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:49:25 +0800 Subject: [PATCH] fix(host): modernize default host selection order --- pkg/config/config_test.go | 4 +- pkg/config/defaults.go | 2 +- pkg/config/gateway.go | 104 ++++++++++++++- pkg/config/gateway_host_env_test.go | 23 +++- pkg/gateway/gateway.go | 13 +- pkg/health/server.go | 5 +- pkg/health/server_test.go | 10 ++ web/backend/api/gateway.go | 2 +- web/backend/api/gateway_host.go | 100 ++++++++++---- web/backend/api/gateway_host_test.go | 83 ++++++++++-- web/backend/api/router.go | 11 +- web/backend/main.go | 192 +++++++++++++++++++++++---- web/backend/main_test.go | 46 ++++++- 13 files changed, 497 insertions(+), 98 deletions(-) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 42e2d266c..0b54be986 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -503,7 +503,7 @@ func TestDefaultConfig_Temperature(t *testing.T) { func TestDefaultConfig_Gateway(t *testing.T) { cfg := DefaultConfig() - if cfg.Gateway.Host != "127.0.0.1" { + if cfg.Gateway.Host != "localhost" { t.Error("Gateway host should have default value") } if cfg.Gateway.Port == 0 { @@ -739,7 +739,7 @@ func TestConfig_Complete(t *testing.T) { if cfg.Agents.Defaults.MaxToolIterations == 0 { t.Error("MaxToolIterations should not be zero") } - if cfg.Gateway.Host != "127.0.0.1" { + if cfg.Gateway.Host != "localhost" { t.Error("Gateway host should have default value") } if cfg.Gateway.Port == 0 { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index b2054b90c..16bf9afd8 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -259,7 +259,7 @@ func DefaultConfig() *Config { }, }, Gateway: GatewayConfig{ - Host: "127.0.0.1", + Host: "localhost", Port: 18790, HotReload: false, LogLevel: DefaultGatewayLogLevel, diff --git a/pkg/config/gateway.go b/pkg/config/gateway.go index 5cae346cc..b3aa70e4b 100644 --- a/pkg/config/gateway.go +++ b/pkg/config/gateway.go @@ -2,8 +2,10 @@ package config import ( "encoding/json" + "net" "os" "strings" + "sync" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -50,17 +52,105 @@ func EffectiveGatewayLogLevel(cfg *Config) string { return normalizeGatewayLogLevel(cfg.Gateway.LogLevel) } +var ( + gatewayIPFamiliesOnce sync.Once + gatewayHasIPv4 bool + gatewayHasIPv6 bool +) + +func detectGatewayIPFamilies() (bool, bool) { + gatewayIPFamiliesOnce.Do(func() { + if ips, err := net.LookupIP("localhost"); err == nil { + for _, ip := range ips { + if ip == nil { + continue + } + if ip.To4() != nil { + gatewayHasIPv4 = true + continue + } + gatewayHasIPv6 = true + } + } + + if gatewayHasIPv4 && gatewayHasIPv6 { + return + } + + if addrs, err := net.InterfaceAddrs(); err == nil { + for _, addr := range addrs { + ipnet, ok := addr.(*net.IPNet) + if !ok || ipnet.IP == nil { + continue + } + if ipnet.IP.To4() != nil { + gatewayHasIPv4 = true + continue + } + gatewayHasIPv6 = true + } + } + }) + + return gatewayHasIPv4, gatewayHasIPv6 +} + +func selectAdaptiveGatewayLoopbackHost(hasIPv4, hasIPv6 bool) string { + switch { + case hasIPv4 && hasIPv6: + return "localhost" + case hasIPv6: + return "::1" + case hasIPv4: + return "127.0.0.1" + default: + return "localhost" + } +} + +func selectAdaptiveGatewayAnyHost(hasIPv4, hasIPv6 bool) string { + switch { + case hasIPv4 && hasIPv6: + return "::" + case hasIPv6: + return "::" + case hasIPv4: + return "0.0.0.0" + default: + return "::" + } +} + +func resolveAdaptiveGatewayLoopbackHost() string { + hasIPv4, hasIPv6 := detectGatewayIPFamilies() + return selectAdaptiveGatewayLoopbackHost(hasIPv4, hasIPv6) +} + +func resolveAdaptiveGatewayAnyHost() string { + hasIPv4, hasIPv6 := detectGatewayIPFamilies() + return selectAdaptiveGatewayAnyHost(hasIPv4, hasIPv6) +} + func normalizeGatewayHost(host string) string { host = strings.TrimSpace(host) - if host != "" { - return host + if host == "" { + host = strings.TrimSpace(DefaultConfig().Gateway.Host) } - defaultHost := strings.TrimSpace(DefaultConfig().Gateway.Host) - if defaultHost == "" { - return "127.0.0.1" + if host == "" { + host = "localhost" } - return defaultHost + + if strings.EqualFold(host, "localhost") { + return resolveAdaptiveGatewayLoopbackHost() + } + + trimmed := strings.Trim(host, "[]") + if ip := net.ParseIP(trimmed); ip != nil && ip.IsUnspecified() { + return resolveAdaptiveGatewayAnyHost() + } + + return host } func resolveGatewayHostFromEnv(baseHost string) string { @@ -74,7 +164,7 @@ func resolveGatewayHostFromEnv(baseHost string) string { return normalizeGatewayHost(baseHost) } - return envHost + return normalizeGatewayHost(envHost) } // ResolveGatewayLogLevel reads the configured gateway log level without triggering diff --git a/pkg/config/gateway_host_env_test.go b/pkg/config/gateway_host_env_test.go index 3754eefdf..5a75f4e33 100644 --- a/pkg/config/gateway_host_env_test.go +++ b/pkg/config/gateway_host_env_test.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "testing" ) @@ -40,8 +39,9 @@ func TestLoadConfig_GatewayHostBlankEnvFallsBackToConfigHost(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error: %v", err) } - if cfg.Gateway.Host != "localhost" { - t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, "localhost") + want := normalizeGatewayHost("localhost") + if cfg.Gateway.Host != want { + t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, want) } } @@ -54,8 +54,23 @@ func TestLoadConfig_GatewayHostBlankEnvAndConfigFallsBackToDefault(t *testing.T) t.Fatalf("LoadConfig() error: %v", err) } - defaultHost := strings.TrimSpace(DefaultConfig().Gateway.Host) + defaultHost := normalizeGatewayHost(DefaultConfig().Gateway.Host) if cfg.Gateway.Host != defaultHost { t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, defaultHost) } } + +func TestLoadConfig_GatewayHostEnvWildcardUsesAdaptiveAnyHost(t *testing.T) { + configPath := writeGatewayHostTestConfig(t, "localhost") + t.Setenv(EnvGatewayHost, " 0.0.0.0 ") + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + + want := normalizeGatewayHost("0.0.0.0") + if cfg.Gateway.Host != want { + t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, want) + } +} diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index a5afb0eb8..363b20e97 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -3,10 +3,12 @@ package gateway import ( "context" "fmt" + "net" "os" "os/signal" "path/filepath" "sort" + "strconv" "strings" "sync" "sync/atomic" @@ -217,7 +219,8 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr runningServices.HealthServer.SetReloadFunc(reloadTrigger) agentLoop.SetReloadFunc(reloadTrigger) - fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) + listenAddr := net.JoinHostPort(cfg.Gateway.Host, strconv.Itoa(cfg.Gateway.Port)) + fmt.Printf("✓ Gateway started on %s\n", listenAddr) fmt.Println("Press Ctrl+C to stop") ctx, cancel := context.WithCancel(context.Background()) @@ -390,7 +393,7 @@ func setupAndStartServices( fmt.Println("⚠ Warning: No channels enabled") } - addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) + addr := net.JoinHostPort(cfg.Gateway.Host, strconv.Itoa(cfg.Gateway.Port)) runningServices.authToken = authToken runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port, authToken) runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer) @@ -409,10 +412,10 @@ func setupAndStartServices( voiceAgent.Start(vaCtx) } + healthAddr := net.JoinHostPort(cfg.Gateway.Host, strconv.Itoa(cfg.Gateway.Port)) fmt.Printf( - "✓ Health endpoints available at http://%s:%d/health, /ready and /reload (POST)\n", - cfg.Gateway.Host, - cfg.Gateway.Port, + "✓ Health endpoints available at http://%s/health, /ready and /reload (POST)\n", + healthAddr, ) stateManager := state.NewManager(cfg.WorkspacePath()) diff --git a/pkg/health/server.go b/pkg/health/server.go index a152d8ab1..22346490c 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -4,10 +4,11 @@ import ( "context" "crypto/subtle" "encoding/json" - "fmt" "maps" + "net" "net/http" "os" + "strconv" "sync" "time" ) @@ -49,7 +50,7 @@ func NewServer(host string, port int, token string) *Server { mux.HandleFunc("/ready", s.readyHandler) mux.HandleFunc("/reload", s.reloadHandler) - addr := fmt.Sprintf("%s:%d", host, port) + addr := net.JoinHostPort(host, strconv.Itoa(port)) s.server = &http.Server{ Addr: addr, Handler: mux, diff --git a/pkg/health/server_test.go b/pkg/health/server_test.go index c4982fff9..31dbc37c0 100644 --- a/pkg/health/server_test.go +++ b/pkg/health/server_test.go @@ -305,6 +305,16 @@ func TestNewServer(t *testing.T) { } } +func TestNewServer_IPv6ListenAddrFormatting(t *testing.T) { + s := NewServer("::", 18790, "") + if s.server == nil { + t.Fatal("server should be initialized") + } + if s.server.Addr != "[::]:18790" { + t.Fatalf("server.Addr = %q, want %q", s.server.Addr, "[::]:18790") + } +} + func TestStartContext_Cancellation(t *testing.T) { s := NewServer("127.0.0.1", 0, "") diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 28b5f3540..273ef4a62 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -262,7 +262,7 @@ func (h *Handler) getGatewayHealthForPidData( host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) } if host == "" { - host = "127.0.0.1" + host = resolveDefaultLoopbackHost() } url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/health" diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index a5aa33c32..6934c2652 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -12,8 +12,11 @@ import ( ) var ( - adaptiveLoopbackHostOnce sync.Once - adaptiveLoopbackHost string + adaptiveIPFamiliesOnce sync.Once + adaptiveHasIPv4 bool + adaptiveHasIPv6 bool + lookupLocalhostIPs = func() ([]net.IP, error) { return net.LookupIP("localhost") } + listInterfaceAddrs = net.InterfaceAddrs ) func selectAdaptiveLoopbackHost(hasIPv4, hasIPv6 bool) string { @@ -25,7 +28,20 @@ func selectAdaptiveLoopbackHost(hasIPv4, hasIPv6 bool) string { case hasIPv4: return "127.0.0.1" default: - return "127.0.0.1" + return "localhost" + } +} + +func selectAdaptiveAnyHost(hasIPv4, hasIPv6 bool) string { + switch { + case hasIPv4 && hasIPv6: + return "::" + case hasIPv6: + return "::" + case hasIPv4: + return "0.0.0.0" + default: + return "::" } } @@ -42,36 +58,61 @@ func isLoopbackEquivalentHost(host string) bool { return ip != nil && ip.IsLoopback() } -func resolveAdaptiveLoopbackHost() string { - adaptiveLoopbackHostOnce.Do(func() { - ips, err := net.LookupIP("localhost") - if err != nil { - adaptiveLoopbackHost = selectAdaptiveLoopbackHost(false, false) +func detectAdaptiveIPFamilies() (bool, bool) { + adaptiveIPFamiliesOnce.Do(func() { + if ips, err := lookupLocalhostIPs(); err == nil { + for _, ip := range ips { + if ip == nil { + continue + } + if ip.To4() != nil { + adaptiveHasIPv4 = true + continue + } + adaptiveHasIPv6 = true + } + } + + if adaptiveHasIPv4 && adaptiveHasIPv6 { return } - hasIPv4 := false - hasIPv6 := false - for _, ip := range ips { - if ip == nil { - continue + if addrs, err := listInterfaceAddrs(); err == nil { + for _, addr := range addrs { + ipnet, ok := addr.(*net.IPNet) + if !ok || ipnet.IP == nil { + continue + } + if ipnet.IP.To4() != nil { + adaptiveHasIPv4 = true + continue + } + adaptiveHasIPv6 = true } - if ip.To4() != nil { - hasIPv4 = true - continue - } - hasIPv6 = true } - - adaptiveLoopbackHost = selectAdaptiveLoopbackHost(hasIPv4, hasIPv6) }) - return adaptiveLoopbackHost + + return adaptiveHasIPv4, adaptiveHasIPv6 +} + +func resolveAdaptiveLoopbackHost() string { + hasIPv4, hasIPv6 := detectAdaptiveIPFamilies() + return selectAdaptiveLoopbackHost(hasIPv4, hasIPv6) +} + +func resolveAdaptiveAnyHost() string { + hasIPv4, hasIPv6 := detectAdaptiveIPFamilies() + return selectAdaptiveAnyHost(hasIPv4, hasIPv6) } func resolveDefaultLoopbackHost() string { return resolveAdaptiveLoopbackHost() } +func resolveDefaultAnyHost() string { + return resolveAdaptiveAnyHost() +} + func resolveLocalhostLoopbackHost() string { return resolveAdaptiveLoopbackHost() } @@ -102,6 +143,10 @@ func canonicalLauncherBindHost(host string) string { if strings.EqualFold(host, "localhost") { return resolveLocalhostLoopbackHost() } + trimmed := strings.Trim(host, "[]") + if ip := net.ParseIP(trimmed); ip != nil && ip.IsUnspecified() { + return resolveDefaultAnyHost() + } return host } @@ -110,8 +155,8 @@ func (h *Handler) launcherAndGatewayBindHostsAligned(cfg *config.Config) bool { return false } - // With -host specified, -public is ignored, so launcher's legacy bind host is loopback. - launcherHost := canonicalLauncherBindHost("127.0.0.1") + // With -host specified, -public is ignored, so launcher baseline bind host is loopback. + launcherHost := canonicalLauncherBindHost("") gatewayHost := canonicalLauncherBindHost(cfg.Gateway.Host) if isLoopbackEquivalentHost(launcherHost) && isLoopbackEquivalentHost(gatewayHost) { return true @@ -129,7 +174,7 @@ func (h *Handler) gatewayHostOverrideForConfig(cfg *config.Config) string { } if h.effectiveLauncherPublic() { - return "0.0.0.0" + return resolveDefaultAnyHost() } return "" } @@ -167,10 +212,7 @@ func gatewayProbeHost(bindHost string) string { trimmed := strings.Trim(bindHost, "[]") if ip := net.ParseIP(trimmed); ip != nil && ip.IsUnspecified() { - if ip.To4() == nil { - return "::1" - } - return "127.0.0.1" + return resolveDefaultLoopbackHost() } return bindHost } @@ -200,7 +242,7 @@ func requestHostName(r *http.Request) string { if strings.TrimSpace(r.Host) != "" { return r.Host } - return "127.0.0.1" + return resolveDefaultLoopbackHost() } func requestWSScheme(r *http.Request) string { diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index 56d4a9ca8..71de515f9 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -3,9 +3,11 @@ package api import ( "crypto/tls" "errors" + "net" "net/http" "net/http/httptest" "path/filepath" + "sync" "testing" "time" @@ -13,6 +15,12 @@ import ( "github.com/sipeed/picoclaw/web/backend/launcherconfig" ) +func resetAdaptiveIPFamiliesForTest() { + adaptiveIPFamiliesOnce = sync.Once{} + adaptiveHasIPv4 = false + adaptiveHasIPv6 = false +} + func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") launcherPath := launcherconfig.PathForAppConfig(configPath) @@ -26,8 +34,8 @@ func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) { h := NewHandler(configPath) h.SetServerOptions(18800, true, true, nil) - if got := h.gatewayHostOverride(); got != "0.0.0.0" { - t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0") + if got := h.gatewayHostOverride(); got != resolveDefaultAnyHost() { + t.Fatalf("gatewayHostOverride() = %q, want %q", got, resolveDefaultAnyHost()) } } @@ -73,7 +81,7 @@ func TestSelectAdaptiveLoopbackHost(t *testing.T) { {name: "dual stack prefers localhost", hasIPv4: true, hasIPv6: true, want: "localhost"}, {name: "ipv6 only", hasIPv4: false, hasIPv6: true, want: "::1"}, {name: "ipv4 only", hasIPv4: true, hasIPv6: false, want: "127.0.0.1"}, - {name: "fallback", hasIPv4: false, hasIPv6: false, want: "127.0.0.1"}, + {name: "fallback", hasIPv4: false, hasIPv6: false, want: "localhost"}, } for _, tt := range tests { @@ -85,9 +93,60 @@ func TestSelectAdaptiveLoopbackHost(t *testing.T) { } } +func TestSelectAdaptiveAnyHost(t *testing.T) { + tests := []struct { + name string + hasIPv4 bool + hasIPv6 bool + want string + }{ + {name: "dual stack prefers ipv6 wildcard", hasIPv4: true, hasIPv6: true, want: "::"}, + {name: "ipv6 only", hasIPv4: false, hasIPv6: true, want: "::"}, + {name: "ipv4 only", hasIPv4: true, hasIPv6: false, want: "0.0.0.0"}, + {name: "fallback", hasIPv4: false, hasIPv6: false, want: "::"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := selectAdaptiveAnyHost(tt.hasIPv4, tt.hasIPv6); got != tt.want { + t.Fatalf("selectAdaptiveAnyHost(%t, %t) = %q, want %q", tt.hasIPv4, tt.hasIPv6, got, tt.want) + } + }) + } +} + +func TestAdaptiveHostSelectionFallsBackToInterfaceAddrs(t *testing.T) { + oldLookup := lookupLocalhostIPs + oldList := listInterfaceAddrs + lookupLocalhostIPs = func() ([]net.IP, error) { + return nil, errors.New("lookup failed") + } + _, v4Net, err := net.ParseCIDR("192.0.2.10/24") + if err != nil { + t.Fatalf("ParseCIDR() error = %v", err) + } + listInterfaceAddrs = func() ([]net.Addr, error) { + return []net.Addr{v4Net}, nil + } + resetAdaptiveIPFamiliesForTest() + t.Cleanup(func() { + lookupLocalhostIPs = oldLookup + listInterfaceAddrs = oldList + resetAdaptiveIPFamiliesForTest() + }) + + if got := resolveDefaultAnyHost(); got != "0.0.0.0" { + t.Fatalf("resolveDefaultAnyHost() = %q, want %q", got, "0.0.0.0") + } + if got := resolveDefaultLoopbackHost(); got != "127.0.0.1" { + t.Fatalf("resolveDefaultLoopbackHost() = %q, want %q", got, "127.0.0.1") + } +} + func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) { - if got := gatewayProbeHost("0.0.0.0"); got != "127.0.0.1" { - t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1") + want := resolveDefaultLoopbackHost() + if got := gatewayProbeHost("0.0.0.0"); got != want { + t.Fatalf("gatewayProbeHost() = %q, want %q", got, want) } } @@ -106,8 +165,9 @@ func TestGatewayProbeHostUsesPreferredLoopbackForLocalhostBind(t *testing.T) { } func TestGatewayProbeHostUsesLoopbackForIPv6WildcardBind(t *testing.T) { - if got := gatewayProbeHost("::"); got != "::1" { - t.Fatalf("gatewayProbeHost(::) = %q, want %q", got, "::1") + want := resolveDefaultLoopbackHost() + if got := gatewayProbeHost("::"); got != want { + t.Fatalf("gatewayProbeHost(::) = %q, want %q", got, want) } } @@ -179,8 +239,9 @@ func TestGetGatewayHealthUsesProbeHostForPublicLauncher(t *testing.T) { _ = statusCode _ = err - if requestedURL != "http://127.0.0.1:18791/health" { - t.Fatalf("health url = %q, want %q", requestedURL, "http://127.0.0.1:18791/health") + want := "http://" + net.JoinHostPort(resolveDefaultLoopbackHost(), "18791") + "/health" + if requestedURL != want { + t.Fatalf("health url = %q, want %q", requestedURL, want) } } @@ -291,8 +352,8 @@ func TestGatewayHostOverrideWithExplicitHostAndAlignedGatewayHost(t *testing.T) h.SetServerOptions(18800, false, false, nil) h.SetServerBindHost("0.0.0.0", true) - if got := h.gatewayHostOverride(); got != "0.0.0.0" { - t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0") + if got := h.gatewayHostOverride(); got != resolveDefaultAnyHost() { + t.Fatalf("gatewayHostOverride() = %q, want %q", got, resolveDefaultAnyHost()) } } diff --git a/web/backend/api/router.go b/web/backend/api/router.go index 4ea5d7d30..d88a339f9 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -32,7 +32,7 @@ func NewHandler(configPath string) *Handler { return &Handler{ configPath: configPath, serverPort: launcherconfig.DefaultPort, - serverHost: "127.0.0.1", + serverHost: resolveDefaultLoopbackHost(), oauthFlows: make(map[string]*oauthFlow), oauthState: make(map[string]string), weixinFlows: make(map[string]*weixinFlow), @@ -45,9 +45,9 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a h.serverPort = port h.serverPublic = public h.serverPublicExplicit = publicExplicit - h.serverHost = "127.0.0.1" + h.serverHost = resolveDefaultLoopbackHost() if public { - h.serverHost = "0.0.0.0" + h.serverHost = resolveDefaultAnyHost() } h.serverHostExplicit = false h.serverCIDRs = append([]string(nil), allowedCIDRs...) @@ -58,12 +58,13 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a func (h *Handler) SetServerBindHost(host string, explicit bool) { host = strings.TrimSpace(host) if host == "" { - host = "127.0.0.1" + host = resolveDefaultLoopbackHost() if h.serverPublic { - host = "0.0.0.0" + host = resolveDefaultAnyHost() } explicit = false } + host = canonicalLauncherBindHost(host) h.serverHost = host h.serverHostExplicit = explicit diff --git a/web/backend/main.go b/web/backend/main.go index 41251d1bf..e6cfa2247 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -23,6 +23,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "syscall" "time" @@ -46,6 +47,10 @@ const ( var ( appVersion = config.Version + launcherIPFamiliesOnce sync.Once + launcherHasIPv4 bool + launcherHasIPv6 bool + server *http.Server serverAddr string // browserLaunchURL is opened by openBrowser() (auto-open + tray "open console"). @@ -67,6 +72,103 @@ func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, la return launcherPath } +func detectLauncherIPFamilies() (bool, bool) { + launcherIPFamiliesOnce.Do(func() { + if ips, err := net.LookupIP("localhost"); err == nil { + for _, ip := range ips { + if ip == nil { + continue + } + if ip.To4() != nil { + launcherHasIPv4 = true + continue + } + launcherHasIPv6 = true + } + } + + if launcherHasIPv4 && launcherHasIPv6 { + return + } + + if addrs, err := net.InterfaceAddrs(); err == nil { + for _, addr := range addrs { + ipnet, ok := addr.(*net.IPNet) + if !ok || ipnet.IP == nil { + continue + } + if ipnet.IP.To4() != nil { + launcherHasIPv4 = true + continue + } + launcherHasIPv6 = true + } + } + }) + + return launcherHasIPv4, launcherHasIPv6 +} + +func selectAdaptiveLauncherLoopbackHost(hasIPv4, hasIPv6 bool) string { + switch { + case hasIPv4 && hasIPv6: + return "localhost" + case hasIPv6: + return "::1" + case hasIPv4: + return "127.0.0.1" + default: + return "localhost" + } +} + +func selectAdaptiveLauncherAnyHost(hasIPv4, hasIPv6 bool) string { + switch { + case hasIPv4 && hasIPv6: + return "::" + case hasIPv6: + return "::" + case hasIPv4: + return "0.0.0.0" + default: + return "::" + } +} + +func resolveDefaultLauncherLoopbackHost() string { + hasIPv4, hasIPv6 := detectLauncherIPFamilies() + return selectAdaptiveLauncherLoopbackHost(hasIPv4, hasIPv6) +} + +func resolveDefaultLauncherAnyHost() string { + hasIPv4, hasIPv6 := detectLauncherIPFamilies() + return selectAdaptiveLauncherAnyHost(hasIPv4, hasIPv6) +} + +func resolveDefaultLauncherPrivateHost() string { + hasIPv4, hasIPv6 := detectLauncherIPFamilies() + if hasIPv4 && hasIPv6 { + // In dual-stack environments, use wildcard IPv6 bind so localhost can serve both families. + return selectAdaptiveLauncherAnyHost(hasIPv4, hasIPv6) + } + return selectAdaptiveLauncherLoopbackHost(hasIPv4, hasIPv6) +} + +func normalizeLauncherSpecialHost(host string) string { + host = strings.TrimSpace(host) + if host == "" { + return host + } + if strings.EqualFold(host, "localhost") { + return resolveDefaultLauncherLoopbackHost() + } + trimmed := strings.Trim(host, "[]") + if ip := net.ParseIP(trimmed); ip != nil && ip.IsUnspecified() { + return resolveDefaultLauncherAnyHost() + } + return host +} + func resolveLauncherBindHost( host string, explicitHost bool, @@ -79,25 +181,30 @@ func resolveLauncherBindHost( return "", false, false, errors.New("host cannot be empty") } // When -host is specified, -public is ignored. - return host, false, true, nil + return normalizeLauncherSpecialHost(host), false, true, nil } envHost = strings.TrimSpace(envHost) if envHost != "" { // Environment host follows explicit override semantics. - return envHost, false, true, nil + return normalizeLauncherSpecialHost(envHost), false, true, nil } if effectivePublic { - return "0.0.0.0", true, false, nil + return resolveDefaultLauncherAnyHost(), true, false, nil } - return "127.0.0.1", false, false, nil + return resolveDefaultLauncherPrivateHost(), false, false, nil } func isWildcardBindHost(host string) bool { host = strings.TrimSpace(host) - return host == "0.0.0.0" || host == "::" + if host == "" { + return false + } + trimmed := strings.Trim(host, "[]") + ip := net.ParseIP(trimmed) + return ip != nil && ip.IsUnspecified() } func browserHostForLauncher(bindHost string) string { @@ -109,20 +216,57 @@ func browserHostForLauncher(bindHost string) string { } func wildcardAdvertiseIP(bindHost, ipv4, ipv6 string) string { - switch strings.TrimSpace(bindHost) { - case "0.0.0.0": - return strings.TrimSpace(ipv4) - case "::": - return strings.TrimSpace(ipv6) - default: + if !isWildcardBindHost(bindHost) { return "" } + + if v6 := strings.TrimSpace(ipv6); v6 != "" { + return v6 + } + return strings.TrimSpace(ipv4) } func advertiseIPForWildcardBindHost(bindHost string) string { return wildcardAdvertiseIP(bindHost, utils.GetLocalIPv4(), utils.GetLocalIPv6()) } +func appendUniqueHost(hosts []string, seen map[string]struct{}, host string) []string { + host = strings.TrimSpace(host) + if host == "" { + return hosts + } + key := strings.ToLower(host) + if _, ok := seen[key]; ok { + return hosts + } + seen[key] = struct{}{} + return append(hosts, host) +} + +func launcherConsoleHosts(bindHost string, hostExplicit bool, effectivePublic bool) []string { + hosts := make([]string, 0, 6) + seen := make(map[string]struct{}, 6) + + hosts = appendUniqueHost(hosts, seen, "localhost") + + if isWildcardBindHost(bindHost) { + hosts = appendUniqueHost(hosts, seen, "::1") + hosts = appendUniqueHost(hosts, seen, "127.0.0.1") + + if effectivePublic || hostExplicit { + hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv6()) + hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv4()) + } + return hosts + } + + if hostExplicit { + hosts = appendUniqueHost(hosts, seen, bindHost) + } + + return hosts +} + // maskSecret masks a secret for display. It always shows up to the first 3 // runes. The last 4 runes are only appended when at least 5 runes remain // hidden in the middle (i.e. string length >= 12), so an 8-char minimum @@ -144,7 +288,7 @@ func maskSecret(s string) string { func main() { port := flag.String("port", "18800", "Port to listen on") host := flag.String("host", "", "Host to listen on (overrides -public when set)") - public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only") + public := flag.Bool("public", false, "Listen on all interfaces (dual-stack) instead of localhost only") noBrowser = flag.Bool("no-browser", false, "Do not auto-open browser on startup") lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale") console := flag.Bool("console", false, "Console mode, no GUI") @@ -171,8 +315,8 @@ func main() { os.Args[0], ) fmt.Fprintf(os.Stderr, " Allow access from other devices on the local network\n") - fmt.Fprintf(os.Stderr, " %s -host 0.0.0.0 ./config.json\n", os.Args[0]) - fmt.Fprintf(os.Stderr, " Bind launcher host explicitly (gateway forwarding follows compatibility rules)\n") + fmt.Fprintf(os.Stderr, " %s -host :: ./config.json\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " Bind launcher host explicitly (dual-stack normalization applies)\n") fmt.Fprintf(os.Stderr, " %s -console -d ./config.json\n", os.Args[0]) fmt.Fprintf(os.Stderr, " Run in the terminal with debug logs enabled\n") } @@ -287,6 +431,12 @@ func main() { logger.Fatalf("Invalid host %q: %v", *host, err) } + effectiveAllowedCIDRs := append([]string(nil), launcherCfg.AllowedCIDRs...) + if len(effectiveAllowedCIDRs) == 0 && !effectivePublic && !hostExplicit && isWildcardBindHost(effectiveHost) { + effectiveAllowedCIDRs = []string{"127.0.0.1/32", "::1/128"} + logger.InfoC("web", "Applying loopback-only access policy for default dual-stack bind") + } + if !explicitHost && envHost != "" { logger.InfoC("web", "Using launcher host from environment PICOCLAW_LAUNCHER_HOST") } @@ -349,14 +499,14 @@ func main() { if _, err = apiHandler.EnsurePicoChannel(""); err != nil { logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err)) } - apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) + apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, effectiveAllowedCIDRs) apiHandler.SetServerBindHost(effectiveHost, hostExplicit) apiHandler.RegisterRoutes(mux) // Frontend Embedded Assets registerEmbedRoutes(mux) - accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux) + accessControlledMux, err := middleware.IPAllowlist(effectiveAllowedCIDRs, mux) if err != nil { logger.Fatalf("Invalid allowed CIDR configuration: %v", err) } @@ -381,14 +531,8 @@ func main() { fmt.Println() fmt.Println(" Open the following URL in your browser:") fmt.Println() - fmt.Printf(" >> http://localhost:%s <<\n", effectivePort) - if isWildcardBindHost(effectiveHost) { - if ip := advertiseIPForWildcardBindHost(effectiveHost); ip != "" { - fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(ip, effectivePort)) - } - } - if hostExplicit { - fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(browserHostForLauncher(effectiveHost), effectivePort)) + for _, host := range launcherConsoleHosts(effectiveHost, hostExplicit, effectivePublic) { + fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort)) } fmt.Println() switch dashboardTokenSource { diff --git a/web/backend/main_test.go b/web/backend/main_test.go index 6f68e61ac..1ac3f0ccf 100644 --- a/web/backend/main_test.go +++ b/web/backend/main_test.go @@ -113,7 +113,7 @@ func TestResolveLauncherBindHost(t *testing.T) { host: "0.0.0.0", explicitHost: true, effectivePub: true, - wantHost: "0.0.0.0", + wantHost: resolveDefaultLauncherAnyHost(), wantPublic: false, wantExplicit: true, }, @@ -139,7 +139,7 @@ func TestResolveLauncherBindHost(t *testing.T) { envHost: "0.0.0.0", explicitHost: false, effectivePub: true, - wantHost: "0.0.0.0", + wantHost: resolveDefaultLauncherAnyHost(), wantPublic: false, wantExplicit: true, }, @@ -148,7 +148,7 @@ func TestResolveLauncherBindHost(t *testing.T) { host: "", explicitHost: false, effectivePub: true, - wantHost: "0.0.0.0", + wantHost: resolveDefaultLauncherAnyHost(), wantPublic: true, wantExplicit: false, }, @@ -157,7 +157,7 @@ func TestResolveLauncherBindHost(t *testing.T) { host: "", explicitHost: false, effectivePub: false, - wantHost: "127.0.0.1", + wantHost: resolveDefaultLauncherPrivateHost(), wantPublic: false, wantExplicit: false, }, @@ -190,6 +190,38 @@ func TestResolveLauncherBindHost(t *testing.T) { } } +func TestLauncherConsoleHosts(t *testing.T) { + t.Run("explicit wildcard dedupes localhost and includes loopback ipv6", func(t *testing.T) { + hosts := launcherConsoleHosts("0.0.0.0", true, false) + seen := make(map[string]bool, len(hosts)) + for _, host := range hosts { + if seen[host] { + t.Fatalf("duplicate host %q in %#v", host, hosts) + } + seen[host] = true + } + if !seen["localhost"] { + t.Fatalf("expected localhost in %#v", hosts) + } + if !seen["::1"] { + t.Fatalf("expected ::1 in %#v", hosts) + } + if !seen["127.0.0.1"] { + t.Fatalf("expected 127.0.0.1 in %#v", hosts) + } + }) + + t.Run("explicit ipv6 host remains visible", func(t *testing.T) { + hosts := launcherConsoleHosts("::1", true, false) + if len(hosts) != 2 { + t.Fatalf("len(hosts) = %d, want 2 (%#v)", len(hosts), hosts) + } + if hosts[0] != "localhost" || hosts[1] != "::1" { + t.Fatalf("hosts = %#v, want [localhost ::1]", hosts) + } + }) +} + func TestBrowserHostForLauncher(t *testing.T) { if got := browserHostForLauncher("0.0.0.0"); got != "localhost" { t.Fatalf("browserHostForLauncher(0.0.0.0) = %q, want %q", got, "localhost") @@ -210,10 +242,10 @@ func TestWildcardAdvertiseIP(t *testing.T) { ipv6 string want string }{ - {name: "ipv4 wildcard uses ipv4", bindHost: "0.0.0.0", ipv4: "192.168.1.2", ipv6: "2001:db8::1", want: "192.168.1.2"}, + {name: "ipv4 wildcard prefers ipv6 when available", bindHost: "0.0.0.0", ipv4: "192.168.1.2", ipv6: "2001:db8::1", want: "2001:db8::1"}, {name: "ipv6 wildcard uses ipv6", bindHost: "::", ipv4: "192.168.1.2", ipv6: "2001:db8::1", want: "2001:db8::1"}, - {name: "ipv6 wildcard with no ipv6 address", bindHost: "::", ipv4: "192.168.1.2", ipv6: "", want: ""}, - {name: "ipv4 wildcard with no ipv4 address", bindHost: "0.0.0.0", ipv4: "", ipv6: "2001:db8::1", want: ""}, + {name: "ipv6 wildcard falls back to ipv4", bindHost: "::", ipv4: "192.168.1.2", ipv6: "", want: "192.168.1.2"}, + {name: "ipv4 wildcard uses ipv6-only network", bindHost: "0.0.0.0", ipv4: "", ipv6: "2001:db8::1", want: "2001:db8::1"}, {name: "non wildcard does not advertise", bindHost: "127.0.0.1", ipv4: "192.168.1.2", ipv6: "2001:db8::1", want: ""}, }