fix(host): modernize default host selection order
This commit is contained in:
parent
448027c02a
commit
e7b3654313
13 changed files with 497 additions and 98 deletions
|
|
@ -503,7 +503,7 @@ func TestDefaultConfig_Temperature(t *testing.T) {
|
||||||
func TestDefaultConfig_Gateway(t *testing.T) {
|
func TestDefaultConfig_Gateway(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
if cfg.Gateway.Host != "127.0.0.1" {
|
if cfg.Gateway.Host != "localhost" {
|
||||||
t.Error("Gateway host should have default value")
|
t.Error("Gateway host should have default value")
|
||||||
}
|
}
|
||||||
if cfg.Gateway.Port == 0 {
|
if cfg.Gateway.Port == 0 {
|
||||||
|
|
@ -739,7 +739,7 @@ func TestConfig_Complete(t *testing.T) {
|
||||||
if cfg.Agents.Defaults.MaxToolIterations == 0 {
|
if cfg.Agents.Defaults.MaxToolIterations == 0 {
|
||||||
t.Error("MaxToolIterations should not be zero")
|
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")
|
t.Error("Gateway host should have default value")
|
||||||
}
|
}
|
||||||
if cfg.Gateway.Port == 0 {
|
if cfg.Gateway.Port == 0 {
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,7 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Gateway: GatewayConfig{
|
Gateway: GatewayConfig{
|
||||||
Host: "127.0.0.1",
|
Host: "localhost",
|
||||||
Port: 18790,
|
Port: 18790,
|
||||||
HotReload: false,
|
HotReload: false,
|
||||||
LogLevel: DefaultGatewayLogLevel,
|
LogLevel: DefaultGatewayLogLevel,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
@ -50,17 +52,105 @@ func EffectiveGatewayLogLevel(cfg *Config) string {
|
||||||
return normalizeGatewayLogLevel(cfg.Gateway.LogLevel)
|
return normalizeGatewayLogLevel(cfg.Gateway.LogLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeGatewayHost(host string) string {
|
var (
|
||||||
host = strings.TrimSpace(host)
|
gatewayIPFamiliesOnce sync.Once
|
||||||
if host != "" {
|
gatewayHasIPv4 bool
|
||||||
return host
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultHost := strings.TrimSpace(DefaultConfig().Gateway.Host)
|
if gatewayHasIPv4 && gatewayHasIPv6 {
|
||||||
if defaultHost == "" {
|
return
|
||||||
return "127.0.0.1"
|
|
||||||
}
|
}
|
||||||
return defaultHost
|
|
||||||
|
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 {
|
||||||
|
|
@ -74,7 +164,7 @@ func resolveGatewayHostFromEnv(baseHost string) string {
|
||||||
return normalizeGatewayHost(baseHost)
|
return normalizeGatewayHost(baseHost)
|
||||||
}
|
}
|
||||||
|
|
||||||
return envHost
|
return normalizeGatewayHost(envHost)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveGatewayLogLevel reads the configured gateway log level without triggering
|
// ResolveGatewayLogLevel reads the configured gateway log level without triggering
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -40,8 +39,9 @@ func TestLoadConfig_GatewayHostBlankEnvFallsBackToConfigHost(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig() error: %v", err)
|
t.Fatalf("LoadConfig() error: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.Gateway.Host != "localhost" {
|
want := normalizeGatewayHost("localhost")
|
||||||
t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, "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)
|
t.Fatalf("LoadConfig() error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultHost := strings.TrimSpace(DefaultConfig().Gateway.Host)
|
defaultHost := normalizeGatewayHost(DefaultConfig().Gateway.Host)
|
||||||
if cfg.Gateway.Host != defaultHost {
|
if cfg.Gateway.Host != defaultHost {
|
||||||
t.Fatalf("cfg.Gateway.Host = %q, want %q", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ package gateway
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
@ -217,7 +219,8 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
|
||||||
runningServices.HealthServer.SetReloadFunc(reloadTrigger)
|
runningServices.HealthServer.SetReloadFunc(reloadTrigger)
|
||||||
agentLoop.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")
|
fmt.Println("Press Ctrl+C to stop")
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
@ -390,7 +393,7 @@ func setupAndStartServices(
|
||||||
fmt.Println("⚠ Warning: No channels enabled")
|
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.authToken = authToken
|
||||||
runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port, authToken)
|
runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port, authToken)
|
||||||
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
|
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
|
||||||
|
|
@ -409,10 +412,10 @@ func setupAndStartServices(
|
||||||
voiceAgent.Start(vaCtx)
|
voiceAgent.Start(vaCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
healthAddr := net.JoinHostPort(cfg.Gateway.Host, strconv.Itoa(cfg.Gateway.Port))
|
||||||
fmt.Printf(
|
fmt.Printf(
|
||||||
"✓ Health endpoints available at http://%s:%d/health, /ready and /reload (POST)\n",
|
"✓ Health endpoints available at http://%s/health, /ready and /reload (POST)\n",
|
||||||
cfg.Gateway.Host,
|
healthAddr,
|
||||||
cfg.Gateway.Port,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
stateManager := state.NewManager(cfg.WorkspacePath())
|
stateManager := state.NewManager(cfg.WorkspacePath())
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"maps"
|
"maps"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -49,7 +50,7 @@ func NewServer(host string, port int, token string) *Server {
|
||||||
mux.HandleFunc("/ready", s.readyHandler)
|
mux.HandleFunc("/ready", s.readyHandler)
|
||||||
mux.HandleFunc("/reload", s.reloadHandler)
|
mux.HandleFunc("/reload", s.reloadHandler)
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", host, port)
|
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
s.server = &http.Server{
|
s.server = &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestStartContext_Cancellation(t *testing.T) {
|
||||||
s := NewServer("127.0.0.1", 0, "")
|
s := NewServer("127.0.0.1", 0, "")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -262,7 +262,7 @@ func (h *Handler) getGatewayHealthForPidData(
|
||||||
host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
|
host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
|
||||||
}
|
}
|
||||||
if host == "" {
|
if host == "" {
|
||||||
host = "127.0.0.1"
|
host = resolveDefaultLoopbackHost()
|
||||||
}
|
}
|
||||||
|
|
||||||
url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/health"
|
url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/health"
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,11 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
adaptiveLoopbackHostOnce sync.Once
|
adaptiveIPFamiliesOnce sync.Once
|
||||||
adaptiveLoopbackHost string
|
adaptiveHasIPv4 bool
|
||||||
|
adaptiveHasIPv6 bool
|
||||||
|
lookupLocalhostIPs = func() ([]net.IP, error) { return net.LookupIP("localhost") }
|
||||||
|
listInterfaceAddrs = net.InterfaceAddrs
|
||||||
)
|
)
|
||||||
|
|
||||||
func selectAdaptiveLoopbackHost(hasIPv4, hasIPv6 bool) string {
|
func selectAdaptiveLoopbackHost(hasIPv4, hasIPv6 bool) string {
|
||||||
|
|
@ -25,7 +28,20 @@ func selectAdaptiveLoopbackHost(hasIPv4, hasIPv6 bool) string {
|
||||||
case hasIPv4:
|
case hasIPv4:
|
||||||
return "127.0.0.1"
|
return "127.0.0.1"
|
||||||
default:
|
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()
|
return ip != nil && ip.IsLoopback()
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveAdaptiveLoopbackHost() string {
|
func detectAdaptiveIPFamilies() (bool, bool) {
|
||||||
adaptiveLoopbackHostOnce.Do(func() {
|
adaptiveIPFamiliesOnce.Do(func() {
|
||||||
ips, err := net.LookupIP("localhost")
|
if ips, err := lookupLocalhostIPs(); err == nil {
|
||||||
if err != nil {
|
|
||||||
adaptiveLoopbackHost = selectAdaptiveLoopbackHost(false, false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hasIPv4 := false
|
|
||||||
hasIPv6 := false
|
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
if ip == nil {
|
if ip == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ip.To4() != nil {
|
if ip.To4() != nil {
|
||||||
hasIPv4 = true
|
adaptiveHasIPv4 = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
hasIPv6 = true
|
adaptiveHasIPv6 = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
adaptiveLoopbackHost = selectAdaptiveLoopbackHost(hasIPv4, hasIPv6)
|
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 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 {
|
func resolveDefaultLoopbackHost() string {
|
||||||
return resolveAdaptiveLoopbackHost()
|
return resolveAdaptiveLoopbackHost()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveDefaultAnyHost() string {
|
||||||
|
return resolveAdaptiveAnyHost()
|
||||||
|
}
|
||||||
|
|
||||||
func resolveLocalhostLoopbackHost() string {
|
func resolveLocalhostLoopbackHost() string {
|
||||||
return resolveAdaptiveLoopbackHost()
|
return resolveAdaptiveLoopbackHost()
|
||||||
}
|
}
|
||||||
|
|
@ -102,6 +143,10 @@ func canonicalLauncherBindHost(host string) string {
|
||||||
if strings.EqualFold(host, "localhost") {
|
if strings.EqualFold(host, "localhost") {
|
||||||
return resolveLocalhostLoopbackHost()
|
return resolveLocalhostLoopbackHost()
|
||||||
}
|
}
|
||||||
|
trimmed := strings.Trim(host, "[]")
|
||||||
|
if ip := net.ParseIP(trimmed); ip != nil && ip.IsUnspecified() {
|
||||||
|
return resolveDefaultAnyHost()
|
||||||
|
}
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,8 +155,8 @@ func (h *Handler) launcherAndGatewayBindHostsAligned(cfg *config.Config) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// With -host specified, -public is ignored, so launcher's legacy bind host is loopback.
|
// With -host specified, -public is ignored, so launcher baseline bind host is loopback.
|
||||||
launcherHost := canonicalLauncherBindHost("127.0.0.1")
|
launcherHost := canonicalLauncherBindHost("")
|
||||||
gatewayHost := canonicalLauncherBindHost(cfg.Gateway.Host)
|
gatewayHost := canonicalLauncherBindHost(cfg.Gateway.Host)
|
||||||
if isLoopbackEquivalentHost(launcherHost) && isLoopbackEquivalentHost(gatewayHost) {
|
if isLoopbackEquivalentHost(launcherHost) && isLoopbackEquivalentHost(gatewayHost) {
|
||||||
return true
|
return true
|
||||||
|
|
@ -129,7 +174,7 @@ func (h *Handler) gatewayHostOverrideForConfig(cfg *config.Config) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.effectiveLauncherPublic() {
|
if h.effectiveLauncherPublic() {
|
||||||
return "0.0.0.0"
|
return resolveDefaultAnyHost()
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -167,10 +212,7 @@ func gatewayProbeHost(bindHost string) string {
|
||||||
|
|
||||||
trimmed := strings.Trim(bindHost, "[]")
|
trimmed := strings.Trim(bindHost, "[]")
|
||||||
if ip := net.ParseIP(trimmed); ip != nil && ip.IsUnspecified() {
|
if ip := net.ParseIP(trimmed); ip != nil && ip.IsUnspecified() {
|
||||||
if ip.To4() == nil {
|
return resolveDefaultLoopbackHost()
|
||||||
return "::1"
|
|
||||||
}
|
|
||||||
return "127.0.0.1"
|
|
||||||
}
|
}
|
||||||
return bindHost
|
return bindHost
|
||||||
}
|
}
|
||||||
|
|
@ -200,7 +242,7 @@ func requestHostName(r *http.Request) string {
|
||||||
if strings.TrimSpace(r.Host) != "" {
|
if strings.TrimSpace(r.Host) != "" {
|
||||||
return r.Host
|
return r.Host
|
||||||
}
|
}
|
||||||
return "127.0.0.1"
|
return resolveDefaultLoopbackHost()
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestWSScheme(r *http.Request) string {
|
func requestWSScheme(r *http.Request) string {
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ package api
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -13,6 +15,12 @@ import (
|
||||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func resetAdaptiveIPFamiliesForTest() {
|
||||||
|
adaptiveIPFamiliesOnce = sync.Once{}
|
||||||
|
adaptiveHasIPv4 = false
|
||||||
|
adaptiveHasIPv6 = false
|
||||||
|
}
|
||||||
|
|
||||||
func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) {
|
func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
launcherPath := launcherconfig.PathForAppConfig(configPath)
|
launcherPath := launcherconfig.PathForAppConfig(configPath)
|
||||||
|
|
@ -26,8 +34,8 @@ func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) {
|
||||||
h := NewHandler(configPath)
|
h := NewHandler(configPath)
|
||||||
h.SetServerOptions(18800, true, true, nil)
|
h.SetServerOptions(18800, true, true, nil)
|
||||||
|
|
||||||
if got := h.gatewayHostOverride(); got != "0.0.0.0" {
|
if got := h.gatewayHostOverride(); got != resolveDefaultAnyHost() {
|
||||||
t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0")
|
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: "dual stack prefers localhost", hasIPv4: true, hasIPv6: true, want: "localhost"},
|
||||||
{name: "ipv6 only", hasIPv4: false, hasIPv6: true, want: "::1"},
|
{name: "ipv6 only", hasIPv4: false, hasIPv6: true, want: "::1"},
|
||||||
{name: "ipv4 only", hasIPv4: true, hasIPv6: false, want: "127.0.0.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 {
|
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) {
|
func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
|
||||||
if got := gatewayProbeHost("0.0.0.0"); got != "127.0.0.1" {
|
want := resolveDefaultLoopbackHost()
|
||||||
t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1")
|
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) {
|
func TestGatewayProbeHostUsesLoopbackForIPv6WildcardBind(t *testing.T) {
|
||||||
if got := gatewayProbeHost("::"); got != "::1" {
|
want := resolveDefaultLoopbackHost()
|
||||||
t.Fatalf("gatewayProbeHost(::) = %q, want %q", got, "::1")
|
if got := gatewayProbeHost("::"); got != want {
|
||||||
|
t.Fatalf("gatewayProbeHost(::) = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,8 +239,9 @@ func TestGetGatewayHealthUsesProbeHostForPublicLauncher(t *testing.T) {
|
||||||
_ = statusCode
|
_ = statusCode
|
||||||
_ = err
|
_ = err
|
||||||
|
|
||||||
if requestedURL != "http://127.0.0.1:18791/health" {
|
want := "http://" + net.JoinHostPort(resolveDefaultLoopbackHost(), "18791") + "/health"
|
||||||
t.Fatalf("health url = %q, want %q", requestedURL, "http://127.0.0.1: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.SetServerOptions(18800, false, false, nil)
|
||||||
h.SetServerBindHost("0.0.0.0", true)
|
h.SetServerBindHost("0.0.0.0", true)
|
||||||
|
|
||||||
if got := h.gatewayHostOverride(); got != "0.0.0.0" {
|
if got := h.gatewayHostOverride(); got != resolveDefaultAnyHost() {
|
||||||
t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0")
|
t.Fatalf("gatewayHostOverride() = %q, want %q", got, resolveDefaultAnyHost())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ func NewHandler(configPath string) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
configPath: configPath,
|
configPath: configPath,
|
||||||
serverPort: launcherconfig.DefaultPort,
|
serverPort: launcherconfig.DefaultPort,
|
||||||
serverHost: "127.0.0.1",
|
serverHost: resolveDefaultLoopbackHost(),
|
||||||
oauthFlows: make(map[string]*oauthFlow),
|
oauthFlows: make(map[string]*oauthFlow),
|
||||||
oauthState: make(map[string]string),
|
oauthState: make(map[string]string),
|
||||||
weixinFlows: make(map[string]*weixinFlow),
|
weixinFlows: make(map[string]*weixinFlow),
|
||||||
|
|
@ -45,9 +45,9 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a
|
||||||
h.serverPort = port
|
h.serverPort = port
|
||||||
h.serverPublic = public
|
h.serverPublic = public
|
||||||
h.serverPublicExplicit = publicExplicit
|
h.serverPublicExplicit = publicExplicit
|
||||||
h.serverHost = "127.0.0.1"
|
h.serverHost = resolveDefaultLoopbackHost()
|
||||||
if public {
|
if public {
|
||||||
h.serverHost = "0.0.0.0"
|
h.serverHost = resolveDefaultAnyHost()
|
||||||
}
|
}
|
||||||
h.serverHostExplicit = false
|
h.serverHostExplicit = false
|
||||||
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
|
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) {
|
func (h *Handler) SetServerBindHost(host string, explicit bool) {
|
||||||
host = strings.TrimSpace(host)
|
host = strings.TrimSpace(host)
|
||||||
if host == "" {
|
if host == "" {
|
||||||
host = "127.0.0.1"
|
host = resolveDefaultLoopbackHost()
|
||||||
if h.serverPublic {
|
if h.serverPublic {
|
||||||
host = "0.0.0.0"
|
host = resolveDefaultAnyHost()
|
||||||
}
|
}
|
||||||
explicit = false
|
explicit = false
|
||||||
}
|
}
|
||||||
|
host = canonicalLauncherBindHost(host)
|
||||||
|
|
||||||
h.serverHost = host
|
h.serverHost = host
|
||||||
h.serverHostExplicit = explicit
|
h.serverHostExplicit = explicit
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -46,6 +47,10 @@ const (
|
||||||
var (
|
var (
|
||||||
appVersion = config.Version
|
appVersion = config.Version
|
||||||
|
|
||||||
|
launcherIPFamiliesOnce sync.Once
|
||||||
|
launcherHasIPv4 bool
|
||||||
|
launcherHasIPv6 bool
|
||||||
|
|
||||||
server *http.Server
|
server *http.Server
|
||||||
serverAddr string
|
serverAddr string
|
||||||
// browserLaunchURL is opened by openBrowser() (auto-open + tray "open console").
|
// browserLaunchURL is opened by openBrowser() (auto-open + tray "open console").
|
||||||
|
|
@ -67,6 +72,103 @@ func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, la
|
||||||
return launcherPath
|
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(
|
func resolveLauncherBindHost(
|
||||||
host string,
|
host string,
|
||||||
explicitHost bool,
|
explicitHost bool,
|
||||||
|
|
@ -79,25 +181,30 @@ func resolveLauncherBindHost(
|
||||||
return "", false, false, errors.New("host cannot be empty")
|
return "", false, false, errors.New("host cannot be empty")
|
||||||
}
|
}
|
||||||
// When -host is specified, -public is ignored.
|
// When -host is specified, -public is ignored.
|
||||||
return host, false, true, nil
|
return normalizeLauncherSpecialHost(host), false, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
envHost = strings.TrimSpace(envHost)
|
envHost = strings.TrimSpace(envHost)
|
||||||
if envHost != "" {
|
if envHost != "" {
|
||||||
// Environment host follows explicit override semantics.
|
// Environment host follows explicit override semantics.
|
||||||
return envHost, false, true, nil
|
return normalizeLauncherSpecialHost(envHost), false, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if effectivePublic {
|
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 {
|
func isWildcardBindHost(host string) bool {
|
||||||
host = strings.TrimSpace(host)
|
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 {
|
func browserHostForLauncher(bindHost string) string {
|
||||||
|
|
@ -109,20 +216,57 @@ func browserHostForLauncher(bindHost string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func wildcardAdvertiseIP(bindHost, ipv4, ipv6 string) string {
|
func wildcardAdvertiseIP(bindHost, ipv4, ipv6 string) string {
|
||||||
switch strings.TrimSpace(bindHost) {
|
if !isWildcardBindHost(bindHost) {
|
||||||
case "0.0.0.0":
|
|
||||||
return strings.TrimSpace(ipv4)
|
|
||||||
case "::":
|
|
||||||
return strings.TrimSpace(ipv6)
|
|
||||||
default:
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if v6 := strings.TrimSpace(ipv6); v6 != "" {
|
||||||
|
return v6
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(ipv4)
|
||||||
}
|
}
|
||||||
|
|
||||||
func advertiseIPForWildcardBindHost(bindHost string) string {
|
func advertiseIPForWildcardBindHost(bindHost string) string {
|
||||||
return wildcardAdvertiseIP(bindHost, utils.GetLocalIPv4(), utils.GetLocalIPv6())
|
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
|
// 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
|
// 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
|
// 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() {
|
func main() {
|
||||||
port := flag.String("port", "18800", "Port to listen on")
|
port := flag.String("port", "18800", "Port to listen on")
|
||||||
host := flag.String("host", "", "Host to listen on (overrides -public when set)")
|
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")
|
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")
|
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")
|
console := flag.Bool("console", false, "Console mode, no GUI")
|
||||||
|
|
@ -171,8 +315,8 @@ func main() {
|
||||||
os.Args[0],
|
os.Args[0],
|
||||||
)
|
)
|
||||||
fmt.Fprintf(os.Stderr, " Allow access from other devices on the local network\n")
|
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, " %s -host :: ./config.json\n", os.Args[0])
|
||||||
fmt.Fprintf(os.Stderr, " Bind launcher host explicitly (gateway forwarding follows compatibility rules)\n")
|
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, " %s -console -d ./config.json\n", os.Args[0])
|
||||||
fmt.Fprintf(os.Stderr, " Run in the terminal with debug logs enabled\n")
|
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)
|
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 != "" {
|
if !explicitHost && envHost != "" {
|
||||||
logger.InfoC("web", "Using launcher host from environment PICOCLAW_LAUNCHER_HOST")
|
logger.InfoC("web", "Using launcher host from environment PICOCLAW_LAUNCHER_HOST")
|
||||||
}
|
}
|
||||||
|
|
@ -349,14 +499,14 @@ func main() {
|
||||||
if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
|
if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
|
||||||
logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err))
|
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.SetServerBindHost(effectiveHost, hostExplicit)
|
||||||
apiHandler.RegisterRoutes(mux)
|
apiHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
// Frontend Embedded Assets
|
// Frontend Embedded Assets
|
||||||
registerEmbedRoutes(mux)
|
registerEmbedRoutes(mux)
|
||||||
|
|
||||||
accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux)
|
accessControlledMux, err := middleware.IPAllowlist(effectiveAllowedCIDRs, mux)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
|
logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -381,14 +531,8 @@ func main() {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println(" Open the following URL in your browser:")
|
fmt.Println(" Open the following URL in your browser:")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
|
for _, host := range launcherConsoleHosts(effectiveHost, hostExplicit, effectivePublic) {
|
||||||
if isWildcardBindHost(effectiveHost) {
|
fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
switch dashboardTokenSource {
|
switch dashboardTokenSource {
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ func TestResolveLauncherBindHost(t *testing.T) {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
explicitHost: true,
|
explicitHost: true,
|
||||||
effectivePub: true,
|
effectivePub: true,
|
||||||
wantHost: "0.0.0.0",
|
wantHost: resolveDefaultLauncherAnyHost(),
|
||||||
wantPublic: false,
|
wantPublic: false,
|
||||||
wantExplicit: true,
|
wantExplicit: true,
|
||||||
},
|
},
|
||||||
|
|
@ -139,7 +139,7 @@ func TestResolveLauncherBindHost(t *testing.T) {
|
||||||
envHost: "0.0.0.0",
|
envHost: "0.0.0.0",
|
||||||
explicitHost: false,
|
explicitHost: false,
|
||||||
effectivePub: true,
|
effectivePub: true,
|
||||||
wantHost: "0.0.0.0",
|
wantHost: resolveDefaultLauncherAnyHost(),
|
||||||
wantPublic: false,
|
wantPublic: false,
|
||||||
wantExplicit: true,
|
wantExplicit: true,
|
||||||
},
|
},
|
||||||
|
|
@ -148,7 +148,7 @@ func TestResolveLauncherBindHost(t *testing.T) {
|
||||||
host: "",
|
host: "",
|
||||||
explicitHost: false,
|
explicitHost: false,
|
||||||
effectivePub: true,
|
effectivePub: true,
|
||||||
wantHost: "0.0.0.0",
|
wantHost: resolveDefaultLauncherAnyHost(),
|
||||||
wantPublic: true,
|
wantPublic: true,
|
||||||
wantExplicit: false,
|
wantExplicit: false,
|
||||||
},
|
},
|
||||||
|
|
@ -157,7 +157,7 @@ func TestResolveLauncherBindHost(t *testing.T) {
|
||||||
host: "",
|
host: "",
|
||||||
explicitHost: false,
|
explicitHost: false,
|
||||||
effectivePub: false,
|
effectivePub: false,
|
||||||
wantHost: "127.0.0.1",
|
wantHost: resolveDefaultLauncherPrivateHost(),
|
||||||
wantPublic: false,
|
wantPublic: false,
|
||||||
wantExplicit: 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) {
|
func TestBrowserHostForLauncher(t *testing.T) {
|
||||||
if got := browserHostForLauncher("0.0.0.0"); got != "localhost" {
|
if got := browserHostForLauncher("0.0.0.0"); got != "localhost" {
|
||||||
t.Fatalf("browserHostForLauncher(0.0.0.0) = %q, want %q", 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
|
ipv6 string
|
||||||
want 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 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: "ipv6 wildcard falls back to ipv4", bindHost: "::", ipv4: "192.168.1.2", ipv6: "", want: "192.168.1.2"},
|
||||||
{name: "ipv4 wildcard with no ipv4 address", bindHost: "0.0.0.0", ipv4: "", ipv6: "2001:db8::1", want: ""},
|
{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: "non wildcard does not advertise", bindHost: "127.0.0.1", ipv4: "192.168.1.2", ipv6: "2001:db8::1", want: ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue