mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(launcher): add host overrides for launcher and gateway
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user