mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Merge pull request #2514 from lc6464/fix/issue-2488-host-binding
feat(launcher): add host overrides for launcher and gateway
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/health"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/netbind"
|
||||
ppid "github.com/sipeed/picoclaw/pkg/pid"
|
||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||
)
|
||||
@@ -119,6 +120,7 @@ var (
|
||||
gatewayRestartGracePeriod = 5 * time.Second
|
||||
gatewayRestartForceKillWindow = 3 * time.Second
|
||||
gatewayRestartPollInterval = 100 * time.Millisecond
|
||||
gatewayExecCommand = exec.Command
|
||||
)
|
||||
|
||||
var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) {
|
||||
@@ -262,7 +264,7 @@ func (h *Handler) getGatewayHealthForPidData(
|
||||
host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
|
||||
}
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
host = netbind.ResolveAdaptiveLoopbackHost()
|
||||
}
|
||||
|
||||
url := "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/health"
|
||||
@@ -723,7 +725,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
|
||||
execPath := utils.FindPicoclawBinary()
|
||||
logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath))
|
||||
|
||||
cmd = exec.Command(execPath, h.gatewayCommandArgs()...)
|
||||
cmd = gatewayExecCommand(execPath, h.gatewayCommandArgs()...)
|
||||
cmd.Env = os.Environ()
|
||||
// Forward the launcher's config path via the environment variable that
|
||||
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
||||
@@ -731,8 +733,9 @@ 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.gatewayHostOverride()
|
||||
if gatewayHostOverride != "" {
|
||||
cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+gatewayHostOverride)
|
||||
}
|
||||
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
|
||||
@@ -8,9 +8,15 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/netbind"
|
||||
)
|
||||
|
||||
func (h *Handler) effectiveLauncherPublic() bool {
|
||||
if h.serverHostExplicit {
|
||||
// -host takes precedence over -public and launcher-config public setting.
|
||||
return false
|
||||
}
|
||||
|
||||
if h.serverPublicExplicit {
|
||||
return h.serverPublic
|
||||
}
|
||||
@@ -24,8 +30,11 @@ func (h *Handler) effectiveLauncherPublic() bool {
|
||||
}
|
||||
|
||||
func (h *Handler) gatewayHostOverride() string {
|
||||
if h.serverHostExplicit {
|
||||
return strings.TrimSpace(h.serverHostInput)
|
||||
}
|
||||
if h.effectiveLauncherPublic() {
|
||||
return "0.0.0.0"
|
||||
return "*"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -41,10 +50,11 @@ func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string {
|
||||
}
|
||||
|
||||
func gatewayProbeHost(bindHost string) string {
|
||||
if bindHost == "" || bindHost == "0.0.0.0" {
|
||||
return "127.0.0.1"
|
||||
plan, err := netbind.BuildPlan(bindHost, netbind.DefaultLoopback)
|
||||
if err != nil || strings.TrimSpace(plan.ProbeHost) == "" {
|
||||
return netbind.ResolveAdaptiveLoopbackHost()
|
||||
}
|
||||
return bindHost
|
||||
return plan.ProbeHost
|
||||
}
|
||||
|
||||
func (h *Handler) gatewayProxyURL() *url.URL {
|
||||
@@ -72,7 +82,7 @@ func requestHostName(r *http.Request) string {
|
||||
if strings.TrimSpace(r.Host) != "" {
|
||||
return r.Host
|
||||
}
|
||||
return "127.0.0.1"
|
||||
return netbind.ResolveAdaptiveLoopbackHost()
|
||||
}
|
||||
|
||||
func requestWSScheme(r *http.Request) string {
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/netbind"
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
)
|
||||
|
||||
@@ -26,8 +28,8 @@ func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) {
|
||||
h := NewHandler(configPath)
|
||||
h.SetServerOptions(18800, true, true, nil)
|
||||
|
||||
if got := h.gatewayHostOverride(); got != "0.0.0.0" {
|
||||
t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0")
|
||||
if got := h.gatewayHostOverride(); got != "*" {
|
||||
t.Fatalf("gatewayHostOverride() = %q, want %q", got, "*")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +66,36 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
|
||||
}
|
||||
|
||||
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")
|
||||
want := "127.0.0.1"
|
||||
if got := gatewayProbeHost("0.0.0.0"); got != want {
|
||||
t.Fatalf("gatewayProbeHost() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayProbeHostUsesPreferredLoopbackForEmptyBind(t *testing.T) {
|
||||
want := netbind.ResolveAdaptiveLoopbackHost()
|
||||
if got := gatewayProbeHost(""); got != want {
|
||||
t.Fatalf("gatewayProbeHost(empty) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayProbeHostUsesPreferredLoopbackForLocalhostBind(t *testing.T) {
|
||||
want := netbind.ResolveAdaptiveLoopbackHost()
|
||||
if got := gatewayProbeHost("localhost"); got != want {
|
||||
t.Fatalf("gatewayProbeHost(localhost) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayProbeHostUsesLoopbackForIPv6WildcardBind(t *testing.T) {
|
||||
want := "::1"
|
||||
if got := gatewayProbeHost("::"); got != want {
|
||||
t.Fatalf("gatewayProbeHost(::) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayProbeHostUsesFirstConcreteHostForMultiHostBind(t *testing.T) {
|
||||
if got := gatewayProbeHost("127.0.0.1,::1"); got != "127.0.0.1" {
|
||||
t.Fatalf("gatewayProbeHost(multi) = %q, want %q", got, "127.0.0.1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,8 +167,9 @@ func TestGetGatewayHealthUsesProbeHostForPublicLauncher(t *testing.T) {
|
||||
_ = statusCode
|
||||
_ = err
|
||||
|
||||
if requestedURL != "http://127.0.0.1:18791/health" {
|
||||
t.Fatalf("health url = %q, want %q", requestedURL, "http://127.0.0.1:18791/health")
|
||||
want := "http://" + net.JoinHostPort(netbind.ResolveAdaptiveLoopbackHost(), "18791") + "/health"
|
||||
if requestedURL != want {
|
||||
t.Fatalf("health url = %q, want %q", requestedURL, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,3 +271,43 @@ func TestBuildWsURLUsesRequestHostNotGatewayBindLoopback(t *testing.T) {
|
||||
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://localhost:18800/pico/ws")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayHostOverrideWithExplicitHostAndAlignedGatewayHost(t *testing.T) {
|
||||
h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
|
||||
h.SetServerOptions(18800, false, false, nil)
|
||||
h.SetServerBindHost("0.0.0.0", true)
|
||||
|
||||
if got := h.gatewayHostOverride(); got != "0.0.0.0" {
|
||||
t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayHostOverrideWithExplicitHostAndLocalhostGatewayHost(t *testing.T) {
|
||||
h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
|
||||
h.SetServerOptions(18800, false, false, nil)
|
||||
h.SetServerBindHost("::", true)
|
||||
|
||||
if got := h.gatewayHostOverride(); got != "::" {
|
||||
t.Fatalf("gatewayHostOverride() = %q, want %q", got, "::")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayHostOverrideWithExplicitMultiHost(t *testing.T) {
|
||||
h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
|
||||
h.SetServerOptions(18800, false, false, nil)
|
||||
h.SetServerBindHost("127.0.0.1,::1", true)
|
||||
|
||||
if got := h.gatewayHostOverride(); got != "127.0.0.1,::1" {
|
||||
t.Fatalf("gatewayHostOverride() = %q, want %q", got, "127.0.0.1,::1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayHostExplicitIgnoresPublicFlag(t *testing.T) {
|
||||
h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
|
||||
h.SetServerOptions(18800, true, true, nil)
|
||||
h.SetServerBindHost("127.0.0.1", true)
|
||||
|
||||
if got := h.effectiveLauncherPublic(); got {
|
||||
t.Fatalf("effectiveLauncherPublic() = %t, want false when explicit host is set", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ func resetGatewayTestState(t *testing.T) {
|
||||
|
||||
originalHealthGet := gatewayHealthGet
|
||||
originalProcessMatcher := gatewayProcessMatcher
|
||||
originalExecCommand := gatewayExecCommand
|
||||
originalRestartGracePeriod := gatewayRestartGracePeriod
|
||||
originalRestartForceKillWindow := gatewayRestartForceKillWindow
|
||||
originalRestartPollInterval := gatewayRestartPollInterval
|
||||
@@ -104,6 +105,7 @@ func resetGatewayTestState(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
gatewayHealthGet = originalHealthGet
|
||||
gatewayProcessMatcher = originalProcessMatcher
|
||||
gatewayExecCommand = originalExecCommand
|
||||
gatewayRestartGracePeriod = originalRestartGracePeriod
|
||||
gatewayRestartForceKillWindow = originalRestartForceKillWindow
|
||||
gatewayRestartPollInterval = originalRestartPollInterval
|
||||
@@ -119,6 +121,159 @@ func resetGatewayTestState(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
type gatewayStartEnvSnapshot struct {
|
||||
GatewayHost string `json:"gateway_host"`
|
||||
GatewayHostSet bool `json:"gateway_host_set"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
}
|
||||
|
||||
func TestGatewayStartHelperProcess(t *testing.T) {
|
||||
var envPath string
|
||||
for i, arg := range os.Args {
|
||||
if arg == "--" && i+2 < len(os.Args) && os.Args[i+1] == "gateway-env-helper" {
|
||||
envPath = os.Args[i+2]
|
||||
break
|
||||
}
|
||||
}
|
||||
if envPath == "" {
|
||||
t.Skip("helper process")
|
||||
}
|
||||
|
||||
host, ok := os.LookupEnv(config.EnvGatewayHost)
|
||||
raw, err := json.Marshal(gatewayStartEnvSnapshot{
|
||||
GatewayHost: host,
|
||||
GatewayHostSet: ok,
|
||||
ConfigPath: os.Getenv(config.EnvConfig),
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = io.WriteString(os.Stderr, err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.WriteFile(envPath, raw, 0o600); err != nil {
|
||||
_, _ = io.WriteString(os.Stderr, err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func unsetGatewayStartEnvForTest(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
|
||||
prev, hadPrev := os.LookupEnv(key)
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
t.Fatalf("Unsetenv(%q) error = %v", key, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if hadPrev {
|
||||
_ = os.Setenv(key, prev)
|
||||
return
|
||||
}
|
||||
_ = os.Unsetenv(key)
|
||||
})
|
||||
}
|
||||
|
||||
func newGatewayStartTestHandler(t *testing.T) *Handler {
|
||||
t.Helper()
|
||||
resetGatewayTestState(t)
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
cfg := config.DefaultConfig()
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
h.SetServerOptions(18800, false, false, nil)
|
||||
return h
|
||||
}
|
||||
|
||||
func startGatewayAndCaptureEnv(t *testing.T, h *Handler) gatewayStartEnvSnapshot {
|
||||
t.Helper()
|
||||
|
||||
unsetGatewayStartEnvForTest(t, config.EnvGatewayHost)
|
||||
|
||||
envPath := filepath.Join(t.TempDir(), "gateway-child-env.json")
|
||||
gatewayExecCommand = func(_ string, _ ...string) *exec.Cmd {
|
||||
return exec.Command(
|
||||
os.Args[0],
|
||||
"-test.run=TestGatewayStartHelperProcess",
|
||||
"--",
|
||||
"gateway-env-helper",
|
||||
envPath,
|
||||
)
|
||||
}
|
||||
|
||||
pid, err := h.startGatewayLocked("starting", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("startGatewayLocked() error = %v", err)
|
||||
}
|
||||
if pid <= 0 {
|
||||
t.Fatalf("startGatewayLocked() pid = %d, want > 0", pid)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
raw, err := os.ReadFile(envPath)
|
||||
if err == nil {
|
||||
var snapshot gatewayStartEnvSnapshot
|
||||
err = json.Unmarshal(raw, &snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("Unmarshal(child env) error = %v", err)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
t.Fatalf("ReadFile(%q) error = %v", envPath, err)
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timed out waiting for gateway child env snapshot %q", envPath)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartGatewayLocked_ForwardsLauncherHostOverrideToGatewayEnv(t *testing.T) {
|
||||
h := newGatewayStartTestHandler(t)
|
||||
h.SetServerBindHost("127.0.0.1,::1", true)
|
||||
|
||||
snapshot := startGatewayAndCaptureEnv(t, h)
|
||||
if !snapshot.GatewayHostSet {
|
||||
t.Fatal("gateway host env was not set")
|
||||
}
|
||||
if snapshot.GatewayHost != "127.0.0.1,::1" {
|
||||
t.Fatalf("gateway host env = %q, want %q", snapshot.GatewayHost, "127.0.0.1,::1")
|
||||
}
|
||||
if snapshot.ConfigPath != h.configPath {
|
||||
t.Fatalf("config env = %q, want %q", snapshot.ConfigPath, h.configPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartGatewayLocked_ForwardsLauncherHostFromEnvironmentToGatewayEnv(t *testing.T) {
|
||||
h := newGatewayStartTestHandler(t)
|
||||
h.SetServerBindHost("::", true)
|
||||
|
||||
snapshot := startGatewayAndCaptureEnv(t, h)
|
||||
if !snapshot.GatewayHostSet {
|
||||
t.Fatal("gateway host env was not set")
|
||||
}
|
||||
if snapshot.GatewayHost != "::" {
|
||||
t.Fatalf("gateway host env = %q, want %q", snapshot.GatewayHost, "::")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartGatewayLocked_ForwardsWildcardHostForPublicLauncher(t *testing.T) {
|
||||
h := newGatewayStartTestHandler(t)
|
||||
h.SetServerOptions(18800, true, true, nil)
|
||||
|
||||
snapshot := startGatewayAndCaptureEnv(t, h)
|
||||
if !snapshot.GatewayHostSet {
|
||||
t.Fatal("gateway host env was not set")
|
||||
}
|
||||
if snapshot.GatewayHost != "*" {
|
||||
t.Fatalf("gateway host env = %q, want %q", snapshot.GatewayHost, "*")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
h := NewHandler(configPath)
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
@@ -13,6 +14,8 @@ type Handler struct {
|
||||
serverPort int
|
||||
serverPublic bool
|
||||
serverPublicExplicit bool
|
||||
serverHostInput string
|
||||
serverHostExplicit bool
|
||||
serverCIDRs []string
|
||||
debug bool
|
||||
oauthMu sync.Mutex
|
||||
@@ -41,9 +44,21 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a
|
||||
h.serverPort = port
|
||||
h.serverPublic = public
|
||||
h.serverPublicExplicit = publicExplicit
|
||||
h.serverHostInput = ""
|
||||
h.serverHostExplicit = false
|
||||
h.serverCIDRs = append([]string(nil), allowedCIDRs...)
|
||||
}
|
||||
|
||||
// SetServerBindHost stores the launcher's effective bind host.
|
||||
// When explicit is true, hostInput is the normalized -host / PICOCLAW_LAUNCHER_HOST value.
|
||||
func (h *Handler) SetServerBindHost(hostInput string, explicit bool) {
|
||||
h.serverHostInput = strings.TrimSpace(hostInput)
|
||||
if !explicit {
|
||||
h.serverHostInput = ""
|
||||
}
|
||||
h.serverHostExplicit = explicit
|
||||
}
|
||||
|
||||
func (h *Handler) SetDebug(debug bool) {
|
||||
h.debug = debug
|
||||
}
|
||||
|
||||
+23
-15
@@ -34,22 +34,30 @@ func shutdownApp() {
|
||||
apiHandler.Shutdown()
|
||||
}
|
||||
|
||||
if server != nil {
|
||||
// Disable keep-alive to allow graceful shutdown
|
||||
server.SetKeepAlivesEnabled(false)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
// Context deadline exceeded is expected if there are active connections
|
||||
// This is not necessarily an error, so log it at info level
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
logger.Infof("Server shutdown timeout after %v, forcing close", shutdownTimeout)
|
||||
} else {
|
||||
logger.Errorf("Server shutdown error: %v", err)
|
||||
if len(servers) > 0 {
|
||||
for _, srv := range servers {
|
||||
if srv == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Disable keep-alive to allow graceful shutdown
|
||||
srv.SetKeepAlivesEnabled(false)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
err := srv.Shutdown(ctx)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
// Context deadline exceeded is expected if there are active connections
|
||||
// This is not necessarily an error, so log it at info level
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
logger.Infof("Server shutdown timeout after %v, forcing close", shutdownTimeout)
|
||||
} else {
|
||||
logger.Errorf("Server shutdown error: %v", err)
|
||||
}
|
||||
} else {
|
||||
logger.Infof("Server shutdown completed successfully")
|
||||
}
|
||||
} else {
|
||||
logger.Infof("Server shutdown completed successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ const (
|
||||
FileName = "launcher-config.json"
|
||||
// DefaultPort is the default port for the web launcher.
|
||||
DefaultPort = 18800
|
||||
// EnvLauncherToken overrides launcher dashboard token.
|
||||
EnvLauncherToken = "PICOCLAW_LAUNCHER_TOKEN"
|
||||
// EnvLauncherHost overrides launcher listen host.
|
||||
EnvLauncherHost = "PICOCLAW_LAUNCHER_HOST"
|
||||
|
||||
// dashboardSigningKeyBytes is the HMAC-SHA256 key size (256 bits).
|
||||
dashboardSigningKeyBytes = 32
|
||||
@@ -59,7 +63,7 @@ func Validate(cfg Config) error {
|
||||
|
||||
// EnsureDashboardSecrets returns signing key bytes and the effective dashboard token for this
|
||||
// process. The signing key is freshly random each call; the token comes from
|
||||
// PICOCLAW_LAUNCHER_TOKEN when set, otherwise launcher-config.json launcher_token,
|
||||
// EnvLauncherToken when set, otherwise launcher-config.json launcher_token,
|
||||
// otherwise a new random token.
|
||||
func EnsureDashboardSecrets(
|
||||
cfg Config,
|
||||
@@ -69,7 +73,7 @@ func EnsureDashboardSecrets(
|
||||
return "", nil, "", err
|
||||
}
|
||||
|
||||
effectiveToken = strings.TrimSpace(os.Getenv("PICOCLAW_LAUNCHER_TOKEN"))
|
||||
effectiveToken = strings.TrimSpace(os.Getenv(EnvLauncherToken))
|
||||
if effectiveToken != "" {
|
||||
return effectiveToken, signingKey, DashboardTokenSourceEnv, nil
|
||||
}
|
||||
|
||||
+310
-29
@@ -15,17 +15,20 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/netbind"
|
||||
"github.com/sipeed/picoclaw/web/backend/api"
|
||||
"github.com/sipeed/picoclaw/web/backend/dashboardauth"
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
@@ -44,7 +47,7 @@ const (
|
||||
var (
|
||||
appVersion = config.Version
|
||||
|
||||
server *http.Server
|
||||
servers []*http.Server
|
||||
serverAddr string
|
||||
// browserLaunchURL is opened by openBrowser() (auto-open + tray "open console").
|
||||
// Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use.
|
||||
@@ -65,6 +68,255 @@ func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, la
|
||||
return launcherPath
|
||||
}
|
||||
|
||||
func resolveLauncherHostInput(flagHost string, explicitFlag bool, envHost string) (string, bool, error) {
|
||||
if explicitFlag {
|
||||
normalized, err := netbind.NormalizeHostInput(flagHost)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
envHost = strings.TrimSpace(envHost)
|
||||
if envHost == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
normalized, err := netbind.NormalizeHostInput(envHost)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
func openLauncherListeners(hostInput string, public bool, port string) (netbind.OpenResult, error) {
|
||||
defaultMode := netbind.DefaultLoopback
|
||||
if strings.TrimSpace(hostInput) == "" && public {
|
||||
defaultMode = netbind.DefaultAny
|
||||
}
|
||||
|
||||
plan, err := netbind.BuildPlan(hostInput, defaultMode)
|
||||
if err != nil {
|
||||
return netbind.OpenResult{}, err
|
||||
}
|
||||
return netbind.OpenPlan(plan, port)
|
||||
}
|
||||
|
||||
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 hasWildcardBindHosts(bindHosts []string) bool {
|
||||
for _, bindHost := range bindHosts {
|
||||
if netbind.IsUnspecifiedHost(bindHost) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func wildcardBindHostFamilies(bindHosts []string) (hasIPv4, hasIPv6 bool) {
|
||||
for _, bindHost := range bindHosts {
|
||||
host := strings.TrimSpace(bindHost)
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !netbind.IsUnspecifiedHost(host) {
|
||||
continue
|
||||
}
|
||||
|
||||
ip := net.ParseIP(strings.Trim(host, "[]"))
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if ip.To4() != nil {
|
||||
hasIPv4 = true
|
||||
continue
|
||||
}
|
||||
hasIPv6 = true
|
||||
}
|
||||
|
||||
return hasIPv4, hasIPv6
|
||||
}
|
||||
|
||||
func wildcardAdvertiseIP(bindHosts []string, ipv4, ipv6 string) string {
|
||||
hasIPv4Wildcard, hasIPv6Wildcard := wildcardBindHostFamilies(bindHosts)
|
||||
v4 := strings.TrimSpace(ipv4)
|
||||
v6 := strings.TrimSpace(ipv6)
|
||||
|
||||
switch {
|
||||
case hasIPv4Wildcard && hasIPv6Wildcard:
|
||||
if v6 != "" {
|
||||
return v6
|
||||
}
|
||||
return v4
|
||||
case hasIPv6Wildcard:
|
||||
return v6
|
||||
case hasIPv4Wildcard:
|
||||
return v4
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func advertiseIPForWildcardBindHosts(bindHosts []string) string {
|
||||
return wildcardAdvertiseIP(bindHosts, utils.GetLocalIPv4(), utils.GetLocalIPv6())
|
||||
}
|
||||
|
||||
func appendLauncherConsoleHostList(hosts []string, seen map[string]struct{}, values []string) []string {
|
||||
for _, value := range values {
|
||||
hosts = appendUniqueHost(hosts, seen, value)
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
func shouldShowLocalhostConsoleEntry(hostInput string) bool {
|
||||
normalizedHostInput := strings.TrimSpace(hostInput)
|
||||
if normalizedHostInput == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
for token := range strings.SplitSeq(normalizedHostInput, ",") {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
if token == "*" || strings.EqualFold(token, "localhost") {
|
||||
return true
|
||||
}
|
||||
|
||||
ip := net.ParseIP(strings.Trim(token, "[]"))
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
if ip4.String() == "127.0.0.1" || ip4.String() == "0.0.0.0" {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ip.String() == "::1" || ip.String() == "::" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isConsoleDisplayGlobalIPv6(ip net.IP) bool {
|
||||
if ip == nil || ip.IsLoopback() || ip.To4() != nil {
|
||||
return false
|
||||
}
|
||||
ip = ip.To16()
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
return ip[0]&0xe0 == 0x20
|
||||
}
|
||||
|
||||
func launcherConsoleHostsWithLocalAddrs(
|
||||
hostInput string,
|
||||
public bool,
|
||||
ipv4s []string,
|
||||
globalIPv6s []string,
|
||||
) []string {
|
||||
hosts := make([]string, 0, 8)
|
||||
seen := make(map[string]struct{}, 8)
|
||||
|
||||
if shouldShowLocalhostConsoleEntry(hostInput) {
|
||||
hosts = appendUniqueHost(hosts, seen, "localhost")
|
||||
}
|
||||
|
||||
normalizedHostInput := strings.TrimSpace(hostInput)
|
||||
if normalizedHostInput == "" {
|
||||
if public {
|
||||
hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s)
|
||||
hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s)
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
hasStar := false
|
||||
hasIPv4Any := false
|
||||
hasIPv6Any := false
|
||||
for _, token := range strings.Split(normalizedHostInput, ",") {
|
||||
switch strings.TrimSpace(token) {
|
||||
case "*":
|
||||
hasStar = true
|
||||
case "0.0.0.0":
|
||||
hasIPv4Any = true
|
||||
case "::":
|
||||
hasIPv6Any = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasStar {
|
||||
hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s)
|
||||
hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s)
|
||||
return hosts
|
||||
}
|
||||
|
||||
for _, token := range strings.Split(normalizedHostInput, ",") {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" || strings.EqualFold(token, "localhost") || netbind.IsLoopbackHost(token) {
|
||||
continue
|
||||
}
|
||||
|
||||
ip := net.ParseIP(strings.Trim(token, "[]"))
|
||||
switch {
|
||||
case token == "::":
|
||||
hosts = appendLauncherConsoleHostList(hosts, seen, globalIPv6s)
|
||||
case token == "0.0.0.0":
|
||||
hosts = appendLauncherConsoleHostList(hosts, seen, ipv4s)
|
||||
case ip != nil && ip.To4() != nil:
|
||||
if hasIPv4Any {
|
||||
continue
|
||||
}
|
||||
hosts = appendUniqueHost(hosts, seen, ip.String())
|
||||
case ip != nil:
|
||||
if hasIPv6Any {
|
||||
continue
|
||||
}
|
||||
if isConsoleDisplayGlobalIPv6(ip) {
|
||||
hosts = appendUniqueHost(hosts, seen, ip.String())
|
||||
}
|
||||
default:
|
||||
hosts = appendUniqueHost(hosts, seen, token)
|
||||
}
|
||||
}
|
||||
|
||||
return hosts
|
||||
}
|
||||
|
||||
func launcherConsoleHosts(hostInput string, public bool) []string {
|
||||
return launcherConsoleHostsWithLocalAddrs(
|
||||
hostInput,
|
||||
public,
|
||||
utils.GetLocalIPv4s(),
|
||||
utils.GetGlobalIPv6s(),
|
||||
)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// maskSecret masks a secret for display. It always shows up to the first 3
|
||||
// runes. The last 4 runes are only appended when at least 5 runes remain
|
||||
// hidden in the middle (i.e. string length >= 12), so an 8-char minimum
|
||||
@@ -85,7 +337,8 @@ func maskSecret(s string) string {
|
||||
|
||||
func main() {
|
||||
port := flag.String("port", "18800", "Port to listen on")
|
||||
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
|
||||
host := flag.String("host", "", "Host to listen on (overrides -public when set)")
|
||||
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")
|
||||
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")
|
||||
@@ -112,6 +365,8 @@ func main() {
|
||||
os.Args[0],
|
||||
)
|
||||
fmt.Fprintf(os.Stderr, " Allow access from other devices on the local network\n")
|
||||
fmt.Fprintf(os.Stderr, " %s -host :: ./config.json\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, " Bind launcher host explicitly with exact host semantics\n")
|
||||
fmt.Fprintf(os.Stderr, " %s -console -d ./config.json\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, " Run in the terminal with debug logs enabled\n")
|
||||
}
|
||||
@@ -175,8 +430,9 @@ func main() {
|
||||
logger.DebugC(
|
||||
"web",
|
||||
fmt.Sprintf(
|
||||
"Launcher flags: console=%t public=%t no_browser=%t config=%s",
|
||||
"Launcher flags: console=%t host=%q public=%t no_browser=%t config=%s",
|
||||
enableConsole,
|
||||
*host,
|
||||
*public,
|
||||
*noBrowser,
|
||||
absPath,
|
||||
@@ -186,10 +442,13 @@ func main() {
|
||||
|
||||
var explicitPort bool
|
||||
var explicitPublic bool
|
||||
var explicitHost bool
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
switch f.Name {
|
||||
case "port":
|
||||
explicitPort = true
|
||||
case "host":
|
||||
explicitHost = true
|
||||
case "public":
|
||||
explicitPublic = true
|
||||
}
|
||||
@@ -210,6 +469,23 @@ func main() {
|
||||
if !explicitPublic {
|
||||
effectivePublic = launcherCfg.Public
|
||||
}
|
||||
envHost := strings.TrimSpace(os.Getenv(launcherconfig.EnvLauncherHost))
|
||||
|
||||
hostInput, hostOverrideActive, err := resolveLauncherHostInput(*host, explicitHost, envHost)
|
||||
if err != nil {
|
||||
logger.Fatalf("Invalid host %q: %v", firstNonEmpty(strings.TrimSpace(*host), envHost), err)
|
||||
}
|
||||
if hostOverrideActive {
|
||||
effectivePublic = false
|
||||
}
|
||||
|
||||
if !explicitHost && hostOverrideActive {
|
||||
logger.InfoC("web", "Using launcher host from environment PICOCLAW_LAUNCHER_HOST")
|
||||
}
|
||||
|
||||
if hostOverrideActive && explicitPublic {
|
||||
logger.InfoC("web", "Ignoring -public because launcher host was explicitly set")
|
||||
}
|
||||
|
||||
portNum, err := strconv.Atoi(effectivePort)
|
||||
if err != nil || portNum < 1 || portNum > 65535 {
|
||||
@@ -219,6 +495,12 @@ func main() {
|
||||
logger.Fatalf("Invalid port %q: %v", effectivePort, err)
|
||||
}
|
||||
|
||||
openResult, err := openLauncherListeners(hostInput, effectivePublic, effectivePort)
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to open launcher listener(s): %v", err)
|
||||
}
|
||||
listeners := openResult.Listeners
|
||||
|
||||
dashboardToken, dashboardSigningKey, dashboardTokenSource, dashErr := launcherconfig.EnsureDashboardSecrets(
|
||||
launcherCfg,
|
||||
)
|
||||
@@ -246,14 +528,6 @@ func main() {
|
||||
logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
|
||||
}
|
||||
|
||||
// Determine listen address
|
||||
var addr string
|
||||
if effectivePublic {
|
||||
addr = "0.0.0.0:" + effectivePort
|
||||
} else {
|
||||
addr = "127.0.0.1:" + effectivePort
|
||||
}
|
||||
|
||||
// Initialize Server components
|
||||
mux := http.NewServeMux()
|
||||
|
||||
@@ -271,6 +545,7 @@ func main() {
|
||||
logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err))
|
||||
}
|
||||
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
||||
apiHandler.SetServerBindHost(hostInput, hostOverrideActive)
|
||||
apiHandler.RegisterRoutes(mux)
|
||||
|
||||
// Frontend Embedded Assets
|
||||
@@ -297,15 +572,14 @@ func main() {
|
||||
|
||||
// Print startup banner and token (console mode only).
|
||||
if enableConsole || debug {
|
||||
consoleHosts := launcherConsoleHosts(hostInput, effectivePublic)
|
||||
|
||||
fmt.Print(utils.Banner)
|
||||
fmt.Println()
|
||||
fmt.Println(" Open the following URL in your browser:")
|
||||
fmt.Println()
|
||||
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
|
||||
if effectivePublic {
|
||||
if ip := utils.GetLocalIP(); ip != "" {
|
||||
fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
|
||||
}
|
||||
for _, host := range consoleHosts {
|
||||
fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort))
|
||||
}
|
||||
fmt.Println()
|
||||
switch dashboardTokenSource {
|
||||
@@ -331,15 +605,17 @@ func main() {
|
||||
}
|
||||
|
||||
// Log startup info to file
|
||||
logger.InfoC("web", fmt.Sprintf("Server will listen on http://localhost:%s", effectivePort))
|
||||
if effectivePublic {
|
||||
if ip := utils.GetLocalIP(); ip != "" {
|
||||
logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s:%s", ip, effectivePort))
|
||||
for _, ln := range listeners {
|
||||
logger.InfoC("web", fmt.Sprintf("Server will listen on http://%s", ln.Addr().String()))
|
||||
}
|
||||
if hasWildcardBindHosts(openResult.BindHosts) {
|
||||
if ip := advertiseIPForWildcardBindHosts(openResult.BindHosts); ip != "" {
|
||||
logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s", net.JoinHostPort(ip, effectivePort)))
|
||||
}
|
||||
}
|
||||
|
||||
// Share the local URL with the launcher runtime.
|
||||
serverAddr = fmt.Sprintf("http://localhost:%s", effectivePort)
|
||||
serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(openResult.ProbeHost, effectivePort))
|
||||
if dashboardToken != "" {
|
||||
browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken)
|
||||
} else {
|
||||
@@ -354,14 +630,19 @@ func main() {
|
||||
apiHandler.TryAutoStartGateway()
|
||||
}()
|
||||
|
||||
// Start the Server in a goroutine
|
||||
server = &http.Server{Addr: addr, Handler: handler}
|
||||
go func() {
|
||||
logger.InfoC("web", fmt.Sprintf("Server listening on %s", addr))
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatalf("Server failed to start: %v", err)
|
||||
}
|
||||
}()
|
||||
// Start the server(s) in goroutines.
|
||||
servers = make([]*http.Server, 0, len(listeners))
|
||||
for _, ln := range listeners {
|
||||
srv := &http.Server{Handler: handler}
|
||||
servers = append(servers, srv)
|
||||
|
||||
go func(s *http.Server, l net.Listener) {
|
||||
logger.InfoC("web", fmt.Sprintf("Server listening on %s", l.Addr().String()))
|
||||
if serveErr := s.Serve(l); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
||||
logger.Fatalf("Server failed to start on %s: %v", l.Addr().String(), serveErr)
|
||||
}
|
||||
}(srv, ln)
|
||||
}
|
||||
|
||||
defer shutdownApp()
|
||||
|
||||
|
||||
+341
-6
@@ -1,8 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/netbind"
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
)
|
||||
|
||||
@@ -73,25 +82,351 @@ func TestMaskSecret(t *testing.T) {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
// Long token (>=12 chars): first 3 + 10 stars + last 4
|
||||
{"sdhjflsjdflksdf", "sdh**********ksdf"},
|
||||
{"abcdefghijklmnopqrstuvwxyz", "abc**********wxyz"},
|
||||
// Exactly 12 chars (3+4+5 hidden): suffix shown
|
||||
{"abcdefghijkl", "abc**********ijkl"},
|
||||
// 8 chars (minimum password length): suffix NOT shown — only prefix+stars
|
||||
{"abcdefgh", "abc**********"},
|
||||
// 11 chars (one below threshold): suffix NOT shown
|
||||
{"abcdefghijk", "abc**********"},
|
||||
// 4..3 chars: prefix shown, no suffix
|
||||
{"abcdefg", "abc**********"},
|
||||
{"abcd", "abc**********"},
|
||||
// <=3 chars: fully masked
|
||||
{"abc", "**********"},
|
||||
{"", "**********"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := maskSecret(tt.input); got != tt.want {
|
||||
t.Errorf("maskSecret(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLauncherHostInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flagHost string
|
||||
explicitFlag bool
|
||||
envHost string
|
||||
wantHost string
|
||||
wantActive bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "flag host wins",
|
||||
flagHost: "127.0.0.1",
|
||||
explicitFlag: true,
|
||||
envHost: "::",
|
||||
wantHost: "127.0.0.1",
|
||||
wantActive: true,
|
||||
},
|
||||
{name: "env host used when flag absent", envHost: "127.0.0.1,::1", wantHost: "127.0.0.1,::1", wantActive: true},
|
||||
{name: "blank env ignored", envHost: " ", wantHost: "", wantActive: false},
|
||||
{name: "invalid flag rejected", flagHost: "127.0.0.1, ", explicitFlag: true, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotHost, gotActive, err := resolveLauncherHostInput(tt.flagHost, tt.explicitFlag, tt.envHost)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("resolveLauncherHostInput() err = %v, wantErr %t", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
if gotHost != tt.wantHost {
|
||||
t.Fatalf("resolveLauncherHostInput() host = %q, want %q", gotHost, tt.wantHost)
|
||||
}
|
||||
if gotActive != tt.wantActive {
|
||||
t.Fatalf("resolveLauncherHostInput() active = %t, want %t", gotActive, tt.wantActive)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherConsoleHosts(t *testing.T) {
|
||||
t.Run("default loopback shows localhost only", func(t *testing.T) {
|
||||
hosts := launcherConsoleHostsWithLocalAddrs(
|
||||
"",
|
||||
false,
|
||||
[]string{"192.168.1.2", "10.0.0.8"},
|
||||
[]string{"2001:db8::1", "2001:db8::2"},
|
||||
)
|
||||
want := []string{"localhost"}
|
||||
if strings.Join(hosts, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("hosts = %#v, want %#v", hosts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit loopback hosts collapse to localhost", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hostInput string
|
||||
}{
|
||||
{name: "ipv6 loopback", hostInput: "::1"},
|
||||
{name: "ipv4 loopback", hostInput: "127.0.0.1"},
|
||||
{name: "localhost", hostInput: "localhost"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hosts := launcherConsoleHostsWithLocalAddrs(
|
||||
tt.hostInput,
|
||||
false,
|
||||
[]string{"192.168.1.2", "10.0.0.8"},
|
||||
[]string{"2001:db8::1", "2001:db8::2"},
|
||||
)
|
||||
want := []string{"localhost"}
|
||||
if strings.Join(hosts, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("hosts = %#v, want %#v", hosts, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public wildcard shows localhost then ipv6 and ipv4", func(t *testing.T) {
|
||||
hosts := launcherConsoleHostsWithLocalAddrs(
|
||||
"",
|
||||
true,
|
||||
[]string{"192.168.1.2", "10.0.0.8"},
|
||||
[]string{"2001:db8::1", "2001:db8::2"},
|
||||
)
|
||||
want := []string{"localhost", "2001:db8::1", "2001:db8::2", "192.168.1.2", "10.0.0.8"}
|
||||
if strings.Join(hosts, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("hosts = %#v, want %#v", hosts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit ipv6 any shows localhost then ipv6 variants", func(t *testing.T) {
|
||||
hosts := launcherConsoleHostsWithLocalAddrs(
|
||||
"::",
|
||||
false,
|
||||
[]string{"192.168.1.2", "10.0.0.8"},
|
||||
[]string{"2001:db8::1", "2001:db8::2"},
|
||||
)
|
||||
want := []string{"localhost", "2001:db8::1", "2001:db8::2"}
|
||||
if strings.Join(hosts, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("hosts = %#v, want %#v", hosts, want)
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
if host == "::1" || host == "127.0.0.1" || strings.HasPrefix(strings.ToLower(host), "fe80:") {
|
||||
t.Fatalf("hosts = %#v, loopback IPs must not be displayed", hosts)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit ipv4 any shows localhost then lan ipv4", func(t *testing.T) {
|
||||
hosts := launcherConsoleHostsWithLocalAddrs(
|
||||
"0.0.0.0",
|
||||
false,
|
||||
[]string{"192.168.1.2", "10.0.0.8"},
|
||||
[]string{"2001:db8::1", "2001:db8::2"},
|
||||
)
|
||||
want := []string{"localhost", "192.168.1.2", "10.0.0.8"}
|
||||
if strings.Join(hosts, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("hosts = %#v, want %#v", hosts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit wildcard star shows localhost first", func(t *testing.T) {
|
||||
hosts := launcherConsoleHostsWithLocalAddrs(
|
||||
"*",
|
||||
false,
|
||||
[]string{"192.168.1.2", "10.0.0.8"},
|
||||
[]string{"2001:db8::1", "2001:db8::2"},
|
||||
)
|
||||
want := []string{"localhost", "2001:db8::1", "2001:db8::2", "192.168.1.2", "10.0.0.8"}
|
||||
if strings.Join(hosts, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("hosts = %#v, want %#v", hosts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit multi-address binding without local tokens hides localhost", func(t *testing.T) {
|
||||
hosts := launcherConsoleHostsWithLocalAddrs(
|
||||
"192.168.1.2,10.0.0.8,2001:db8::1,2001:db8::2,fe80::1",
|
||||
false,
|
||||
[]string{"192.168.1.2", "10.0.0.8"},
|
||||
[]string{"2001:db8::1", "2001:db8::2"},
|
||||
)
|
||||
want := []string{"192.168.1.2", "10.0.0.8", "2001:db8::1", "2001:db8::2"}
|
||||
if strings.Join(hosts, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("hosts = %#v, want %#v", hosts, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWildcardAdvertiseIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bindHosts []string
|
||||
ipv4 string
|
||||
ipv6 string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ipv4 wildcard uses ipv4",
|
||||
bindHosts: []string{"0.0.0.0"},
|
||||
ipv4: "192.168.1.2",
|
||||
ipv6: "2001:db8::1",
|
||||
want: "192.168.1.2",
|
||||
},
|
||||
{
|
||||
name: "dual wildcard prefers ipv6",
|
||||
bindHosts: []string{"0.0.0.0", "::"},
|
||||
ipv4: "192.168.1.2",
|
||||
ipv6: "2001:db8::1",
|
||||
want: "2001:db8::1",
|
||||
},
|
||||
{
|
||||
name: "ipv6 wildcard uses ipv6",
|
||||
bindHosts: []string{"::"},
|
||||
ipv4: "192.168.1.2",
|
||||
ipv6: "2001:db8::1",
|
||||
want: "2001:db8::1",
|
||||
},
|
||||
{
|
||||
name: "dual wildcard falls back to ipv4 when ipv6 missing",
|
||||
bindHosts: []string{"0.0.0.0", "::"},
|
||||
ipv4: "192.168.1.2",
|
||||
ipv6: "",
|
||||
want: "192.168.1.2",
|
||||
},
|
||||
{
|
||||
name: "ipv6 wildcard without ipv6 does not advertise ipv4",
|
||||
bindHosts: []string{"::"},
|
||||
ipv4: "192.168.1.2",
|
||||
ipv6: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "non wildcard does not advertise",
|
||||
bindHosts: []string{"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.bindHosts, tt.ipv4, tt.ipv6); got != tt.want {
|
||||
t.Fatalf("wildcardAdvertiseIP(%#v, %q, %q) = %q, want %q", tt.bindHosts, tt.ipv4, tt.ipv6, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLauncherListeners_HonorsIPv6OnlyHost(t *testing.T) {
|
||||
hasIPv4, hasIPv6 := netbind.DetectIPFamilies()
|
||||
if !hasIPv6 {
|
||||
t.Skip("IPv6 is unavailable in this environment")
|
||||
}
|
||||
|
||||
result, err := openLauncherListeners("::", false, "0")
|
||||
if err != nil {
|
||||
t.Fatalf("openLauncherListeners() error = %v", err)
|
||||
}
|
||||
startLauncherTestHTTPServer(t, result.Listeners)
|
||||
port := mustAtoi(t, result.Port)
|
||||
|
||||
requireLauncherHTTPReachable(t, "::1", port)
|
||||
if hasIPv4 {
|
||||
requireLauncherHTTPUnreachable(t, "127.0.0.1", port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLauncherListeners_SupportsExplicitMultiHost(t *testing.T) {
|
||||
hasIPv4, hasIPv6 := netbind.DetectIPFamilies()
|
||||
if !hasIPv4 || !hasIPv6 {
|
||||
t.Skip("dual-stack loopback is unavailable in this environment")
|
||||
}
|
||||
|
||||
result, err := openLauncherListeners("127.0.0.1,::1", false, "0")
|
||||
if err != nil {
|
||||
t.Fatalf("openLauncherListeners() error = %v", err)
|
||||
}
|
||||
startLauncherTestHTTPServer(t, result.Listeners)
|
||||
port := mustAtoi(t, result.Port)
|
||||
|
||||
requireLauncherHTTPReachable(t, "127.0.0.1", port)
|
||||
requireLauncherHTTPReachable(t, "::1", port)
|
||||
}
|
||||
|
||||
func startLauncherTestHTTPServer(t *testing.T, listeners []net.Listener) {
|
||||
t.Helper()
|
||||
|
||||
server := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}),
|
||||
}
|
||||
|
||||
errCh := make(chan error, len(listeners))
|
||||
for _, listener := range listeners {
|
||||
ln := listener
|
||||
go func() {
|
||||
errCh <- server.Serve(ln)
|
||||
}()
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(ctx)
|
||||
for range listeners {
|
||||
err := <-errCh
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
t.Fatalf("server.Serve() error = %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func requireLauncherHTTPReachable(t *testing.T, host string, port int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
err := launcherHTTPGet(host, port)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("expected %s:%d to be reachable: %v", host, port, err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func requireLauncherHTTPUnreachable(t *testing.T, host string, port int) {
|
||||
t.Helper()
|
||||
if err := launcherHTTPGet(host, port); err == nil {
|
||||
t.Fatalf("expected %s:%d to be unreachable", host, port)
|
||||
}
|
||||
}
|
||||
|
||||
func launcherHTTPGet(host string, port int) error {
|
||||
client := &http.Client{
|
||||
Timeout: 300 * time.Millisecond,
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get("http://" + net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return errors.New(resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustAtoi(t *testing.T, value string) int {
|
||||
t.Helper()
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
t.Fatalf("Atoi(%q) error = %v", value, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
@@ -54,18 +55,93 @@ func FindPicoclawBinary() string {
|
||||
return "picoclaw"
|
||||
}
|
||||
|
||||
// GetLocalIP returns the local IP address of the machine.
|
||||
func GetLocalIP() string {
|
||||
func appendUniqueIP(addrs []string, seen map[string]struct{}, value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return addrs
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
return addrs
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
return append(addrs, value)
|
||||
}
|
||||
|
||||
// GetLocalIPv4s returns all non-loopback local IPv4 addresses.
|
||||
func GetLocalIPv4s() []string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
results := make([]string, 0, 4)
|
||||
seen := make(map[string]struct{}, 4)
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
|
||||
return ipnet.IP.String()
|
||||
ipnet, ok := a.(*net.IPNet)
|
||||
if !ok || ipnet.IP == nil || ipnet.IP.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
if ip4 := ipnet.IP.To4(); ip4 != nil {
|
||||
results = appendUniqueIP(results, seen, ip4.String())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return results
|
||||
}
|
||||
|
||||
func isDisplayGlobalIPv6(ip net.IP) bool {
|
||||
if ip == nil || ip.IsLoopback() || ip.To4() != nil {
|
||||
return false
|
||||
}
|
||||
ip = ip.To16()
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
// Only show IPv6 global unicast addresses in 2000::/3.
|
||||
return ip[0]&0xe0 == 0x20
|
||||
}
|
||||
|
||||
// GetGlobalIPv6s returns all IPv6 global unicast addresses.
|
||||
func GetGlobalIPv6s() []string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
results := make([]string, 0, 4)
|
||||
seen := make(map[string]struct{}, 4)
|
||||
for _, a := range addrs {
|
||||
ipnet, ok := a.(*net.IPNet)
|
||||
if !ok || ipnet.IP == nil {
|
||||
continue
|
||||
}
|
||||
ip := ipnet.IP
|
||||
if !isDisplayGlobalIPv6(ip) {
|
||||
continue
|
||||
}
|
||||
results = appendUniqueIP(results, seen, ip.String())
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// GetLocalIPv4 returns the first non-loopback local IPv4 address.
|
||||
func GetLocalIPv4() string {
|
||||
addrs := GetLocalIPv4s()
|
||||
if len(addrs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return addrs[0]
|
||||
}
|
||||
|
||||
// GetLocalIPv6 returns the first IPv6 global unicast address.
|
||||
func GetLocalIPv6() string {
|
||||
addrs := GetGlobalIPv6s()
|
||||
if len(addrs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return addrs[0]
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user