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") + } +}