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