feat(launcher): add host overrides for launcher and gateway

This commit is contained in:
lc6464
2026-04-13 17:29:22 +08:00
parent df9124b824
commit 4e977367c2
8 changed files with 323 additions and 13 deletions
+32
View File
@@ -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"
}
+49
View File
@@ -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)
}
}
+25
View File
@@ -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
}
+6 -2
View File
@@ -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
}
+79 -11
View File
@@ -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 {
+106
View File
@@ -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")
}
}