fix(host): align launcher and gateway host normalization semantics
This commit is contained in:
parent
4e977367c2
commit
448027c02a
11 changed files with 380 additions and 22 deletions
|
|
@ -14,6 +14,14 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func resolveGatewayHostOverride(explicit bool, host string) (string, error) {
|
||||||
|
host = strings.TrimSpace(host)
|
||||||
|
if explicit && host == "" {
|
||||||
|
return "", fmt.Errorf("the --host option cannot be empty")
|
||||||
|
}
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
|
||||||
func NewGatewayCommand() *cobra.Command {
|
func NewGatewayCommand() *cobra.Command {
|
||||||
var debug bool
|
var debug bool
|
||||||
var noTruncate bool
|
var noTruncate bool
|
||||||
|
|
@ -37,11 +45,14 @@ func NewGatewayCommand() *cobra.Command {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
host = strings.TrimSpace(host)
|
resolvedHost, err := resolveGatewayHostOverride(cmd.Flags().Changed("host"), host)
|
||||||
if host != "" {
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resolvedHost != "" {
|
||||||
prevHost, hadPrev := os.LookupEnv(config.EnvGatewayHost)
|
prevHost, hadPrev := os.LookupEnv(config.EnvGatewayHost)
|
||||||
if err := os.Setenv(config.EnvGatewayHost, host); err != nil {
|
if err := os.Setenv(config.EnvGatewayHost, resolvedHost); err != nil {
|
||||||
return fmt.Errorf("failed to set %s: %w", config.EnvGatewayHost, err)
|
return fmt.Errorf("failed to set %s: %w", config.EnvGatewayHost, err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
|
||||||
|
|
@ -31,3 +31,29 @@ func TestNewGatewayCommand(t *testing.T) {
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
|
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("host"))
|
assert.NotNil(t, cmd.Flags().Lookup("host"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveGatewayHostOverride(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
explicit bool
|
||||||
|
host string
|
||||||
|
wantHost string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "implicit empty host is allowed", explicit: false, host: "", wantHost: "", wantErr: false},
|
||||||
|
{name: "explicit empty host rejected", explicit: true, host: " ", wantHost: "", wantErr: true},
|
||||||
|
{name: "explicit localhost kept", explicit: true, host: " localhost ", wantHost: "localhost", wantErr: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := resolveGatewayHostOverride(tt.explicit, tt.host)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("resolveGatewayHostOverride() err = %v, wantErr %t", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if got != tt.wantHost {
|
||||||
|
t.Fatalf("resolveGatewayHostOverride() host = %q, want %q", got, tt.wantHost)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1073,6 +1073,8 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
|
|
||||||
applyLegacyBindingsMigration(data, cfg)
|
applyLegacyBindingsMigration(data, cfg)
|
||||||
|
|
||||||
|
gatewayHostBeforeEnv := cfg.Gateway.Host
|
||||||
|
|
||||||
if err = env.Parse(cfg); err != nil {
|
if err = env.Parse(cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -1080,6 +1082,7 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
if err = InitChannelList(cfg.Channels); err != nil {
|
if err = InitChannelList(cfg.Channels); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
cfg.Gateway.Host = resolveGatewayHostFromEnv(gatewayHostBeforeEnv)
|
||||||
|
|
||||||
// Expand multi-key configs into separate entries for key-level failover
|
// Expand multi-key configs into separate entries for key-level failover
|
||||||
cfg.ModelList = expandMultiKeyModels(cfg.ModelList)
|
cfg.ModelList = expandMultiKeyModels(cfg.ModelList)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package config
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
@ -49,6 +50,33 @@ func EffectiveGatewayLogLevel(cfg *Config) string {
|
||||||
return normalizeGatewayLogLevel(cfg.Gateway.LogLevel)
|
return normalizeGatewayLogLevel(cfg.Gateway.LogLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeGatewayHost(host string) string {
|
||||||
|
host = strings.TrimSpace(host)
|
||||||
|
if host != "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultHost := strings.TrimSpace(DefaultConfig().Gateway.Host)
|
||||||
|
if defaultHost == "" {
|
||||||
|
return "127.0.0.1"
|
||||||
|
}
|
||||||
|
return defaultHost
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveGatewayHostFromEnv(baseHost string) string {
|
||||||
|
envHost, ok := os.LookupEnv(EnvGatewayHost)
|
||||||
|
if !ok {
|
||||||
|
return normalizeGatewayHost(baseHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
envHost = strings.TrimSpace(envHost)
|
||||||
|
if envHost == "" {
|
||||||
|
return normalizeGatewayHost(baseHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
return envHost
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveGatewayLogLevel reads the configured gateway log level without triggering
|
// ResolveGatewayLogLevel reads the configured gateway log level without triggering
|
||||||
// the full config loader, so startup code can apply logging before config load logs run.
|
// the full config loader, so startup code can apply logging before config load logs run.
|
||||||
// The PICOCLAW_LOG_LEVEL environment variable overrides the file value.
|
// The PICOCLAW_LOG_LEVEL environment variable overrides the file value.
|
||||||
|
|
|
||||||
61
pkg/config/gateway_host_env_test.go
Normal file
61
pkg/config/gateway_host_env_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeGatewayHostTestConfig(t *testing.T, host string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
raw := fmt.Sprintf(`{"version":2,"gateway":{"host":%q,"port":18790}}`, host)
|
||||||
|
if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile(configPath): %v", err)
|
||||||
|
}
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_GatewayHostEnvTrimmed(t *testing.T) {
|
||||||
|
configPath := writeGatewayHostTestConfig(t, "127.0.0.1")
|
||||||
|
t.Setenv(EnvGatewayHost, " ::1 ")
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Gateway.Host != "::1" {
|
||||||
|
t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, "::1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_GatewayHostBlankEnvFallsBackToConfigHost(t *testing.T) {
|
||||||
|
configPath := writeGatewayHostTestConfig(t, " localhost ")
|
||||||
|
t.Setenv(EnvGatewayHost, " ")
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Gateway.Host != "localhost" {
|
||||||
|
t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, "localhost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_GatewayHostBlankEnvAndConfigFallsBackToDefault(t *testing.T) {
|
||||||
|
configPath := writeGatewayHostTestConfig(t, " ")
|
||||||
|
t.Setenv(EnvGatewayHost, " ")
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultHost := strings.TrimSpace(DefaultConfig().Gateway.Host)
|
||||||
|
if cfg.Gateway.Host != defaultHost {
|
||||||
|
t.Fatalf("cfg.Gateway.Host = %q, want %q", cfg.Gateway.Host, defaultHost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -731,8 +731,19 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
|
||||||
if h.configPath != "" {
|
if h.configPath != "" {
|
||||||
cmd.Env = append(cmd.Env, config.EnvConfig+"="+h.configPath)
|
cmd.Env = append(cmd.Env, config.EnvConfig+"="+h.configPath)
|
||||||
}
|
}
|
||||||
if host := h.gatewayHostOverride(); host != "" {
|
gatewayHostOverride := h.gatewayHostOverrideForConfig(cfg)
|
||||||
cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+host)
|
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()
|
stdoutPipe, err := cmd.StdoutPipe()
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,76 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"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 {
|
func (h *Handler) effectiveLauncherPublic() bool {
|
||||||
if h.serverHostExplicit {
|
if h.serverHostExplicit {
|
||||||
// -host takes precedence over -public and launcher-config public setting.
|
// -host takes precedence over -public and launcher-config public setting.
|
||||||
|
|
@ -30,27 +96,33 @@ func (h *Handler) effectiveLauncherPublic() bool {
|
||||||
|
|
||||||
func canonicalLauncherBindHost(host string) string {
|
func canonicalLauncherBindHost(host string) string {
|
||||||
host = strings.TrimSpace(host)
|
host = strings.TrimSpace(host)
|
||||||
if host == "" || strings.EqualFold(host, "localhost") {
|
if host == "" {
|
||||||
return "127.0.0.1"
|
return resolveDefaultLoopbackHost()
|
||||||
|
}
|
||||||
|
if strings.EqualFold(host, "localhost") {
|
||||||
|
return resolveLocalhostLoopbackHost()
|
||||||
}
|
}
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) launcherAndGatewayBindHostsAligned() bool {
|
func (h *Handler) launcherAndGatewayBindHostsAligned(cfg *config.Config) bool {
|
||||||
cfg, err := config.LoadConfig(h.configPath)
|
if cfg == nil {
|
||||||
if err != nil || cfg == nil {
|
|
||||||
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's legacy bind host is loopback.
|
||||||
launcherHost := canonicalLauncherBindHost("127.0.0.1")
|
launcherHost := canonicalLauncherBindHost("127.0.0.1")
|
||||||
gatewayHost := canonicalLauncherBindHost(cfg.Gateway.Host)
|
gatewayHost := canonicalLauncherBindHost(cfg.Gateway.Host)
|
||||||
|
if isLoopbackEquivalentHost(launcherHost) && isLoopbackEquivalentHost(gatewayHost) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return launcherHost == gatewayHost
|
return launcherHost == gatewayHost
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) gatewayHostOverride() string {
|
func (h *Handler) gatewayHostOverrideForConfig(cfg *config.Config) string {
|
||||||
if h.serverHostExplicit {
|
if h.serverHostExplicit {
|
||||||
if h.launcherAndGatewayBindHostsAligned() {
|
if h.launcherAndGatewayBindHostsAligned(cfg) {
|
||||||
return strings.TrimSpace(h.serverHost)
|
return strings.TrimSpace(h.serverHost)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -62,8 +134,20 @@ func (h *Handler) gatewayHostOverride() string {
|
||||||
return ""
|
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 {
|
func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string {
|
||||||
if override := h.gatewayHostOverride(); override != "" {
|
if override := h.gatewayHostOverrideForConfig(cfg); override != "" {
|
||||||
return override
|
return override
|
||||||
}
|
}
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
|
|
@ -73,7 +157,19 @@ func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func gatewayProbeHost(bindHost string) 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 "127.0.0.1"
|
||||||
}
|
}
|
||||||
return bindHost
|
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) {
|
func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
|
||||||
if got := gatewayProbeHost("0.0.0.0"); got != "127.0.0.1" {
|
if got := gatewayProbeHost("0.0.0.0"); got != "127.0.0.1" {
|
||||||
t.Fatalf("gatewayProbeHost() = %q, want %q", 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) {
|
func TestGatewayProxyURLUsesConfiguredHost(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
h := NewHandler(configPath)
|
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) {
|
func TestGatewayHostOverrideWithExplicitHostAndMismatchedGatewayHost(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
writeGatewayHostConfig(t, configPath, "0.0.0.0")
|
writeGatewayHostConfig(t, configPath, "0.0.0.0")
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,21 @@ func browserHostForLauncher(bindHost string) string {
|
||||||
return bindHost
|
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
|
// 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
|
||||||
|
|
@ -157,7 +172,7 @@ func main() {
|
||||||
)
|
)
|
||||||
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 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, " %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")
|
||||||
}
|
}
|
||||||
|
|
@ -368,8 +383,8 @@ func main() {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
|
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
|
||||||
if isWildcardBindHost(effectiveHost) {
|
if isWildcardBindHost(effectiveHost) {
|
||||||
if ip := utils.GetLocalIP(); ip != "" {
|
if ip := advertiseIPForWildcardBindHost(effectiveHost); ip != "" {
|
||||||
fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
|
fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(ip, effectivePort))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if hostExplicit {
|
if hostExplicit {
|
||||||
|
|
@ -401,8 +416,8 @@ func main() {
|
||||||
// Log startup info to file
|
// Log startup info to file
|
||||||
logger.InfoC("web", fmt.Sprintf("Server will listen on http://%s", net.JoinHostPort(effectiveHost, effectivePort)))
|
logger.InfoC("web", fmt.Sprintf("Server will listen on http://%s", net.JoinHostPort(effectiveHost, effectivePort)))
|
||||||
if isWildcardBindHost(effectiveHost) {
|
if isWildcardBindHost(effectiveHost) {
|
||||||
if ip := utils.GetLocalIP(); ip != "" {
|
if ip := advertiseIPForWildcardBindHost(effectiveHost); ip != "" {
|
||||||
logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s:%s", ip, effectivePort))
|
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")
|
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"
|
return "picoclaw"
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLocalIP returns the local IP address of the machine.
|
// GetLocalIPv4 returns a non-loopback local IPv4 address.
|
||||||
func GetLocalIP() string {
|
func GetLocalIPv4() string {
|
||||||
addrs, err := net.InterfaceAddrs()
|
addrs, err := net.InterfaceAddrs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -68,6 +68,34 @@ func GetLocalIP() string {
|
||||||
return ""
|
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.
|
// OpenBrowser automatically opens the given URL in the default browser.
|
||||||
func OpenBrowser(url string) error {
|
func OpenBrowser(url string) error {
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue