From 4e977367c2e80dbffee59fd25bef8d3cab38a447 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:29:22 +0800 Subject: [PATCH 01/15] feat(launcher): add host overrides for launcher and gateway --- cmd/picoclaw/internal/gateway/command.go | 25 +++++ cmd/picoclaw/internal/gateway/command_test.go | 1 + web/backend/api/gateway_host.go | 32 ++++++ web/backend/api/gateway_host_test.go | 49 ++++++++ web/backend/api/router.go | 25 +++++ web/backend/launcherconfig/config.go | 8 +- web/backend/main.go | 90 +++++++++++++-- web/backend/main_test.go | 106 ++++++++++++++++++ 8 files changed, 323 insertions(+), 13 deletions(-) diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go index 7fa588c5c..5d81cb24e 100644 --- a/cmd/picoclaw/internal/gateway/command.go +++ b/cmd/picoclaw/internal/gateway/command.go @@ -2,10 +2,13 @@ package gateway import ( "fmt" + "os" + "strings" "github.com/spf13/cobra" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/gateway" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" @@ -15,6 +18,7 @@ func NewGatewayCommand() *cobra.Command { var debug bool var noTruncate bool var allowEmpty bool + var host string cmd := &cobra.Command{ Use: "gateway", @@ -34,6 +38,21 @@ func NewGatewayCommand() *cobra.Command { return nil }, RunE: func(_ *cobra.Command, _ []string) error { + host = strings.TrimSpace(host) + if host != "" { + prevHost, hadPrev := os.LookupEnv(config.EnvGatewayHost) + if err := os.Setenv(config.EnvGatewayHost, host); err != nil { + return fmt.Errorf("failed to set %s: %w", config.EnvGatewayHost, err) + } + defer func() { + if hadPrev { + _ = os.Setenv(config.EnvGatewayHost, prevHost) + return + } + _ = os.Unsetenv(config.EnvGatewayHost) + }() + } + return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty) }, } @@ -47,6 +66,12 @@ func NewGatewayCommand() *cobra.Command { false, "Continue starting even when no default model is configured", ) + cmd.Flags().StringVar( + &host, + "host", + "", + "Host address for gateway binding (overrides gateway.host for this run)", + ) return cmd } diff --git a/cmd/picoclaw/internal/gateway/command_test.go b/cmd/picoclaw/internal/gateway/command_test.go index 839a7315a..6be5f0ba3 100644 --- a/cmd/picoclaw/internal/gateway/command_test.go +++ b/cmd/picoclaw/internal/gateway/command_test.go @@ -29,4 +29,5 @@ func TestNewGatewayCommand(t *testing.T) { assert.True(t, cmd.HasFlags()) assert.NotNil(t, cmd.Flags().Lookup("debug")) assert.NotNil(t, cmd.Flags().Lookup("allow-empty")) + assert.NotNil(t, cmd.Flags().Lookup("host")) } diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index f8e8eadba..19f65d34e 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -11,6 +11,11 @@ import ( ) func (h *Handler) effectiveLauncherPublic() bool { + if h.serverHostExplicit { + // -host takes precedence over -public and launcher-config public setting. + return false + } + if h.serverPublicExplicit { return h.serverPublic } @@ -23,7 +28,34 @@ func (h *Handler) effectiveLauncherPublic() bool { return h.serverPublic } +func canonicalLauncherBindHost(host string) string { + host = strings.TrimSpace(host) + if host == "" || strings.EqualFold(host, "localhost") { + return "127.0.0.1" + } + return host +} + +func (h *Handler) launcherAndGatewayBindHostsAligned() bool { + cfg, err := config.LoadConfig(h.configPath) + if err != nil || cfg == nil { + return false + } + + // With -host specified, -public is ignored, so launcher's legacy bind host is loopback. + launcherHost := canonicalLauncherBindHost("127.0.0.1") + gatewayHost := canonicalLauncherBindHost(cfg.Gateway.Host) + return launcherHost == gatewayHost +} + func (h *Handler) gatewayHostOverride() string { + if h.serverHostExplicit { + if h.launcherAndGatewayBindHostsAligned() { + return strings.TrimSpace(h.serverHost) + } + return "" + } + if h.effectiveLauncherPublic() { return "0.0.0.0" } diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index 7150b6fee..c71d1a24d 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -240,3 +240,52 @@ func TestBuildWsURLUsesRequestHostNotGatewayBindLoopback(t *testing.T) { t.Fatalf("buildWsURL() = %q, want %q", got, "ws://localhost:18800/pico/ws") } } + +func TestGatewayHostOverrideWithExplicitHostAndAlignedGatewayHost(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + writeGatewayHostConfig(t, configPath, "127.0.0.1") + + h := NewHandler(configPath) + 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") + } +} + +func TestGatewayHostOverrideWithExplicitHostAndMismatchedGatewayHost(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + writeGatewayHostConfig(t, configPath, "0.0.0.0") + + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + h.SetServerBindHost("192.168.1.10", true) + + if got := h.gatewayHostOverride(); got != "" { + t.Fatalf("gatewayHostOverride() = %q, want empty", got) + } +} + +func TestGatewayHostExplicitIgnoresPublicFlag(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + writeGatewayHostConfig(t, configPath, "127.0.0.1") + + h := NewHandler(configPath) + h.SetServerOptions(18800, true, true, nil) + h.SetServerBindHost("127.0.0.1", true) + + if got := h.effectiveLauncherPublic(); got { + t.Fatalf("effectiveLauncherPublic() = %t, want false when explicit host is set", got) + } +} + +func writeGatewayHostConfig(t *testing.T, configPath, host string) { + t.Helper() + + cfg := config.DefaultConfig() + cfg.Gateway.Host = host + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go index c6781baf1..4ea5d7d30 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "strings" "sync" "github.com/sipeed/picoclaw/web/backend/launcherconfig" @@ -13,6 +14,8 @@ type Handler struct { serverPort int serverPublic bool serverPublicExplicit bool + serverHost string + serverHostExplicit bool serverCIDRs []string debug bool oauthMu sync.Mutex @@ -29,6 +32,7 @@ func NewHandler(configPath string) *Handler { return &Handler{ configPath: configPath, serverPort: launcherconfig.DefaultPort, + serverHost: "127.0.0.1", oauthFlows: make(map[string]*oauthFlow), oauthState: make(map[string]string), weixinFlows: make(map[string]*weixinFlow), @@ -41,9 +45,30 @@ 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" + if public { + h.serverHost = "0.0.0.0" + } + h.serverHostExplicit = false h.serverCIDRs = append([]string(nil), allowedCIDRs...) } +// SetServerBindHost stores the launcher's effective bind host. +// When explicit is true, the value came from the -host flag. +func (h *Handler) SetServerBindHost(host string, explicit bool) { + host = strings.TrimSpace(host) + if host == "" { + host = "127.0.0.1" + if h.serverPublic { + host = "0.0.0.0" + } + explicit = false + } + + h.serverHost = host + h.serverHostExplicit = explicit +} + func (h *Handler) SetDebug(debug bool) { h.debug = debug } diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go index 60c369f4f..b6faa63fe 100644 --- a/web/backend/launcherconfig/config.go +++ b/web/backend/launcherconfig/config.go @@ -16,6 +16,10 @@ const ( FileName = "launcher-config.json" // DefaultPort is the default port for the web launcher. DefaultPort = 18800 + // EnvLauncherToken overrides launcher dashboard token. + EnvLauncherToken = "PICOCLAW_LAUNCHER_TOKEN" + // EnvLauncherHost overrides launcher listen host. + EnvLauncherHost = "PICOCLAW_LAUNCHER_HOST" // dashboardSigningKeyBytes is the HMAC-SHA256 key size (256 bits). dashboardSigningKeyBytes = 32 @@ -59,7 +63,7 @@ func Validate(cfg Config) error { // EnsureDashboardSecrets returns signing key bytes and the effective dashboard token for this // process. The signing key is freshly random each call; the token comes from -// PICOCLAW_LAUNCHER_TOKEN when set, otherwise launcher-config.json launcher_token, +// EnvLauncherToken when set, otherwise launcher-config.json launcher_token, // otherwise a new random token. func EnsureDashboardSecrets( cfg Config, @@ -69,7 +73,7 @@ func EnsureDashboardSecrets( return "", nil, "", err } - effectiveToken = strings.TrimSpace(os.Getenv("PICOCLAW_LAUNCHER_TOKEN")) + effectiveToken = strings.TrimSpace(os.Getenv(EnvLauncherToken)) if effectiveToken != "" { return effectiveToken, signingKey, DashboardTokenSourceEnv, nil } diff --git a/web/backend/main.go b/web/backend/main.go index c5d25f6ef..088fda3d5 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -15,12 +15,14 @@ import ( "errors" "flag" "fmt" + "net" "net/http" "net/url" "os" "os/signal" "path/filepath" "strconv" + "strings" "syscall" "time" @@ -65,6 +67,47 @@ func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, la return launcherPath } +func resolveLauncherBindHost( + host string, + explicitHost bool, + envHost string, + effectivePublic bool, +) (string, bool, bool, error) { + if explicitHost { + host = strings.TrimSpace(host) + if host == "" { + return "", false, false, errors.New("host cannot be empty") + } + // When -host is specified, -public is ignored. + return host, false, true, nil + } + + envHost = strings.TrimSpace(envHost) + if envHost != "" { + // Environment host follows explicit override semantics. + return envHost, false, true, nil + } + + if effectivePublic { + return "0.0.0.0", true, false, nil + } + + return "127.0.0.1", false, false, nil +} + +func isWildcardBindHost(host string) bool { + host = strings.TrimSpace(host) + return host == "0.0.0.0" || host == "::" +} + +func browserHostForLauncher(bindHost string) string { + bindHost = strings.TrimSpace(bindHost) + if bindHost == "" || isWildcardBindHost(bindHost) { + return "localhost" + } + return bindHost +} + // 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 @@ -85,6 +128,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") 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") @@ -112,6 +156,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 and gateway host explicitly\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") } @@ -175,8 +221,9 @@ func main() { logger.DebugC( "web", fmt.Sprintf( - "Launcher flags: console=%t public=%t no_browser=%t config=%s", + "Launcher flags: console=%t host=%q public=%t no_browser=%t config=%s", enableConsole, + *host, *public, *noBrowser, absPath, @@ -186,10 +233,13 @@ func main() { var explicitPort bool var explicitPublic bool + var explicitHost bool flag.Visit(func(f *flag.Flag) { switch f.Name { case "port": explicitPort = true + case "host": + explicitHost = true case "public": explicitPublic = true } @@ -210,6 +260,25 @@ func main() { if !explicitPublic { effectivePublic = launcherCfg.Public } + envHost := strings.TrimSpace(os.Getenv(launcherconfig.EnvLauncherHost)) + + effectiveHost, effectivePublic, hostExplicit, err := resolveLauncherBindHost( + *host, + explicitHost, + envHost, + effectivePublic, + ) + if err != nil { + logger.Fatalf("Invalid host %q: %v", *host, err) + } + + if !explicitHost && envHost != "" { + logger.InfoC("web", "Using launcher host from environment PICOCLAW_LAUNCHER_HOST") + } + + if hostExplicit && explicitPublic { + logger.InfoC("web", "Ignoring -public because launcher host was explicitly set") + } portNum, err := strconv.Atoi(effectivePort) if err != nil || portNum < 1 || portNum > 65535 { @@ -247,12 +316,7 @@ func main() { } // Determine listen address - var addr string - if effectivePublic { - addr = "0.0.0.0:" + effectivePort - } else { - addr = "127.0.0.1:" + effectivePort - } + addr := net.JoinHostPort(effectiveHost, effectivePort) // Initialize Server components mux := http.NewServeMux() @@ -271,6 +335,7 @@ func main() { logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err)) } apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) + apiHandler.SetServerBindHost(effectiveHost, hostExplicit) apiHandler.RegisterRoutes(mux) // Frontend Embedded Assets @@ -302,11 +367,14 @@ func main() { fmt.Println(" Open the following URL in your browser:") fmt.Println() fmt.Printf(" >> http://localhost:%s <<\n", effectivePort) - if effectivePublic { + if isWildcardBindHost(effectiveHost) { if ip := utils.GetLocalIP(); ip != "" { fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort) } } + if hostExplicit { + fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(browserHostForLauncher(effectiveHost), effectivePort)) + } fmt.Println() switch dashboardTokenSource { case launcherconfig.DashboardTokenSourceRandom: @@ -331,15 +399,15 @@ func main() { } // Log startup info to file - logger.InfoC("web", fmt.Sprintf("Server will listen on http://localhost:%s", effectivePort)) - if effectivePublic { + logger.InfoC("web", fmt.Sprintf("Server will listen on http://%s", net.JoinHostPort(effectiveHost, effectivePort))) + if isWildcardBindHost(effectiveHost) { if ip := utils.GetLocalIP(); ip != "" { logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s:%s", ip, effectivePort)) } } // Share the local URL with the launcher runtime. - serverAddr = fmt.Sprintf("http://localhost:%s", effectivePort) + serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(browserHostForLauncher(effectiveHost), effectivePort)) if dashboardToken != "" { browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken) } else { diff --git a/web/backend/main_test.go b/web/backend/main_test.go index 82bf12b40..40555dbe1 100644 --- a/web/backend/main_test.go +++ b/web/backend/main_test.go @@ -95,3 +95,109 @@ func TestMaskSecret(t *testing.T) { } } } + +func TestResolveLauncherBindHost(t *testing.T) { + tests := []struct { + name string + host string + envHost string + explicitHost bool + effectivePub bool + wantHost string + wantPublic bool + wantExplicit bool + wantErr bool + }{ + { + name: "explicit host overrides public", + host: "0.0.0.0", + explicitHost: true, + effectivePub: true, + wantHost: "0.0.0.0", + wantPublic: false, + wantExplicit: true, + }, + { + name: "explicit host overrides env host", + host: "127.0.0.1", + envHost: "0.0.0.0", + explicitHost: true, + effectivePub: true, + wantHost: "127.0.0.1", + wantPublic: false, + wantExplicit: true, + }, + { + name: "explicit host cannot be empty", + host: " ", + explicitHost: true, + effectivePub: false, + wantErr: true, + }, + { + name: "env host overrides public", + envHost: "0.0.0.0", + explicitHost: false, + effectivePub: true, + wantHost: "0.0.0.0", + wantPublic: false, + wantExplicit: true, + }, + { + name: "public mode without explicit host", + host: "", + explicitHost: false, + effectivePub: true, + wantHost: "0.0.0.0", + wantPublic: true, + wantExplicit: false, + }, + { + name: "private mode without explicit host", + host: "", + explicitHost: false, + effectivePub: false, + wantHost: "127.0.0.1", + wantPublic: false, + wantExplicit: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotHost, gotPublic, gotExplicit, err := resolveLauncherBindHost( + tt.host, + tt.explicitHost, + tt.envHost, + tt.effectivePub, + ) + if (err != nil) != tt.wantErr { + t.Fatalf("resolveLauncherBindHost() error = %v, wantErr %t", err, tt.wantErr) + } + if tt.wantErr { + return + } + if gotHost != tt.wantHost { + t.Fatalf("resolveLauncherBindHost() host = %q, want %q", gotHost, tt.wantHost) + } + if gotPublic != tt.wantPublic { + t.Fatalf("resolveLauncherBindHost() public = %t, want %t", gotPublic, tt.wantPublic) + } + if gotExplicit != tt.wantExplicit { + t.Fatalf("resolveLauncherBindHost() explicit = %t, want %t", gotExplicit, tt.wantExplicit) + } + }) + } +} + +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") + } + if got := browserHostForLauncher("::"); got != "localhost" { + t.Fatalf("browserHostForLauncher(::) = %q, want %q", got, "localhost") + } + if got := browserHostForLauncher("192.168.1.10"); got != "192.168.1.10" { + t.Fatalf("browserHostForLauncher(192.168.1.10) = %q, want %q", got, "192.168.1.10") + } +} From 448027c02ae571aa9fe6a22e5f7dd5924cbe52ae Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:33:22 +0800 Subject: [PATCH 02/15] fix(host): align launcher and gateway host normalization semantics --- cmd/picoclaw/internal/gateway/command.go | 19 ++- cmd/picoclaw/internal/gateway/command_test.go | 26 ++++ pkg/config/config.go | 3 + pkg/config/gateway.go | 28 +++++ pkg/config/gateway_host_env_test.go | 61 ++++++++++ web/backend/api/gateway.go | 15 ++- web/backend/api/gateway_host.go | 114 ++++++++++++++++-- web/backend/api/gateway_host_test.go | 55 +++++++++ web/backend/main.go | 25 +++- web/backend/main_test.go | 24 ++++ web/backend/utils/runtime.go | 32 ++++- 11 files changed, 380 insertions(+), 22 deletions(-) create mode 100644 pkg/config/gateway_host_env_test.go diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go index 5d81cb24e..5487a20bb 100644 --- a/cmd/picoclaw/internal/gateway/command.go +++ b/cmd/picoclaw/internal/gateway/command.go @@ -14,6 +14,14 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +func resolveGatewayHostOverride(explicit bool, host string) (string, error) { + host = strings.TrimSpace(host) + if explicit && host == "" { + return "", fmt.Errorf("the --host option cannot be empty") + } + return host, nil +} + func NewGatewayCommand() *cobra.Command { var debug bool var noTruncate bool @@ -37,11 +45,14 @@ func NewGatewayCommand() *cobra.Command { return nil }, - RunE: func(_ *cobra.Command, _ []string) error { - host = strings.TrimSpace(host) - if host != "" { + RunE: func(cmd *cobra.Command, _ []string) error { + resolvedHost, err := resolveGatewayHostOverride(cmd.Flags().Changed("host"), host) + if err != nil { + return err + } + if resolvedHost != "" { prevHost, hadPrev := os.LookupEnv(config.EnvGatewayHost) - if err := os.Setenv(config.EnvGatewayHost, host); err != nil { + if err := os.Setenv(config.EnvGatewayHost, resolvedHost); err != nil { return fmt.Errorf("failed to set %s: %w", config.EnvGatewayHost, err) } defer func() { diff --git a/cmd/picoclaw/internal/gateway/command_test.go b/cmd/picoclaw/internal/gateway/command_test.go index 6be5f0ba3..b53d5253c 100644 --- a/cmd/picoclaw/internal/gateway/command_test.go +++ b/cmd/picoclaw/internal/gateway/command_test.go @@ -31,3 +31,29 @@ func TestNewGatewayCommand(t *testing.T) { assert.NotNil(t, cmd.Flags().Lookup("allow-empty")) assert.NotNil(t, cmd.Flags().Lookup("host")) } + +func TestResolveGatewayHostOverride(t *testing.T) { + tests := []struct { + name string + explicit bool + host string + wantHost string + wantErr bool + }{ + {name: "implicit empty host is allowed", explicit: false, host: "", wantHost: "", wantErr: false}, + {name: "explicit empty host rejected", explicit: true, host: " ", wantHost: "", wantErr: true}, + {name: "explicit localhost kept", explicit: true, host: " localhost ", wantHost: "localhost", wantErr: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveGatewayHostOverride(tt.explicit, tt.host) + if (err != nil) != tt.wantErr { + t.Fatalf("resolveGatewayHostOverride() err = %v, wantErr %t", err, tt.wantErr) + } + if got != tt.wantHost { + t.Fatalf("resolveGatewayHostOverride() host = %q, want %q", got, tt.wantHost) + } + }) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 9488fd96c..07e52de97 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1073,6 +1073,8 @@ func LoadConfig(path string) (*Config, error) { applyLegacyBindingsMigration(data, cfg) + gatewayHostBeforeEnv := cfg.Gateway.Host + if err = env.Parse(cfg); err != nil { return nil, err } @@ -1080,6 +1082,7 @@ func LoadConfig(path string) (*Config, error) { if err = InitChannelList(cfg.Channels); err != nil { return nil, err } + cfg.Gateway.Host = resolveGatewayHostFromEnv(gatewayHostBeforeEnv) // Expand multi-key configs into separate entries for key-level failover cfg.ModelList = expandMultiKeyModels(cfg.ModelList) diff --git a/pkg/config/gateway.go b/pkg/config/gateway.go index e9f4085d3..5cae346cc 100644 --- a/pkg/config/gateway.go +++ b/pkg/config/gateway.go @@ -3,6 +3,7 @@ package config import ( "encoding/json" "os" + "strings" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -49,6 +50,33 @@ func EffectiveGatewayLogLevel(cfg *Config) string { return normalizeGatewayLogLevel(cfg.Gateway.LogLevel) } +func normalizeGatewayHost(host string) string { + host = strings.TrimSpace(host) + if host != "" { + return host + } + + defaultHost := strings.TrimSpace(DefaultConfig().Gateway.Host) + if defaultHost == "" { + return "127.0.0.1" + } + return defaultHost +} + +func resolveGatewayHostFromEnv(baseHost string) string { + envHost, ok := os.LookupEnv(EnvGatewayHost) + if !ok { + return normalizeGatewayHost(baseHost) + } + + envHost = strings.TrimSpace(envHost) + if envHost == "" { + return normalizeGatewayHost(baseHost) + } + + return envHost +} + // ResolveGatewayLogLevel reads the configured gateway log level without triggering // the full config loader, so startup code can apply logging before config load logs run. // The PICOCLAW_LOG_LEVEL environment variable overrides the file value. diff --git a/pkg/config/gateway_host_env_test.go b/pkg/config/gateway_host_env_test.go new file mode 100644 index 000000000..3754eefdf --- /dev/null +++ b/pkg/config/gateway_host_env_test.go @@ -0,0 +1,61 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeGatewayHostTestConfig(t *testing.T, host string) string { + t.Helper() + + configPath := filepath.Join(t.TempDir(), "config.json") + raw := fmt.Sprintf(`{"version":2,"gateway":{"host":%q,"port":18790}}`, host) + if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil { + t.Fatalf("WriteFile(configPath): %v", err) + } + return configPath +} + +func TestLoadConfig_GatewayHostEnvTrimmed(t *testing.T) { + configPath := writeGatewayHostTestConfig(t, "127.0.0.1") + t.Setenv(EnvGatewayHost, " ::1 ") + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Gateway.Host != "::1" { + t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, "::1") + } +} + +func TestLoadConfig_GatewayHostBlankEnvFallsBackToConfigHost(t *testing.T) { + configPath := writeGatewayHostTestConfig(t, " localhost ") + t.Setenv(EnvGatewayHost, " ") + + cfg, err := LoadConfig(configPath) + 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") + } +} + +func TestLoadConfig_GatewayHostBlankEnvAndConfigFallsBackToDefault(t *testing.T) { + configPath := writeGatewayHostTestConfig(t, " ") + t.Setenv(EnvGatewayHost, " ") + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + + defaultHost := strings.TrimSpace(DefaultConfig().Gateway.Host) + if cfg.Gateway.Host != defaultHost { + t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, defaultHost) + } +} diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 0dec45cba..28b5f3540 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -731,8 +731,19 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int if h.configPath != "" { cmd.Env = append(cmd.Env, config.EnvConfig+"="+h.configPath) } - if host := h.gatewayHostOverride(); host != "" { - cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+host) + gatewayHostOverride := h.gatewayHostOverrideForConfig(cfg) + if h.serverHostExplicit && gatewayHostOverride == "" { + logger.WarnC( + "gateway", + fmt.Sprintf( + "Explicit launcher host %q was not forwarded to gateway because configured gateway host is %q; gateway keeps original bind host", + strings.TrimSpace(h.serverHost), + strings.TrimSpace(cfg.Gateway.Host), + ), + ) + } + if gatewayHostOverride != "" { + cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+gatewayHostOverride) } stdoutPipe, err := cmd.StdoutPipe() diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index 19f65d34e..a5aa33c32 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -6,10 +6,76 @@ import ( "net/url" "strconv" "strings" + "sync" "github.com/sipeed/picoclaw/pkg/config" ) +var ( + adaptiveLoopbackHostOnce sync.Once + adaptiveLoopbackHost string +) + +func selectAdaptiveLoopbackHost(hasIPv4, hasIPv6 bool) string { + switch { + case hasIPv4 && hasIPv6: + return "localhost" + case hasIPv6: + return "::1" + case hasIPv4: + return "127.0.0.1" + default: + return "127.0.0.1" + } +} + +func isLoopbackEquivalentHost(host string) bool { + host = strings.TrimSpace(host) + if host == "" { + return false + } + if strings.EqualFold(host, "localhost") { + return true + } + trimmed := strings.Trim(host, "[]") + ip := net.ParseIP(trimmed) + return ip != nil && ip.IsLoopback() +} + +func resolveAdaptiveLoopbackHost() string { + adaptiveLoopbackHostOnce.Do(func() { + ips, err := net.LookupIP("localhost") + if err != nil { + adaptiveLoopbackHost = selectAdaptiveLoopbackHost(false, false) + return + } + + hasIPv4 := false + hasIPv6 := false + for _, ip := range ips { + if ip == nil { + continue + } + if ip.To4() != nil { + hasIPv4 = true + continue + } + hasIPv6 = true + } + + adaptiveLoopbackHost = selectAdaptiveLoopbackHost(hasIPv4, hasIPv6) + }) + return adaptiveLoopbackHost +} + +func resolveDefaultLoopbackHost() string { + return resolveAdaptiveLoopbackHost() +} + +func resolveLocalhostLoopbackHost() string { + return resolveAdaptiveLoopbackHost() +} + func (h *Handler) effectiveLauncherPublic() bool { if h.serverHostExplicit { // -host takes precedence over -public and launcher-config public setting. @@ -30,27 +96,33 @@ func (h *Handler) effectiveLauncherPublic() bool { func canonicalLauncherBindHost(host string) string { host = strings.TrimSpace(host) - if host == "" || strings.EqualFold(host, "localhost") { - return "127.0.0.1" + if host == "" { + return resolveDefaultLoopbackHost() + } + if strings.EqualFold(host, "localhost") { + return resolveLocalhostLoopbackHost() } return host } -func (h *Handler) launcherAndGatewayBindHostsAligned() bool { - cfg, err := config.LoadConfig(h.configPath) - if err != nil || cfg == nil { +func (h *Handler) launcherAndGatewayBindHostsAligned(cfg *config.Config) bool { + if cfg == nil { return false } // With -host specified, -public is ignored, so launcher's legacy bind host is loopback. launcherHost := canonicalLauncherBindHost("127.0.0.1") gatewayHost := canonicalLauncherBindHost(cfg.Gateway.Host) + if isLoopbackEquivalentHost(launcherHost) && isLoopbackEquivalentHost(gatewayHost) { + return true + } + return launcherHost == gatewayHost } -func (h *Handler) gatewayHostOverride() string { +func (h *Handler) gatewayHostOverrideForConfig(cfg *config.Config) string { if h.serverHostExplicit { - if h.launcherAndGatewayBindHostsAligned() { + if h.launcherAndGatewayBindHostsAligned(cfg) { return strings.TrimSpace(h.serverHost) } return "" @@ -62,8 +134,20 @@ func (h *Handler) gatewayHostOverride() string { return "" } +func (h *Handler) gatewayHostOverride() string { + if !h.serverHostExplicit { + return h.gatewayHostOverrideForConfig(nil) + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + return "" + } + return h.gatewayHostOverrideForConfig(cfg) +} + func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string { - if override := h.gatewayHostOverride(); override != "" { + if override := h.gatewayHostOverrideForConfig(cfg); override != "" { return override } if cfg == nil { @@ -73,7 +157,19 @@ func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string { } func gatewayProbeHost(bindHost string) string { - if bindHost == "" || bindHost == "0.0.0.0" { + bindHost = strings.TrimSpace(bindHost) + if bindHost == "" { + return resolveDefaultLoopbackHost() + } + if strings.EqualFold(bindHost, "localhost") { + return resolveLocalhostLoopbackHost() + } + + 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 bindHost diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index c71d1a24d..56d4a9ca8 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -63,12 +63,54 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { } } +func TestSelectAdaptiveLoopbackHost(t *testing.T) { + tests := []struct { + name string + hasIPv4 bool + hasIPv6 bool + want string + }{ + {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"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := selectAdaptiveLoopbackHost(tt.hasIPv4, tt.hasIPv6); got != tt.want { + t.Fatalf("selectAdaptiveLoopbackHost(%t, %t) = %q, want %q", tt.hasIPv4, tt.hasIPv6, got, tt.want) + } + }) + } +} + 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") } } +func TestGatewayProbeHostUsesPreferredLoopbackForEmptyBind(t *testing.T) { + want := resolveDefaultLoopbackHost() + if got := gatewayProbeHost(""); got != want { + t.Fatalf("gatewayProbeHost(empty) = %q, want %q", got, want) + } +} + +func TestGatewayProbeHostUsesPreferredLoopbackForLocalhostBind(t *testing.T) { + want := resolveLocalhostLoopbackHost() + if got := gatewayProbeHost("localhost"); got != want { + t.Fatalf("gatewayProbeHost(localhost) = %q, want %q", got, want) + } +} + +func TestGatewayProbeHostUsesLoopbackForIPv6WildcardBind(t *testing.T) { + if got := gatewayProbeHost("::"); got != "::1" { + t.Fatalf("gatewayProbeHost(::) = %q, want %q", got, "::1") + } +} + func TestGatewayProxyURLUsesConfiguredHost(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -254,6 +296,19 @@ func TestGatewayHostOverrideWithExplicitHostAndAlignedGatewayHost(t *testing.T) } } +func TestGatewayHostOverrideWithExplicitHostAndLocalhostGatewayHost(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + writeGatewayHostConfig(t, configPath, "localhost") + + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + h.SetServerBindHost("::", true) + + if got := h.gatewayHostOverride(); got != "::" { + t.Fatalf("gatewayHostOverride() = %q, want %q", got, "::") + } +} + func TestGatewayHostOverrideWithExplicitHostAndMismatchedGatewayHost(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") writeGatewayHostConfig(t, configPath, "0.0.0.0") diff --git a/web/backend/main.go b/web/backend/main.go index 088fda3d5..41251d1bf 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -108,6 +108,21 @@ func browserHostForLauncher(bindHost string) string { return bindHost } +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: + return "" + } +} + +func advertiseIPForWildcardBindHost(bindHost string) string { + return wildcardAdvertiseIP(bindHost, utils.GetLocalIPv4(), utils.GetLocalIPv6()) +} + // 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 @@ -157,7 +172,7 @@ func main() { ) 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 and gateway host explicitly\n") + fmt.Fprintf(os.Stderr, " Bind launcher host explicitly (gateway forwarding follows compatibility rules)\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") } @@ -368,8 +383,8 @@ func main() { fmt.Println() fmt.Printf(" >> http://localhost:%s <<\n", effectivePort) if isWildcardBindHost(effectiveHost) { - if ip := utils.GetLocalIP(); ip != "" { - fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort) + if ip := advertiseIPForWildcardBindHost(effectiveHost); ip != "" { + fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(ip, effectivePort)) } } if hostExplicit { @@ -401,8 +416,8 @@ func main() { // Log startup info to file logger.InfoC("web", fmt.Sprintf("Server will listen on http://%s", net.JoinHostPort(effectiveHost, effectivePort))) if isWildcardBindHost(effectiveHost) { - if ip := utils.GetLocalIP(); ip != "" { - logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s:%s", ip, effectivePort)) + if ip := advertiseIPForWildcardBindHost(effectiveHost); ip != "" { + logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s", net.JoinHostPort(ip, effectivePort))) } } diff --git a/web/backend/main_test.go b/web/backend/main_test.go index 40555dbe1..6f68e61ac 100644 --- a/web/backend/main_test.go +++ b/web/backend/main_test.go @@ -201,3 +201,27 @@ func TestBrowserHostForLauncher(t *testing.T) { t.Fatalf("browserHostForLauncher(192.168.1.10) = %q, want %q", got, "192.168.1.10") } } + +func TestWildcardAdvertiseIP(t *testing.T) { + tests := []struct { + name string + bindHost string + ipv4 string + 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: "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: "non wildcard does not advertise", bindHost: "127.0.0.1", ipv4: "192.168.1.2", ipv6: "2001:db8::1", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := wildcardAdvertiseIP(tt.bindHost, tt.ipv4, tt.ipv6); got != tt.want { + t.Fatalf("wildcardAdvertiseIP(%q, %q, %q) = %q, want %q", tt.bindHost, tt.ipv4, tt.ipv6, got, tt.want) + } + }) + } +} diff --git a/web/backend/utils/runtime.go b/web/backend/utils/runtime.go index 0b9e30979..7cceff707 100644 --- a/web/backend/utils/runtime.go +++ b/web/backend/utils/runtime.go @@ -54,8 +54,8 @@ func FindPicoclawBinary() string { return "picoclaw" } -// GetLocalIP returns the local IP address of the machine. -func GetLocalIP() string { +// GetLocalIPv4 returns a non-loopback local IPv4 address. +func GetLocalIPv4() string { addrs, err := net.InterfaceAddrs() if err != nil { return "" @@ -68,6 +68,34 @@ func GetLocalIP() string { return "" } +// GetLocalIPv6 returns a non-loopback local IPv6 address. +func GetLocalIPv6() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, a := range addrs { + ipnet, ok := a.(*net.IPNet) + if !ok || ipnet.IP == nil { + continue + } + ip := ipnet.IP + if ip.IsLoopback() || ip.To4() != nil { + continue + } + if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + continue + } + return ip.String() + } + return "" +} + +// GetLocalIP returns a non-loopback local IPv4 address for backward compatibility. +func GetLocalIP() string { + return GetLocalIPv4() +} + // OpenBrowser automatically opens the given URL in the default browser. func OpenBrowser(url string) error { switch runtime.GOOS { 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 03/15] 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: ""}, } From 7b38d437ba7fe5197a8e459195ad39fb220891c9 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:10:44 +0800 Subject: [PATCH 04/15] feat(launcher): support multi-host bind and strict host semantics --- web/backend/api/gateway_host.go | 91 +------ web/backend/api/gateway_host_test.go | 37 +-- web/backend/app_runtime.go | 33 ++- web/backend/main.go | 388 ++++++++++++++++++--------- web/backend/main_test.go | 99 ++++++- web/backend/utils/runtime.go | 80 ++++++ web/backend/utils/runtime_test.go | 59 ++++ 7 files changed, 526 insertions(+), 261 deletions(-) create mode 100644 web/backend/utils/runtime_test.go diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index 6934c2652..055c90bdf 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -6,43 +6,17 @@ import ( "net/url" "strconv" "strings" - "sync" "github.com/sipeed/picoclaw/pkg/config" -) - -var ( - adaptiveIPFamiliesOnce sync.Once - adaptiveHasIPv4 bool - adaptiveHasIPv6 bool - lookupLocalhostIPs = func() ([]net.IP, error) { return net.LookupIP("localhost") } - listInterfaceAddrs = net.InterfaceAddrs + "github.com/sipeed/picoclaw/web/backend/utils" ) func selectAdaptiveLoopbackHost(hasIPv4, hasIPv6 bool) string { - switch { - case hasIPv4 && hasIPv6: - return "localhost" - case hasIPv6: - return "::1" - case hasIPv4: - return "127.0.0.1" - default: - return "localhost" - } + return utils.SelectAdaptiveLoopbackHost(hasIPv4, hasIPv6) } func selectAdaptiveAnyHost(hasIPv4, hasIPv6 bool) string { - switch { - case hasIPv4 && hasIPv6: - return "::" - case hasIPv6: - return "::" - case hasIPv4: - return "0.0.0.0" - default: - return "::" - } + return utils.SelectAdaptiveAnyHost(hasIPv4, hasIPv6) } func isLoopbackEquivalentHost(host string) bool { @@ -58,63 +32,12 @@ func isLoopbackEquivalentHost(host string) bool { return ip != nil && ip.IsLoopback() } -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 - } - - 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 - } - } - }) - - 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() + return utils.ResolveAdaptiveLoopbackHost() } func resolveDefaultAnyHost() string { - return resolveAdaptiveAnyHost() -} - -func resolveLocalhostLoopbackHost() string { - return resolveAdaptiveLoopbackHost() + return utils.ResolveAdaptiveAnyHost() } func (h *Handler) effectiveLauncherPublic() bool { @@ -141,7 +64,7 @@ func canonicalLauncherBindHost(host string) string { return resolveDefaultLoopbackHost() } if strings.EqualFold(host, "localhost") { - return resolveLocalhostLoopbackHost() + return resolveDefaultLoopbackHost() } trimmed := strings.Trim(host, "[]") if ip := net.ParseIP(trimmed); ip != nil && ip.IsUnspecified() { @@ -207,7 +130,7 @@ func gatewayProbeHost(bindHost string) string { return resolveDefaultLoopbackHost() } if strings.EqualFold(bindHost, "localhost") { - return resolveLocalhostLoopbackHost() + return resolveDefaultLoopbackHost() } trimmed := strings.Trim(bindHost, "[]") diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index 71de515f9..5f3181085 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -7,7 +7,6 @@ import ( "net/http" "net/http/httptest" "path/filepath" - "sync" "testing" "time" @@ -15,12 +14,6 @@ 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) @@ -115,34 +108,6 @@ func TestSelectAdaptiveAnyHost(t *testing.T) { } } -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) { want := resolveDefaultLoopbackHost() if got := gatewayProbeHost("0.0.0.0"); got != want { @@ -158,7 +123,7 @@ func TestGatewayProbeHostUsesPreferredLoopbackForEmptyBind(t *testing.T) { } func TestGatewayProbeHostUsesPreferredLoopbackForLocalhostBind(t *testing.T) { - want := resolveLocalhostLoopbackHost() + want := resolveDefaultLoopbackHost() if got := gatewayProbeHost("localhost"); got != want { t.Fatalf("gatewayProbeHost(localhost) = %q, want %q", got, want) } diff --git a/web/backend/app_runtime.go b/web/backend/app_runtime.go index ab564db2c..674c0d4e6 100644 --- a/web/backend/app_runtime.go +++ b/web/backend/app_runtime.go @@ -34,22 +34,29 @@ func shutdownApp() { apiHandler.Shutdown() } - if server != nil { - // Disable keep-alive to allow graceful shutdown - server.SetKeepAlivesEnabled(false) - + if len(servers) > 0 { ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() - if err := server.Shutdown(ctx); err != nil { - // Context deadline exceeded is expected if there are active connections - // This is not necessarily an error, so log it at info level - if errors.Is(err, context.DeadlineExceeded) { - logger.Infof("Server shutdown timeout after %v, forcing close", shutdownTimeout) - } else { - logger.Errorf("Server shutdown error: %v", err) + + for _, srv := range servers { + if srv == nil { + continue + } + + // Disable keep-alive to allow graceful shutdown + srv.SetKeepAlivesEnabled(false) + + if err := srv.Shutdown(ctx); err != nil { + // Context deadline exceeded is expected if there are active connections + // This is not necessarily an error, so log it at info level + if errors.Is(err, context.DeadlineExceeded) { + logger.Infof("Server shutdown timeout after %v, forcing close", shutdownTimeout) + } else { + logger.Errorf("Server shutdown error: %v", err) + } + } else { + logger.Infof("Server shutdown completed successfully") } - } else { - logger.Infof("Server shutdown completed successfully") } } } diff --git a/web/backend/main.go b/web/backend/main.go index e6cfa2247..6201c130a 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -23,7 +23,6 @@ import ( "path/filepath" "strconv" "strings" - "sync" "syscall" "time" @@ -47,11 +46,7 @@ const ( var ( appVersion = config.Version - launcherIPFamiliesOnce sync.Once - launcherHasIPv4 bool - launcherHasIPv6 bool - - server *http.Server + servers []*http.Server serverAddr string // browserLaunchURL is opened by openBrowser() (auto-open + tray "open console"). // Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use. @@ -61,6 +56,50 @@ var ( noBrowser *bool ) +type launcherBindMode string + +type launcherRuntimeBinding struct { + mode launcherBindMode + host string +} + +const ( + launcherBindModeAutoPrivate launcherBindMode = "auto-private" + launcherBindModeAutoPublic launcherBindMode = "auto-public" + launcherBindModeExplicitLiteral launcherBindMode = "explicit-literal" + launcherBindModeExplicitAdaptiveAny launcherBindMode = "explicit-adaptive-any" + launcherBindModeExplicitAdaptiveLocal launcherBindMode = "explicit-adaptive-localhost" +) + +func parseLauncherHostList(raw string) ([]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("host cannot be empty") + } + + parts := strings.Split(raw, ",") + hosts := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + host := strings.TrimSpace(part) + if host == "" { + return nil, errors.New("host list contains an empty entry") + } + key := strings.ToLower(host) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + hosts = append(hosts, host) + } + + if len(hosts) == 0 { + return nil, errors.New("host cannot be empty") + } + + return hosts, nil +} + func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool { return !enableConsole || debug } @@ -72,86 +111,12 @@ 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) + return utils.ResolveAdaptiveAnyHost() } 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) + return utils.ResolveAdaptiveLoopbackHost() } func normalizeLauncherSpecialHost(host string) string { @@ -159,16 +124,36 @@ func normalizeLauncherSpecialHost(host string) string { if host == "" { return host } - if strings.EqualFold(host, "localhost") { - return resolveDefaultLauncherLoopbackHost() - } - trimmed := strings.Trim(host, "[]") - if ip := net.ParseIP(trimmed); ip != nil && ip.IsUnspecified() { + if host == "*" { return resolveDefaultLauncherAnyHost() } + if strings.EqualFold(host, "localhost") { + return resolveDefaultLauncherPrivateHost() + } + if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil { + return ip.String() + } return host } +func resolveLauncherBindMode(rawHost string, hostExplicit bool, effectivePublic bool) launcherBindMode { + if !hostExplicit { + if effectivePublic { + return launcherBindModeAutoPublic + } + return launcherBindModeAutoPrivate + } + + rawHost = strings.TrimSpace(rawHost) + if rawHost == "*" { + return launcherBindModeExplicitAdaptiveAny + } + if strings.EqualFold(rawHost, "localhost") { + return launcherBindModeExplicitAdaptiveLocal + } + return launcherBindModeExplicitLiteral +} + func resolveLauncherBindHost( host string, explicitHost bool, @@ -243,30 +228,126 @@ func appendUniqueHost(hosts []string, seen map[string]struct{}, host string) []s return append(hosts, host) } -func launcherConsoleHosts(bindHost string, hostExplicit bool, effectivePublic bool) []string { +func launcherConsoleHosts(bindMode launcherBindMode, bindHost string, effectivePublic bool) []string { hosts := make([]string, 0, 6) seen := make(map[string]struct{}, 6) hosts = appendUniqueHost(hosts, seen, "localhost") - if isWildcardBindHost(bindHost) { + switch bindMode { + case launcherBindModeAutoPrivate, launcherBindModeExplicitAdaptiveLocal: 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 + case launcherBindModeAutoPublic, launcherBindModeExplicitAdaptiveAny: + hosts = appendUniqueHost(hosts, seen, "::1") + hosts = appendUniqueHost(hosts, seen, "127.0.0.1") + hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv6()) + hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv4()) + return hosts + case launcherBindModeExplicitLiteral: + trimmed := strings.Trim(strings.TrimSpace(bindHost), "[]") + if ip := net.ParseIP(trimmed); ip != nil { + if ip.IsUnspecified() { + if ip.To4() != nil { + hosts = appendUniqueHost(hosts, seen, "127.0.0.1") + hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv4()) + return hosts + } + hosts = appendUniqueHost(hosts, seen, "::1") + hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv6()) + return hosts + } + hosts = appendUniqueHost(hosts, seen, ip.String()) + return hosts } + } + + if effectivePublic && isWildcardBindHost(bindHost) { + hosts = appendUniqueHost(hosts, seen, "::1") + hosts = appendUniqueHost(hosts, seen, "127.0.0.1") + hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv6()) + hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv4()) return hosts } - if hostExplicit { - hosts = appendUniqueHost(hosts, seen, bindHost) - } + hosts = appendUniqueHost(hosts, seen, bindHost) return hosts } +func openLauncherListener(network, host, port string) (net.Listener, error) { + return net.Listen(network, net.JoinHostPort(host, port)) +} + +func openLauncherPrivateListeners(port string) ([]net.Listener, string, error) { + if ln6, err6 := openLauncherListener("tcp6", "::1", port); err6 == nil { + if ln4, err4 := openLauncherListener("tcp4", "127.0.0.1", port); err4 == nil { + return []net.Listener{ln6, ln4}, "localhost", nil + } + _ = ln6.Close() + } + + if ln6, err := openLauncherListener("tcp6", "::1", port); err == nil { + return []net.Listener{ln6}, "::1", nil + } + + if ln4, err := openLauncherListener("tcp4", "127.0.0.1", port); err == nil { + return []net.Listener{ln4}, "127.0.0.1", nil + } + + return nil, "", fmt.Errorf("failed to open private localhost listener on port %s", port) +} + +func openLauncherAnyListener(port string) ([]net.Listener, string, error) { + // For auto-public and -host=* we intentionally bind :: on "tcp" first. + // Go's compatibility layer will provide dual-stack behavior on environments where it is supported. + if ln, err := openLauncherListener("tcp", "::", port); err == nil { + return []net.Listener{ln}, "::", nil + } + + if ln4, err := openLauncherListener("tcp4", "0.0.0.0", port); err == nil { + return []net.Listener{ln4}, "0.0.0.0", nil + } + + return nil, "", fmt.Errorf("failed to open adaptive any-host listener on port %s", port) +} + +func openLauncherLiteralListener(host, port string) ([]net.Listener, string, error) { + host = strings.TrimSpace(host) + trimmed := strings.Trim(host, "[]") + network := "tcp" + + if ip := net.ParseIP(trimmed); ip != nil { + host = ip.String() + if ip.To4() != nil { + network = "tcp4" + } else { + network = "tcp6" + } + } + + ln, err := openLauncherListener(network, host, port) + if err != nil { + return nil, "", err + } + + return []net.Listener{ln}, host, nil +} + +func openLauncherListeners(mode launcherBindMode, bindHost, port string) ([]net.Listener, string, error) { + switch mode { + case launcherBindModeAutoPrivate, launcherBindModeExplicitAdaptiveLocal: + return openLauncherPrivateListeners(port) + case launcherBindModeAutoPublic, launcherBindModeExplicitAdaptiveAny: + return openLauncherAnyListener(port) + case launcherBindModeExplicitLiteral: + return openLauncherLiteralListener(bindHost, port) + default: + return nil, "", fmt.Errorf("unsupported launcher bind mode: %s", mode) + } +} + // 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 @@ -421,20 +502,47 @@ func main() { } envHost := strings.TrimSpace(os.Getenv(launcherconfig.EnvLauncherHost)) - effectiveHost, effectivePublic, hostExplicit, err := resolveLauncherBindHost( - *host, - explicitHost, - envHost, - effectivePublic, - ) - if err != nil { - logger.Fatalf("Invalid host %q: %v", *host, err) + rawHostInput := strings.TrimSpace(*host) + if !explicitHost { + rawHostInput = envHost } - 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") + hostExplicit := false + effectiveHost := "" + bindMode := launcherBindModeAutoPrivate + bindTargets := make([]launcherRuntimeBinding, 0, 1) + if rawHostInput != "" { + hosts, parseErr := parseLauncherHostList(rawHostInput) + if parseErr != nil { + logger.Fatalf("Invalid host %q: %v", rawHostInput, parseErr) + } + hostExplicit = true + effectivePublic = false + for _, raw := range hosts { + resolvedHost, _, _, resolveErr := resolveLauncherBindHost(raw, true, "", false) + if resolveErr != nil { + logger.Fatalf("Invalid host %q: %v", raw, resolveErr) + } + mode := resolveLauncherBindMode(raw, true, false) + bindTargets = append(bindTargets, launcherRuntimeBinding{mode: mode, host: resolvedHost}) + } + effectiveHost = bindTargets[0].host + bindMode = bindTargets[0].mode + } else { + resolvedHost, resolvedPublic, resolvedExplicit, resolveErr := resolveLauncherBindHost( + "", + false, + "", + effectivePublic, + ) + if resolveErr != nil { + logger.Fatalf("Invalid default host: %v", resolveErr) + } + effectiveHost = resolvedHost + effectivePublic = resolvedPublic + hostExplicit = resolvedExplicit + bindMode = resolveLauncherBindMode("", false, effectivePublic) + bindTargets = append(bindTargets, launcherRuntimeBinding{mode: bindMode, host: effectiveHost}) } if !explicitHost && envHost != "" { @@ -453,6 +561,22 @@ func main() { logger.Fatalf("Invalid port %q: %v", effectivePort, err) } + listeners := make([]net.Listener, 0, len(bindTargets)) + runtimeBindings := make([]launcherRuntimeBinding, 0, len(bindTargets)) + for _, target := range bindTargets { + targetListeners, runtimeHost, listenErr := openLauncherListeners(target.mode, target.host, effectivePort) + if listenErr != nil { + for _, ln := range listeners { + _ = ln.Close() + } + logger.Fatalf("Failed to open launcher listener(s): %v", listenErr) + } + listeners = append(listeners, targetListeners...) + runtimeBindings = append(runtimeBindings, launcherRuntimeBinding{mode: target.mode, host: runtimeHost}) + } + effectiveHost = runtimeBindings[0].host + bindMode = runtimeBindings[0].mode + dashboardToken, dashboardSigningKey, dashboardTokenSource, dashErr := launcherconfig.EnsureDashboardSecrets( launcherCfg, ) @@ -480,9 +604,6 @@ func main() { logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr)) } - // Determine listen address - addr := net.JoinHostPort(effectiveHost, effectivePort) - // Initialize Server components mux := http.NewServeMux() @@ -499,14 +620,18 @@ 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, effectiveAllowedCIDRs) - apiHandler.SetServerBindHost(effectiveHost, hostExplicit) + gatewayHostExplicit := hostExplicit && len(runtimeBindings) == 1 + if hostExplicit && len(runtimeBindings) > 1 { + logger.WarnC("web", "Multiple launcher hosts are configured; gateway host override is disabled for this run") + } + apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) + apiHandler.SetServerBindHost(effectiveHost, gatewayHostExplicit) apiHandler.RegisterRoutes(mux) // Frontend Embedded Assets registerEmbedRoutes(mux) - accessControlledMux, err := middleware.IPAllowlist(effectiveAllowedCIDRs, mux) + accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux) if err != nil { logger.Fatalf("Invalid allowed CIDR configuration: %v", err) } @@ -527,11 +652,19 @@ func main() { // Print startup banner and token (console mode only). if enableConsole || debug { + consoleHosts := make([]string, 0, 8) + consoleSeen := make(map[string]struct{}, 8) + for _, binding := range runtimeBindings { + for _, host := range launcherConsoleHosts(binding.mode, binding.host, effectivePublic) { + consoleHosts = appendUniqueHost(consoleHosts, consoleSeen, host) + } + } + fmt.Print(utils.Banner) fmt.Println() fmt.Println(" Open the following URL in your browser:") fmt.Println() - for _, host := range launcherConsoleHosts(effectiveHost, hostExplicit, effectivePublic) { + for _, host := range consoleHosts { fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort)) } fmt.Println() @@ -558,7 +691,9 @@ func main() { } // Log startup info to file - logger.InfoC("web", fmt.Sprintf("Server will listen on http://%s", net.JoinHostPort(effectiveHost, effectivePort))) + for _, ln := range listeners { + logger.InfoC("web", fmt.Sprintf("Server will listen on http://%s", ln.Addr().String())) + } if isWildcardBindHost(effectiveHost) { if ip := advertiseIPForWildcardBindHost(effectiveHost); ip != "" { logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s", net.JoinHostPort(ip, effectivePort))) @@ -581,14 +716,19 @@ func main() { apiHandler.TryAutoStartGateway() }() - // Start the Server in a goroutine - server = &http.Server{Addr: addr, Handler: handler} - go func() { - logger.InfoC("web", fmt.Sprintf("Server listening on %s", addr)) - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Fatalf("Server failed to start: %v", err) - } - }() + // Start the server(s) in goroutines. + servers = make([]*http.Server, 0, len(listeners)) + for _, ln := range listeners { + srv := &http.Server{Handler: handler} + servers = append(servers, srv) + + go func(s *http.Server, l net.Listener) { + logger.InfoC("web", fmt.Sprintf("Server listening on %s", l.Addr().String())) + if serveErr := s.Serve(l); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + logger.Fatalf("Server failed to start on %s: %v", l.Addr().String(), serveErr) + } + }(srv, ln) + } defer shutdownApp() diff --git a/web/backend/main_test.go b/web/backend/main_test.go index 1ac3f0ccf..47df1c269 100644 --- a/web/backend/main_test.go +++ b/web/backend/main_test.go @@ -96,6 +96,41 @@ func TestMaskSecret(t *testing.T) { } } +func TestParseLauncherHostList(t *testing.T) { + tests := []struct { + name string + raw string + want []string + wantErr bool + }{ + {name: "single host", raw: "127.0.0.1", want: []string{"127.0.0.1"}}, + {name: "multiple hosts", raw: "127.0.0.1, 192.168.2.5", want: []string{"127.0.0.1", "192.168.2.5"}}, + {name: "dedupe hosts", raw: "127.0.0.1,127.0.0.1", want: []string{"127.0.0.1"}}, + {name: "reject empty entry", raw: "127.0.0.1, ", wantErr: true}, + {name: "reject empty input", raw: " ", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseLauncherHostList(tt.raw) + if (err != nil) != tt.wantErr { + t.Fatalf("parseLauncherHostList() err = %v, wantErr %t", err, tt.wantErr) + } + if tt.wantErr { + return + } + if len(got) != len(tt.want) { + t.Fatalf("len(got) = %d, want %d (%#v)", len(got), len(tt.want), got) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("got[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + func TestResolveLauncherBindHost(t *testing.T) { tests := []struct { name string @@ -113,7 +148,7 @@ func TestResolveLauncherBindHost(t *testing.T) { host: "0.0.0.0", explicitHost: true, effectivePub: true, - wantHost: resolveDefaultLauncherAnyHost(), + wantHost: "0.0.0.0", wantPublic: false, wantExplicit: true, }, @@ -139,6 +174,24 @@ func TestResolveLauncherBindHost(t *testing.T) { envHost: "0.0.0.0", explicitHost: false, effectivePub: true, + wantHost: "0.0.0.0", + wantPublic: false, + wantExplicit: true, + }, + { + name: "explicit localhost uses adaptive private host", + host: "localhost", + explicitHost: true, + effectivePub: false, + wantHost: resolveDefaultLauncherPrivateHost(), + wantPublic: false, + wantExplicit: true, + }, + { + name: "explicit star uses adaptive any host", + host: "*", + explicitHost: true, + effectivePub: false, wantHost: resolveDefaultLauncherAnyHost(), wantPublic: false, wantExplicit: true, @@ -190,9 +243,33 @@ func TestResolveLauncherBindHost(t *testing.T) { } } +func TestResolveLauncherBindMode(t *testing.T) { + tests := []struct { + name string + rawHost string + hostExplicit bool + effectivePub bool + wantMode launcherBindMode + }{ + {name: "auto private", rawHost: "", hostExplicit: false, effectivePub: false, wantMode: launcherBindModeAutoPrivate}, + {name: "auto public", rawHost: "", hostExplicit: false, effectivePub: true, wantMode: launcherBindModeAutoPublic}, + {name: "explicit localhost", rawHost: "localhost", hostExplicit: true, effectivePub: false, wantMode: launcherBindModeExplicitAdaptiveLocal}, + {name: "explicit star", rawHost: "*", hostExplicit: true, effectivePub: false, wantMode: launcherBindModeExplicitAdaptiveAny}, + {name: "explicit literal", rawHost: "0.0.0.0", hostExplicit: true, effectivePub: false, wantMode: launcherBindModeExplicitLiteral}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolveLauncherBindMode(tt.rawHost, tt.hostExplicit, tt.effectivePub); got != tt.wantMode { + t.Fatalf("resolveLauncherBindMode() = %q, want %q", got, tt.wantMode) + } + }) + } +} + 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) + t.Run("auto private includes dual loopback hints", func(t *testing.T) { + hosts := launcherConsoleHosts(launcherBindModeAutoPrivate, "localhost", false) seen := make(map[string]bool, len(hosts)) for _, host := range hosts { if seen[host] { @@ -211,8 +288,22 @@ func TestLauncherConsoleHosts(t *testing.T) { } }) + t.Run("explicit ipv4 wildcard excludes ipv6 loopback", func(t *testing.T) { + hosts := launcherConsoleHosts(launcherBindModeExplicitLiteral, "0.0.0.0", false) + seen := make(map[string]bool, len(hosts)) + for _, host := range hosts { + seen[host] = true + } + if seen["::1"] { + t.Fatalf("did not expect ::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) + hosts := launcherConsoleHosts(launcherBindModeExplicitLiteral, "::1", false) if len(hosts) != 2 { t.Fatalf("len(hosts) = %d, want 2 (%#v)", len(hosts), hosts) } diff --git a/web/backend/utils/runtime.go b/web/backend/utils/runtime.go index 7cceff707..9b5516fc1 100644 --- a/web/backend/utils/runtime.go +++ b/web/backend/utils/runtime.go @@ -7,11 +7,91 @@ import ( "os/exec" "path/filepath" "runtime" + "sync" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" ) +var ( + ipFamiliesOnce sync.Once + hasIPv4 bool + hasIPv6 bool +) + +func DetectIPFamilies() (bool, bool) { + ipFamiliesOnce.Do(func() { + if ips, err := net.LookupIP("localhost"); err == nil { + for _, ip := range ips { + if ip == nil { + continue + } + if ip.To4() != nil { + hasIPv4 = true + continue + } + hasIPv6 = true + } + } + + if hasIPv4 && hasIPv6 { + 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 { + hasIPv4 = true + continue + } + hasIPv6 = true + } + } + }) + + return hasIPv4, hasIPv6 +} + +func SelectAdaptiveLoopbackHost(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 SelectAdaptiveAnyHost(hasIPv4, hasIPv6 bool) string { + switch { + case hasIPv4 && hasIPv6: + return "::" + case hasIPv6: + return "::" + case hasIPv4: + return "0.0.0.0" + default: + return "::" + } +} + +func ResolveAdaptiveLoopbackHost() string { + hasIPv4, hasIPv6 := DetectIPFamilies() + return SelectAdaptiveLoopbackHost(hasIPv4, hasIPv6) +} + +func ResolveAdaptiveAnyHost() string { + hasIPv4, hasIPv6 := DetectIPFamilies() + return SelectAdaptiveAnyHost(hasIPv4, hasIPv6) +} + // GetPicoclawHome returns the picoclaw home directory. // Priority: $PICOCLAW_HOME > ~/.picoclaw func GetPicoclawHome() string { diff --git a/web/backend/utils/runtime_test.go b/web/backend/utils/runtime_test.go new file mode 100644 index 000000000..dbcacdc9a --- /dev/null +++ b/web/backend/utils/runtime_test.go @@ -0,0 +1,59 @@ +package utils + +import "testing" + +func TestSelectAdaptiveLoopbackHost(t *testing.T) { + tests := []struct { + name string + hasIPv4 bool + hasIPv6 bool + want string + }{ + {name: "dual stack", 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: "localhost"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SelectAdaptiveLoopbackHost(tt.hasIPv4, tt.hasIPv6); got != tt.want { + t.Fatalf("SelectAdaptiveLoopbackHost(%t, %t) = %q, want %q", tt.hasIPv4, tt.hasIPv6, got, tt.want) + } + }) + } +} + +func TestSelectAdaptiveAnyHost(t *testing.T) { + tests := []struct { + name string + hasIPv4 bool + hasIPv6 bool + want string + }{ + {name: "dual stack", 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 TestResolveAdaptiveHosts(t *testing.T) { + loopback := ResolveAdaptiveLoopbackHost() + if loopback == "" { + t.Fatal("ResolveAdaptiveLoopbackHost() returned empty host") + } + + anyHost := ResolveAdaptiveAnyHost() + if anyHost == "" { + t.Fatal("ResolveAdaptiveAnyHost() returned empty host") + } +} From d4d652b455b3114786047f57ccd54907980bb0d0 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:43:49 +0800 Subject: [PATCH 05/15] feat(host): complete launcher and gateway multi-host binding support - add shared netbind planning for strict tcp4/tcp6 bind semantics - support launcher/gateway host env overrides and launcher-to-gateway forwarding - cover host binding and forwarding with network and subprocess env tests --- cmd/picoclaw/internal/gateway/command.go | 13 +- cmd/picoclaw/internal/gateway/command_test.go | 1 + config/config.example.json | 2 +- pkg/channels/manager.go | 45 +- pkg/config/config.go | 5 +- pkg/config/envkeys.go | 2 +- pkg/config/gateway.go | 123 +--- pkg/config/gateway_host_env_test.go | 30 +- pkg/gateway/gateway.go | 47 +- pkg/gateway/listen.go | 21 + pkg/gateway/listen_test.go | 130 ++++ pkg/netbind/netbind.go | 580 ++++++++++++++++++ pkg/netbind/netbind_test.go | 269 ++++++++ pkg/netbind/socket_v6only_unix.go | 25 + pkg/netbind/socket_v6only_windows.go | 25 + web/backend/api/gateway.go | 18 +- web/backend/api/gateway_host.go | 103 +--- web/backend/api/gateway_host_test.go | 107 +--- web/backend/api/gateway_test.go | 154 +++++ web/backend/api/router.go | 25 +- web/backend/main.go | 387 +++--------- web/backend/main_test.go | 376 +++++------- web/backend/utils/runtime.go | 80 --- web/backend/utils/runtime_test.go | 59 -- 24 files changed, 1625 insertions(+), 1002 deletions(-) create mode 100644 pkg/gateway/listen.go create mode 100644 pkg/gateway/listen_test.go create mode 100644 pkg/netbind/netbind.go create mode 100644 pkg/netbind/netbind_test.go create mode 100644 pkg/netbind/socket_v6only_unix.go create mode 100644 pkg/netbind/socket_v6only_windows.go delete mode 100644 web/backend/utils/runtime_test.go diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go index 5487a20bb..7dd03b495 100644 --- a/cmd/picoclaw/internal/gateway/command.go +++ b/cmd/picoclaw/internal/gateway/command.go @@ -3,7 +3,6 @@ package gateway import ( "fmt" "os" - "strings" "github.com/spf13/cobra" @@ -11,15 +10,19 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/gateway" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/netbind" "github.com/sipeed/picoclaw/pkg/utils" ) func resolveGatewayHostOverride(explicit bool, host string) (string, error) { - host = strings.TrimSpace(host) - if explicit && host == "" { - return "", fmt.Errorf("the --host option cannot be empty") + if !explicit { + return "", nil } - return host, nil + normalized, err := netbind.NormalizeHostInput(host) + if err != nil { + return "", fmt.Errorf("invalid --host value: %w", err) + } + return normalized, nil } func NewGatewayCommand() *cobra.Command { diff --git a/cmd/picoclaw/internal/gateway/command_test.go b/cmd/picoclaw/internal/gateway/command_test.go index b53d5253c..8dc56fc6d 100644 --- a/cmd/picoclaw/internal/gateway/command_test.go +++ b/cmd/picoclaw/internal/gateway/command_test.go @@ -43,6 +43,7 @@ func TestResolveGatewayHostOverride(t *testing.T) { {name: "implicit empty host is allowed", explicit: false, host: "", wantHost: "", wantErr: false}, {name: "explicit empty host rejected", explicit: true, host: " ", wantHost: "", wantErr: true}, {name: "explicit localhost kept", explicit: true, host: " localhost ", wantHost: "localhost", wantErr: false}, + {name: "explicit multi host normalized", explicit: true, host: " [::1] , 127.0.0.1 ", wantHost: "::1,127.0.0.1", wantErr: false}, } for _, tt := range tests { diff --git a/config/config.example.json b/config/config.example.json index f0cce6d72..4c91e9ce5 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -465,7 +465,7 @@ }, "gateway": { "_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.", - "host": "127.0.0.1", + "host": "localhost", "port": 18790, "hot_reload": false, "log_level": "fatal" diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 4d8e47c0f..928676cbc 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "math" + "net" "net/http" "sort" "sync" @@ -86,6 +87,7 @@ type Manager struct { dispatchTask *asyncTask mux *dynamicServeMux httpServer *http.Server + httpListeners []net.Listener mu sync.RWMutex placeholders sync.Map // "channel:chatID" → placeholderID (string) typingStops sync.Map // "channel:chatID" → func() @@ -474,6 +476,12 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error { // It registers health endpoints from the health server and discovers channels // that implement WebhookHandler and/or HealthChecker to register their handlers. func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) { + m.SetupHTTPServerListeners(nil, addr, healthServer) +} + +// SetupHTTPServerListeners creates a shared HTTP server on pre-opened listeners. +// When listeners is empty it falls back to Addr-based ListenAndServe behavior. +func (m *Manager) SetupHTTPServerListeners(listeners []net.Listener, addr string, healthServer *health.Server) { m.mux = newDynamicServeMux() // Register health endpoints @@ -490,6 +498,7 @@ func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) { ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, } + m.httpListeners = append([]net.Listener(nil), listeners...) } // registerHTTPHandlersLocked registers webhook and health-check handlers for @@ -619,16 +628,33 @@ func (m *Manager) StartAll(ctx context.Context) error { // Start shared HTTP server if configured if m.httpServer != nil { - go func() { - logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{ - "addr": m.httpServer.Addr, - }) - if err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.FatalCF("channels", "Shared HTTP server error", map[string]any{ - "error": err.Error(), - }) + if len(m.httpListeners) > 0 { + for _, listener := range m.httpListeners { + ln := listener + go func() { + logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{ + "addr": ln.Addr().String(), + }) + if err := m.httpServer.Serve(ln); err != nil && err != http.ErrServerClosed { + logger.FatalCF("channels", "Shared HTTP server error", map[string]any{ + "addr": ln.Addr().String(), + "error": err.Error(), + }) + } + }() } - }() + } else { + go func() { + logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{ + "addr": m.httpServer.Addr, + }) + if err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.FatalCF("channels", "Shared HTTP server error", map[string]any{ + "error": err.Error(), + }) + } + }() + } } logger.InfoCF("channels", "Channel startup completed", map[string]any{ @@ -655,6 +681,7 @@ func (m *Manager) StopAll(ctx context.Context) error { }) } m.httpServer = nil + m.httpListeners = nil } // Cancel dispatcher diff --git a/pkg/config/config.go b/pkg/config/config.go index 07e52de97..73116b039 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1082,7 +1082,10 @@ func LoadConfig(path string) (*Config, error) { if err = InitChannelList(cfg.Channels); err != nil { return nil, err } - cfg.Gateway.Host = resolveGatewayHostFromEnv(gatewayHostBeforeEnv) + cfg.Gateway.Host, err = resolveGatewayHostFromEnv(gatewayHostBeforeEnv) + if err != nil { + return nil, fmt.Errorf("invalid gateway host: %w", err) + } // Expand multi-key configs into separate entries for key-level failover cfg.ModelList = expandMultiKeyModels(cfg.ModelList) diff --git a/pkg/config/envkeys.go b/pkg/config/envkeys.go index 615769d3c..5a2590299 100644 --- a/pkg/config/envkeys.go +++ b/pkg/config/envkeys.go @@ -39,7 +39,7 @@ const ( EnvBinary = "PICOCLAW_BINARY" // EnvGatewayHost overrides the host address for the gateway server. - // Default: "127.0.0.1" + // Default: "localhost" EnvGatewayHost = "PICOCLAW_GATEWAY_HOST" ) diff --git a/pkg/config/gateway.go b/pkg/config/gateway.go index b3aa70e4b..392a4ca5e 100644 --- a/pkg/config/gateway.go +++ b/pkg/config/gateway.go @@ -2,12 +2,11 @@ package config import ( "encoding/json" - "net" "os" "strings" - "sync" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/netbind" ) const DefaultGatewayLogLevel = "warn" @@ -52,119 +51,29 @@ 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 == "" { - host = strings.TrimSpace(DefaultConfig().Gateway.Host) - } - - if host == "" { - host = "localhost" - } - - 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 { +func resolveGatewayHostFromEnv(baseHost string) (string, error) { envHost, ok := os.LookupEnv(EnvGatewayHost) if !ok { - return normalizeGatewayHost(baseHost) + return normalizeGatewayHostInput(baseHost) } envHost = strings.TrimSpace(envHost) if envHost == "" { - return normalizeGatewayHost(baseHost) + return normalizeGatewayHostInput(baseHost) } - return normalizeGatewayHost(envHost) + return normalizeGatewayHostInput(envHost) +} + +func normalizeGatewayHostInput(host string) (string, error) { + host = strings.TrimSpace(host) + if host == "" { + host = strings.TrimSpace(DefaultConfig().Gateway.Host) + } + if host == "" { + host = "localhost" + } + return netbind.NormalizeHostInput(host) } // 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 5a75f4e33..40fabb1a3 100644 --- a/pkg/config/gateway_host_env_test.go +++ b/pkg/config/gateway_host_env_test.go @@ -39,7 +39,10 @@ func TestLoadConfig_GatewayHostBlankEnvFallsBackToConfigHost(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error: %v", err) } - want := normalizeGatewayHost("localhost") + want, err := normalizeGatewayHostInput("localhost") + if err != nil { + t.Fatalf("normalizeGatewayHostInput() error: %v", err) + } if cfg.Gateway.Host != want { t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, want) } @@ -54,13 +57,16 @@ func TestLoadConfig_GatewayHostBlankEnvAndConfigFallsBackToDefault(t *testing.T) t.Fatalf("LoadConfig() error: %v", err) } - defaultHost := normalizeGatewayHost(DefaultConfig().Gateway.Host) + defaultHost, err := normalizeGatewayHostInput(DefaultConfig().Gateway.Host) + if err != nil { + t.Fatalf("normalizeGatewayHostInput() error: %v", err) + } if cfg.Gateway.Host != defaultHost { t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, defaultHost) } } -func TestLoadConfig_GatewayHostEnvWildcardUsesAdaptiveAnyHost(t *testing.T) { +func TestLoadConfig_GatewayHostEnvPreservesExplicitWildcardHost(t *testing.T) { configPath := writeGatewayHostTestConfig(t, "localhost") t.Setenv(EnvGatewayHost, " 0.0.0.0 ") @@ -69,8 +75,24 @@ func TestLoadConfig_GatewayHostEnvWildcardUsesAdaptiveAnyHost(t *testing.T) { t.Fatalf("LoadConfig() error: %v", err) } - want := normalizeGatewayHost("0.0.0.0") + want, err := normalizeGatewayHostInput("0.0.0.0") + if err != nil { + t.Fatalf("normalizeGatewayHostInput() error: %v", err) + } if cfg.Gateway.Host != want { t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, want) } } + +func TestLoadConfig_GatewayHostEnvNormalizesMultiHostInput(t *testing.T) { + configPath := writeGatewayHostTestConfig(t, "localhost") + t.Setenv(EnvGatewayHost, " [::1] , 127.0.0.1 , ::1 ") + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Gateway.Host != "::1,127.0.0.1" { + t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, "::1,127.0.0.1") + } +} diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 363b20e97..79c86fa96 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -44,6 +44,7 @@ import ( "github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/netbind" "github.com/sipeed/picoclaw/pkg/pid" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/state" @@ -161,13 +162,30 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr logger.Infof("Log level set to %q", effectiveLogLevel) } + bindPlan, listenResult, err := openGatewayListeners(cfg.Gateway.Host, cfg.Gateway.Port) + if err != nil { + return fmt.Errorf("error opening gateway listeners: %w", err) + } + // Enforce singleton: write PID file with generated token. - pidData, err := pid.WritePidFile(homePath, cfg.Gateway.Host, cfg.Gateway.Port) + pidData, err := pid.WritePidFile(homePath, bindPlan.ProbeHost, cfg.Gateway.Port) if err != nil { logger.Warnf("write pid file failed: %v", err) + for _, ln := range listenResult.Listeners { + _ = ln.Close() + } return fmt.Errorf("singleton check failed: %w", err) } defer pid.RemovePidFile(homePath) + closeListeners := true + defer func() { + if !closeListeners { + return + } + for _, ln := range listenResult.Listeners { + _ = ln.Close() + } + }() provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup) if err != nil { @@ -195,10 +213,11 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr "skills_available": skillsInfo["available"], }) - runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus, pidData.Token) + runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus, pidData.Token, listenResult) if err != nil { return err } + closeListeners = false // Setup manual reload channel for /reload endpoint manualReloadChan := make(chan struct{}, 1) @@ -219,8 +238,9 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr runningServices.HealthServer.SetReloadFunc(reloadTrigger) agentLoop.SetReloadFunc(reloadTrigger) - listenAddr := net.JoinHostPort(cfg.Gateway.Host, strconv.Itoa(cfg.Gateway.Port)) - fmt.Printf("✓ Gateway started on %s\n", listenAddr) + for _, bindHost := range listenResult.BindHosts { + fmt.Printf("✓ Gateway started on %s\n", net.JoinHostPort(bindHost, strconv.Itoa(cfg.Gateway.Port))) + } fmt.Println("Press Ctrl+C to stop") ctx, cancel := context.WithCancel(context.Background()) @@ -323,6 +343,7 @@ func setupAndStartServices( agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, authToken string, + listenResult netbind.OpenResult, ) (*services, error) { runningServices := &services{} @@ -393,10 +414,20 @@ func setupAndStartServices( fmt.Println("⚠ Warning: No channels enabled") } - 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) + runningServices.HealthServer = health.NewServer(listenResult.ProbeHost, cfg.Gateway.Port, authToken) + + listenAddr := "" + if len(listenResult.Listeners) > 0 { + listenAddr = listenResult.Listeners[0].Addr().String() + } else { + listenAddr = net.JoinHostPort(listenResult.ProbeHost, strconv.Itoa(cfg.Gateway.Port)) + } + runningServices.ChannelManager.SetupHTTPServerListeners( + listenResult.Listeners, + listenAddr, + runningServices.HealthServer, + ) if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil { return nil, fmt.Errorf("error starting channels: %w", err) @@ -412,7 +443,7 @@ func setupAndStartServices( voiceAgent.Start(vaCtx) } - healthAddr := net.JoinHostPort(cfg.Gateway.Host, strconv.Itoa(cfg.Gateway.Port)) + healthAddr := net.JoinHostPort(listenResult.ProbeHost, strconv.Itoa(cfg.Gateway.Port)) fmt.Printf( "✓ Health endpoints available at http://%s/health, /ready and /reload (POST)\n", healthAddr, diff --git a/pkg/gateway/listen.go b/pkg/gateway/listen.go new file mode 100644 index 000000000..99be63096 --- /dev/null +++ b/pkg/gateway/listen.go @@ -0,0 +1,21 @@ +package gateway + +import ( + "strconv" + + "github.com/sipeed/picoclaw/pkg/netbind" +) + +func openGatewayListeners(host string, port int) (netbind.Plan, netbind.OpenResult, error) { + plan, err := netbind.BuildPlan(host, netbind.DefaultLoopback) + if err != nil { + return netbind.Plan{}, netbind.OpenResult{}, err + } + + result, err := netbind.OpenPlan(plan, strconv.Itoa(port)) + if err != nil { + return netbind.Plan{}, netbind.OpenResult{}, err + } + + return plan, result, nil +} diff --git a/pkg/gateway/listen_test.go b/pkg/gateway/listen_test.go new file mode 100644 index 000000000..9b932f852 --- /dev/null +++ b/pkg/gateway/listen_test.go @@ -0,0 +1,130 @@ +package gateway + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "strconv" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/netbind" +) + +func TestOpenGatewayListeners_HonorsIPv6OnlyHost(t *testing.T) { + hasIPv4, hasIPv6 := netbind.DetectIPFamilies() + if !hasIPv6 { + t.Skip("IPv6 is unavailable in this environment") + } + + _, result, err := openGatewayListeners("::", 0) + if err != nil { + t.Fatalf("openGatewayListeners() error = %v", err) + } + startGatewayTestHTTPServer(t, result.Listeners) + port := mustGatewayAtoi(t, result.Port) + + requireGatewayHTTPReachable(t, "::1", port) + if hasIPv4 { + requireGatewayHTTPUnreachable(t, "127.0.0.1", port) + } +} + +func TestOpenGatewayListeners_SupportsExplicitMultiHost(t *testing.T) { + hasIPv4, hasIPv6 := netbind.DetectIPFamilies() + if !hasIPv4 || !hasIPv6 { + t.Skip("dual-stack loopback is unavailable in this environment") + } + + _, result, err := openGatewayListeners("127.0.0.1,::1", 0) + if err != nil { + t.Fatalf("openGatewayListeners() error = %v", err) + } + startGatewayTestHTTPServer(t, result.Listeners) + port := mustGatewayAtoi(t, result.Port) + + requireGatewayHTTPReachable(t, "127.0.0.1", port) + requireGatewayHTTPReachable(t, "::1", port) +} + +func startGatewayTestHTTPServer(t *testing.T, listeners []net.Listener) { + t.Helper() + + server := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok") + }), + } + + errCh := make(chan error, len(listeners)) + for _, listener := range listeners { + ln := listener + go func() { + errCh <- server.Serve(ln) + }() + } + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + for range listeners { + err := <-errCh + if err != nil && !errors.Is(err, http.ErrServerClosed) { + t.Fatalf("server.Serve() error = %v", err) + } + } + }) +} + +func requireGatewayHTTPReachable(t *testing.T, host string, port int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + err := gatewayHTTPGet(host, port) + if err == nil { + return + } + if time.Now().After(deadline) { + t.Fatalf("expected %s:%d to be reachable: %v", host, port, err) + } + time.Sleep(50 * time.Millisecond) + } +} + +func requireGatewayHTTPUnreachable(t *testing.T, host string, port int) { + t.Helper() + if err := gatewayHTTPGet(host, port); err == nil { + t.Fatalf("expected %s:%d to be unreachable", host, port) + } +} + +func gatewayHTTPGet(host string, port int) error { + client := &http.Client{ + Timeout: 300 * time.Millisecond, + Transport: &http.Transport{ + Proxy: nil, + }, + } + + resp, err := client.Get("http://" + net.JoinHostPort(host, strconv.Itoa(port))) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New(resp.Status) + } + return nil +} + +func mustGatewayAtoi(t *testing.T, value string) int { + t.Helper() + n, err := strconv.Atoi(value) + if err != nil { + t.Fatalf("Atoi(%q) error = %v", value, err) + } + return n +} diff --git a/pkg/netbind/netbind.go b/pkg/netbind/netbind.go new file mode 100644 index 000000000..7f6121f28 --- /dev/null +++ b/pkg/netbind/netbind.go @@ -0,0 +1,580 @@ +package netbind + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "strings" + "sync" +) + +type DefaultMode int + +const ( + DefaultLoopback DefaultMode = iota + DefaultAny +) + +type groupKind int + +const ( + groupAdaptiveLoopback groupKind = iota + groupAdaptiveAny + groupExact +) + +type exactBinding struct { + host string + network string + v6Only bool +} + +type bindGroup struct { + kind groupKind + allowIPv4 bool + allowIPv6 bool + exact exactBinding +} + +type Plan struct { + groups []bindGroup + ProbeHost string +} + +type OpenResult struct { + Listeners []net.Listener + BindHosts []string + Port string + ProbeHost string +} + +type tokenKind int + +const ( + tokenName tokenKind = iota + tokenLocalhost + tokenStar + tokenIPv4 + tokenIPv6 + tokenIPv4Any + tokenIPv6Any +) + +type hostToken struct { + kind tokenKind + canonical string + key string +} + +var ( + ipFamiliesOnce sync.Once + hasIPv4 bool + hasIPv6 bool +) + +func DetectIPFamilies() (bool, bool) { + ipFamiliesOnce.Do(func() { + if ips, err := net.LookupIP("localhost"); err == nil { + for _, ip := range ips { + if ip == nil { + continue + } + if ip.To4() != nil { + hasIPv4 = true + continue + } + hasIPv6 = true + } + } + + if hasIPv4 && hasIPv6 { + 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 { + hasIPv4 = true + continue + } + hasIPv6 = true + } + } + }) + + return hasIPv4, hasIPv6 +} + +func SelectAdaptiveLoopbackHost(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 SelectAdaptiveAnyHost(hasIPv4, hasIPv6 bool) string { + switch { + case hasIPv4 && hasIPv6: + return "::" + case hasIPv6: + return "::" + case hasIPv4: + return "0.0.0.0" + default: + return "::" + } +} + +func ResolveAdaptiveLoopbackHost() string { + hasIPv4, hasIPv6 := DetectIPFamilies() + return SelectAdaptiveLoopbackHost(hasIPv4, hasIPv6) +} + +func ResolveAdaptiveAnyHost() string { + hasIPv4, hasIPv6 := DetectIPFamilies() + return SelectAdaptiveAnyHost(hasIPv4, hasIPv6) +} + +func IsLoopbackHost(host string) bool { + host = strings.TrimSpace(host) + if host == "" { + return false + } + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(strings.Trim(host, "[]")) + return ip != nil && ip.IsLoopback() +} + +func IsUnspecifiedHost(host string) bool { + host = strings.TrimSpace(host) + if host == "" { + return false + } + ip := net.ParseIP(strings.Trim(host, "[]")) + return ip != nil && ip.IsUnspecified() +} + +func NormalizeHostInput(raw string) (string, error) { + tokens, err := parseHostTokens(raw) + if err != nil { + return "", err + } + + parts := make([]string, 0, len(tokens)) + for _, token := range tokens { + parts = append(parts, token.canonical) + } + return strings.Join(parts, ","), nil +} + +func BuildPlan(raw string, defaultMode DefaultMode) (Plan, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return buildDefaultPlan(defaultMode), nil + } + + tokens, err := parseHostTokens(raw) + if err != nil { + return Plan{}, err + } + + for _, token := range tokens { + if token.kind == tokenStar { + return Plan{ + groups: []bindGroup{{kind: groupAdaptiveAny}}, + ProbeHost: ResolveAdaptiveLoopbackHost(), + }, nil + } + } + + hasIPv4Any := false + hasIPv6Any := false + for _, token := range tokens { + switch token.kind { + case tokenIPv4Any: + hasIPv4Any = true + case tokenIPv6Any: + hasIPv6Any = true + } + } + + allowLocalhostIPv4 := !hasIPv4Any + allowLocalhostIPv6 := !hasIPv6Any + + groups := make([]bindGroup, 0, len(tokens)) + seenExact := make(map[string]struct{}, len(tokens)) + addedLocalhost := false + + for _, token := range tokens { + switch token.kind { + case tokenLocalhost: + if addedLocalhost || (!allowLocalhostIPv4 && !allowLocalhostIPv6) { + continue + } + groups = append(groups, bindGroup{ + kind: groupAdaptiveLoopback, + allowIPv4: allowLocalhostIPv4, + allowIPv6: allowLocalhostIPv6, + }) + addedLocalhost = true + case tokenIPv4Any: + key := "exact:tcp4:0.0.0.0" + if _, ok := seenExact[key]; ok { + continue + } + seenExact[key] = struct{}{} + groups = append(groups, bindGroup{ + kind: groupExact, + exact: exactBinding{ + host: "0.0.0.0", + network: "tcp4", + }, + }) + case tokenIPv6Any: + key := "exact:tcp6:::" + if _, ok := seenExact[key]; ok { + continue + } + seenExact[key] = struct{}{} + groups = append(groups, bindGroup{ + kind: groupExact, + exact: exactBinding{ + host: "::", + network: "tcp6", + v6Only: true, + }, + }) + case tokenIPv4: + if hasIPv4Any { + continue + } + key := "exact:tcp4:" + strings.ToLower(token.canonical) + if _, ok := seenExact[key]; ok { + continue + } + seenExact[key] = struct{}{} + groups = append(groups, bindGroup{ + kind: groupExact, + exact: exactBinding{ + host: token.canonical, + network: "tcp4", + }, + }) + case tokenIPv6: + if hasIPv6Any { + continue + } + key := "exact:tcp6:" + strings.ToLower(token.canonical) + if _, ok := seenExact[key]; ok { + continue + } + seenExact[key] = struct{}{} + groups = append(groups, bindGroup{ + kind: groupExact, + exact: exactBinding{ + host: token.canonical, + network: "tcp6", + v6Only: true, + }, + }) + case tokenName: + key := "exact:tcp:" + token.key + if _, ok := seenExact[key]; ok { + continue + } + seenExact[key] = struct{}{} + groups = append(groups, bindGroup{ + kind: groupExact, + exact: exactBinding{ + host: token.canonical, + network: "tcp", + }, + }) + } + } + + plan := Plan{groups: groups} + plan.ProbeHost = probeHostForGroups(groups) + return plan, nil +} + +func OpenPlan(plan Plan, port string) (OpenResult, error) { + if port == "" { + return OpenResult{}, errors.New("port cannot be empty") + } + + selectedPort := port + listeners := make([]net.Listener, 0, len(plan.groups)) + bindHosts := make([]string, 0, len(plan.groups)) + bindSeen := make(map[string]struct{}, len(plan.groups)) + + closeAll := func() { + for _, ln := range listeners { + _ = ln.Close() + } + } + + for _, group := range plan.groups { + groupListeners, groupHosts, actualPort, err := openGroup(group, selectedPort) + if err != nil { + closeAll() + return OpenResult{}, err + } + if selectedPort == "0" && actualPort != "" { + selectedPort = actualPort + } + listeners = append(listeners, groupListeners...) + for _, host := range groupHosts { + key := strings.ToLower(host) + if _, ok := bindSeen[key]; ok { + continue + } + bindSeen[key] = struct{}{} + bindHosts = append(bindHosts, host) + } + } + + return OpenResult{ + Listeners: listeners, + BindHosts: bindHosts, + Port: selectedPort, + ProbeHost: plan.ProbeHost, + }, nil +} + +func buildDefaultPlan(defaultMode DefaultMode) Plan { + switch defaultMode { + case DefaultAny: + return Plan{ + groups: []bindGroup{{kind: groupAdaptiveAny}}, + ProbeHost: ResolveAdaptiveLoopbackHost(), + } + default: + return Plan{ + groups: []bindGroup{{ + kind: groupAdaptiveLoopback, + allowIPv4: true, + allowIPv6: true, + }}, + ProbeHost: ResolveAdaptiveLoopbackHost(), + } + } +} + +func probeHostForGroups(groups []bindGroup) string { + hasIPv4Any := false + hasIPv6Any := false + for _, group := range groups { + if group.kind == groupAdaptiveLoopback { + switch { + case group.allowIPv4 && group.allowIPv6: + return ResolveAdaptiveLoopbackHost() + case group.allowIPv6: + return "::1" + case group.allowIPv4: + return "127.0.0.1" + } + } + if group.kind == groupAdaptiveAny { + return ResolveAdaptiveLoopbackHost() + } + if group.kind != groupExact { + continue + } + switch group.exact.host { + case "0.0.0.0": + hasIPv4Any = true + case "::": + hasIPv6Any = true + } + } + + switch { + case hasIPv4Any && hasIPv6Any: + return ResolveAdaptiveLoopbackHost() + case hasIPv6Any: + return "::1" + case hasIPv4Any: + return "127.0.0.1" + } + + for _, group := range groups { + if group.kind == groupExact { + return group.exact.host + } + } + return ResolveAdaptiveLoopbackHost() +} + +func parseHostTokens(raw string) ([]hostToken, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("host cannot be empty") + } + + parts := strings.Split(raw, ",") + tokens := make([]hostToken, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + token, err := parseHostToken(part) + if err != nil { + return nil, err + } + if _, ok := seen[token.key]; ok { + continue + } + seen[token.key] = struct{}{} + tokens = append(tokens, token) + } + + if len(tokens) == 0 { + return nil, errors.New("host cannot be empty") + } + + return tokens, nil +} + +func parseHostToken(raw string) (hostToken, error) { + host := strings.TrimSpace(raw) + if host == "" { + return hostToken{}, errors.New("host list contains an empty entry") + } + + if host == "*" { + return hostToken{kind: tokenStar, canonical: "*", key: "*"}, nil + } + if strings.EqualFold(host, "localhost") { + return hostToken{kind: tokenLocalhost, canonical: "localhost", key: "localhost"}, nil + } + + trimmed := strings.Trim(host, "[]") + if ip := net.ParseIP(trimmed); ip != nil { + if ip4 := ip.To4(); ip4 != nil { + canonical := ip4.String() + kind := tokenIPv4 + if ip4.IsUnspecified() { + kind = tokenIPv4Any + } + return hostToken{kind: kind, canonical: canonical, key: canonical}, nil + } + + canonical := ip.String() + kind := tokenIPv6 + if ip.IsUnspecified() { + kind = tokenIPv6Any + } + return hostToken{kind: kind, canonical: canonical, key: strings.ToLower(canonical)}, nil + } + + return hostToken{ + kind: tokenName, + canonical: host, + key: strings.ToLower(host), + }, nil +} + +func openGroup(group bindGroup, port string) ([]net.Listener, []string, string, error) { + switch group.kind { + case groupAdaptiveLoopback: + return openAdaptiveLoopbackGroup(group.allowIPv6, group.allowIPv4, port) + case groupAdaptiveAny: + return openAdaptiveAnyGroup(port) + case groupExact: + ln, actualPort, err := openExactListener(group.exact, port) + if err != nil { + return nil, nil, "", err + } + return []net.Listener{ln}, []string{group.exact.host}, actualPort, nil + default: + return nil, nil, "", fmt.Errorf("unsupported bind group kind: %d", group.kind) + } +} + +func openAdaptiveLoopbackGroup(allowIPv6, allowIPv4 bool, port string) ([]net.Listener, []string, string, error) { + if allowIPv6 && allowIPv4 { + if ln6, actualPort, err6 := openExactListener(exactBinding{host: "::1", network: "tcp6", v6Only: true}, port); err6 == nil { + if ln4, _, err4 := openExactListener(exactBinding{host: "127.0.0.1", network: "tcp4"}, actualPort); err4 == nil { + return []net.Listener{ln6, ln4}, []string{"::1", "127.0.0.1"}, actualPort, nil + } + _ = ln6.Close() + } + } + + if allowIPv6 { + ln6, actualPort, err := openExactListener(exactBinding{host: "::1", network: "tcp6", v6Only: true}, port) + if err == nil { + return []net.Listener{ln6}, []string{"::1"}, actualPort, nil + } + } + + if allowIPv4 { + ln4, actualPort, err := openExactListener(exactBinding{host: "127.0.0.1", network: "tcp4"}, port) + if err == nil { + return []net.Listener{ln4}, []string{"127.0.0.1"}, actualPort, nil + } + } + + return nil, nil, "", fmt.Errorf("failed to open adaptive localhost listener on port %s", port) +} + +func openAdaptiveAnyGroup(port string) ([]net.Listener, []string, string, error) { + // Intentionally bind tcp/:: here. Go's compatibility layer handles dual-stack + // wildcard binding where the platform supports it, while tcp4 remains the + // fallback for IPv4-only environments. + if ln, actualPort, err := openExactListener(exactBinding{host: "::", network: "tcp"}, port); err == nil { + return []net.Listener{ln}, []string{"::"}, actualPort, nil + } + + ln4, actualPort, err := openExactListener(exactBinding{host: "0.0.0.0", network: "tcp4"}, port) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to open adaptive any-host listener on port %s", port) + } + return []net.Listener{ln4}, []string{"0.0.0.0"}, actualPort, nil +} + +func openExactListener(binding exactBinding, port string) (net.Listener, string, error) { + listenConfig := net.ListenConfig{} + if binding.network == "tcp6" && binding.v6Only { + listenConfig.Control = applyIPv6OnlyControl(true) + } + + ln, err := listenConfig.Listen(context.Background(), binding.network, net.JoinHostPort(binding.host, port)) + if err != nil { + return nil, "", err + } + + actualPort, err := listenerPort(ln) + if err != nil { + _ = ln.Close() + return nil, "", err + } + + return ln, actualPort, nil +} + +func listenerPort(ln net.Listener) (string, error) { + addr, ok := ln.Addr().(*net.TCPAddr) + if ok { + return strconv.Itoa(addr.Port), nil + } + + _, port, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + return "", err + } + return port, nil +} diff --git a/pkg/netbind/netbind_test.go b/pkg/netbind/netbind_test.go new file mode 100644 index 000000000..bfb524ac8 --- /dev/null +++ b/pkg/netbind/netbind_test.go @@ -0,0 +1,269 @@ +package netbind + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "strconv" + "testing" + "time" +) + +func TestNormalizeHostInput(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr bool + }{ + {name: "single host", raw: "127.0.0.1", want: "127.0.0.1"}, + {name: "trim and dedupe", raw: " [::1] , ::1 , 127.0.0.1 ", want: "::1,127.0.0.1"}, + {name: "star preserved", raw: "*,127.0.0.1", want: "*,127.0.0.1"}, + {name: "reject empty", raw: "127.0.0.1, ", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeHostInput(tt.raw) + if (err != nil) != tt.wantErr { + t.Fatalf("NormalizeHostInput() err = %v, wantErr %t", err, tt.wantErr) + } + if tt.wantErr { + return + } + if got != tt.want { + t.Fatalf("NormalizeHostInput() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildPlan_DefaultAnyUsesLoopbackProbe(t *testing.T) { + plan, err := BuildPlan("", DefaultAny) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + if plan.ProbeHost != ResolveAdaptiveLoopbackHost() { + t.Fatalf("ProbeHost = %q, want %q", plan.ProbeHost, ResolveAdaptiveLoopbackHost()) + } +} + +func TestOpenPlan_LocalhostSupportsLoopbackCommunication(t *testing.T) { + hasIPv4, hasIPv6 := DetectIPFamilies() + + plan, err := BuildPlan("localhost", DefaultLoopback) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + result, err := OpenPlan(plan, "0") + if err != nil { + t.Fatalf("OpenPlan() error = %v", err) + } + startTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + if hasIPv6 { + requireHTTPReachable(t, "::1", port) + } + if hasIPv4 { + requireHTTPReachable(t, "127.0.0.1", port) + } +} + +func TestOpenPlan_DefaultAnySupportsDualStackLoopback(t *testing.T) { + hasIPv4, hasIPv6 := DetectIPFamilies() + + plan, err := BuildPlan("", DefaultAny) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + result, err := OpenPlan(plan, "0") + if err != nil { + t.Fatalf("OpenPlan() error = %v", err) + } + startTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + if hasIPv6 { + requireHTTPReachable(t, "::1", port) + } + if hasIPv4 { + requireHTTPReachable(t, "127.0.0.1", port) + } +} + +func TestOpenPlan_ExplicitIPv6AnyIsIPv6Only(t *testing.T) { + hasIPv4, hasIPv6 := DetectIPFamilies() + if !hasIPv6 { + t.Skip("IPv6 is unavailable in this environment") + } + + plan, err := BuildPlan("::", DefaultLoopback) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + result, err := OpenPlan(plan, "0") + if err != nil { + t.Fatalf("OpenPlan() error = %v", err) + } + startTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + requireHTTPReachable(t, "::1", port) + if hasIPv4 { + requireHTTPUnreachable(t, "127.0.0.1", port) + } +} + +func TestOpenPlan_ExplicitIPv4AnyIsIPv4Only(t *testing.T) { + hasIPv4, hasIPv6 := DetectIPFamilies() + if !hasIPv4 { + t.Skip("IPv4 is unavailable in this environment") + } + + plan, err := BuildPlan("0.0.0.0", DefaultLoopback) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + result, err := OpenPlan(plan, "0") + if err != nil { + t.Fatalf("OpenPlan() error = %v", err) + } + startTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + requireHTTPReachable(t, "127.0.0.1", port) + if hasIPv6 { + requireHTTPUnreachable(t, "::1", port) + } +} + +func TestOpenPlan_MultiHostSupportsExplicitIPv4AndIPv6(t *testing.T) { + hasIPv4, hasIPv6 := DetectIPFamilies() + if !hasIPv4 || !hasIPv6 { + t.Skip("dual-stack loopback is unavailable in this environment") + } + + plan, err := BuildPlan("127.0.0.1,::1", DefaultLoopback) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + result, err := OpenPlan(plan, "0") + if err != nil { + t.Fatalf("OpenPlan() error = %v", err) + } + startTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + requireHTTPReachable(t, "127.0.0.1", port) + requireHTTPReachable(t, "::1", port) +} + +func TestOpenPlan_WildcardRulesKeepIPv4AndIPv6AnyHosts(t *testing.T) { + hasIPv4, hasIPv6 := DetectIPFamilies() + if !hasIPv4 || !hasIPv6 { + t.Skip("dual-stack loopback is unavailable in this environment") + } + + plan, err := BuildPlan("::,::1,0.0.0.0,127.0.0.1", DefaultLoopback) + if err != nil { + t.Fatalf("BuildPlan() error = %v", err) + } + result, err := OpenPlan(plan, "0") + if err != nil { + t.Fatalf("OpenPlan() error = %v", err) + } + startTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + requireHTTPReachable(t, "127.0.0.1", port) + requireHTTPReachable(t, "::1", port) + if len(result.BindHosts) != 2 { + t.Fatalf("len(BindHosts) = %d, want 2 (%#v)", len(result.BindHosts), result.BindHosts) + } +} + +func startTestHTTPServer(t *testing.T, listeners []net.Listener) { + t.Helper() + + server := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok") + }), + } + + errCh := make(chan error, len(listeners)) + for _, listener := range listeners { + ln := listener + go func() { + errCh <- server.Serve(ln) + }() + } + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + for range listeners { + err := <-errCh + if err != nil && !errors.Is(err, http.ErrServerClosed) { + t.Fatalf("server.Serve() error = %v", err) + } + } + }) +} + +func requireHTTPReachable(t *testing.T, host string, port int) { + t.Helper() + + deadline := time.Now().Add(2 * time.Second) + for { + err := httpGET(host, port) + if err == nil { + return + } + if time.Now().After(deadline) { + t.Fatalf("expected %s:%d to be reachable: %v", host, port, err) + } + time.Sleep(50 * time.Millisecond) + } +} + +func requireHTTPUnreachable(t *testing.T, host string, port int) { + t.Helper() + + if err := httpGET(host, port); err == nil { + t.Fatalf("expected %s:%d to be unreachable", host, port) + } +} + +func httpGET(host string, port int) error { + client := &http.Client{ + Timeout: 300 * time.Millisecond, + Transport: &http.Transport{ + Proxy: nil, + }, + } + + resp, err := client.Get("http://" + net.JoinHostPort(host, strconv.Itoa(port))) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errors.New(resp.Status) + } + return nil +} + +func mustAtoi(t *testing.T, value string) int { + t.Helper() + n, err := strconv.Atoi(value) + if err != nil { + t.Fatalf("Atoi(%q) error = %v", value, err) + } + return n +} diff --git a/pkg/netbind/socket_v6only_unix.go b/pkg/netbind/socket_v6only_unix.go new file mode 100644 index 000000000..20cf7bbce --- /dev/null +++ b/pkg/netbind/socket_v6only_unix.go @@ -0,0 +1,25 @@ +//go:build !windows + +package netbind + +import ( + "syscall" + + "golang.org/x/sys/unix" +) + +func applyIPv6OnlyControl(enabled bool) func(string, string, syscall.RawConn) error { + return func(_, _ string, rawConn syscall.RawConn) error { + var controlErr error + if err := rawConn.Control(func(fd uintptr) { + value := 0 + if enabled { + value = 1 + } + controlErr = unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_V6ONLY, value) + }); err != nil { + return err + } + return controlErr + } +} diff --git a/pkg/netbind/socket_v6only_windows.go b/pkg/netbind/socket_v6only_windows.go new file mode 100644 index 000000000..006b4e1ac --- /dev/null +++ b/pkg/netbind/socket_v6only_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package netbind + +import ( + "syscall" + + "golang.org/x/sys/windows" +) + +func applyIPv6OnlyControl(enabled bool) func(string, string, syscall.RawConn) error { + return func(_, _ string, rawConn syscall.RawConn) error { + var controlErr error + if err := rawConn.Control(func(fd uintptr) { + value := 0 + if enabled { + value = 1 + } + controlErr = windows.SetsockoptInt(windows.Handle(fd), windows.IPPROTO_IPV6, windows.IPV6_V6ONLY, value) + }); err != nil { + return err + } + return controlErr + } +} diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 273ef4a62..fa5652323 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -21,6 +21,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/netbind" ppid "github.com/sipeed/picoclaw/pkg/pid" "github.com/sipeed/picoclaw/web/backend/utils" ) @@ -119,6 +120,7 @@ var ( gatewayRestartGracePeriod = 5 * time.Second gatewayRestartForceKillWindow = 3 * time.Second gatewayRestartPollInterval = 100 * time.Millisecond + gatewayExecCommand = exec.Command ) var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) { @@ -262,7 +264,7 @@ func (h *Handler) getGatewayHealthForPidData( host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) } if host == "" { - host = resolveDefaultLoopbackHost() + host = netbind.ResolveAdaptiveLoopbackHost() } url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/health" @@ -723,7 +725,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int execPath := utils.FindPicoclawBinary() logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath)) - cmd = exec.Command(execPath, h.gatewayCommandArgs()...) + cmd = gatewayExecCommand(execPath, h.gatewayCommandArgs()...) cmd.Env = os.Environ() // Forward the launcher's config path via the environment variable that // GetConfigPath() already reads, so the gateway sub-process uses the same @@ -731,17 +733,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int if h.configPath != "" { cmd.Env = append(cmd.Env, config.EnvConfig+"="+h.configPath) } - gatewayHostOverride := h.gatewayHostOverrideForConfig(cfg) - if h.serverHostExplicit && gatewayHostOverride == "" { - logger.WarnC( - "gateway", - fmt.Sprintf( - "Explicit launcher host %q was not forwarded to gateway because configured gateway host is %q; gateway keeps original bind host", - strings.TrimSpace(h.serverHost), - strings.TrimSpace(cfg.Gateway.Host), - ), - ) - } + gatewayHostOverride := h.gatewayHostOverride() if gatewayHostOverride != "" { cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+gatewayHostOverride) } diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index 055c90bdf..c6c2073e2 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -8,38 +8,9 @@ import ( "strings" "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/web/backend/utils" + "github.com/sipeed/picoclaw/pkg/netbind" ) -func selectAdaptiveLoopbackHost(hasIPv4, hasIPv6 bool) string { - return utils.SelectAdaptiveLoopbackHost(hasIPv4, hasIPv6) -} - -func selectAdaptiveAnyHost(hasIPv4, hasIPv6 bool) string { - return utils.SelectAdaptiveAnyHost(hasIPv4, hasIPv6) -} - -func isLoopbackEquivalentHost(host string) bool { - host = strings.TrimSpace(host) - if host == "" { - return false - } - if strings.EqualFold(host, "localhost") { - return true - } - trimmed := strings.Trim(host, "[]") - ip := net.ParseIP(trimmed) - return ip != nil && ip.IsLoopback() -} - -func resolveDefaultLoopbackHost() string { - return utils.ResolveAdaptiveLoopbackHost() -} - -func resolveDefaultAnyHost() string { - return utils.ResolveAdaptiveAnyHost() -} - func (h *Handler) effectiveLauncherPublic() bool { if h.serverHostExplicit { // -host takes precedence over -public and launcher-config public setting. @@ -58,64 +29,18 @@ func (h *Handler) effectiveLauncherPublic() bool { return h.serverPublic } -func canonicalLauncherBindHost(host string) string { - host = strings.TrimSpace(host) - if host == "" { - return resolveDefaultLoopbackHost() - } - if strings.EqualFold(host, "localhost") { - return resolveDefaultLoopbackHost() - } - trimmed := strings.Trim(host, "[]") - if ip := net.ParseIP(trimmed); ip != nil && ip.IsUnspecified() { - return resolveDefaultAnyHost() - } - return host -} - -func (h *Handler) launcherAndGatewayBindHostsAligned(cfg *config.Config) bool { - if cfg == nil { - return false - } - - // 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 - } - - return launcherHost == gatewayHost -} - -func (h *Handler) gatewayHostOverrideForConfig(cfg *config.Config) string { +func (h *Handler) gatewayHostOverride() string { if h.serverHostExplicit { - if h.launcherAndGatewayBindHostsAligned(cfg) { - return strings.TrimSpace(h.serverHost) - } - return "" + return strings.TrimSpace(h.serverHostInput) } - if h.effectiveLauncherPublic() { - return resolveDefaultAnyHost() + return "*" } return "" } -func (h *Handler) gatewayHostOverride() string { - if !h.serverHostExplicit { - return h.gatewayHostOverrideForConfig(nil) - } - - cfg, err := config.LoadConfig(h.configPath) - if err != nil { - return "" - } - return h.gatewayHostOverrideForConfig(cfg) -} - func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string { - if override := h.gatewayHostOverrideForConfig(cfg); override != "" { + if override := h.gatewayHostOverride(); override != "" { return override } if cfg == nil { @@ -125,19 +50,11 @@ func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string { } func gatewayProbeHost(bindHost string) string { - bindHost = strings.TrimSpace(bindHost) - if bindHost == "" { - return resolveDefaultLoopbackHost() + plan, err := netbind.BuildPlan(bindHost, netbind.DefaultLoopback) + if err != nil || strings.TrimSpace(plan.ProbeHost) == "" { + return netbind.ResolveAdaptiveLoopbackHost() } - if strings.EqualFold(bindHost, "localhost") { - return resolveDefaultLoopbackHost() - } - - trimmed := strings.Trim(bindHost, "[]") - if ip := net.ParseIP(trimmed); ip != nil && ip.IsUnspecified() { - return resolveDefaultLoopbackHost() - } - return bindHost + return plan.ProbeHost } func (h *Handler) gatewayProxyURL() *url.URL { @@ -165,7 +82,7 @@ func requestHostName(r *http.Request) string { if strings.TrimSpace(r.Host) != "" { return r.Host } - return resolveDefaultLoopbackHost() + return netbind.ResolveAdaptiveLoopbackHost() } 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 5f3181085..d0fc26d7b 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/netbind" "github.com/sipeed/picoclaw/web/backend/launcherconfig" ) @@ -27,8 +28,8 @@ func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) { h := NewHandler(configPath) h.SetServerOptions(18800, true, true, nil) - if got := h.gatewayHostOverride(); got != resolveDefaultAnyHost() { - t.Fatalf("gatewayHostOverride() = %q, want %q", got, resolveDefaultAnyHost()) + if got := h.gatewayHostOverride(); got != "*" { + t.Fatalf("gatewayHostOverride() = %q, want %q", got, "*") } } @@ -64,78 +65,40 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { } } -func TestSelectAdaptiveLoopbackHost(t *testing.T) { - tests := []struct { - name string - hasIPv4 bool - hasIPv6 bool - want string - }{ - {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: "localhost"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := selectAdaptiveLoopbackHost(tt.hasIPv4, tt.hasIPv6); got != tt.want { - t.Fatalf("selectAdaptiveLoopbackHost(%t, %t) = %q, want %q", tt.hasIPv4, tt.hasIPv6, got, tt.want) - } - }) - } -} - -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 TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) { - want := resolveDefaultLoopbackHost() + want := "127.0.0.1" if got := gatewayProbeHost("0.0.0.0"); got != want { t.Fatalf("gatewayProbeHost() = %q, want %q", got, want) } } func TestGatewayProbeHostUsesPreferredLoopbackForEmptyBind(t *testing.T) { - want := resolveDefaultLoopbackHost() + want := netbind.ResolveAdaptiveLoopbackHost() if got := gatewayProbeHost(""); got != want { t.Fatalf("gatewayProbeHost(empty) = %q, want %q", got, want) } } func TestGatewayProbeHostUsesPreferredLoopbackForLocalhostBind(t *testing.T) { - want := resolveDefaultLoopbackHost() + want := netbind.ResolveAdaptiveLoopbackHost() if got := gatewayProbeHost("localhost"); got != want { t.Fatalf("gatewayProbeHost(localhost) = %q, want %q", got, want) } } func TestGatewayProbeHostUsesLoopbackForIPv6WildcardBind(t *testing.T) { - want := resolveDefaultLoopbackHost() + want := "::1" if got := gatewayProbeHost("::"); got != want { t.Fatalf("gatewayProbeHost(::) = %q, want %q", got, want) } } +func TestGatewayProbeHostUsesFirstConcreteHostForMultiHostBind(t *testing.T) { + if got := gatewayProbeHost("127.0.0.1,::1"); got != "127.0.0.1" { + t.Fatalf("gatewayProbeHost(multi) = %q, want %q", got, "127.0.0.1") + } +} + func TestGatewayProxyURLUsesConfiguredHost(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -204,7 +167,7 @@ func TestGetGatewayHealthUsesProbeHostForPublicLauncher(t *testing.T) { _ = statusCode _ = err - want := "http://" + net.JoinHostPort(resolveDefaultLoopbackHost(), "18791") + "/health" + want := "http://" + net.JoinHostPort(netbind.ResolveAdaptiveLoopbackHost(), "18791") + "/health" if requestedURL != want { t.Fatalf("health url = %q, want %q", requestedURL, want) } @@ -310,23 +273,17 @@ func TestBuildWsURLUsesRequestHostNotGatewayBindLoopback(t *testing.T) { } func TestGatewayHostOverrideWithExplicitHostAndAlignedGatewayHost(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.json") - writeGatewayHostConfig(t, configPath, "127.0.0.1") - - h := NewHandler(configPath) + h := NewHandler(filepath.Join(t.TempDir(), "config.json")) h.SetServerOptions(18800, false, false, nil) h.SetServerBindHost("0.0.0.0", true) - if got := h.gatewayHostOverride(); got != resolveDefaultAnyHost() { - t.Fatalf("gatewayHostOverride() = %q, want %q", got, resolveDefaultAnyHost()) + if got := h.gatewayHostOverride(); got != "0.0.0.0" { + t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0") } } func TestGatewayHostOverrideWithExplicitHostAndLocalhostGatewayHost(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.json") - writeGatewayHostConfig(t, configPath, "localhost") - - h := NewHandler(configPath) + h := NewHandler(filepath.Join(t.TempDir(), "config.json")) h.SetServerOptions(18800, false, false, nil) h.SetServerBindHost("::", true) @@ -335,24 +292,18 @@ func TestGatewayHostOverrideWithExplicitHostAndLocalhostGatewayHost(t *testing.T } } -func TestGatewayHostOverrideWithExplicitHostAndMismatchedGatewayHost(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.json") - writeGatewayHostConfig(t, configPath, "0.0.0.0") - - h := NewHandler(configPath) +func TestGatewayHostOverrideWithExplicitMultiHost(t *testing.T) { + h := NewHandler(filepath.Join(t.TempDir(), "config.json")) h.SetServerOptions(18800, false, false, nil) - h.SetServerBindHost("192.168.1.10", true) + h.SetServerBindHost("127.0.0.1,::1", true) - if got := h.gatewayHostOverride(); got != "" { - t.Fatalf("gatewayHostOverride() = %q, want empty", got) + if got := h.gatewayHostOverride(); got != "127.0.0.1,::1" { + t.Fatalf("gatewayHostOverride() = %q, want %q", got, "127.0.0.1,::1") } } func TestGatewayHostExplicitIgnoresPublicFlag(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.json") - writeGatewayHostConfig(t, configPath, "127.0.0.1") - - h := NewHandler(configPath) + h := NewHandler(filepath.Join(t.TempDir(), "config.json")) h.SetServerOptions(18800, true, true, nil) h.SetServerBindHost("127.0.0.1", true) @@ -360,13 +311,3 @@ func TestGatewayHostExplicitIgnoresPublicFlag(t *testing.T) { t.Fatalf("effectiveLauncherPublic() = %t, want false when explicit host is set", got) } } - -func writeGatewayHostConfig(t *testing.T, configPath, host string) { - t.Helper() - - cfg := config.DefaultConfig() - cfg.Gateway.Host = host - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("SaveConfig() error = %v", err) - } -} diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index d300b657c..9e14bf42d 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -97,6 +97,7 @@ func resetGatewayTestState(t *testing.T) { originalHealthGet := gatewayHealthGet originalProcessMatcher := gatewayProcessMatcher + originalExecCommand := gatewayExecCommand originalRestartGracePeriod := gatewayRestartGracePeriod originalRestartForceKillWindow := gatewayRestartForceKillWindow originalRestartPollInterval := gatewayRestartPollInterval @@ -104,6 +105,7 @@ func resetGatewayTestState(t *testing.T) { t.Cleanup(func() { gatewayHealthGet = originalHealthGet gatewayProcessMatcher = originalProcessMatcher + gatewayExecCommand = originalExecCommand gatewayRestartGracePeriod = originalRestartGracePeriod gatewayRestartForceKillWindow = originalRestartForceKillWindow gatewayRestartPollInterval = originalRestartPollInterval @@ -119,6 +121,158 @@ func resetGatewayTestState(t *testing.T) { }) } +type gatewayStartEnvSnapshot struct { + GatewayHost string `json:"gateway_host"` + GatewayHostSet bool `json:"gateway_host_set"` + ConfigPath string `json:"config_path"` +} + +func TestGatewayStartHelperProcess(t *testing.T) { + var envPath string + for i, arg := range os.Args { + if arg == "--" && i+2 < len(os.Args) && os.Args[i+1] == "gateway-env-helper" { + envPath = os.Args[i+2] + break + } + } + if envPath == "" { + t.Skip("helper process") + } + + host, ok := os.LookupEnv(config.EnvGatewayHost) + raw, err := json.Marshal(gatewayStartEnvSnapshot{ + GatewayHost: host, + GatewayHostSet: ok, + ConfigPath: os.Getenv(config.EnvConfig), + }) + if err != nil { + _, _ = io.WriteString(os.Stderr, err.Error()) + os.Exit(2) + } + if err := os.WriteFile(envPath, raw, 0o600); err != nil { + _, _ = io.WriteString(os.Stderr, err.Error()) + os.Exit(2) + } + os.Exit(0) +} + +func unsetGatewayStartEnvForTest(t *testing.T, key string) { + t.Helper() + + prev, hadPrev := os.LookupEnv(key) + if err := os.Unsetenv(key); err != nil { + t.Fatalf("Unsetenv(%q) error = %v", key, err) + } + t.Cleanup(func() { + if hadPrev { + _ = os.Setenv(key, prev) + return + } + _ = os.Unsetenv(key) + }) +} + +func newGatewayStartTestHandler(t *testing.T) *Handler { + t.Helper() + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + return h +} + +func startGatewayAndCaptureEnv(t *testing.T, h *Handler) gatewayStartEnvSnapshot { + t.Helper() + + unsetGatewayStartEnvForTest(t, config.EnvGatewayHost) + + envPath := filepath.Join(t.TempDir(), "gateway-child-env.json") + gatewayExecCommand = func(_ string, _ ...string) *exec.Cmd { + return exec.Command( + os.Args[0], + "-test.run=TestGatewayStartHelperProcess", + "--", + "gateway-env-helper", + envPath, + ) + } + + pid, err := h.startGatewayLocked("starting", 0) + if err != nil { + t.Fatalf("startGatewayLocked() error = %v", err) + } + if pid <= 0 { + t.Fatalf("startGatewayLocked() pid = %d, want > 0", pid) + } + + deadline := time.Now().Add(3 * time.Second) + for { + raw, err := os.ReadFile(envPath) + if err == nil { + var snapshot gatewayStartEnvSnapshot + if err := json.Unmarshal(raw, &snapshot); err != nil { + t.Fatalf("Unmarshal(child env) error = %v", err) + } + return snapshot + } + if !os.IsNotExist(err) { + t.Fatalf("ReadFile(%q) error = %v", envPath, err) + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for gateway child env snapshot %q", envPath) + } + time.Sleep(20 * time.Millisecond) + } +} + +func TestStartGatewayLocked_ForwardsLauncherHostOverrideToGatewayEnv(t *testing.T) { + h := newGatewayStartTestHandler(t) + h.SetServerBindHost("127.0.0.1,::1", true) + + snapshot := startGatewayAndCaptureEnv(t, h) + if !snapshot.GatewayHostSet { + t.Fatal("gateway host env was not set") + } + if snapshot.GatewayHost != "127.0.0.1,::1" { + t.Fatalf("gateway host env = %q, want %q", snapshot.GatewayHost, "127.0.0.1,::1") + } + if snapshot.ConfigPath != h.configPath { + t.Fatalf("config env = %q, want %q", snapshot.ConfigPath, h.configPath) + } +} + +func TestStartGatewayLocked_ForwardsLauncherHostFromEnvironmentToGatewayEnv(t *testing.T) { + h := newGatewayStartTestHandler(t) + h.SetServerBindHost("::", true) + + snapshot := startGatewayAndCaptureEnv(t, h) + if !snapshot.GatewayHostSet { + t.Fatal("gateway host env was not set") + } + if snapshot.GatewayHost != "::" { + t.Fatalf("gateway host env = %q, want %q", snapshot.GatewayHost, "::") + } +} + +func TestStartGatewayLocked_ForwardsWildcardHostForPublicLauncher(t *testing.T) { + h := newGatewayStartTestHandler(t) + h.SetServerOptions(18800, true, true, nil) + + snapshot := startGatewayAndCaptureEnv(t, h) + if !snapshot.GatewayHostSet { + t.Fatal("gateway host env was not set") + } + if snapshot.GatewayHost != "*" { + t.Fatalf("gateway host env = %q, want %q", snapshot.GatewayHost, "*") + } +} + func TestGatewayStartReady_NoDefaultModel(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) diff --git a/web/backend/api/router.go b/web/backend/api/router.go index d88a339f9..76f63607e 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -14,7 +14,7 @@ type Handler struct { serverPort int serverPublic bool serverPublicExplicit bool - serverHost string + serverHostInput string serverHostExplicit bool serverCIDRs []string debug bool @@ -32,7 +32,6 @@ func NewHandler(configPath string) *Handler { return &Handler{ configPath: configPath, serverPort: launcherconfig.DefaultPort, - serverHost: resolveDefaultLoopbackHost(), oauthFlows: make(map[string]*oauthFlow), oauthState: make(map[string]string), weixinFlows: make(map[string]*weixinFlow), @@ -45,28 +44,18 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a h.serverPort = port h.serverPublic = public h.serverPublicExplicit = publicExplicit - h.serverHost = resolveDefaultLoopbackHost() - if public { - h.serverHost = resolveDefaultAnyHost() - } + h.serverHostInput = "" h.serverHostExplicit = false h.serverCIDRs = append([]string(nil), allowedCIDRs...) } // SetServerBindHost stores the launcher's effective bind host. -// When explicit is true, the value came from the -host flag. -func (h *Handler) SetServerBindHost(host string, explicit bool) { - host = strings.TrimSpace(host) - if host == "" { - host = resolveDefaultLoopbackHost() - if h.serverPublic { - host = resolveDefaultAnyHost() - } - explicit = false +// When explicit is true, hostInput is the normalized -host / PICOCLAW_LAUNCHER_HOST value. +func (h *Handler) SetServerBindHost(hostInput string, explicit bool) { + h.serverHostInput = strings.TrimSpace(hostInput) + if !explicit { + h.serverHostInput = "" } - host = canonicalLauncherBindHost(host) - - h.serverHost = host h.serverHostExplicit = explicit } diff --git a/web/backend/main.go b/web/backend/main.go index 6201c130a..0de9fa5da 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -28,6 +28,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/netbind" "github.com/sipeed/picoclaw/web/backend/api" "github.com/sipeed/picoclaw/web/backend/dashboardauth" "github.com/sipeed/picoclaw/web/backend/launcherconfig" @@ -56,50 +57,6 @@ var ( noBrowser *bool ) -type launcherBindMode string - -type launcherRuntimeBinding struct { - mode launcherBindMode - host string -} - -const ( - launcherBindModeAutoPrivate launcherBindMode = "auto-private" - launcherBindModeAutoPublic launcherBindMode = "auto-public" - launcherBindModeExplicitLiteral launcherBindMode = "explicit-literal" - launcherBindModeExplicitAdaptiveAny launcherBindMode = "explicit-adaptive-any" - launcherBindModeExplicitAdaptiveLocal launcherBindMode = "explicit-adaptive-localhost" -) - -func parseLauncherHostList(raw string) ([]string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil, errors.New("host cannot be empty") - } - - parts := strings.Split(raw, ",") - hosts := make([]string, 0, len(parts)) - seen := make(map[string]struct{}, len(parts)) - for _, part := range parts { - host := strings.TrimSpace(part) - if host == "" { - return nil, errors.New("host list contains an empty entry") - } - key := strings.ToLower(host) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - hosts = append(hosts, host) - } - - if len(hosts) == 0 { - return nil, errors.New("host cannot be empty") - } - - return hosts, nil -} - func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool { return !enableConsole || debug } @@ -111,108 +68,38 @@ func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, la return launcherPath } -func resolveDefaultLauncherAnyHost() string { - return utils.ResolveAdaptiveAnyHost() -} - -func resolveDefaultLauncherPrivateHost() string { - return utils.ResolveAdaptiveLoopbackHost() -} - -func normalizeLauncherSpecialHost(host string) string { - host = strings.TrimSpace(host) - if host == "" { - return host - } - if host == "*" { - return resolveDefaultLauncherAnyHost() - } - if strings.EqualFold(host, "localhost") { - return resolveDefaultLauncherPrivateHost() - } - if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil { - return ip.String() - } - return host -} - -func resolveLauncherBindMode(rawHost string, hostExplicit bool, effectivePublic bool) launcherBindMode { - if !hostExplicit { - if effectivePublic { - return launcherBindModeAutoPublic +func resolveLauncherHostInput(flagHost string, explicitFlag bool, envHost string) (string, bool, error) { + if explicitFlag { + normalized, err := netbind.NormalizeHostInput(flagHost) + if err != nil { + return "", false, err } - return launcherBindModeAutoPrivate - } - - rawHost = strings.TrimSpace(rawHost) - if rawHost == "*" { - return launcherBindModeExplicitAdaptiveAny - } - if strings.EqualFold(rawHost, "localhost") { - return launcherBindModeExplicitAdaptiveLocal - } - return launcherBindModeExplicitLiteral -} - -func resolveLauncherBindHost( - host string, - explicitHost bool, - envHost string, - effectivePublic bool, -) (string, bool, bool, error) { - if explicitHost { - host = strings.TrimSpace(host) - if host == "" { - return "", false, false, errors.New("host cannot be empty") - } - // When -host is specified, -public is ignored. - return normalizeLauncherSpecialHost(host), false, true, nil + return normalized, true, nil } envHost = strings.TrimSpace(envHost) - if envHost != "" { - // Environment host follows explicit override semantics. - return normalizeLauncherSpecialHost(envHost), false, true, nil + if envHost == "" { + return "", false, nil } - if effectivePublic { - return resolveDefaultLauncherAnyHost(), true, false, nil + normalized, err := netbind.NormalizeHostInput(envHost) + if err != nil { + return "", false, err } - - return resolveDefaultLauncherPrivateHost(), false, false, nil + return normalized, true, nil } -func isWildcardBindHost(host string) bool { - host = strings.TrimSpace(host) - if host == "" { - return false - } - trimmed := strings.Trim(host, "[]") - ip := net.ParseIP(trimmed) - return ip != nil && ip.IsUnspecified() -} - -func browserHostForLauncher(bindHost string) string { - bindHost = strings.TrimSpace(bindHost) - if bindHost == "" || isWildcardBindHost(bindHost) { - return "localhost" - } - return bindHost -} - -func wildcardAdvertiseIP(bindHost, ipv4, ipv6 string) string { - if !isWildcardBindHost(bindHost) { - return "" +func openLauncherListeners(hostInput string, public bool, port string) (netbind.OpenResult, error) { + defaultMode := netbind.DefaultLoopback + if strings.TrimSpace(hostInput) == "" && public { + defaultMode = netbind.DefaultAny } - if v6 := strings.TrimSpace(ipv6); v6 != "" { - return v6 + plan, err := netbind.BuildPlan(hostInput, defaultMode) + if err != nil { + return netbind.OpenResult{}, err } - return strings.TrimSpace(ipv4) -} - -func advertiseIPForWildcardBindHost(bindHost string) string { - return wildcardAdvertiseIP(bindHost, utils.GetLocalIPv4(), utils.GetLocalIPv6()) + return netbind.OpenPlan(plan, port) } func appendUniqueHost(hosts []string, seen map[string]struct{}, host string) []string { @@ -228,124 +115,77 @@ func appendUniqueHost(hosts []string, seen map[string]struct{}, host string) []s return append(hosts, host) } -func launcherConsoleHosts(bindMode launcherBindMode, bindHost string, effectivePublic bool) []string { +func hasWildcardBindHosts(bindHosts []string) bool { + for _, bindHost := range bindHosts { + if netbind.IsUnspecifiedHost(bindHost) { + return true + } + } + return false +} + +func wildcardAdvertiseIP(bindHosts []string, ipv4, ipv6 string) string { + if !hasWildcardBindHosts(bindHosts) { + return "" + } + + if v6 := strings.TrimSpace(ipv6); v6 != "" { + return v6 + } + return strings.TrimSpace(ipv4) +} + +func advertiseIPForWildcardBindHosts(bindHosts []string) string { + return wildcardAdvertiseIP(bindHosts, utils.GetLocalIPv4(), utils.GetLocalIPv6()) +} + +func launcherConsoleHosts(bindHosts []string, probeHost string) []string { hosts := make([]string, 0, 6) seen := make(map[string]struct{}, 6) - hosts = appendUniqueHost(hosts, seen, "localhost") + hosts = appendUniqueHost(hosts, seen, probeHost) - switch bindMode { - case launcherBindModeAutoPrivate, launcherBindModeExplicitAdaptiveLocal: - hosts = appendUniqueHost(hosts, seen, "::1") - hosts = appendUniqueHost(hosts, seen, "127.0.0.1") - return hosts - case launcherBindModeAutoPublic, launcherBindModeExplicitAdaptiveAny: - hosts = appendUniqueHost(hosts, seen, "::1") - hosts = appendUniqueHost(hosts, seen, "127.0.0.1") - hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv6()) - hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv4()) - return hosts - case launcherBindModeExplicitLiteral: - trimmed := strings.Trim(strings.TrimSpace(bindHost), "[]") - if ip := net.ParseIP(trimmed); ip != nil { - if ip.IsUnspecified() { + for _, bindHost := range bindHosts { + switch { + case netbind.IsUnspecifiedHost(bindHost): + if ip := net.ParseIP(strings.Trim(bindHost, "[]")); ip != nil && ip.To4() != nil { + hosts = appendUniqueHost(hosts, seen, "127.0.0.1") + } else { + hosts = appendUniqueHost(hosts, seen, "::1") + } + case netbind.IsLoopbackHost(bindHost): + hosts = appendUniqueHost(hosts, seen, "localhost") + if ip := net.ParseIP(strings.Trim(bindHost, "[]")); ip != nil { if ip.To4() != nil { hosts = appendUniqueHost(hosts, seen, "127.0.0.1") - hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv4()) - return hosts + } else { + hosts = appendUniqueHost(hosts, seen, "::1") } - hosts = appendUniqueHost(hosts, seen, "::1") - hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv6()) - return hosts } - hosts = appendUniqueHost(hosts, seen, ip.String()) - return hosts + default: + hosts = appendUniqueHost(hosts, seen, bindHost) } } - if effectivePublic && isWildcardBindHost(bindHost) { + if hasWildcardBindHosts(bindHosts) { + hosts = appendUniqueHost(hosts, seen, "localhost") hosts = appendUniqueHost(hosts, seen, "::1") hosts = appendUniqueHost(hosts, seen, "127.0.0.1") hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv6()) hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv4()) - return hosts } - hosts = appendUniqueHost(hosts, seen, bindHost) - return hosts } -func openLauncherListener(network, host, port string) (net.Listener, error) { - return net.Listen(network, net.JoinHostPort(host, port)) -} - -func openLauncherPrivateListeners(port string) ([]net.Listener, string, error) { - if ln6, err6 := openLauncherListener("tcp6", "::1", port); err6 == nil { - if ln4, err4 := openLauncherListener("tcp4", "127.0.0.1", port); err4 == nil { - return []net.Listener{ln6, ln4}, "localhost", nil - } - _ = ln6.Close() - } - - if ln6, err := openLauncherListener("tcp6", "::1", port); err == nil { - return []net.Listener{ln6}, "::1", nil - } - - if ln4, err := openLauncherListener("tcp4", "127.0.0.1", port); err == nil { - return []net.Listener{ln4}, "127.0.0.1", nil - } - - return nil, "", fmt.Errorf("failed to open private localhost listener on port %s", port) -} - -func openLauncherAnyListener(port string) ([]net.Listener, string, error) { - // For auto-public and -host=* we intentionally bind :: on "tcp" first. - // Go's compatibility layer will provide dual-stack behavior on environments where it is supported. - if ln, err := openLauncherListener("tcp", "::", port); err == nil { - return []net.Listener{ln}, "::", nil - } - - if ln4, err := openLauncherListener("tcp4", "0.0.0.0", port); err == nil { - return []net.Listener{ln4}, "0.0.0.0", nil - } - - return nil, "", fmt.Errorf("failed to open adaptive any-host listener on port %s", port) -} - -func openLauncherLiteralListener(host, port string) ([]net.Listener, string, error) { - host = strings.TrimSpace(host) - trimmed := strings.Trim(host, "[]") - network := "tcp" - - if ip := net.ParseIP(trimmed); ip != nil { - host = ip.String() - if ip.To4() != nil { - network = "tcp4" - } else { - network = "tcp6" +func firstNonEmpty(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + return value } } - - ln, err := openLauncherListener(network, host, port) - if err != nil { - return nil, "", err - } - - return []net.Listener{ln}, host, nil -} - -func openLauncherListeners(mode launcherBindMode, bindHost, port string) ([]net.Listener, string, error) { - switch mode { - case launcherBindModeAutoPrivate, launcherBindModeExplicitAdaptiveLocal: - return openLauncherPrivateListeners(port) - case launcherBindModeAutoPublic, launcherBindModeExplicitAdaptiveAny: - return openLauncherAnyListener(port) - case launcherBindModeExplicitLiteral: - return openLauncherLiteralListener(bindHost, port) - default: - return nil, "", fmt.Errorf("unsupported launcher bind mode: %s", mode) - } + return "" } // maskSecret masks a secret for display. It always shows up to the first 3 @@ -397,7 +237,7 @@ func main() { ) fmt.Fprintf(os.Stderr, " Allow access from other devices on the local network\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, " Bind launcher host explicitly with exact host semantics\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") } @@ -502,54 +342,19 @@ func main() { } envHost := strings.TrimSpace(os.Getenv(launcherconfig.EnvLauncherHost)) - rawHostInput := strings.TrimSpace(*host) - if !explicitHost { - rawHostInput = envHost + hostInput, hostOverrideActive, err := resolveLauncherHostInput(*host, explicitHost, envHost) + if err != nil { + logger.Fatalf("Invalid host %q: %v", firstNonEmpty(strings.TrimSpace(*host), envHost), err) } - - hostExplicit := false - effectiveHost := "" - bindMode := launcherBindModeAutoPrivate - bindTargets := make([]launcherRuntimeBinding, 0, 1) - if rawHostInput != "" { - hosts, parseErr := parseLauncherHostList(rawHostInput) - if parseErr != nil { - logger.Fatalf("Invalid host %q: %v", rawHostInput, parseErr) - } - hostExplicit = true + if hostOverrideActive { effectivePublic = false - for _, raw := range hosts { - resolvedHost, _, _, resolveErr := resolveLauncherBindHost(raw, true, "", false) - if resolveErr != nil { - logger.Fatalf("Invalid host %q: %v", raw, resolveErr) - } - mode := resolveLauncherBindMode(raw, true, false) - bindTargets = append(bindTargets, launcherRuntimeBinding{mode: mode, host: resolvedHost}) - } - effectiveHost = bindTargets[0].host - bindMode = bindTargets[0].mode - } else { - resolvedHost, resolvedPublic, resolvedExplicit, resolveErr := resolveLauncherBindHost( - "", - false, - "", - effectivePublic, - ) - if resolveErr != nil { - logger.Fatalf("Invalid default host: %v", resolveErr) - } - effectiveHost = resolvedHost - effectivePublic = resolvedPublic - hostExplicit = resolvedExplicit - bindMode = resolveLauncherBindMode("", false, effectivePublic) - bindTargets = append(bindTargets, launcherRuntimeBinding{mode: bindMode, host: effectiveHost}) } - if !explicitHost && envHost != "" { + if !explicitHost && hostOverrideActive { logger.InfoC("web", "Using launcher host from environment PICOCLAW_LAUNCHER_HOST") } - if hostExplicit && explicitPublic { + if hostOverrideActive && explicitPublic { logger.InfoC("web", "Ignoring -public because launcher host was explicitly set") } @@ -561,21 +366,11 @@ func main() { logger.Fatalf("Invalid port %q: %v", effectivePort, err) } - listeners := make([]net.Listener, 0, len(bindTargets)) - runtimeBindings := make([]launcherRuntimeBinding, 0, len(bindTargets)) - for _, target := range bindTargets { - targetListeners, runtimeHost, listenErr := openLauncherListeners(target.mode, target.host, effectivePort) - if listenErr != nil { - for _, ln := range listeners { - _ = ln.Close() - } - logger.Fatalf("Failed to open launcher listener(s): %v", listenErr) - } - listeners = append(listeners, targetListeners...) - runtimeBindings = append(runtimeBindings, launcherRuntimeBinding{mode: target.mode, host: runtimeHost}) + openResult, err := openLauncherListeners(hostInput, effectivePublic, effectivePort) + if err != nil { + logger.Fatalf("Failed to open launcher listener(s): %v", err) } - effectiveHost = runtimeBindings[0].host - bindMode = runtimeBindings[0].mode + listeners := openResult.Listeners dashboardToken, dashboardSigningKey, dashboardTokenSource, dashErr := launcherconfig.EnsureDashboardSecrets( launcherCfg, @@ -620,12 +415,8 @@ func main() { if _, err = apiHandler.EnsurePicoChannel(""); err != nil { logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err)) } - gatewayHostExplicit := hostExplicit && len(runtimeBindings) == 1 - if hostExplicit && len(runtimeBindings) > 1 { - logger.WarnC("web", "Multiple launcher hosts are configured; gateway host override is disabled for this run") - } apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) - apiHandler.SetServerBindHost(effectiveHost, gatewayHostExplicit) + apiHandler.SetServerBindHost(hostInput, hostOverrideActive) apiHandler.RegisterRoutes(mux) // Frontend Embedded Assets @@ -652,13 +443,7 @@ func main() { // Print startup banner and token (console mode only). if enableConsole || debug { - consoleHosts := make([]string, 0, 8) - consoleSeen := make(map[string]struct{}, 8) - for _, binding := range runtimeBindings { - for _, host := range launcherConsoleHosts(binding.mode, binding.host, effectivePublic) { - consoleHosts = appendUniqueHost(consoleHosts, consoleSeen, host) - } - } + consoleHosts := launcherConsoleHosts(openResult.BindHosts, openResult.ProbeHost) fmt.Print(utils.Banner) fmt.Println() @@ -694,14 +479,14 @@ func main() { for _, ln := range listeners { logger.InfoC("web", fmt.Sprintf("Server will listen on http://%s", ln.Addr().String())) } - if isWildcardBindHost(effectiveHost) { - if ip := advertiseIPForWildcardBindHost(effectiveHost); ip != "" { + if hasWildcardBindHosts(openResult.BindHosts) { + if ip := advertiseIPForWildcardBindHosts(openResult.BindHosts); ip != "" { logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s", net.JoinHostPort(ip, effectivePort))) } } // Share the local URL with the launcher runtime. - serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(browserHostForLauncher(effectiveHost), effectivePort)) + serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(openResult.ProbeHost, effectivePort)) if dashboardToken != "" { browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken) } else { diff --git a/web/backend/main_test.go b/web/backend/main_test.go index 47df1c269..8ad132a69 100644 --- a/web/backend/main_test.go +++ b/web/backend/main_test.go @@ -1,8 +1,16 @@ package main import ( + "context" + "errors" + "io" + "net" + "net/http" + "strconv" "testing" + "time" + "github.com/sipeed/picoclaw/pkg/netbind" "github.com/sipeed/picoclaw/web/backend/launcherconfig" ) @@ -42,21 +50,9 @@ func TestDashboardTokenConfigHelpPath(t *testing.T) { source launcherconfig.DashboardTokenSource want string }{ - { - name: "env token does not expose config path", - source: launcherconfig.DashboardTokenSourceEnv, - want: "", - }, - { - name: "config token exposes config path", - source: launcherconfig.DashboardTokenSourceConfig, - want: launcherPath, - }, - { - name: "random token does not expose config path", - source: launcherconfig.DashboardTokenSourceRandom, - want: "", - }, + {name: "env token does not expose config path", source: launcherconfig.DashboardTokenSourceEnv, want: ""}, + {name: "config token exposes config path", source: launcherconfig.DashboardTokenSourceConfig, want: launcherPath}, + {name: "random token does not expose config path", source: launcherconfig.DashboardTokenSourceRandom, want: ""}, } for _, tt := range tests { @@ -73,22 +69,17 @@ func TestMaskSecret(t *testing.T) { input string want string }{ - // Long token (>=12 chars): first 3 + 10 stars + last 4 {"sdhjflsjdflksdf", "sdh**********ksdf"}, {"abcdefghijklmnopqrstuvwxyz", "abc**********wxyz"}, - // Exactly 12 chars (3+4+5 hidden): suffix shown {"abcdefghijkl", "abc**********ijkl"}, - // 8 chars (minimum password length): suffix NOT shown — only prefix+stars {"abcdefgh", "abc**********"}, - // 11 chars (one below threshold): suffix NOT shown {"abcdefghijk", "abc**********"}, - // 4..3 chars: prefix shown, no suffix {"abcdefg", "abc**********"}, {"abcd", "abc**********"}, - // <=3 chars: fully masked {"abc", "**********"}, {"", "**********"}, } + for _, tt := range tests { if got := maskSecret(tt.input); got != tt.want { t.Errorf("maskSecret(%q) = %q, want %q", tt.input, got, tt.want) @@ -96,185 +87,46 @@ func TestMaskSecret(t *testing.T) { } } -func TestParseLauncherHostList(t *testing.T) { - tests := []struct { - name string - raw string - want []string - wantErr bool - }{ - {name: "single host", raw: "127.0.0.1", want: []string{"127.0.0.1"}}, - {name: "multiple hosts", raw: "127.0.0.1, 192.168.2.5", want: []string{"127.0.0.1", "192.168.2.5"}}, - {name: "dedupe hosts", raw: "127.0.0.1,127.0.0.1", want: []string{"127.0.0.1"}}, - {name: "reject empty entry", raw: "127.0.0.1, ", wantErr: true}, - {name: "reject empty input", raw: " ", wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := parseLauncherHostList(tt.raw) - if (err != nil) != tt.wantErr { - t.Fatalf("parseLauncherHostList() err = %v, wantErr %t", err, tt.wantErr) - } - if tt.wantErr { - return - } - if len(got) != len(tt.want) { - t.Fatalf("len(got) = %d, want %d (%#v)", len(got), len(tt.want), got) - } - for i := range got { - if got[i] != tt.want[i] { - t.Fatalf("got[%d] = %q, want %q", i, got[i], tt.want[i]) - } - } - }) - } -} - -func TestResolveLauncherBindHost(t *testing.T) { +func TestResolveLauncherHostInput(t *testing.T) { tests := []struct { name string - host string + flagHost string + explicitFlag bool envHost string - explicitHost bool - effectivePub bool wantHost string - wantPublic bool - wantExplicit bool + wantActive bool wantErr bool }{ - { - name: "explicit host overrides public", - host: "0.0.0.0", - explicitHost: true, - effectivePub: true, - wantHost: "0.0.0.0", - wantPublic: false, - wantExplicit: true, - }, - { - name: "explicit host overrides env host", - host: "127.0.0.1", - envHost: "0.0.0.0", - explicitHost: true, - effectivePub: true, - wantHost: "127.0.0.1", - wantPublic: false, - wantExplicit: true, - }, - { - name: "explicit host cannot be empty", - host: " ", - explicitHost: true, - effectivePub: false, - wantErr: true, - }, - { - name: "env host overrides public", - envHost: "0.0.0.0", - explicitHost: false, - effectivePub: true, - wantHost: "0.0.0.0", - wantPublic: false, - wantExplicit: true, - }, - { - name: "explicit localhost uses adaptive private host", - host: "localhost", - explicitHost: true, - effectivePub: false, - wantHost: resolveDefaultLauncherPrivateHost(), - wantPublic: false, - wantExplicit: true, - }, - { - name: "explicit star uses adaptive any host", - host: "*", - explicitHost: true, - effectivePub: false, - wantHost: resolveDefaultLauncherAnyHost(), - wantPublic: false, - wantExplicit: true, - }, - { - name: "public mode without explicit host", - host: "", - explicitHost: false, - effectivePub: true, - wantHost: resolveDefaultLauncherAnyHost(), - wantPublic: true, - wantExplicit: false, - }, - { - name: "private mode without explicit host", - host: "", - explicitHost: false, - effectivePub: false, - wantHost: resolveDefaultLauncherPrivateHost(), - wantPublic: false, - wantExplicit: false, - }, + {name: "flag host wins", flagHost: "127.0.0.1", explicitFlag: true, envHost: "::", wantHost: "127.0.0.1", wantActive: true}, + {name: "env host used when flag absent", envHost: "127.0.0.1,::1", wantHost: "127.0.0.1,::1", wantActive: true}, + {name: "blank env ignored", envHost: " ", wantHost: "", wantActive: false}, + {name: "invalid flag rejected", flagHost: "127.0.0.1, ", explicitFlag: true, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotHost, gotPublic, gotExplicit, err := resolveLauncherBindHost( - tt.host, - tt.explicitHost, - tt.envHost, - tt.effectivePub, - ) + gotHost, gotActive, err := resolveLauncherHostInput(tt.flagHost, tt.explicitFlag, tt.envHost) if (err != nil) != tt.wantErr { - t.Fatalf("resolveLauncherBindHost() error = %v, wantErr %t", err, tt.wantErr) + t.Fatalf("resolveLauncherHostInput() err = %v, wantErr %t", err, tt.wantErr) } if tt.wantErr { return } if gotHost != tt.wantHost { - t.Fatalf("resolveLauncherBindHost() host = %q, want %q", gotHost, tt.wantHost) + t.Fatalf("resolveLauncherHostInput() host = %q, want %q", gotHost, tt.wantHost) } - if gotPublic != tt.wantPublic { - t.Fatalf("resolveLauncherBindHost() public = %t, want %t", gotPublic, tt.wantPublic) - } - if gotExplicit != tt.wantExplicit { - t.Fatalf("resolveLauncherBindHost() explicit = %t, want %t", gotExplicit, tt.wantExplicit) - } - }) - } -} - -func TestResolveLauncherBindMode(t *testing.T) { - tests := []struct { - name string - rawHost string - hostExplicit bool - effectivePub bool - wantMode launcherBindMode - }{ - {name: "auto private", rawHost: "", hostExplicit: false, effectivePub: false, wantMode: launcherBindModeAutoPrivate}, - {name: "auto public", rawHost: "", hostExplicit: false, effectivePub: true, wantMode: launcherBindModeAutoPublic}, - {name: "explicit localhost", rawHost: "localhost", hostExplicit: true, effectivePub: false, wantMode: launcherBindModeExplicitAdaptiveLocal}, - {name: "explicit star", rawHost: "*", hostExplicit: true, effectivePub: false, wantMode: launcherBindModeExplicitAdaptiveAny}, - {name: "explicit literal", rawHost: "0.0.0.0", hostExplicit: true, effectivePub: false, wantMode: launcherBindModeExplicitLiteral}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := resolveLauncherBindMode(tt.rawHost, tt.hostExplicit, tt.effectivePub); got != tt.wantMode { - t.Fatalf("resolveLauncherBindMode() = %q, want %q", got, tt.wantMode) + if gotActive != tt.wantActive { + t.Fatalf("resolveLauncherHostInput() active = %t, want %t", gotActive, tt.wantActive) } }) } } func TestLauncherConsoleHosts(t *testing.T) { - t.Run("auto private includes dual loopback hints", func(t *testing.T) { - hosts := launcherConsoleHosts(launcherBindModeAutoPrivate, "localhost", false) + t.Run("wildcard exposes local loopback hints", func(t *testing.T) { + hosts := launcherConsoleHosts([]string{"::"}, netbind.ResolveAdaptiveLoopbackHost()) 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"] { @@ -288,63 +140,149 @@ func TestLauncherConsoleHosts(t *testing.T) { } }) - t.Run("explicit ipv4 wildcard excludes ipv6 loopback", func(t *testing.T) { - hosts := launcherConsoleHosts(launcherBindModeExplicitLiteral, "0.0.0.0", false) - seen := make(map[string]bool, len(hosts)) - for _, host := range hosts { - seen[host] = true - } - if seen["::1"] { - t.Fatalf("did not expect ::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(launcherBindModeExplicitLiteral, "::1", 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) + hosts := launcherConsoleHosts([]string{"::1"}, "::1") + if len(hosts) < 1 || hosts[0] != "::1" { + t.Fatalf("hosts = %#v, want probe host first", 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") - } - if got := browserHostForLauncher("::"); got != "localhost" { - t.Fatalf("browserHostForLauncher(::) = %q, want %q", got, "localhost") - } - if got := browserHostForLauncher("192.168.1.10"); got != "192.168.1.10" { - t.Fatalf("browserHostForLauncher(192.168.1.10) = %q, want %q", got, "192.168.1.10") - } -} - func TestWildcardAdvertiseIP(t *testing.T) { tests := []struct { - name string - bindHost string - ipv4 string - ipv6 string - want string + name string + bindHosts []string + ipv4 string + ipv6 string + want string }{ - {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 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: ""}, + {name: "ipv4 wildcard prefers ipv6 when available", bindHosts: []string{"0.0.0.0"}, ipv4: "192.168.1.2", ipv6: "2001:db8::1", want: "2001:db8::1"}, + {name: "ipv6 wildcard uses ipv6", bindHosts: []string{"::"}, ipv4: "192.168.1.2", ipv6: "2001:db8::1", want: "2001:db8::1"}, + {name: "ipv6 wildcard falls back to ipv4", bindHosts: []string{"::"}, ipv4: "192.168.1.2", ipv6: "", want: "192.168.1.2"}, + {name: "non wildcard does not advertise", bindHosts: []string{"127.0.0.1"}, ipv4: "192.168.1.2", ipv6: "2001:db8::1", want: ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := wildcardAdvertiseIP(tt.bindHost, tt.ipv4, tt.ipv6); got != tt.want { - t.Fatalf("wildcardAdvertiseIP(%q, %q, %q) = %q, want %q", tt.bindHost, tt.ipv4, tt.ipv6, got, tt.want) + if got := wildcardAdvertiseIP(tt.bindHosts, tt.ipv4, tt.ipv6); got != tt.want { + t.Fatalf("wildcardAdvertiseIP(%#v, %q, %q) = %q, want %q", tt.bindHosts, tt.ipv4, tt.ipv6, got, tt.want) } }) } } + +func TestOpenLauncherListeners_HonorsIPv6OnlyHost(t *testing.T) { + hasIPv4, hasIPv6 := netbind.DetectIPFamilies() + if !hasIPv6 { + t.Skip("IPv6 is unavailable in this environment") + } + + result, err := openLauncherListeners("::", false, "0") + if err != nil { + t.Fatalf("openLauncherListeners() error = %v", err) + } + startLauncherTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + requireLauncherHTTPReachable(t, "::1", port) + if hasIPv4 { + requireLauncherHTTPUnreachable(t, "127.0.0.1", port) + } +} + +func TestOpenLauncherListeners_SupportsExplicitMultiHost(t *testing.T) { + hasIPv4, hasIPv6 := netbind.DetectIPFamilies() + if !hasIPv4 || !hasIPv6 { + t.Skip("dual-stack loopback is unavailable in this environment") + } + + result, err := openLauncherListeners("127.0.0.1,::1", false, "0") + if err != nil { + t.Fatalf("openLauncherListeners() error = %v", err) + } + startLauncherTestHTTPServer(t, result.Listeners) + port := mustAtoi(t, result.Port) + + requireLauncherHTTPReachable(t, "127.0.0.1", port) + requireLauncherHTTPReachable(t, "::1", port) +} + +func startLauncherTestHTTPServer(t *testing.T, listeners []net.Listener) { + t.Helper() + + server := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok") + }), + } + + errCh := make(chan error, len(listeners)) + for _, listener := range listeners { + ln := listener + go func() { + errCh <- server.Serve(ln) + }() + } + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + for range listeners { + err := <-errCh + if err != nil && !errors.Is(err, http.ErrServerClosed) { + t.Fatalf("server.Serve() error = %v", err) + } + } + }) +} + +func requireLauncherHTTPReachable(t *testing.T, host string, port int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + err := launcherHTTPGet(host, port) + if err == nil { + return + } + if time.Now().After(deadline) { + t.Fatalf("expected %s:%d to be reachable: %v", host, port, err) + } + time.Sleep(50 * time.Millisecond) + } +} + +func requireLauncherHTTPUnreachable(t *testing.T, host string, port int) { + t.Helper() + if err := launcherHTTPGet(host, port); err == nil { + t.Fatalf("expected %s:%d to be unreachable", host, port) + } +} + +func launcherHTTPGet(host string, port int) error { + client := &http.Client{ + Timeout: 300 * time.Millisecond, + Transport: &http.Transport{ + Proxy: nil, + }, + } + + resp, err := client.Get("http://" + net.JoinHostPort(host, strconv.Itoa(port))) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New(resp.Status) + } + return nil +} + +func mustAtoi(t *testing.T, value string) int { + t.Helper() + n, err := strconv.Atoi(value) + if err != nil { + t.Fatalf("Atoi(%q) error = %v", value, err) + } + return n +} diff --git a/web/backend/utils/runtime.go b/web/backend/utils/runtime.go index 9b5516fc1..7cceff707 100644 --- a/web/backend/utils/runtime.go +++ b/web/backend/utils/runtime.go @@ -7,91 +7,11 @@ import ( "os/exec" "path/filepath" "runtime" - "sync" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" ) -var ( - ipFamiliesOnce sync.Once - hasIPv4 bool - hasIPv6 bool -) - -func DetectIPFamilies() (bool, bool) { - ipFamiliesOnce.Do(func() { - if ips, err := net.LookupIP("localhost"); err == nil { - for _, ip := range ips { - if ip == nil { - continue - } - if ip.To4() != nil { - hasIPv4 = true - continue - } - hasIPv6 = true - } - } - - if hasIPv4 && hasIPv6 { - 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 { - hasIPv4 = true - continue - } - hasIPv6 = true - } - } - }) - - return hasIPv4, hasIPv6 -} - -func SelectAdaptiveLoopbackHost(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 SelectAdaptiveAnyHost(hasIPv4, hasIPv6 bool) string { - switch { - case hasIPv4 && hasIPv6: - return "::" - case hasIPv6: - return "::" - case hasIPv4: - return "0.0.0.0" - default: - return "::" - } -} - -func ResolveAdaptiveLoopbackHost() string { - hasIPv4, hasIPv6 := DetectIPFamilies() - return SelectAdaptiveLoopbackHost(hasIPv4, hasIPv6) -} - -func ResolveAdaptiveAnyHost() string { - hasIPv4, hasIPv6 := DetectIPFamilies() - return SelectAdaptiveAnyHost(hasIPv4, hasIPv6) -} - // GetPicoclawHome returns the picoclaw home directory. // Priority: $PICOCLAW_HOME > ~/.picoclaw func GetPicoclawHome() string { diff --git a/web/backend/utils/runtime_test.go b/web/backend/utils/runtime_test.go deleted file mode 100644 index dbcacdc9a..000000000 --- a/web/backend/utils/runtime_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package utils - -import "testing" - -func TestSelectAdaptiveLoopbackHost(t *testing.T) { - tests := []struct { - name string - hasIPv4 bool - hasIPv6 bool - want string - }{ - {name: "dual stack", 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: "localhost"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := SelectAdaptiveLoopbackHost(tt.hasIPv4, tt.hasIPv6); got != tt.want { - t.Fatalf("SelectAdaptiveLoopbackHost(%t, %t) = %q, want %q", tt.hasIPv4, tt.hasIPv6, got, tt.want) - } - }) - } -} - -func TestSelectAdaptiveAnyHost(t *testing.T) { - tests := []struct { - name string - hasIPv4 bool - hasIPv6 bool - want string - }{ - {name: "dual stack", 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 TestResolveAdaptiveHosts(t *testing.T) { - loopback := ResolveAdaptiveLoopbackHost() - if loopback == "" { - t.Fatal("ResolveAdaptiveLoopbackHost() returned empty host") - } - - anyHost := ResolveAdaptiveAnyHost() - if anyHost == "" { - t.Fatal("ResolveAdaptiveAnyHost() returned empty host") - } -} From 93bf871bd205562f6ea034e1be786ad3da504e43 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:35:48 +0800 Subject: [PATCH 06/15] fix(launcher): refine console host display --- web/backend/main.go | 120 ++++++++++++++++++++++++++--------- web/backend/main_test.go | 110 ++++++++++++++++++++++++++------ web/backend/utils/runtime.go | 86 +++++++++++++++++++------ 3 files changed, 249 insertions(+), 67 deletions(-) diff --git a/web/backend/main.go b/web/backend/main.go index 0de9fa5da..4318a8a4e 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -139,45 +139,105 @@ func advertiseIPForWildcardBindHosts(bindHosts []string) string { return wildcardAdvertiseIP(bindHosts, utils.GetLocalIPv4(), utils.GetLocalIPv6()) } -func launcherConsoleHosts(bindHosts []string, probeHost string) []string { - hosts := make([]string, 0, 6) - seen := make(map[string]struct{}, 6) +func appendLauncherConsoleHostList(hosts []string, seen map[string]struct{}, values []string) []string { + for _, value := range values { + hosts = appendUniqueHost(hosts, seen, value) + } + return hosts +} - hosts = appendUniqueHost(hosts, seen, probeHost) +func isConsoleDisplayGlobalIPv6(ip net.IP) bool { + if ip == nil || ip.IsLoopback() || ip.To4() != nil { + return false + } + ip = ip.To16() + if ip == nil { + return false + } + return ip[0]&0xe0 == 0x20 +} - for _, bindHost := range bindHosts { - switch { - case netbind.IsUnspecifiedHost(bindHost): - if ip := net.ParseIP(strings.Trim(bindHost, "[]")); ip != nil && ip.To4() != nil { - hosts = appendUniqueHost(hosts, seen, "127.0.0.1") - } else { - hosts = appendUniqueHost(hosts, seen, "::1") - } - case netbind.IsLoopbackHost(bindHost): - hosts = appendUniqueHost(hosts, seen, "localhost") - if ip := net.ParseIP(strings.Trim(bindHost, "[]")); ip != nil { - if ip.To4() != nil { - hosts = appendUniqueHost(hosts, seen, "127.0.0.1") - } else { - hosts = appendUniqueHost(hosts, seen, "::1") - } - } - default: - hosts = appendUniqueHost(hosts, seen, bindHost) +func launcherConsoleHostsWithLocalAddrs( + hostInput string, + public bool, + ipv4s []string, + globalIPv6s []string, +) []string { + hosts := make([]string, 0, 8) + seen := make(map[string]struct{}, 8) + + hosts = appendUniqueHost(hosts, seen, "localhost") + + normalizedHostInput := strings.TrimSpace(hostInput) + if normalizedHostInput == "" { + if public { + hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s) + hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s) + } + return hosts + } + + hasStar := false + hasIPv4Any := false + hasIPv6Any := false + for _, token := range strings.Split(normalizedHostInput, ",") { + switch strings.TrimSpace(token) { + case "*": + hasStar = true + case "0.0.0.0": + hasIPv4Any = true + case "::": + hasIPv6Any = true } } - if hasWildcardBindHosts(bindHosts) { - hosts = appendUniqueHost(hosts, seen, "localhost") - hosts = appendUniqueHost(hosts, seen, "::1") - hosts = appendUniqueHost(hosts, seen, "127.0.0.1") - hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv6()) - hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv4()) + if hasStar { + hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s) + hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s) + return hosts + } + + for _, token := range strings.Split(normalizedHostInput, ",") { + token = strings.TrimSpace(token) + if token == "" || strings.EqualFold(token, "localhost") || netbind.IsLoopbackHost(token) { + continue + } + + ip := net.ParseIP(strings.Trim(token, "[]")) + switch { + case token == "::": + hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s) + case token == "0.0.0.0": + hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s) + case ip != nil && ip.To4() != nil: + if hasIPv4Any { + continue + } + hosts = appendUniqueHost(hosts, seen, ip.String()) + case ip != nil: + if hasIPv6Any { + continue + } + if isConsoleDisplayGlobalIPv6(ip) { + hosts = appendUniqueHost(hosts, seen, ip.String()) + } + default: + hosts = appendUniqueHost(hosts, seen, token) + } } return hosts } +func launcherConsoleHosts(_ []string, hostInput string, public bool) []string { + return launcherConsoleHostsWithLocalAddrs( + hostInput, + public, + utils.GetLocalIPv4s(), + utils.GetGlobalIPv6s(), + ) +} + func firstNonEmpty(values ...string) string { for _, value := range values { value = strings.TrimSpace(value) @@ -443,7 +503,7 @@ func main() { // Print startup banner and token (console mode only). if enableConsole || debug { - consoleHosts := launcherConsoleHosts(openResult.BindHosts, openResult.ProbeHost) + consoleHosts := launcherConsoleHosts(openResult.BindHosts, hostInput, effectivePublic) fmt.Print(utils.Banner) fmt.Println() diff --git a/web/backend/main_test.go b/web/backend/main_test.go index 8ad132a69..3047a3fa3 100644 --- a/web/backend/main_test.go +++ b/web/backend/main_test.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "strconv" + "strings" "testing" "time" @@ -123,27 +124,100 @@ func TestResolveLauncherHostInput(t *testing.T) { } func TestLauncherConsoleHosts(t *testing.T) { - t.Run("wildcard exposes local loopback hints", func(t *testing.T) { - hosts := launcherConsoleHosts([]string{"::"}, netbind.ResolveAdaptiveLoopbackHost()) - seen := make(map[string]bool, len(hosts)) - for _, host := range 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("default loopback shows localhost only", func(t *testing.T) { + hosts := launcherConsoleHostsWithLocalAddrs( + "", + false, + []string{"192.168.1.2", "10.0.0.8"}, + []string{"2001:db8::1", "2001:db8::2"}, + ) + want := []string{"localhost"} + if strings.Join(hosts, ",") != strings.Join(want, ",") { + t.Fatalf("hosts = %#v, want %#v", hosts, want) } }) - t.Run("explicit ipv6 host remains visible", func(t *testing.T) { - hosts := launcherConsoleHosts([]string{"::1"}, "::1") - if len(hosts) < 1 || hosts[0] != "::1" { - t.Fatalf("hosts = %#v, want probe host first", hosts) + t.Run("explicit loopback hosts collapse to localhost", func(t *testing.T) { + tests := []struct { + name string + hostInput string + }{ + {name: "ipv6 loopback", hostInput: "::1"}, + {name: "ipv4 loopback", hostInput: "127.0.0.1"}, + {name: "localhost", hostInput: "localhost"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hosts := launcherConsoleHostsWithLocalAddrs( + tt.hostInput, + false, + []string{"192.168.1.2", "10.0.0.8"}, + []string{"2001:db8::1", "2001:db8::2"}, + ) + want := []string{"localhost"} + if strings.Join(hosts, ",") != strings.Join(want, ",") { + t.Fatalf("hosts = %#v, want %#v", hosts, want) + } + }) + } + }) + + t.Run("public wildcard shows localhost then ipv6 and ipv4", func(t *testing.T) { + hosts := launcherConsoleHostsWithLocalAddrs( + "", + true, + []string{"192.168.1.2", "10.0.0.8"}, + []string{"2001:db8::1", "2001:db8::2"}, + ) + want := []string{"localhost", "2001:db8::1", "2001:db8::2", "192.168.1.2", "10.0.0.8"} + if strings.Join(hosts, ",") != strings.Join(want, ",") { + t.Fatalf("hosts = %#v, want %#v", hosts, want) + } + }) + + t.Run("explicit ipv6 any shows localhost then ipv6 variants", func(t *testing.T) { + hosts := launcherConsoleHostsWithLocalAddrs( + "::", + false, + []string{"192.168.1.2", "10.0.0.8"}, + []string{"2001:db8::1", "2001:db8::2"}, + ) + want := []string{"localhost", "2001:db8::1", "2001:db8::2"} + if strings.Join(hosts, ",") != strings.Join(want, ",") { + t.Fatalf("hosts = %#v, want %#v", hosts, want) + } + + for _, host := range hosts { + if host == "::1" || host == "127.0.0.1" || strings.HasPrefix(strings.ToLower(host), "fe80:") { + t.Fatalf("hosts = %#v, loopback IPs must not be displayed", hosts) + } + } + }) + + t.Run("explicit ipv4 any shows localhost then lan ipv4", func(t *testing.T) { + hosts := launcherConsoleHostsWithLocalAddrs( + "0.0.0.0", + false, + []string{"192.168.1.2", "10.0.0.8"}, + []string{"2001:db8::1", "2001:db8::2"}, + ) + want := []string{"localhost", "192.168.1.2", "10.0.0.8"} + if strings.Join(hosts, ",") != strings.Join(want, ",") { + t.Fatalf("hosts = %#v, want %#v", hosts, want) + } + }) + + t.Run("explicit multi-address binding shows all exact ipv4 and global ipv6 addresses", func(t *testing.T) { + hosts := launcherConsoleHostsWithLocalAddrs( + "192.168.1.2,10.0.0.8,2001:db8::1,2001:db8::2,fe80::1", + false, + []string{"192.168.1.2", "10.0.0.8"}, + []string{"2001:db8::1", "2001:db8::2"}, + ) + want := []string{"localhost", "192.168.1.2", "10.0.0.8", "2001:db8::1", "2001:db8::2"} + if strings.Join(hosts, ",") != strings.Join(want, ",") { + t.Fatalf("hosts = %#v, want %#v", hosts, want) } }) } diff --git a/web/backend/utils/runtime.go b/web/backend/utils/runtime.go index 7cceff707..8899a664b 100644 --- a/web/backend/utils/runtime.go +++ b/web/backend/utils/runtime.go @@ -7,6 +7,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" @@ -54,41 +55,88 @@ func FindPicoclawBinary() string { return "picoclaw" } -// GetLocalIPv4 returns a non-loopback local IPv4 address. -func GetLocalIPv4() string { - addrs, err := net.InterfaceAddrs() - if err != nil { - return "" +func appendUniqueIP(addrs []string, seen map[string]struct{}, value string) []string { + value = strings.TrimSpace(value) + if value == "" { + return addrs } - for _, a := range addrs { - if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { - return ipnet.IP.String() - } + if _, ok := seen[value]; ok { + return addrs } - return "" + seen[value] = struct{}{} + return append(addrs, value) } -// GetLocalIPv6 returns a non-loopback local IPv6 address. -func GetLocalIPv6() string { +// GetLocalIPv4s returns all non-loopback local IPv4 addresses. +func GetLocalIPv4s() []string { addrs, err := net.InterfaceAddrs() if err != nil { - return "" + return nil } + results := make([]string, 0, 4) + seen := make(map[string]struct{}, 4) + for _, a := range addrs { + ipnet, ok := a.(*net.IPNet) + if !ok || ipnet.IP == nil || ipnet.IP.IsLoopback() { + continue + } + if ip4 := ipnet.IP.To4(); ip4 != nil { + results = appendUniqueIP(results, seen, ip4.String()) + } + } + return results +} + +func isDisplayGlobalIPv6(ip net.IP) bool { + if ip == nil || ip.IsLoopback() || ip.To4() != nil { + return false + } + ip = ip.To16() + if ip == nil { + return false + } + // Only show IPv6 global unicast addresses in 2000::/3. + return ip[0]&0xe0 == 0x20 +} + +// GetGlobalIPv6s returns all IPv6 global unicast addresses. +func GetGlobalIPv6s() []string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return nil + } + results := make([]string, 0, 4) + seen := make(map[string]struct{}, 4) for _, a := range addrs { ipnet, ok := a.(*net.IPNet) if !ok || ipnet.IP == nil { continue } ip := ipnet.IP - if ip.IsLoopback() || ip.To4() != nil { + if !isDisplayGlobalIPv6(ip) { continue } - if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { - continue - } - return ip.String() + results = appendUniqueIP(results, seen, ip.String()) } - return "" + return results +} + +// GetLocalIPv4 returns the first non-loopback local IPv4 address. +func GetLocalIPv4() string { + addrs := GetLocalIPv4s() + if len(addrs) == 0 { + return "" + } + return addrs[0] +} + +// GetLocalIPv6 returns the first IPv6 global unicast address. +func GetLocalIPv6() string { + addrs := GetGlobalIPv6s() + if len(addrs) == 0 { + return "" + } + return addrs[0] } // GetLocalIP returns a non-loopback local IPv4 address for backward compatibility. From ae195831bbc2abca37378d04a3220c0a113d402a Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:30:37 +0800 Subject: [PATCH 07/15] fix: resolve PR2514 lint regressions --- cmd/picoclaw/internal/gateway/command_test.go | 8 ++- pkg/gateway/gateway.go | 2 +- pkg/netbind/netbind.go | 10 +++- web/backend/api/gateway_test.go | 3 +- web/backend/main_test.go | 59 ++++++++++++++++--- 5 files changed, 69 insertions(+), 13 deletions(-) diff --git a/cmd/picoclaw/internal/gateway/command_test.go b/cmd/picoclaw/internal/gateway/command_test.go index 8dc56fc6d..825369abb 100644 --- a/cmd/picoclaw/internal/gateway/command_test.go +++ b/cmd/picoclaw/internal/gateway/command_test.go @@ -43,7 +43,13 @@ func TestResolveGatewayHostOverride(t *testing.T) { {name: "implicit empty host is allowed", explicit: false, host: "", wantHost: "", wantErr: false}, {name: "explicit empty host rejected", explicit: true, host: " ", wantHost: "", wantErr: true}, {name: "explicit localhost kept", explicit: true, host: " localhost ", wantHost: "localhost", wantErr: false}, - {name: "explicit multi host normalized", explicit: true, host: " [::1] , 127.0.0.1 ", wantHost: "::1,127.0.0.1", wantErr: false}, + { + name: "explicit multi host normalized", + explicit: true, + host: " [::1] , 127.0.0.1 ", + wantHost: "::1,127.0.0.1", + wantErr: false, + }, } for _, tt := range tests { diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 79c86fa96..039f45075 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -417,7 +417,7 @@ func setupAndStartServices( runningServices.authToken = authToken runningServices.HealthServer = health.NewServer(listenResult.ProbeHost, cfg.Gateway.Port, authToken) - listenAddr := "" + var listenAddr string if len(listenResult.Listeners) > 0 { listenAddr = listenResult.Listeners[0].Addr().String() } else { diff --git a/pkg/netbind/netbind.go b/pkg/netbind/netbind.go index 7f6121f28..ceff0757b 100644 --- a/pkg/netbind/netbind.go +++ b/pkg/netbind/netbind.go @@ -506,8 +506,14 @@ func openGroup(group bindGroup, port string) ([]net.Listener, []string, string, func openAdaptiveLoopbackGroup(allowIPv6, allowIPv4 bool, port string) ([]net.Listener, []string, string, error) { if allowIPv6 && allowIPv4 { - if ln6, actualPort, err6 := openExactListener(exactBinding{host: "::1", network: "tcp6", v6Only: true}, port); err6 == nil { - if ln4, _, err4 := openExactListener(exactBinding{host: "127.0.0.1", network: "tcp4"}, actualPort); err4 == nil { + if ln6, actualPort, err6 := openExactListener( + exactBinding{host: "::1", network: "tcp6", v6Only: true}, + port, + ); err6 == nil { + if ln4, _, err4 := openExactListener( + exactBinding{host: "127.0.0.1", network: "tcp4"}, + actualPort, + ); err4 == nil { return []net.Listener{ln6, ln4}, []string{"::1", "127.0.0.1"}, actualPort, nil } _ = ln6.Close() diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 9e14bf42d..78bf34a63 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -216,7 +216,8 @@ func startGatewayAndCaptureEnv(t *testing.T, h *Handler) gatewayStartEnvSnapshot raw, err := os.ReadFile(envPath) if err == nil { var snapshot gatewayStartEnvSnapshot - if err := json.Unmarshal(raw, &snapshot); err != nil { + err = json.Unmarshal(raw, &snapshot) + if err != nil { t.Fatalf("Unmarshal(child env) error = %v", err) } return snapshot diff --git a/web/backend/main_test.go b/web/backend/main_test.go index 3047a3fa3..ea2a34104 100644 --- a/web/backend/main_test.go +++ b/web/backend/main_test.go @@ -51,9 +51,21 @@ func TestDashboardTokenConfigHelpPath(t *testing.T) { source launcherconfig.DashboardTokenSource want string }{ - {name: "env token does not expose config path", source: launcherconfig.DashboardTokenSourceEnv, want: ""}, - {name: "config token exposes config path", source: launcherconfig.DashboardTokenSourceConfig, want: launcherPath}, - {name: "random token does not expose config path", source: launcherconfig.DashboardTokenSourceRandom, want: ""}, + { + name: "env token does not expose config path", + source: launcherconfig.DashboardTokenSourceEnv, + want: "", + }, + { + name: "config token exposes config path", + source: launcherconfig.DashboardTokenSourceConfig, + want: launcherPath, + }, + { + name: "random token does not expose config path", + source: launcherconfig.DashboardTokenSourceRandom, + want: "", + }, } for _, tt := range tests { @@ -98,7 +110,14 @@ func TestResolveLauncherHostInput(t *testing.T) { wantActive bool wantErr bool }{ - {name: "flag host wins", flagHost: "127.0.0.1", explicitFlag: true, envHost: "::", wantHost: "127.0.0.1", wantActive: true}, + { + name: "flag host wins", + flagHost: "127.0.0.1", + explicitFlag: true, + envHost: "::", + wantHost: "127.0.0.1", + wantActive: true, + }, {name: "env host used when flag absent", envHost: "127.0.0.1,::1", wantHost: "127.0.0.1,::1", wantActive: true}, {name: "blank env ignored", envHost: " ", wantHost: "", wantActive: false}, {name: "invalid flag rejected", flagHost: "127.0.0.1, ", explicitFlag: true, wantErr: true}, @@ -230,10 +249,34 @@ func TestWildcardAdvertiseIP(t *testing.T) { ipv6 string want string }{ - {name: "ipv4 wildcard prefers ipv6 when available", bindHosts: []string{"0.0.0.0"}, ipv4: "192.168.1.2", ipv6: "2001:db8::1", want: "2001:db8::1"}, - {name: "ipv6 wildcard uses ipv6", bindHosts: []string{"::"}, ipv4: "192.168.1.2", ipv6: "2001:db8::1", want: "2001:db8::1"}, - {name: "ipv6 wildcard falls back to ipv4", bindHosts: []string{"::"}, ipv4: "192.168.1.2", ipv6: "", want: "192.168.1.2"}, - {name: "non wildcard does not advertise", bindHosts: []string{"127.0.0.1"}, ipv4: "192.168.1.2", ipv6: "2001:db8::1", want: ""}, + { + name: "ipv4 wildcard prefers ipv6 when available", + bindHosts: []string{"0.0.0.0"}, + ipv4: "192.168.1.2", + ipv6: "2001:db8::1", + want: "2001:db8::1", + }, + { + name: "ipv6 wildcard uses ipv6", + bindHosts: []string{"::"}, + ipv4: "192.168.1.2", + ipv6: "2001:db8::1", + want: "2001:db8::1", + }, + { + name: "ipv6 wildcard falls back to ipv4", + bindHosts: []string{"::"}, + ipv4: "192.168.1.2", + ipv6: "", + want: "192.168.1.2", + }, + { + name: "non wildcard does not advertise", + bindHosts: []string{"127.0.0.1"}, + ipv4: "192.168.1.2", + ipv6: "2001:db8::1", + want: "", + }, } for _, tt := range tests { From 24382271d6fb64e90c131d5c60b7dba44fea6380 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:17:27 +0800 Subject: [PATCH 08/15] fix(web): align wildcard advertise IP preference --- web/backend/main.go | 44 +++++++++++++++++++++++++++++++++++----- web/backend/main_test.go | 20 +++++++++++++++--- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/web/backend/main.go b/web/backend/main.go index 4318a8a4e..3ee47cb07 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -124,15 +124,49 @@ func hasWildcardBindHosts(bindHosts []string) bool { return false } -func wildcardAdvertiseIP(bindHosts []string, ipv4, ipv6 string) string { - if !hasWildcardBindHosts(bindHosts) { - return "" +func wildcardBindHostFamilies(bindHosts []string) (hasIPv4, hasIPv6 bool) { + for _, bindHost := range bindHosts { + host := strings.TrimSpace(bindHost) + if host == "" { + continue + } + + if !netbind.IsUnspecifiedHost(host) { + continue + } + + ip := net.ParseIP(strings.Trim(host, "[]")) + if ip == nil { + continue + } + if ip.To4() != nil { + hasIPv4 = true + continue + } + hasIPv6 = true } - if v6 := strings.TrimSpace(ipv6); v6 != "" { + return hasIPv4, hasIPv6 +} + +func wildcardAdvertiseIP(bindHosts []string, ipv4, ipv6 string) string { + hasIPv4Wildcard, hasIPv6Wildcard := wildcardBindHostFamilies(bindHosts) + v4 := strings.TrimSpace(ipv4) + v6 := strings.TrimSpace(ipv6) + + switch { + case hasIPv4Wildcard && hasIPv6Wildcard: + if v6 != "" { + return v6 + } + return v4 + case hasIPv6Wildcard: return v6 + case hasIPv4Wildcard: + return v4 + default: + return "" } - return strings.TrimSpace(ipv4) } func advertiseIPForWildcardBindHosts(bindHosts []string) string { diff --git a/web/backend/main_test.go b/web/backend/main_test.go index ea2a34104..e1702a61e 100644 --- a/web/backend/main_test.go +++ b/web/backend/main_test.go @@ -250,10 +250,17 @@ func TestWildcardAdvertiseIP(t *testing.T) { want string }{ { - name: "ipv4 wildcard prefers ipv6 when available", + name: "ipv4 wildcard uses ipv4", bindHosts: []string{"0.0.0.0"}, ipv4: "192.168.1.2", ipv6: "2001:db8::1", + want: "192.168.1.2", + }, + { + name: "dual wildcard prefers ipv6", + bindHosts: []string{"0.0.0.0", "::"}, + ipv4: "192.168.1.2", + ipv6: "2001:db8::1", want: "2001:db8::1", }, { @@ -264,12 +271,19 @@ func TestWildcardAdvertiseIP(t *testing.T) { want: "2001:db8::1", }, { - name: "ipv6 wildcard falls back to ipv4", - bindHosts: []string{"::"}, + name: "dual wildcard falls back to ipv4 when ipv6 missing", + bindHosts: []string{"0.0.0.0", "::"}, ipv4: "192.168.1.2", ipv6: "", want: "192.168.1.2", }, + { + name: "ipv6 wildcard without ipv6 does not advertise ipv4", + bindHosts: []string{"::"}, + ipv4: "192.168.1.2", + ipv6: "", + want: "", + }, { name: "non wildcard does not advertise", bindHosts: []string{"127.0.0.1"}, From d4313b5e5f55597e6886b70b8c8f16231714ada1 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:22:30 +0800 Subject: [PATCH 09/15] feat(web): show disabled chat reasons in composer --- .../src/components/chat/chat-composer.tsx | 45 +++++++---- .../src/components/chat/chat-page.tsx | 78 +++++++++++++++++-- web/frontend/src/i18n/locales/en.json | 12 +++ web/frontend/src/i18n/locales/zh.json | 12 +++ 4 files changed, 124 insertions(+), 23 deletions(-) diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx index b0b25d1db..9223449a4 100644 --- a/web/frontend/src/components/chat/chat-composer.tsx +++ b/web/frontend/src/components/chat/chat-composer.tsx @@ -7,6 +7,18 @@ import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import type { ChatAttachment } from "@/store/chat" +export type ChatInputDisabledReason = + | "gatewayUnknown" + | "gatewayStarting" + | "gatewayRestarting" + | "gatewayStopping" + | "gatewayStopped" + | "gatewayError" + | "websocketConnecting" + | "websocketDisconnected" + | "websocketError" + | "noDefaultModel" + interface ChatComposerProps { input: string attachments: ChatAttachment[] @@ -14,8 +26,7 @@ interface ChatComposerProps { onAddImages: () => void onRemoveAttachment: (index: number) => void onSend: () => void - isConnected: boolean - hasDefaultModel: boolean + inputDisabledReason: ChatInputDisabledReason | null canSend: boolean } @@ -26,12 +37,14 @@ export function ChatComposer({ onAddImages, onRemoveAttachment, onSend, - isConnected, - hasDefaultModel, + inputDisabledReason, canSend, }: ChatComposerProps) { const { t } = useTranslation() - const canInput = isConnected && hasDefaultModel + const canInput = inputDisabledReason === null + const placeholder = canInput + ? t("chat.placeholder") + : t(`chat.disabledPlaceholder.${inputDisabledReason}`) const handleKeyDown = (e: KeyboardEvent) => { if (e.nativeEvent.isComposing) return @@ -74,7 +87,7 @@ export function ChatComposer({ value={input} onChange={(e) => onInputChange(e.target.value)} onKeyDown={handleKeyDown} - placeholder={t("chat.placeholder")} + placeholder={placeholder} disabled={!canInput} className={cn( "placeholder:text-muted-foreground/50 max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent", @@ -100,15 +113,17 @@ export function ChatComposer({ - + {canInput ? ( + + ) : null} diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index e8e07a801..30be8d581 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -4,7 +4,10 @@ import { useTranslation } from "react-i18next" import { toast } from "sonner" import { AssistantMessage } from "@/components/chat/assistant-message" -import { ChatComposer } from "@/components/chat/chat-composer" +import { + type ChatInputDisabledReason, + ChatComposer, +} from "@/components/chat/chat-composer" import { ChatEmptyState } from "@/components/chat/chat-empty-state" import { ModelSelector } from "@/components/chat/model-selector" import { SessionHistoryMenu } from "@/components/chat/session-history-menu" @@ -16,7 +19,9 @@ import { useChatModels } from "@/hooks/use-chat-models" import { useGateway } from "@/hooks/use-gateway" import { usePicoChat } from "@/hooks/use-pico-chat" import { useSessionHistory } from "@/hooks/use-session-history" +import type { ConnectionState } from "@/store/chat" import type { ChatAttachment } from "@/store/chat" +import type { GatewayState } from "@/store/gateway" const MAX_IMAGE_SIZE_BYTES = 7 * 1024 * 1024 const MAX_IMAGE_SIZE_LABEL = "7 MB" @@ -44,6 +49,58 @@ function readFileAsDataUrl(file: File): Promise { }) } +function resolveChatInputDisabledReason({ + hasDefaultModel, + connectionState, + gatewayState, +}: { + hasDefaultModel: boolean + connectionState: ConnectionState + gatewayState: GatewayState +}): ChatInputDisabledReason | null { + if (gatewayState === "unknown") { + return "gatewayUnknown" + } + + if (gatewayState === "starting") { + return "gatewayStarting" + } + + if (gatewayState === "restarting") { + return "gatewayRestarting" + } + + if (gatewayState === "stopping") { + return "gatewayStopping" + } + + if (gatewayState === "stopped") { + return "gatewayStopped" + } + + if (gatewayState === "error") { + return "gatewayError" + } + + if (connectionState === "connecting") { + return "websocketConnecting" + } + + if (connectionState === "error") { + return "websocketError" + } + + if (connectionState === "disconnected") { + return "websocketDisconnected" + } + + if (!hasDefaultModel) { + return "noDefaultModel" + } + + return null +} + export function ChatPage() { const { t } = useTranslation() const scrollRef = useRef(null) @@ -65,7 +122,6 @@ export function ChatPage() { const { state: gwState } = useGateway() const isGatewayRunning = gwState === "running" - const isChatConnected = connectionState === "connected" const { defaultModelName, @@ -75,7 +131,13 @@ export function ChatPage() { localModels, handleSetDefault, } = useChatModels({ isConnected: isGatewayRunning }) - const canSend = isChatConnected && Boolean(defaultModelName) + const hasDefaultModel = Boolean(defaultModelName) + const inputDisabledReason = resolveChatInputDisabledReason({ + hasDefaultModel, + connectionState, + gatewayState: gwState, + }) + const canInput = inputDisabledReason === null const { sessions, @@ -110,7 +172,7 @@ export function ChatPage() { }, [messages, isTyping, isAtBottom]) const handleSend = () => { - if ((!input.trim() && attachments.length === 0) || !canSend) return + if ((!input.trim() && attachments.length === 0) || !canInput) return if ( sendMessage({ content: input, @@ -123,7 +185,7 @@ export function ChatPage() { } const handleAddImages = () => { - if (!canSend) return + if (!canInput) return fileInputRef.current?.click() } @@ -180,7 +242,8 @@ export function ChatPage() { } } - const canSubmit = canSend && (Boolean(input.trim()) || attachments.length > 0) + const canSubmit = + canInput && (Boolean(input.trim()) || attachments.length > 0) return (
@@ -278,8 +341,7 @@ export function ChatPage() { onAddImages={handleAddImages} onRemoveAttachment={handleRemoveAttachment} onSend={handleSend} - isConnected={isChatConnected} - hasDefaultModel={Boolean(defaultModelName)} + inputDisabledReason={inputDisabledReason} canSend={canSubmit} />
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 2434d4576..179c2d35a 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -39,6 +39,18 @@ "welcome": "How can I help you today?", "welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.", "placeholder": "Start a new message...\nPress Enter to send, Shift + Enter for a new line", + "disabledPlaceholder": { + "gatewayUnknown": "Unable to chat: Gateway status is still being checked. Please wait, then refresh the page or restart Launcher if needed.", + "gatewayStarting": "Unable to chat: Gateway is starting. Wait for startup to complete, then try again.", + "gatewayRestarting": "Unable to chat: Gateway is restarting. Please wait for restart to finish.", + "gatewayStopping": "Unable to chat: Gateway is stopping. Wait for it to stop, then start Gateway again.", + "gatewayStopped": "Unable to chat: Gateway is not started. Click Start Gateway in the top bar, then retry.", + "gatewayError": "Unable to chat: Gateway is in an error state. Check logs, then restart Gateway or Launcher.", + "websocketConnecting": "Connecting to chat service... Please wait.", + "websocketDisconnected": "Unable to chat: WebSocket connection is disconnected. Check network and gateway status, then refresh the page or restart Launcher.", + "websocketError": "Unable to chat: WebSocket connection failed. Check network and gateway status, then retry.", + "noDefaultModel": "Unable to chat: No default model is selected. Set a default model on the Models page." + }, "newChat": "New Chat", "notConnected": "Gateway is not running. Start it to chat.", "thinking": { diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index c03d4181d..8aa29d9dc 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -39,6 +39,18 @@ "welcome": "今天我能为您做些什么?", "welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。", "placeholder": "输入新消息...\n按 Enter 发送,Shift + Enter 换行", + "disabledPlaceholder": { + "gatewayUnknown": "无法对话:网关状态仍在检测中。请稍候重试,如仍无效请刷新页面或重启 Launcher。", + "gatewayStarting": "无法对话:网关正在启动。请等待启动完成后重试。", + "gatewayRestarting": "无法对话:网关正在重启。请等待重启完成。", + "gatewayStopping": "无法对话:网关正在停止。请等待停止完成后重新启动服务。", + "gatewayStopped": "无法对话:网关服务未启动。请点击顶部栏的“启动服务”后重试。", + "gatewayError": "无法对话:网关处于错误状态。请检查日志后重启网关或 Launcher。", + "websocketConnecting": "正在连接聊天服务,请稍候。", + "websocketDisconnected": "无法对话:WebSocket 连接已断开。请检查网络与服务状态,然后刷新页面或重启 Launcher。", + "websocketError": "无法对话:WebSocket 连接失败。请检查网络与服务状态后重试。", + "noDefaultModel": "无法对话:尚未设置默认模型。请前往模型页面设置默认模型。" + }, "newChat": "新建对话", "notConnected": "服务未运行,请先启动以进行对话。", "thinking": { From 79f87d151e7a310f9fbf00d27d56ceb05fbdaf4f Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:24:14 +0800 Subject: [PATCH 10/15] fix(web): show localhost entry only for local binds --- web/backend/main.go | 37 ++++++++++++++++++++++++++++++++++++- web/backend/main_test.go | 17 +++++++++++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/web/backend/main.go b/web/backend/main.go index 3ee47cb07..e5350952f 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -180,6 +180,39 @@ func appendLauncherConsoleHostList(hosts []string, seen map[string]struct{}, val return hosts } +func shouldShowLocalhostConsoleEntry(hostInput string) bool { + normalizedHostInput := strings.TrimSpace(hostInput) + if normalizedHostInput == "" { + return true + } + + for token := range strings.SplitSeq(normalizedHostInput, ",") { + token = strings.TrimSpace(token) + if token == "" { + continue + } + if token == "*" || strings.EqualFold(token, "localhost") { + return true + } + + ip := net.ParseIP(strings.Trim(token, "[]")) + if ip == nil { + continue + } + if ip4 := ip.To4(); ip4 != nil { + if ip4.String() == "127.0.0.1" || ip4.String() == "0.0.0.0" { + return true + } + continue + } + if ip.String() == "::1" || ip.String() == "::" { + return true + } + } + + return false +} + func isConsoleDisplayGlobalIPv6(ip net.IP) bool { if ip == nil || ip.IsLoopback() || ip.To4() != nil { return false @@ -200,7 +233,9 @@ func launcherConsoleHostsWithLocalAddrs( hosts := make([]string, 0, 8) seen := make(map[string]struct{}, 8) - hosts = appendUniqueHost(hosts, seen, "localhost") + if shouldShowLocalhostConsoleEntry(hostInput) { + hosts = appendUniqueHost(hosts, seen, "localhost") + } normalizedHostInput := strings.TrimSpace(hostInput) if normalizedHostInput == "" { diff --git a/web/backend/main_test.go b/web/backend/main_test.go index e1702a61e..6df5370b1 100644 --- a/web/backend/main_test.go +++ b/web/backend/main_test.go @@ -227,14 +227,27 @@ func TestLauncherConsoleHosts(t *testing.T) { } }) - t.Run("explicit multi-address binding shows all exact ipv4 and global ipv6 addresses", func(t *testing.T) { + t.Run("explicit wildcard star shows localhost first", func(t *testing.T) { + hosts := launcherConsoleHostsWithLocalAddrs( + "*", + false, + []string{"192.168.1.2", "10.0.0.8"}, + []string{"2001:db8::1", "2001:db8::2"}, + ) + want := []string{"localhost", "2001:db8::1", "2001:db8::2", "192.168.1.2", "10.0.0.8"} + if strings.Join(hosts, ",") != strings.Join(want, ",") { + t.Fatalf("hosts = %#v, want %#v", hosts, want) + } + }) + + t.Run("explicit multi-address binding without local tokens hides localhost", func(t *testing.T) { hosts := launcherConsoleHostsWithLocalAddrs( "192.168.1.2,10.0.0.8,2001:db8::1,2001:db8::2,fe80::1", false, []string{"192.168.1.2", "10.0.0.8"}, []string{"2001:db8::1", "2001:db8::2"}, ) - want := []string{"localhost", "192.168.1.2", "10.0.0.8", "2001:db8::1", "2001:db8::2"} + want := []string{"192.168.1.2", "10.0.0.8", "2001:db8::1", "2001:db8::2"} if strings.Join(hosts, ",") != strings.Join(want, ",") { t.Fatalf("hosts = %#v, want %#v", hosts, want) } From 0bb9bedc44f961e96470aefae80b49c419e9ba2c Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:39:59 +0800 Subject: [PATCH 11/15] fix(web): address latest Copilot review points --- pkg/netbind/netbind.go | 38 ++++++++++++++++++++++++++++--------- pkg/netbind/netbind_test.go | 11 +++++++++++ web/backend/app_runtime.go | 9 +++++---- web/backend/main.go | 4 ++-- 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/pkg/netbind/netbind.go b/pkg/netbind/netbind.go index ceff0757b..ae6cacf49 100644 --- a/pkg/netbind/netbind.go +++ b/pkg/netbind/netbind.go @@ -538,18 +538,38 @@ func openAdaptiveLoopbackGroup(allowIPv6, allowIPv4 bool, port string) ([]net.Li } func openAdaptiveAnyGroup(port string) ([]net.Listener, []string, string, error) { - // Intentionally bind tcp/:: here. Go's compatibility layer handles dual-stack - // wildcard binding where the platform supports it, while tcp4 remains the - // fallback for IPv4-only environments. - if ln, actualPort, err := openExactListener(exactBinding{host: "::", network: "tcp"}, port); err == nil { - return []net.Listener{ln}, []string{"::"}, actualPort, nil + hasIPv4, hasIPv6 := DetectIPFamilies() + + if hasIPv4 && hasIPv6 { + if ln6, actualPort, err6 := openExactListener( + exactBinding{host: "::", network: "tcp6", v6Only: true}, + port, + ); err6 == nil { + if ln4, _, err4 := openExactListener( + exactBinding{host: "0.0.0.0", network: "tcp4"}, + actualPort, + ); err4 == nil { + return []net.Listener{ln6, ln4}, []string{"::", "0.0.0.0"}, actualPort, nil + } + _ = ln6.Close() + } } - ln4, actualPort, err := openExactListener(exactBinding{host: "0.0.0.0", network: "tcp4"}, port) - if err != nil { - return nil, nil, "", fmt.Errorf("failed to open adaptive any-host listener on port %s", port) + if hasIPv6 { + ln6, actualPort, err := openExactListener(exactBinding{host: "::", network: "tcp6", v6Only: true}, port) + if err == nil { + return []net.Listener{ln6}, []string{"::"}, actualPort, nil + } } - return []net.Listener{ln4}, []string{"0.0.0.0"}, actualPort, nil + + if hasIPv4 { + ln4, actualPort, err := openExactListener(exactBinding{host: "0.0.0.0", network: "tcp4"}, port) + if err == nil { + return []net.Listener{ln4}, []string{"0.0.0.0"}, actualPort, nil + } + } + + return nil, nil, "", fmt.Errorf("failed to open adaptive any-host listener on port %s", port) } func openExactListener(binding exactBinding, port string) (net.Listener, string, error) { diff --git a/pkg/netbind/netbind_test.go b/pkg/netbind/netbind_test.go index bfb524ac8..20b7ff141 100644 --- a/pkg/netbind/netbind_test.go +++ b/pkg/netbind/netbind_test.go @@ -92,6 +92,17 @@ func TestOpenPlan_DefaultAnySupportsDualStackLoopback(t *testing.T) { if hasIPv4 { requireHTTPReachable(t, "127.0.0.1", port) } + + switch { + case hasIPv4 && hasIPv6: + if len(result.BindHosts) != 2 { + t.Fatalf("len(BindHosts) = %d, want 2 (%#v)", len(result.BindHosts), result.BindHosts) + } + case hasIPv6 || hasIPv4: + if len(result.BindHosts) != 1 { + t.Fatalf("len(BindHosts) = %d, want 1 (%#v)", len(result.BindHosts), result.BindHosts) + } + } } func TestOpenPlan_ExplicitIPv6AnyIsIPv6Only(t *testing.T) { diff --git a/web/backend/app_runtime.go b/web/backend/app_runtime.go index 674c0d4e6..a06396526 100644 --- a/web/backend/app_runtime.go +++ b/web/backend/app_runtime.go @@ -35,9 +35,6 @@ func shutdownApp() { } if len(servers) > 0 { - ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) - defer cancel() - for _, srv := range servers { if srv == nil { continue @@ -46,7 +43,11 @@ func shutdownApp() { // Disable keep-alive to allow graceful shutdown srv.SetKeepAlivesEnabled(false) - if err := srv.Shutdown(ctx); err != nil { + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + err := srv.Shutdown(ctx) + cancel() + + if err != nil { // Context deadline exceeded is expected if there are active connections // This is not necessarily an error, so log it at info level if errors.Is(err, context.DeadlineExceeded) { diff --git a/web/backend/main.go b/web/backend/main.go index e5350952f..57409f03a 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -298,7 +298,7 @@ func launcherConsoleHostsWithLocalAddrs( return hosts } -func launcherConsoleHosts(_ []string, hostInput string, public bool) []string { +func launcherConsoleHosts(hostInput string, public bool) []string { return launcherConsoleHostsWithLocalAddrs( hostInput, public, @@ -572,7 +572,7 @@ func main() { // Print startup banner and token (console mode only). if enableConsole || debug { - consoleHosts := launcherConsoleHosts(openResult.BindHosts, hostInput, effectivePublic) + consoleHosts := launcherConsoleHosts(hostInput, effectivePublic) fmt.Print(utils.Banner) fmt.Println() From bf6d4fd997d7dab7d89e3f272951d7ed725587ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=82=86=E6=9C=88?= <2835601846@qq.com> Date: Wed, 15 Apr 2026 09:49:45 +0800 Subject: [PATCH 12/15] feat(web): show disabled reasons in tooltips when buttons are disabled (#2430) * feat(web): show disabled reasons in tooltips when buttons are disabled - Add disabled reason tooltips for model card actions (set default, delete) - Add disabled reason tooltips for marketplace skill card install button - Add disabled reason display for chat input when disabled - Add internationalization support for all disabled reasons (en/zh) - Model card: Show specific reasons when set-default or delete buttons are disabled - Marketplace skill card: Show specific reasons when install button is disabled - Chat composer: Show reason text below input when input is disabled * fix: show disabled action reasons via tooltips * fix(web): restore accessible labels for model action tooltips --- .../agent/hub/market-skill-card.tsx | 62 +++++++--- .../src/components/chat/chat-composer.tsx | 12 ++ .../src/components/models/model-card.tsx | 115 ++++++++++++++---- web/frontend/src/i18n/locales/en.json | 22 +++- web/frontend/src/i18n/locales/zh.json | 22 +++- 5 files changed, 187 insertions(+), 46 deletions(-) diff --git a/web/frontend/src/components/agent/hub/market-skill-card.tsx b/web/frontend/src/components/agent/hub/market-skill-card.tsx index f3ee426a1..99b00db92 100644 --- a/web/frontend/src/components/agent/hub/market-skill-card.tsx +++ b/web/frontend/src/components/agent/hub/market-skill-card.tsx @@ -18,6 +18,11 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" export function MarketSkillCard({ result, @@ -36,6 +41,17 @@ export function MarketSkillCard({ }) { const { t } = useTranslation() + const installDisabledReason = (() => { + if (installPending) + return t("pages.agent.skills.marketplace_installDisabled.installing") + if (result.installed) + return t("pages.agent.skills.marketplace_installDisabled.installed") + if (!canInstall) + return t("pages.agent.skills.marketplace_installDisabled.cannotInstall") + return t("pages.agent.skills.marketplace_install_action") + })() + const installDisabled = !canInstall || result.installed || installPending + return (
- + + + + + + + {installDisabledReason} + {result.installed && installedSkill ? ( + + + + + + + {setDefaultDisabledReason} + )} - + + + + + + + {deleteDisabledReason} +
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 179c2d35a..a1310e16f 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -68,6 +68,10 @@ "deleteSession": "Delete session", "messagesCount": "{{count}} messages", "noModel": "Select model", + "inputDisabled": { + "notConnected": "Gateway is not running. Start it to chat.", + "noModel": "No default model configured. Go to Models page to set one." + }, "attachImage": "Add images", "removeImage": "Remove image", "uploadedImage": "Uploaded image", @@ -212,7 +216,16 @@ "action": { "edit": "Edit API key", "setDefault": "Set as default", - "delete": "Delete model" + "delete": "Delete model", + "setDefaultDisabled": { + "setting": "Setting as default...", + "unavailable": "Cannot set unavailable model as default", + "isDefault": "Already the default model", + "isVirtual": "Cannot set virtual model as default" + }, + "deleteDisabled": { + "isDefault": "Cannot delete the default model" + } }, "defaultOnSave": { "label": "Default Model", @@ -500,6 +513,11 @@ "version": "Installed Version", "lines": "Line Count", "characters": "Character Count" + }, + "marketplace_installDisabled": { + "installing": "Installing...", + "installed": "Already installed", + "cannotInstall": "Cannot install: related tool is not enabled" } }, "tools": { @@ -668,4 +686,4 @@ "description": "Need more help? Click the documentation button in the top right corner to view detailed guides and configuration docs." } } -} \ No newline at end of file +} diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 8aa29d9dc..8e58e151a 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -68,6 +68,10 @@ "deleteSession": "删除会话", "messagesCount": "{{count}} 条消息", "noModel": "选择模型", + "inputDisabled": { + "notConnected": "服务未运行,请先启动以进行对话。", + "noModel": "未设置默认模型,请前往模型页面进行配置。" + }, "attachImage": "添加图片", "removeImage": "移除图片", "uploadedImage": "已上传图片", @@ -212,7 +216,16 @@ "action": { "edit": "编辑 API Key", "setDefault": "设为默认", - "delete": "删除模型" + "delete": "删除模型", + "setDefaultDisabled": { + "setting": "正在设为默认...", + "unavailable": "无法将不可用的模型设为默认", + "isDefault": "该模型已是默认模型", + "isVirtual": "无法将虚拟模型设为默认" + }, + "deleteDisabled": { + "isDefault": "无法删除默认模型" + } }, "defaultOnSave": { "label": "默认模型", @@ -500,6 +513,11 @@ "version": "已安装版本", "lines": "行数", "characters": "字符数" + }, + "marketplace_installDisabled": { + "installing": "正在安装...", + "installed": "已安装", + "cannotInstall": "无法安装:相关工具未启用" } }, "tools": { @@ -668,4 +686,4 @@ "description": "需要更多帮助?点击右上角的文档按钮,查看详细的使用文档和配置指南。" } } -} \ No newline at end of file +} From 773a94c41437d21c7cb1fcc429cee1ac605dd509 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:55:05 +0800 Subject: [PATCH 13/15] fix(web_search): validate missing API key/URL directly in Search methods (#2517) --- pkg/tools/web.go | 36 ++++++++++++++++++++++++++++++------ pkg/tools/web_test.go | 9 +++++++-- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 342f7458b..daf5140d4 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -218,6 +218,10 @@ func (p *BraveSearchProvider) Search( count int, rangeCode string, ) (string, error) { + if p.keyPool == nil || len(p.keyPool.keys) == 0 { + return "", errors.New("no API key provided") + } + searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", url.QueryEscape(query), count) if freshness := mapBraveFreshness(rangeCode); freshness != "" { @@ -317,6 +321,10 @@ func (p *TavilySearchProvider) Search( count int, rangeCode string, ) (string, error) { + if p.keyPool == nil || len(p.keyPool.keys) == 0 { + return "", errors.New("no API key provided") + } + searchURL := p.baseURL if searchURL == "" { searchURL = "https://api.tavily.com/search" @@ -532,6 +540,10 @@ func (p *PerplexitySearchProvider) Search( count int, rangeCode string, ) (string, error) { + if p.keyPool == nil || len(p.keyPool.keys) == 0 { + return "", errors.New("no API key provided") + } + searchURL := "https://api.perplexity.ai/chat/completions" var lastErr error @@ -645,6 +657,10 @@ func (p *SearXNGSearchProvider) Search( count int, rangeCode string, ) (string, error) { + if p.baseURL == "" { + return "", errors.New("no SearXNG URL provided") + } + searchURL := fmt.Sprintf("%s/search?q=%s&format=json&categories=general", strings.TrimSuffix(p.baseURL, "/"), url.QueryEscape(query)) @@ -719,6 +735,10 @@ func (p *GLMSearchProvider) Search( count int, rangeCode string, ) (string, error) { + if p.apiKey == "" { + return "", errors.New("no API key provided") + } + searchURL := p.baseURL if searchURL == "" { searchURL = "https://open.bigmodel.cn/api/paas/v4/web_search" @@ -808,6 +828,10 @@ func (p *BaiduSearchProvider) Search( count int, rangeCode string, ) (string, error) { + if p.apiKey == "" { + return "", errors.New("no API key provided") + } + searchURL := p.baseURL if searchURL == "" { searchURL = "https://qianfan.baidubce.com/v2/ai_search/web_search" @@ -921,7 +945,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { var provider SearchProvider maxResults := 10 // Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > Baidu Search > GLM Search - if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 { + if opts.PerplexityEnabled { client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err) @@ -934,7 +958,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { if opts.PerplexityMaxResults > 0 { maxResults = min(opts.PerplexityMaxResults, 10) } - } else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 { + } else if opts.BraveEnabled { client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err) @@ -943,12 +967,12 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { if opts.BraveMaxResults > 0 { maxResults = min(opts.BraveMaxResults, 10) } - } else if opts.SearXNGEnabled && opts.SearXNGBaseURL != "" { + } else if opts.SearXNGEnabled { provider = &SearXNGSearchProvider{baseURL: opts.SearXNGBaseURL} if opts.SearXNGMaxResults > 0 { maxResults = min(opts.SearXNGMaxResults, 10) } - } else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 { + } else if opts.TavilyEnabled { client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err) @@ -971,7 +995,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { if opts.DuckDuckGoMaxResults > 0 { maxResults = min(opts.DuckDuckGoMaxResults, 10) } - } else if opts.BaiduSearchEnabled && opts.BaiduSearchAPIKey != "" { + } else if opts.BaiduSearchEnabled { client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for Baidu Search: %w", err) @@ -985,7 +1009,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { if opts.BaiduSearchMaxResults > 0 { maxResults = min(opts.BaiduSearchMaxResults, 10) } - } else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" { + } else if opts.GLMSearchEnabled { client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err) diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index de6187cfa..2bdd01f6d 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -391,8 +391,13 @@ func TestWebTool_WebSearch_NoApiKey(t *testing.T) { if err != nil { t.Fatalf("Unexpected error: %v", err) } - if tool != nil { - t.Errorf("Expected nil tool when Brave API key is empty") + if tool == nil { + t.Fatalf("Expected tool to be created") + } + ctx := context.Background() + result := tool.Execute(ctx, map[string]any{"query": "test"}) + if !result.IsError { + t.Errorf("Expected error when API key is missing") } // Also nil when nothing is enabled From 51ab3b13854ce2770495143998fcc3dceed0b1b8 Mon Sep 17 00:00:00 2001 From: wenjie Date: Wed, 15 Apr 2026 11:24:27 +0800 Subject: [PATCH 14/15] fix(web): restore chat composer disabled-state messaging and clean up code (#2526) --- web/frontend/src/api/launcher-auth.ts | 9 +- web/frontend/src/components/app-header.tsx | 33 ++- .../src/components/chat/chat-composer.tsx | 22 +- .../src/components/chat/chat-page.tsx | 2 +- web/frontend/src/features/chat/controller.ts | 5 +- web/frontend/src/features/chat/protocol.ts | 5 +- web/frontend/src/hooks/use-gateway.ts | 12 +- web/frontend/src/routes/__root.tsx | 4 +- web/frontend/src/routes/launcher-login.tsx | 9 +- web/frontend/src/routes/launcher-setup.tsx | 244 +++++++++--------- 10 files changed, 184 insertions(+), 161 deletions(-) diff --git a/web/frontend/src/api/launcher-auth.ts b/web/frontend/src/api/launcher-auth.ts index ed2e30687..d6bd93c4d 100644 --- a/web/frontend/src/api/launcher-auth.ts +++ b/web/frontend/src/api/launcher-auth.ts @@ -41,9 +41,7 @@ export async function postLauncherDashboardLogout(): Promise { return res.ok } -export type SetupResult = - | { ok: true } - | { ok: false; error: string } +export type SetupResult = { ok: true } | { ok: false; error: string } export async function postLauncherDashboardSetup( password: string, @@ -53,7 +51,10 @@ export async function postLauncherDashboardSetup( method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", - body: JSON.stringify({ password: password.trim(), confirm: confirm.trim() }), + body: JSON.stringify({ + password: password.trim(), + confirm: confirm.trim(), + }), }) if (res.ok) return { ok: true } let msg = "Unknown error" diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx index 798ac8ad5..e94975075 100644 --- a/web/frontend/src/components/app-header.tsx +++ b/web/frontend/src/components/app-header.tsx @@ -14,6 +14,7 @@ import { Link } from "@tanstack/react-router" import * as React from "react" import { useTranslation } from "react-i18next" +import { postLauncherDashboardLogout } from "@/api/launcher-auth" import { AlertDialog, AlertDialogAction, @@ -40,7 +41,6 @@ import { } from "@/components/ui/tooltip" import { useGateway } from "@/hooks/use-gateway.ts" import { useTheme } from "@/hooks/use-theme.ts" -import { postLauncherDashboardLogout } from "@/api/launcher-auth" export function AppHeader() { const { i18n, t } = useTranslation() @@ -198,27 +198,42 @@ export function AppHeader() { - {gwError ?? t("header.gateway.action.stop")} + + {gwError ?? t("header.gateway.action.stop")} + ) : ( - + {/* Wrap in span so the tooltip still fires when the button is disabled */} - {(gwError || (!canStart && startReason)) ? ( + {gwError || (!canStart && startReason) ? ( {gwError ?? startReason} ) : null} diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx index 53465a788..58612d846 100644 --- a/web/frontend/src/components/chat/chat-composer.tsx +++ b/web/frontend/src/components/chat/chat-composer.tsx @@ -42,15 +42,11 @@ export function ChatComposer({ }: ChatComposerProps) { const { t } = useTranslation() const canInput = inputDisabledReason === null - const placeholder = canInput - ? t("chat.placeholder") - : t(`chat.disabledPlaceholder.${inputDisabledReason}`) - - const inputDisabledReason = (() => { - if (!isConnected) return t("chat.inputDisabled.notConnected") - if (!hasDefaultModel) return t("chat.inputDisabled.noModel") - return null - })() + const disabledMessage = + inputDisabledReason === null + ? null + : t(`chat.disabledPlaceholder.${inputDisabledReason}`) + const placeholder = disabledMessage ?? t("chat.placeholder") const handleKeyDown = (e: KeyboardEvent) => { if (e.nativeEvent.isComposing) return @@ -95,7 +91,7 @@ export function ChatComposer({ onKeyDown={handleKeyDown} placeholder={placeholder} disabled={!canInput} - title={inputDisabledReason || undefined} + title={disabledMessage || undefined} className={cn( "placeholder:text-muted-foreground/50 max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent", !canInput && "cursor-not-allowed", @@ -103,9 +99,9 @@ export function ChatComposer({ minRows={1} maxRows={8} /> - {!canInput && inputDisabledReason && ( -
- {inputDisabledReason} + {!canInput && disabledMessage && ( +
+ {disabledMessage}
)} diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 30be8d581..4129d812a 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -5,8 +5,8 @@ import { toast } from "sonner" import { AssistantMessage } from "@/components/chat/assistant-message" import { - type ChatInputDisabledReason, ChatComposer, + type ChatInputDisabledReason, } from "@/components/chat/chat-composer" import { ChatEmptyState } from "@/components/chat/chat-empty-state" import { ModelSelector } from "@/components/chat/model-selector" diff --git a/web/frontend/src/features/chat/controller.ts b/web/frontend/src/features/chat/controller.ts index c5c93d2e8..28ef491fa 100644 --- a/web/frontend/src/features/chat/controller.ts +++ b/web/frontend/src/features/chat/controller.ts @@ -12,10 +12,7 @@ import { generateSessionId, readStoredSessionId, } from "@/features/chat/state" -import { - invalidateSocket, - isCurrentSocket, -} from "@/features/chat/websocket" +import { invalidateSocket, isCurrentSocket } from "@/features/chat/websocket" import i18n from "@/i18n" import { type ChatAttachment, diff --git a/web/frontend/src/features/chat/protocol.ts b/web/frontend/src/features/chat/protocol.ts index a7edfc21b..717b42f84 100644 --- a/web/frontend/src/features/chat/protocol.ts +++ b/web/frontend/src/features/chat/protocol.ts @@ -1,10 +1,7 @@ import { toast } from "sonner" import { normalizeUnixTimestamp } from "@/features/chat/state" -import { - type AssistantMessageKind, - updateChatStore, -} from "@/store/chat" +import { type AssistantMessageKind, updateChatStore } from "@/store/chat" export interface PicoMessage { type: string diff --git a/web/frontend/src/hooks/use-gateway.ts b/web/frontend/src/hooks/use-gateway.ts index 31bee0e91..cbf132941 100644 --- a/web/frontend/src/hooks/use-gateway.ts +++ b/web/frontend/src/hooks/use-gateway.ts @@ -77,5 +77,15 @@ export function useGateway() { } }, [state]) - return { state, loading, canStart, startReason, restartRequired, start, stop, restart, error } + return { + state, + loading, + canStart, + startReason, + restartRequired, + start, + stop, + restart, + error, + } } diff --git a/web/frontend/src/routes/__root.tsx b/web/frontend/src/routes/__root.tsx index b5af5de45..60d45ef84 100644 --- a/web/frontend/src/routes/__root.tsx +++ b/web/frontend/src/routes/__root.tsx @@ -53,7 +53,9 @@ const RootLayout = () => { globalThis.location.assign("/launcher-login") } else { setAuthError( - err instanceof Error ? err.message : "Auth service unavailable, please try to delete the launcher-auth.db at picoclaw home directory and restart the application.", + err instanceof Error + ? err.message + : "Auth service unavailable, please try to delete the launcher-auth.db at picoclaw home directory and restart the application.", ) } }) diff --git a/web/frontend/src/routes/launcher-login.tsx b/web/frontend/src/routes/launcher-login.tsx index c5626fbb0..caa548c79 100644 --- a/web/frontend/src/routes/launcher-login.tsx +++ b/web/frontend/src/routes/launcher-login.tsx @@ -3,7 +3,10 @@ import { createFileRoute } from "@tanstack/react-router" import * as React from "react" import { useTranslation } from "react-i18next" -import { postLauncherDashboardLogin, getLauncherAuthStatus } from "@/api/launcher-auth" +import { + getLauncherAuthStatus, + postLauncherDashboardLogin, +} from "@/api/launcher-auth" import { Button } from "@/components/ui/button" import { Card, @@ -37,7 +40,9 @@ function LauncherLoginPage() { globalThis.location.assign("/launcher-setup") } }) - .catch(() => { /* network error — stay on login page */ }) + .catch(() => { + /* network error — stay on login page */ + }) }, []) const loginWithToken = React.useCallback( diff --git a/web/frontend/src/routes/launcher-setup.tsx b/web/frontend/src/routes/launcher-setup.tsx index 876af94fb..87c934a09 100644 --- a/web/frontend/src/routes/launcher-setup.tsx +++ b/web/frontend/src/routes/launcher-setup.tsx @@ -6,141 +6,141 @@ import { useTranslation } from "react-i18next" import { postLauncherDashboardSetup } from "@/api/launcher-auth" import { Button } from "@/components/ui/button" import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "@/components/ui/card" import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { useTheme } from "@/hooks/use-theme" function LauncherSetupPage() { - const { t, i18n } = useTranslation() - const { theme, toggleTheme } = useTheme() - const [password, setPassword] = React.useState("") - const [confirm, setConfirm] = React.useState("") - const [submitting, setSubmitting] = React.useState(false) - const [error, setError] = React.useState("") + const { t, i18n } = useTranslation() + const { theme, toggleTheme } = useTheme() + const [password, setPassword] = React.useState("") + const [confirm, setConfirm] = React.useState("") + const [submitting, setSubmitting] = React.useState(false) + const [error, setError] = React.useState("") - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError("") - if (password !== confirm) { - setError(t("launcherSetup.errorMismatch")) - return - } - setSubmitting(true) - try { - const result = await postLauncherDashboardSetup(password, confirm) - if (result.ok) { - globalThis.location.assign("/launcher-login") - return - } - setError(result.error) - } catch { - setError(t("launcherSetup.errorNetwork")) - } finally { - setSubmitting(false) - } + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + if (password !== confirm) { + setError(t("launcherSetup.errorMismatch")) + return } + setSubmitting(true) + try { + const result = await postLauncherDashboardSetup(password, confirm) + if (result.ok) { + globalThis.location.assign("/launcher-login") + return + } + setError(result.error) + } catch { + setError(t("launcherSetup.errorNetwork")) + } finally { + setSubmitting(false) + } + } - return ( -
-
- - - - - - i18n.changeLanguage("en")}> - English - - i18n.changeLanguage("zh")}> - 简体中文 - - - - -
+ return ( +
+
+ + + + + + i18n.changeLanguage("en")}> + English + + i18n.changeLanguage("zh")}> + 简体中文 + + + + +
-
- - - {t("launcherSetup.title")} - {t("launcherSetup.description")} - - -
-
- - setPassword(e.target.value)} - placeholder={t("launcherSetup.passwordPlaceholder")} - /> -
-
- - setConfirm(e.target.value)} - placeholder={t("launcherSetup.confirmPlaceholder")} - /> -
- - {error ? ( -

- {error} -

- ) : null} -
-
-
-
-
- ) +
+ + + {t("launcherSetup.title")} + {t("launcherSetup.description")} + + +
+
+ + setPassword(e.target.value)} + placeholder={t("launcherSetup.passwordPlaceholder")} + /> +
+
+ + setConfirm(e.target.value)} + placeholder={t("launcherSetup.confirmPlaceholder")} + /> +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+
+
+
+
+ ) } export const Route = createFileRoute("/launcher-setup")({ - component: LauncherSetupPage, + component: LauncherSetupPage, }) From d0ff24aa87488bb14a692fc226b9a5197402449f Mon Sep 17 00:00:00 2001 From: Cytown Date: Wed, 15 Apr 2026 11:38:47 +0800 Subject: [PATCH 15/15] remove useless backend output for platform-token (#2500) --- web/backend/main.go | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/web/backend/main.go b/web/backend/main.go index 57409f03a..7f776ff3f 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -501,7 +501,7 @@ func main() { } listeners := openResult.Listeners - dashboardToken, dashboardSigningKey, dashboardTokenSource, dashErr := launcherconfig.EnsureDashboardSecrets( + dashboardToken, dashboardSigningKey, _, dashErr := launcherconfig.EnsureDashboardSecrets( launcherCfg, ) if dashErr != nil { @@ -509,6 +509,7 @@ func main() { } dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken) + fmt.Println("dashboardToken: ", dashboardToken) // Open the bcrypt password store (creates the DB file on first run). authStore, authStoreErr := dashboardauth.New(picoHome) var passwordStore api.PasswordStore @@ -582,26 +583,6 @@ func main() { fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort)) } fmt.Println() - switch dashboardTokenSource { - case launcherconfig.DashboardTokenSourceRandom: - fmt.Printf(" Dashboard password (this run): %s\n", maskSecret(dashboardToken)) - case launcherconfig.DashboardTokenSourceEnv: - fmt.Printf(" Dashboard password: from environment variable PICOCLAW_LAUNCHER_TOKEN\n") - case launcherconfig.DashboardTokenSourceConfig: - fmt.Printf(" Dashboard password: configured in %s\n", launcherPath) - } - fmt.Println() - } - - switch dashboardTokenSource { - case launcherconfig.DashboardTokenSourceEnv: - logger.InfoC("web", "Dashboard password: environment PICOCLAW_LAUNCHER_TOKEN") - case launcherconfig.DashboardTokenSourceConfig: - logger.InfoC("web", fmt.Sprintf("Dashboard password: configured in %s", launcherPath)) - case launcherconfig.DashboardTokenSourceRandom: - if !enableConsole { - logger.InfoC("web", "Dashboard password (this run): "+maskSecret(dashboardToken)) - } } // Log startup info to file