fix(host): align launcher and gateway host normalization semantics

This commit is contained in:
lc6464
2026-04-13 21:33:22 +08:00
parent 4e977367c2
commit 448027c02a
11 changed files with 380 additions and 22 deletions
+13 -2
View File
@@ -731,8 +731,19 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
if h.configPath != "" {
cmd.Env = append(cmd.Env, config.EnvConfig+"="+h.configPath)
}
if host := h.gatewayHostOverride(); host != "" {
cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+host)
gatewayHostOverride := h.gatewayHostOverrideForConfig(cfg)
if h.serverHostExplicit && gatewayHostOverride == "" {
logger.WarnC(
"gateway",
fmt.Sprintf(
"Explicit launcher host %q was not forwarded to gateway because configured gateway host is %q; gateway keeps original bind host",
strings.TrimSpace(h.serverHost),
strings.TrimSpace(cfg.Gateway.Host),
),
)
}
if gatewayHostOverride != "" {
cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+gatewayHostOverride)
}
stdoutPipe, err := cmd.StdoutPipe()
+105 -9
View File
@@ -6,10 +6,76 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"github.com/sipeed/picoclaw/pkg/config"
)
var (
adaptiveLoopbackHostOnce sync.Once
adaptiveLoopbackHost string
)
func selectAdaptiveLoopbackHost(hasIPv4, hasIPv6 bool) string {
switch {
case hasIPv4 && hasIPv6:
return "localhost"
case hasIPv6:
return "::1"
case hasIPv4:
return "127.0.0.1"
default:
return "127.0.0.1"
}
}
func isLoopbackEquivalentHost(host string) bool {
host = strings.TrimSpace(host)
if host == "" {
return false
}
if strings.EqualFold(host, "localhost") {
return true
}
trimmed := strings.Trim(host, "[]")
ip := net.ParseIP(trimmed)
return ip != nil && ip.IsLoopback()
}
func resolveAdaptiveLoopbackHost() string {
adaptiveLoopbackHostOnce.Do(func() {
ips, err := net.LookupIP("localhost")
if err != nil {
adaptiveLoopbackHost = selectAdaptiveLoopbackHost(false, false)
return
}
hasIPv4 := false
hasIPv6 := false
for _, ip := range ips {
if ip == nil {
continue
}
if ip.To4() != nil {
hasIPv4 = true
continue
}
hasIPv6 = true
}
adaptiveLoopbackHost = selectAdaptiveLoopbackHost(hasIPv4, hasIPv6)
})
return adaptiveLoopbackHost
}
func resolveDefaultLoopbackHost() string {
return resolveAdaptiveLoopbackHost()
}
func resolveLocalhostLoopbackHost() string {
return resolveAdaptiveLoopbackHost()
}
func (h *Handler) effectiveLauncherPublic() bool {
if h.serverHostExplicit {
// -host takes precedence over -public and launcher-config public setting.
@@ -30,27 +96,33 @@ func (h *Handler) effectiveLauncherPublic() bool {
func canonicalLauncherBindHost(host string) string {
host = strings.TrimSpace(host)
if host == "" || strings.EqualFold(host, "localhost") {
return "127.0.0.1"
if host == "" {
return resolveDefaultLoopbackHost()
}
if strings.EqualFold(host, "localhost") {
return resolveLocalhostLoopbackHost()
}
return host
}
func (h *Handler) launcherAndGatewayBindHostsAligned() bool {
cfg, err := config.LoadConfig(h.configPath)
if err != nil || cfg == nil {
func (h *Handler) launcherAndGatewayBindHostsAligned(cfg *config.Config) bool {
if cfg == nil {
return false
}
// With -host specified, -public is ignored, so launcher's legacy bind host is loopback.
launcherHost := canonicalLauncherBindHost("127.0.0.1")
gatewayHost := canonicalLauncherBindHost(cfg.Gateway.Host)
if isLoopbackEquivalentHost(launcherHost) && isLoopbackEquivalentHost(gatewayHost) {
return true
}
return launcherHost == gatewayHost
}
func (h *Handler) gatewayHostOverride() string {
func (h *Handler) gatewayHostOverrideForConfig(cfg *config.Config) string {
if h.serverHostExplicit {
if h.launcherAndGatewayBindHostsAligned() {
if h.launcherAndGatewayBindHostsAligned(cfg) {
return strings.TrimSpace(h.serverHost)
}
return ""
@@ -62,8 +134,20 @@ func (h *Handler) gatewayHostOverride() string {
return ""
}
func (h *Handler) gatewayHostOverride() string {
if !h.serverHostExplicit {
return h.gatewayHostOverrideForConfig(nil)
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
return ""
}
return h.gatewayHostOverrideForConfig(cfg)
}
func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string {
if override := h.gatewayHostOverride(); override != "" {
if override := h.gatewayHostOverrideForConfig(cfg); override != "" {
return override
}
if cfg == nil {
@@ -73,7 +157,19 @@ func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string {
}
func gatewayProbeHost(bindHost string) string {
if bindHost == "" || bindHost == "0.0.0.0" {
bindHost = strings.TrimSpace(bindHost)
if bindHost == "" {
return resolveDefaultLoopbackHost()
}
if strings.EqualFold(bindHost, "localhost") {
return resolveLocalhostLoopbackHost()
}
trimmed := strings.Trim(bindHost, "[]")
if ip := net.ParseIP(trimmed); ip != nil && ip.IsUnspecified() {
if ip.To4() == nil {
return "::1"
}
return "127.0.0.1"
}
return bindHost
+55
View File
@@ -63,12 +63,54 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
}
}
func TestSelectAdaptiveLoopbackHost(t *testing.T) {
tests := []struct {
name string
hasIPv4 bool
hasIPv6 bool
want string
}{
{name: "dual stack prefers localhost", hasIPv4: true, hasIPv6: true, want: "localhost"},
{name: "ipv6 only", hasIPv4: false, hasIPv6: true, want: "::1"},
{name: "ipv4 only", hasIPv4: true, hasIPv6: false, want: "127.0.0.1"},
{name: "fallback", hasIPv4: false, hasIPv6: false, want: "127.0.0.1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := selectAdaptiveLoopbackHost(tt.hasIPv4, tt.hasIPv6); got != tt.want {
t.Fatalf("selectAdaptiveLoopbackHost(%t, %t) = %q, want %q", tt.hasIPv4, tt.hasIPv6, got, tt.want)
}
})
}
}
func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
if got := gatewayProbeHost("0.0.0.0"); got != "127.0.0.1" {
t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1")
}
}
func TestGatewayProbeHostUsesPreferredLoopbackForEmptyBind(t *testing.T) {
want := resolveDefaultLoopbackHost()
if got := gatewayProbeHost(""); got != want {
t.Fatalf("gatewayProbeHost(empty) = %q, want %q", got, want)
}
}
func TestGatewayProbeHostUsesPreferredLoopbackForLocalhostBind(t *testing.T) {
want := resolveLocalhostLoopbackHost()
if got := gatewayProbeHost("localhost"); got != want {
t.Fatalf("gatewayProbeHost(localhost) = %q, want %q", got, want)
}
}
func TestGatewayProbeHostUsesLoopbackForIPv6WildcardBind(t *testing.T) {
if got := gatewayProbeHost("::"); got != "::1" {
t.Fatalf("gatewayProbeHost(::) = %q, want %q", got, "::1")
}
}
func TestGatewayProxyURLUsesConfiguredHost(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
@@ -254,6 +296,19 @@ func TestGatewayHostOverrideWithExplicitHostAndAlignedGatewayHost(t *testing.T)
}
}
func TestGatewayHostOverrideWithExplicitHostAndLocalhostGatewayHost(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
writeGatewayHostConfig(t, configPath, "localhost")
h := NewHandler(configPath)
h.SetServerOptions(18800, false, false, nil)
h.SetServerBindHost("::", true)
if got := h.gatewayHostOverride(); got != "::" {
t.Fatalf("gatewayHostOverride() = %q, want %q", got, "::")
}
}
func TestGatewayHostOverrideWithExplicitHostAndMismatchedGatewayHost(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
writeGatewayHostConfig(t, configPath, "0.0.0.0")
+20 -5
View File
@@ -108,6 +108,21 @@ func browserHostForLauncher(bindHost string) string {
return bindHost
}
func wildcardAdvertiseIP(bindHost, ipv4, ipv6 string) string {
switch strings.TrimSpace(bindHost) {
case "0.0.0.0":
return strings.TrimSpace(ipv4)
case "::":
return strings.TrimSpace(ipv6)
default:
return ""
}
}
func advertiseIPForWildcardBindHost(bindHost string) string {
return wildcardAdvertiseIP(bindHost, utils.GetLocalIPv4(), utils.GetLocalIPv6())
}
// maskSecret masks a secret for display. It always shows up to the first 3
// runes. The last 4 runes are only appended when at least 5 runes remain
// hidden in the middle (i.e. string length >= 12), so an 8-char minimum
@@ -157,7 +172,7 @@ func main() {
)
fmt.Fprintf(os.Stderr, " Allow access from other devices on the local network\n")
fmt.Fprintf(os.Stderr, " %s -host 0.0.0.0 ./config.json\n", os.Args[0])
fmt.Fprintf(os.Stderr, " Bind launcher and gateway host explicitly\n")
fmt.Fprintf(os.Stderr, " Bind launcher host explicitly (gateway forwarding follows compatibility rules)\n")
fmt.Fprintf(os.Stderr, " %s -console -d ./config.json\n", os.Args[0])
fmt.Fprintf(os.Stderr, " Run in the terminal with debug logs enabled\n")
}
@@ -368,8 +383,8 @@ func main() {
fmt.Println()
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
if isWildcardBindHost(effectiveHost) {
if ip := utils.GetLocalIP(); ip != "" {
fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
if ip := advertiseIPForWildcardBindHost(effectiveHost); ip != "" {
fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(ip, effectivePort))
}
}
if hostExplicit {
@@ -401,8 +416,8 @@ func main() {
// Log startup info to file
logger.InfoC("web", fmt.Sprintf("Server will listen on http://%s", net.JoinHostPort(effectiveHost, effectivePort)))
if isWildcardBindHost(effectiveHost) {
if ip := utils.GetLocalIP(); ip != "" {
logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s:%s", ip, effectivePort))
if ip := advertiseIPForWildcardBindHost(effectiveHost); ip != "" {
logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s", net.JoinHostPort(ip, effectivePort)))
}
}
+24
View File
@@ -201,3 +201,27 @@ func TestBrowserHostForLauncher(t *testing.T) {
t.Fatalf("browserHostForLauncher(192.168.1.10) = %q, want %q", got, "192.168.1.10")
}
}
func TestWildcardAdvertiseIP(t *testing.T) {
tests := []struct {
name string
bindHost string
ipv4 string
ipv6 string
want string
}{
{name: "ipv4 wildcard uses ipv4", bindHost: "0.0.0.0", ipv4: "192.168.1.2", ipv6: "2001:db8::1", want: "192.168.1.2"},
{name: "ipv6 wildcard uses ipv6", bindHost: "::", ipv4: "192.168.1.2", ipv6: "2001:db8::1", want: "2001:db8::1"},
{name: "ipv6 wildcard with no ipv6 address", bindHost: "::", ipv4: "192.168.1.2", ipv6: "", want: ""},
{name: "ipv4 wildcard with no ipv4 address", bindHost: "0.0.0.0", ipv4: "", ipv6: "2001:db8::1", want: ""},
{name: "non wildcard does not advertise", bindHost: "127.0.0.1", ipv4: "192.168.1.2", ipv6: "2001:db8::1", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := wildcardAdvertiseIP(tt.bindHost, tt.ipv4, tt.ipv6); got != tt.want {
t.Fatalf("wildcardAdvertiseIP(%q, %q, %q) = %q, want %q", tt.bindHost, tt.ipv4, tt.ipv6, got, tt.want)
}
})
}
}
+30 -2
View File
@@ -54,8 +54,8 @@ func FindPicoclawBinary() string {
return "picoclaw"
}
// GetLocalIP returns the local IP address of the machine.
func GetLocalIP() string {
// GetLocalIPv4 returns a non-loopback local IPv4 address.
func GetLocalIPv4() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
@@ -68,6 +68,34 @@ func GetLocalIP() string {
return ""
}
// GetLocalIPv6 returns a non-loopback local IPv6 address.
func GetLocalIPv6() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
for _, a := range addrs {
ipnet, ok := a.(*net.IPNet)
if !ok || ipnet.IP == nil {
continue
}
ip := ipnet.IP
if ip.IsLoopback() || ip.To4() != nil {
continue
}
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
continue
}
return ip.String()
}
return ""
}
// GetLocalIP returns a non-loopback local IPv4 address for backward compatibility.
func GetLocalIP() string {
return GetLocalIPv4()
}
// OpenBrowser automatically opens the given URL in the default browser.
func OpenBrowser(url string) error {
switch runtime.GOOS {