feat(launcher): support multi-host bind and strict host semantics
This commit is contained in:
parent
e7b3654313
commit
7b38d437ba
7 changed files with 526 additions and 261 deletions
|
|
@ -6,43 +6,17 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
|
|
||||||
var (
|
|
||||||
adaptiveIPFamiliesOnce sync.Once
|
|
||||||
adaptiveHasIPv4 bool
|
|
||||||
adaptiveHasIPv6 bool
|
|
||||||
lookupLocalhostIPs = func() ([]net.IP, error) { return net.LookupIP("localhost") }
|
|
||||||
listInterfaceAddrs = net.InterfaceAddrs
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func selectAdaptiveLoopbackHost(hasIPv4, hasIPv6 bool) string {
|
func selectAdaptiveLoopbackHost(hasIPv4, hasIPv6 bool) string {
|
||||||
switch {
|
return utils.SelectAdaptiveLoopbackHost(hasIPv4, hasIPv6)
|
||||||
case hasIPv4 && hasIPv6:
|
|
||||||
return "localhost"
|
|
||||||
case hasIPv6:
|
|
||||||
return "::1"
|
|
||||||
case hasIPv4:
|
|
||||||
return "127.0.0.1"
|
|
||||||
default:
|
|
||||||
return "localhost"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectAdaptiveAnyHost(hasIPv4, hasIPv6 bool) string {
|
func selectAdaptiveAnyHost(hasIPv4, hasIPv6 bool) string {
|
||||||
switch {
|
return utils.SelectAdaptiveAnyHost(hasIPv4, hasIPv6)
|
||||||
case hasIPv4 && hasIPv6:
|
|
||||||
return "::"
|
|
||||||
case hasIPv6:
|
|
||||||
return "::"
|
|
||||||
case hasIPv4:
|
|
||||||
return "0.0.0.0"
|
|
||||||
default:
|
|
||||||
return "::"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isLoopbackEquivalentHost(host string) bool {
|
func isLoopbackEquivalentHost(host string) bool {
|
||||||
|
|
@ -58,63 +32,12 @@ func isLoopbackEquivalentHost(host string) bool {
|
||||||
return ip != nil && ip.IsLoopback()
|
return ip != nil && ip.IsLoopback()
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectAdaptiveIPFamilies() (bool, bool) {
|
|
||||||
adaptiveIPFamiliesOnce.Do(func() {
|
|
||||||
if ips, err := lookupLocalhostIPs(); err == nil {
|
|
||||||
for _, ip := range ips {
|
|
||||||
if ip == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ip.To4() != nil {
|
|
||||||
adaptiveHasIPv4 = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
adaptiveHasIPv6 = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if adaptiveHasIPv4 && adaptiveHasIPv6 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if addrs, err := listInterfaceAddrs(); err == nil {
|
|
||||||
for _, addr := range addrs {
|
|
||||||
ipnet, ok := addr.(*net.IPNet)
|
|
||||||
if !ok || ipnet.IP == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ipnet.IP.To4() != nil {
|
|
||||||
adaptiveHasIPv4 = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
adaptiveHasIPv6 = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return adaptiveHasIPv4, adaptiveHasIPv6
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveAdaptiveLoopbackHost() string {
|
|
||||||
hasIPv4, hasIPv6 := detectAdaptiveIPFamilies()
|
|
||||||
return selectAdaptiveLoopbackHost(hasIPv4, hasIPv6)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveAdaptiveAnyHost() string {
|
|
||||||
hasIPv4, hasIPv6 := detectAdaptiveIPFamilies()
|
|
||||||
return selectAdaptiveAnyHost(hasIPv4, hasIPv6)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveDefaultLoopbackHost() string {
|
func resolveDefaultLoopbackHost() string {
|
||||||
return resolveAdaptiveLoopbackHost()
|
return utils.ResolveAdaptiveLoopbackHost()
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveDefaultAnyHost() string {
|
func resolveDefaultAnyHost() string {
|
||||||
return resolveAdaptiveAnyHost()
|
return utils.ResolveAdaptiveAnyHost()
|
||||||
}
|
|
||||||
|
|
||||||
func resolveLocalhostLoopbackHost() string {
|
|
||||||
return resolveAdaptiveLoopbackHost()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) effectiveLauncherPublic() bool {
|
func (h *Handler) effectiveLauncherPublic() bool {
|
||||||
|
|
@ -141,7 +64,7 @@ func canonicalLauncherBindHost(host string) string {
|
||||||
return resolveDefaultLoopbackHost()
|
return resolveDefaultLoopbackHost()
|
||||||
}
|
}
|
||||||
if strings.EqualFold(host, "localhost") {
|
if strings.EqualFold(host, "localhost") {
|
||||||
return resolveLocalhostLoopbackHost()
|
return resolveDefaultLoopbackHost()
|
||||||
}
|
}
|
||||||
trimmed := strings.Trim(host, "[]")
|
trimmed := strings.Trim(host, "[]")
|
||||||
if ip := net.ParseIP(trimmed); ip != nil && ip.IsUnspecified() {
|
if ip := net.ParseIP(trimmed); ip != nil && ip.IsUnspecified() {
|
||||||
|
|
@ -207,7 +130,7 @@ func gatewayProbeHost(bindHost string) string {
|
||||||
return resolveDefaultLoopbackHost()
|
return resolveDefaultLoopbackHost()
|
||||||
}
|
}
|
||||||
if strings.EqualFold(bindHost, "localhost") {
|
if strings.EqualFold(bindHost, "localhost") {
|
||||||
return resolveLocalhostLoopbackHost()
|
return resolveDefaultLoopbackHost()
|
||||||
}
|
}
|
||||||
|
|
||||||
trimmed := strings.Trim(bindHost, "[]")
|
trimmed := strings.Trim(bindHost, "[]")
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -15,12 +14,6 @@ import (
|
||||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||||
)
|
)
|
||||||
|
|
||||||
func resetAdaptiveIPFamiliesForTest() {
|
|
||||||
adaptiveIPFamiliesOnce = sync.Once{}
|
|
||||||
adaptiveHasIPv4 = false
|
|
||||||
adaptiveHasIPv6 = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) {
|
func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
launcherPath := launcherconfig.PathForAppConfig(configPath)
|
launcherPath := launcherconfig.PathForAppConfig(configPath)
|
||||||
|
|
@ -115,34 +108,6 @@ func TestSelectAdaptiveAnyHost(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdaptiveHostSelectionFallsBackToInterfaceAddrs(t *testing.T) {
|
|
||||||
oldLookup := lookupLocalhostIPs
|
|
||||||
oldList := listInterfaceAddrs
|
|
||||||
lookupLocalhostIPs = func() ([]net.IP, error) {
|
|
||||||
return nil, errors.New("lookup failed")
|
|
||||||
}
|
|
||||||
_, v4Net, err := net.ParseCIDR("192.0.2.10/24")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseCIDR() error = %v", err)
|
|
||||||
}
|
|
||||||
listInterfaceAddrs = func() ([]net.Addr, error) {
|
|
||||||
return []net.Addr{v4Net}, nil
|
|
||||||
}
|
|
||||||
resetAdaptiveIPFamiliesForTest()
|
|
||||||
t.Cleanup(func() {
|
|
||||||
lookupLocalhostIPs = oldLookup
|
|
||||||
listInterfaceAddrs = oldList
|
|
||||||
resetAdaptiveIPFamiliesForTest()
|
|
||||||
})
|
|
||||||
|
|
||||||
if got := resolveDefaultAnyHost(); got != "0.0.0.0" {
|
|
||||||
t.Fatalf("resolveDefaultAnyHost() = %q, want %q", got, "0.0.0.0")
|
|
||||||
}
|
|
||||||
if got := resolveDefaultLoopbackHost(); got != "127.0.0.1" {
|
|
||||||
t.Fatalf("resolveDefaultLoopbackHost() = %q, want %q", got, "127.0.0.1")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
|
func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
|
||||||
want := resolveDefaultLoopbackHost()
|
want := resolveDefaultLoopbackHost()
|
||||||
if got := gatewayProbeHost("0.0.0.0"); got != want {
|
if got := gatewayProbeHost("0.0.0.0"); got != want {
|
||||||
|
|
@ -158,7 +123,7 @@ func TestGatewayProbeHostUsesPreferredLoopbackForEmptyBind(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayProbeHostUsesPreferredLoopbackForLocalhostBind(t *testing.T) {
|
func TestGatewayProbeHostUsesPreferredLoopbackForLocalhostBind(t *testing.T) {
|
||||||
want := resolveLocalhostLoopbackHost()
|
want := resolveDefaultLoopbackHost()
|
||||||
if got := gatewayProbeHost("localhost"); got != want {
|
if got := gatewayProbeHost("localhost"); got != want {
|
||||||
t.Fatalf("gatewayProbeHost(localhost) = %q, want %q", got, want)
|
t.Fatalf("gatewayProbeHost(localhost) = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,13 +34,19 @@ func shutdownApp() {
|
||||||
apiHandler.Shutdown()
|
apiHandler.Shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
if server != nil {
|
if len(servers) > 0 {
|
||||||
// Disable keep-alive to allow graceful shutdown
|
|
||||||
server.SetKeepAlivesEnabled(false)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := server.Shutdown(ctx); err != nil {
|
|
||||||
|
for _, srv := range servers {
|
||||||
|
if srv == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable keep-alive to allow graceful shutdown
|
||||||
|
srv.SetKeepAlivesEnabled(false)
|
||||||
|
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
// Context deadline exceeded is expected if there are active connections
|
// Context deadline exceeded is expected if there are active connections
|
||||||
// This is not necessarily an error, so log it at info level
|
// This is not necessarily an error, so log it at info level
|
||||||
if errors.Is(err, context.DeadlineExceeded) {
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
|
@ -53,6 +59,7 @@ func shutdownApp() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func openBrowser() error {
|
func openBrowser() error {
|
||||||
target := browserLaunchURL
|
target := browserLaunchURL
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -47,11 +46,7 @@ const (
|
||||||
var (
|
var (
|
||||||
appVersion = config.Version
|
appVersion = config.Version
|
||||||
|
|
||||||
launcherIPFamiliesOnce sync.Once
|
servers []*http.Server
|
||||||
launcherHasIPv4 bool
|
|
||||||
launcherHasIPv6 bool
|
|
||||||
|
|
||||||
server *http.Server
|
|
||||||
serverAddr string
|
serverAddr string
|
||||||
// browserLaunchURL is opened by openBrowser() (auto-open + tray "open console").
|
// browserLaunchURL is opened by openBrowser() (auto-open + tray "open console").
|
||||||
// Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use.
|
// Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use.
|
||||||
|
|
@ -61,6 +56,50 @@ var (
|
||||||
noBrowser *bool
|
noBrowser *bool
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type launcherBindMode string
|
||||||
|
|
||||||
|
type launcherRuntimeBinding struct {
|
||||||
|
mode launcherBindMode
|
||||||
|
host string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
launcherBindModeAutoPrivate launcherBindMode = "auto-private"
|
||||||
|
launcherBindModeAutoPublic launcherBindMode = "auto-public"
|
||||||
|
launcherBindModeExplicitLiteral launcherBindMode = "explicit-literal"
|
||||||
|
launcherBindModeExplicitAdaptiveAny launcherBindMode = "explicit-adaptive-any"
|
||||||
|
launcherBindModeExplicitAdaptiveLocal launcherBindMode = "explicit-adaptive-localhost"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseLauncherHostList(raw string) ([]string, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, errors.New("host cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
hosts := make([]string, 0, len(parts))
|
||||||
|
seen := make(map[string]struct{}, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
host := strings.TrimSpace(part)
|
||||||
|
if host == "" {
|
||||||
|
return nil, errors.New("host list contains an empty entry")
|
||||||
|
}
|
||||||
|
key := strings.ToLower(host)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
hosts = append(hosts, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hosts) == 0 {
|
||||||
|
return nil, errors.New("host cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
return hosts, nil
|
||||||
|
}
|
||||||
|
|
||||||
func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool {
|
func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool {
|
||||||
return !enableConsole || debug
|
return !enableConsole || debug
|
||||||
}
|
}
|
||||||
|
|
@ -72,86 +111,12 @@ func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, la
|
||||||
return launcherPath
|
return launcherPath
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectLauncherIPFamilies() (bool, bool) {
|
|
||||||
launcherIPFamiliesOnce.Do(func() {
|
|
||||||
if ips, err := net.LookupIP("localhost"); err == nil {
|
|
||||||
for _, ip := range ips {
|
|
||||||
if ip == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ip.To4() != nil {
|
|
||||||
launcherHasIPv4 = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
launcherHasIPv6 = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if launcherHasIPv4 && launcherHasIPv6 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if addrs, err := net.InterfaceAddrs(); err == nil {
|
|
||||||
for _, addr := range addrs {
|
|
||||||
ipnet, ok := addr.(*net.IPNet)
|
|
||||||
if !ok || ipnet.IP == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ipnet.IP.To4() != nil {
|
|
||||||
launcherHasIPv4 = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
launcherHasIPv6 = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return launcherHasIPv4, launcherHasIPv6
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectAdaptiveLauncherLoopbackHost(hasIPv4, hasIPv6 bool) string {
|
|
||||||
switch {
|
|
||||||
case hasIPv4 && hasIPv6:
|
|
||||||
return "localhost"
|
|
||||||
case hasIPv6:
|
|
||||||
return "::1"
|
|
||||||
case hasIPv4:
|
|
||||||
return "127.0.0.1"
|
|
||||||
default:
|
|
||||||
return "localhost"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectAdaptiveLauncherAnyHost(hasIPv4, hasIPv6 bool) string {
|
|
||||||
switch {
|
|
||||||
case hasIPv4 && hasIPv6:
|
|
||||||
return "::"
|
|
||||||
case hasIPv6:
|
|
||||||
return "::"
|
|
||||||
case hasIPv4:
|
|
||||||
return "0.0.0.0"
|
|
||||||
default:
|
|
||||||
return "::"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveDefaultLauncherLoopbackHost() string {
|
|
||||||
hasIPv4, hasIPv6 := detectLauncherIPFamilies()
|
|
||||||
return selectAdaptiveLauncherLoopbackHost(hasIPv4, hasIPv6)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveDefaultLauncherAnyHost() string {
|
func resolveDefaultLauncherAnyHost() string {
|
||||||
hasIPv4, hasIPv6 := detectLauncherIPFamilies()
|
return utils.ResolveAdaptiveAnyHost()
|
||||||
return selectAdaptiveLauncherAnyHost(hasIPv4, hasIPv6)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveDefaultLauncherPrivateHost() string {
|
func resolveDefaultLauncherPrivateHost() string {
|
||||||
hasIPv4, hasIPv6 := detectLauncherIPFamilies()
|
return utils.ResolveAdaptiveLoopbackHost()
|
||||||
if hasIPv4 && hasIPv6 {
|
|
||||||
// In dual-stack environments, use wildcard IPv6 bind so localhost can serve both families.
|
|
||||||
return selectAdaptiveLauncherAnyHost(hasIPv4, hasIPv6)
|
|
||||||
}
|
|
||||||
return selectAdaptiveLauncherLoopbackHost(hasIPv4, hasIPv6)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeLauncherSpecialHost(host string) string {
|
func normalizeLauncherSpecialHost(host string) string {
|
||||||
|
|
@ -159,16 +124,36 @@ func normalizeLauncherSpecialHost(host string) string {
|
||||||
if host == "" {
|
if host == "" {
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
if strings.EqualFold(host, "localhost") {
|
if host == "*" {
|
||||||
return resolveDefaultLauncherLoopbackHost()
|
|
||||||
}
|
|
||||||
trimmed := strings.Trim(host, "[]")
|
|
||||||
if ip := net.ParseIP(trimmed); ip != nil && ip.IsUnspecified() {
|
|
||||||
return resolveDefaultLauncherAnyHost()
|
return resolveDefaultLauncherAnyHost()
|
||||||
}
|
}
|
||||||
|
if strings.EqualFold(host, "localhost") {
|
||||||
|
return resolveDefaultLauncherPrivateHost()
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil {
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveLauncherBindMode(rawHost string, hostExplicit bool, effectivePublic bool) launcherBindMode {
|
||||||
|
if !hostExplicit {
|
||||||
|
if effectivePublic {
|
||||||
|
return launcherBindModeAutoPublic
|
||||||
|
}
|
||||||
|
return launcherBindModeAutoPrivate
|
||||||
|
}
|
||||||
|
|
||||||
|
rawHost = strings.TrimSpace(rawHost)
|
||||||
|
if rawHost == "*" {
|
||||||
|
return launcherBindModeExplicitAdaptiveAny
|
||||||
|
}
|
||||||
|
if strings.EqualFold(rawHost, "localhost") {
|
||||||
|
return launcherBindModeExplicitAdaptiveLocal
|
||||||
|
}
|
||||||
|
return launcherBindModeExplicitLiteral
|
||||||
|
}
|
||||||
|
|
||||||
func resolveLauncherBindHost(
|
func resolveLauncherBindHost(
|
||||||
host string,
|
host string,
|
||||||
explicitHost bool,
|
explicitHost bool,
|
||||||
|
|
@ -243,30 +228,126 @@ func appendUniqueHost(hosts []string, seen map[string]struct{}, host string) []s
|
||||||
return append(hosts, host)
|
return append(hosts, host)
|
||||||
}
|
}
|
||||||
|
|
||||||
func launcherConsoleHosts(bindHost string, hostExplicit bool, effectivePublic bool) []string {
|
func launcherConsoleHosts(bindMode launcherBindMode, bindHost string, effectivePublic bool) []string {
|
||||||
hosts := make([]string, 0, 6)
|
hosts := make([]string, 0, 6)
|
||||||
seen := make(map[string]struct{}, 6)
|
seen := make(map[string]struct{}, 6)
|
||||||
|
|
||||||
hosts = appendUniqueHost(hosts, seen, "localhost")
|
hosts = appendUniqueHost(hosts, seen, "localhost")
|
||||||
|
|
||||||
if isWildcardBindHost(bindHost) {
|
switch bindMode {
|
||||||
|
case launcherBindModeAutoPrivate, launcherBindModeExplicitAdaptiveLocal:
|
||||||
|
hosts = appendUniqueHost(hosts, seen, "::1")
|
||||||
|
hosts = appendUniqueHost(hosts, seen, "127.0.0.1")
|
||||||
|
return hosts
|
||||||
|
case launcherBindModeAutoPublic, launcherBindModeExplicitAdaptiveAny:
|
||||||
hosts = appendUniqueHost(hosts, seen, "::1")
|
hosts = appendUniqueHost(hosts, seen, "::1")
|
||||||
hosts = appendUniqueHost(hosts, seen, "127.0.0.1")
|
hosts = appendUniqueHost(hosts, seen, "127.0.0.1")
|
||||||
|
|
||||||
if effectivePublic || hostExplicit {
|
|
||||||
hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv6())
|
hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv6())
|
||||||
hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv4())
|
hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv4())
|
||||||
|
return hosts
|
||||||
|
case launcherBindModeExplicitLiteral:
|
||||||
|
trimmed := strings.Trim(strings.TrimSpace(bindHost), "[]")
|
||||||
|
if ip := net.ParseIP(trimmed); ip != nil {
|
||||||
|
if ip.IsUnspecified() {
|
||||||
|
if ip.To4() != nil {
|
||||||
|
hosts = appendUniqueHost(hosts, seen, "127.0.0.1")
|
||||||
|
hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv4())
|
||||||
|
return hosts
|
||||||
}
|
}
|
||||||
|
hosts = appendUniqueHost(hosts, seen, "::1")
|
||||||
|
hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv6())
|
||||||
|
return hosts
|
||||||
|
}
|
||||||
|
hosts = appendUniqueHost(hosts, seen, ip.String())
|
||||||
|
return hosts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if effectivePublic && isWildcardBindHost(bindHost) {
|
||||||
|
hosts = appendUniqueHost(hosts, seen, "::1")
|
||||||
|
hosts = appendUniqueHost(hosts, seen, "127.0.0.1")
|
||||||
|
hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv6())
|
||||||
|
hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv4())
|
||||||
return hosts
|
return hosts
|
||||||
}
|
}
|
||||||
|
|
||||||
if hostExplicit {
|
|
||||||
hosts = appendUniqueHost(hosts, seen, bindHost)
|
hosts = appendUniqueHost(hosts, seen, bindHost)
|
||||||
}
|
|
||||||
|
|
||||||
return hosts
|
return hosts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func openLauncherListener(network, host, port string) (net.Listener, error) {
|
||||||
|
return net.Listen(network, net.JoinHostPort(host, port))
|
||||||
|
}
|
||||||
|
|
||||||
|
func openLauncherPrivateListeners(port string) ([]net.Listener, string, error) {
|
||||||
|
if ln6, err6 := openLauncherListener("tcp6", "::1", port); err6 == nil {
|
||||||
|
if ln4, err4 := openLauncherListener("tcp4", "127.0.0.1", port); err4 == nil {
|
||||||
|
return []net.Listener{ln6, ln4}, "localhost", nil
|
||||||
|
}
|
||||||
|
_ = ln6.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ln6, err := openLauncherListener("tcp6", "::1", port); err == nil {
|
||||||
|
return []net.Listener{ln6}, "::1", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if ln4, err := openLauncherListener("tcp4", "127.0.0.1", port); err == nil {
|
||||||
|
return []net.Listener{ln4}, "127.0.0.1", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", fmt.Errorf("failed to open private localhost listener on port %s", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openLauncherAnyListener(port string) ([]net.Listener, string, error) {
|
||||||
|
// For auto-public and -host=* we intentionally bind :: on "tcp" first.
|
||||||
|
// Go's compatibility layer will provide dual-stack behavior on environments where it is supported.
|
||||||
|
if ln, err := openLauncherListener("tcp", "::", port); err == nil {
|
||||||
|
return []net.Listener{ln}, "::", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if ln4, err := openLauncherListener("tcp4", "0.0.0.0", port); err == nil {
|
||||||
|
return []net.Listener{ln4}, "0.0.0.0", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", fmt.Errorf("failed to open adaptive any-host listener on port %s", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openLauncherLiteralListener(host, port string) ([]net.Listener, string, error) {
|
||||||
|
host = strings.TrimSpace(host)
|
||||||
|
trimmed := strings.Trim(host, "[]")
|
||||||
|
network := "tcp"
|
||||||
|
|
||||||
|
if ip := net.ParseIP(trimmed); ip != nil {
|
||||||
|
host = ip.String()
|
||||||
|
if ip.To4() != nil {
|
||||||
|
network = "tcp4"
|
||||||
|
} else {
|
||||||
|
network = "tcp6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ln, err := openLauncherListener(network, host, port)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return []net.Listener{ln}, host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openLauncherListeners(mode launcherBindMode, bindHost, port string) ([]net.Listener, string, error) {
|
||||||
|
switch mode {
|
||||||
|
case launcherBindModeAutoPrivate, launcherBindModeExplicitAdaptiveLocal:
|
||||||
|
return openLauncherPrivateListeners(port)
|
||||||
|
case launcherBindModeAutoPublic, launcherBindModeExplicitAdaptiveAny:
|
||||||
|
return openLauncherAnyListener(port)
|
||||||
|
case launcherBindModeExplicitLiteral:
|
||||||
|
return openLauncherLiteralListener(bindHost, port)
|
||||||
|
default:
|
||||||
|
return nil, "", fmt.Errorf("unsupported launcher bind mode: %s", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -421,20 +502,47 @@ func main() {
|
||||||
}
|
}
|
||||||
envHost := strings.TrimSpace(os.Getenv(launcherconfig.EnvLauncherHost))
|
envHost := strings.TrimSpace(os.Getenv(launcherconfig.EnvLauncherHost))
|
||||||
|
|
||||||
effectiveHost, effectivePublic, hostExplicit, err := resolveLauncherBindHost(
|
rawHostInput := strings.TrimSpace(*host)
|
||||||
*host,
|
if !explicitHost {
|
||||||
explicitHost,
|
rawHostInput = envHost
|
||||||
envHost,
|
|
||||||
effectivePublic,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatalf("Invalid host %q: %v", *host, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
effectiveAllowedCIDRs := append([]string(nil), launcherCfg.AllowedCIDRs...)
|
hostExplicit := false
|
||||||
if len(effectiveAllowedCIDRs) == 0 && !effectivePublic && !hostExplicit && isWildcardBindHost(effectiveHost) {
|
effectiveHost := ""
|
||||||
effectiveAllowedCIDRs = []string{"127.0.0.1/32", "::1/128"}
|
bindMode := launcherBindModeAutoPrivate
|
||||||
logger.InfoC("web", "Applying loopback-only access policy for default dual-stack bind")
|
bindTargets := make([]launcherRuntimeBinding, 0, 1)
|
||||||
|
if rawHostInput != "" {
|
||||||
|
hosts, parseErr := parseLauncherHostList(rawHostInput)
|
||||||
|
if parseErr != nil {
|
||||||
|
logger.Fatalf("Invalid host %q: %v", rawHostInput, parseErr)
|
||||||
|
}
|
||||||
|
hostExplicit = true
|
||||||
|
effectivePublic = false
|
||||||
|
for _, raw := range hosts {
|
||||||
|
resolvedHost, _, _, resolveErr := resolveLauncherBindHost(raw, true, "", false)
|
||||||
|
if resolveErr != nil {
|
||||||
|
logger.Fatalf("Invalid host %q: %v", raw, resolveErr)
|
||||||
|
}
|
||||||
|
mode := resolveLauncherBindMode(raw, true, false)
|
||||||
|
bindTargets = append(bindTargets, launcherRuntimeBinding{mode: mode, host: resolvedHost})
|
||||||
|
}
|
||||||
|
effectiveHost = bindTargets[0].host
|
||||||
|
bindMode = bindTargets[0].mode
|
||||||
|
} else {
|
||||||
|
resolvedHost, resolvedPublic, resolvedExplicit, resolveErr := resolveLauncherBindHost(
|
||||||
|
"",
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
effectivePublic,
|
||||||
|
)
|
||||||
|
if resolveErr != nil {
|
||||||
|
logger.Fatalf("Invalid default host: %v", resolveErr)
|
||||||
|
}
|
||||||
|
effectiveHost = resolvedHost
|
||||||
|
effectivePublic = resolvedPublic
|
||||||
|
hostExplicit = resolvedExplicit
|
||||||
|
bindMode = resolveLauncherBindMode("", false, effectivePublic)
|
||||||
|
bindTargets = append(bindTargets, launcherRuntimeBinding{mode: bindMode, host: effectiveHost})
|
||||||
}
|
}
|
||||||
|
|
||||||
if !explicitHost && envHost != "" {
|
if !explicitHost && envHost != "" {
|
||||||
|
|
@ -453,6 +561,22 @@ func main() {
|
||||||
logger.Fatalf("Invalid port %q: %v", effectivePort, err)
|
logger.Fatalf("Invalid port %q: %v", effectivePort, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listeners := make([]net.Listener, 0, len(bindTargets))
|
||||||
|
runtimeBindings := make([]launcherRuntimeBinding, 0, len(bindTargets))
|
||||||
|
for _, target := range bindTargets {
|
||||||
|
targetListeners, runtimeHost, listenErr := openLauncherListeners(target.mode, target.host, effectivePort)
|
||||||
|
if listenErr != nil {
|
||||||
|
for _, ln := range listeners {
|
||||||
|
_ = ln.Close()
|
||||||
|
}
|
||||||
|
logger.Fatalf("Failed to open launcher listener(s): %v", listenErr)
|
||||||
|
}
|
||||||
|
listeners = append(listeners, targetListeners...)
|
||||||
|
runtimeBindings = append(runtimeBindings, launcherRuntimeBinding{mode: target.mode, host: runtimeHost})
|
||||||
|
}
|
||||||
|
effectiveHost = runtimeBindings[0].host
|
||||||
|
bindMode = runtimeBindings[0].mode
|
||||||
|
|
||||||
dashboardToken, dashboardSigningKey, dashboardTokenSource, dashErr := launcherconfig.EnsureDashboardSecrets(
|
dashboardToken, dashboardSigningKey, dashboardTokenSource, dashErr := launcherconfig.EnsureDashboardSecrets(
|
||||||
launcherCfg,
|
launcherCfg,
|
||||||
)
|
)
|
||||||
|
|
@ -480,9 +604,6 @@ func main() {
|
||||||
logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
|
logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine listen address
|
|
||||||
addr := net.JoinHostPort(effectiveHost, effectivePort)
|
|
||||||
|
|
||||||
// Initialize Server components
|
// Initialize Server components
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
|
@ -499,14 +620,18 @@ func main() {
|
||||||
if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
|
if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
|
||||||
logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err))
|
logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err))
|
||||||
}
|
}
|
||||||
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, effectiveAllowedCIDRs)
|
gatewayHostExplicit := hostExplicit && len(runtimeBindings) == 1
|
||||||
apiHandler.SetServerBindHost(effectiveHost, hostExplicit)
|
if hostExplicit && len(runtimeBindings) > 1 {
|
||||||
|
logger.WarnC("web", "Multiple launcher hosts are configured; gateway host override is disabled for this run")
|
||||||
|
}
|
||||||
|
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
||||||
|
apiHandler.SetServerBindHost(effectiveHost, gatewayHostExplicit)
|
||||||
apiHandler.RegisterRoutes(mux)
|
apiHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
// Frontend Embedded Assets
|
// Frontend Embedded Assets
|
||||||
registerEmbedRoutes(mux)
|
registerEmbedRoutes(mux)
|
||||||
|
|
||||||
accessControlledMux, err := middleware.IPAllowlist(effectiveAllowedCIDRs, mux)
|
accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
|
logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -527,11 +652,19 @@ func main() {
|
||||||
|
|
||||||
// Print startup banner and token (console mode only).
|
// Print startup banner and token (console mode only).
|
||||||
if enableConsole || debug {
|
if enableConsole || debug {
|
||||||
|
consoleHosts := make([]string, 0, 8)
|
||||||
|
consoleSeen := make(map[string]struct{}, 8)
|
||||||
|
for _, binding := range runtimeBindings {
|
||||||
|
for _, host := range launcherConsoleHosts(binding.mode, binding.host, effectivePublic) {
|
||||||
|
consoleHosts = appendUniqueHost(consoleHosts, consoleSeen, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Print(utils.Banner)
|
fmt.Print(utils.Banner)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println(" Open the following URL in your browser:")
|
fmt.Println(" Open the following URL in your browser:")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
for _, host := range launcherConsoleHosts(effectiveHost, hostExplicit, effectivePublic) {
|
for _, host := range consoleHosts {
|
||||||
fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort))
|
fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort))
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
@ -558,7 +691,9 @@ 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)))
|
for _, ln := range listeners {
|
||||||
|
logger.InfoC("web", fmt.Sprintf("Server will listen on http://%s", ln.Addr().String()))
|
||||||
|
}
|
||||||
if isWildcardBindHost(effectiveHost) {
|
if isWildcardBindHost(effectiveHost) {
|
||||||
if ip := advertiseIPForWildcardBindHost(effectiveHost); ip != "" {
|
if ip := advertiseIPForWildcardBindHost(effectiveHost); ip != "" {
|
||||||
logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s", net.JoinHostPort(ip, effectivePort)))
|
logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s", net.JoinHostPort(ip, effectivePort)))
|
||||||
|
|
@ -581,14 +716,19 @@ func main() {
|
||||||
apiHandler.TryAutoStartGateway()
|
apiHandler.TryAutoStartGateway()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Start the Server in a goroutine
|
// Start the server(s) in goroutines.
|
||||||
server = &http.Server{Addr: addr, Handler: handler}
|
servers = make([]*http.Server, 0, len(listeners))
|
||||||
go func() {
|
for _, ln := range listeners {
|
||||||
logger.InfoC("web", fmt.Sprintf("Server listening on %s", addr))
|
srv := &http.Server{Handler: handler}
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
servers = append(servers, srv)
|
||||||
logger.Fatalf("Server failed to start: %v", err)
|
|
||||||
|
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()
|
defer shutdownApp()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,41 @@ func TestMaskSecret(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseLauncherHostList(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want []string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "single host", raw: "127.0.0.1", want: []string{"127.0.0.1"}},
|
||||||
|
{name: "multiple hosts", raw: "127.0.0.1, 192.168.2.5", want: []string{"127.0.0.1", "192.168.2.5"}},
|
||||||
|
{name: "dedupe hosts", raw: "127.0.0.1,127.0.0.1", want: []string{"127.0.0.1"}},
|
||||||
|
{name: "reject empty entry", raw: "127.0.0.1, ", wantErr: true},
|
||||||
|
{name: "reject empty input", raw: " ", wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseLauncherHostList(tt.raw)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("parseLauncherHostList() err = %v, wantErr %t", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if tt.wantErr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Fatalf("len(got) = %d, want %d (%#v)", len(got), len(tt.want), got)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Fatalf("got[%d] = %q, want %q", i, got[i], tt.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveLauncherBindHost(t *testing.T) {
|
func TestResolveLauncherBindHost(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -113,7 +148,7 @@ func TestResolveLauncherBindHost(t *testing.T) {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
explicitHost: true,
|
explicitHost: true,
|
||||||
effectivePub: true,
|
effectivePub: true,
|
||||||
wantHost: resolveDefaultLauncherAnyHost(),
|
wantHost: "0.0.0.0",
|
||||||
wantPublic: false,
|
wantPublic: false,
|
||||||
wantExplicit: true,
|
wantExplicit: true,
|
||||||
},
|
},
|
||||||
|
|
@ -139,6 +174,24 @@ func TestResolveLauncherBindHost(t *testing.T) {
|
||||||
envHost: "0.0.0.0",
|
envHost: "0.0.0.0",
|
||||||
explicitHost: false,
|
explicitHost: false,
|
||||||
effectivePub: true,
|
effectivePub: true,
|
||||||
|
wantHost: "0.0.0.0",
|
||||||
|
wantPublic: false,
|
||||||
|
wantExplicit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit localhost uses adaptive private host",
|
||||||
|
host: "localhost",
|
||||||
|
explicitHost: true,
|
||||||
|
effectivePub: false,
|
||||||
|
wantHost: resolveDefaultLauncherPrivateHost(),
|
||||||
|
wantPublic: false,
|
||||||
|
wantExplicit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit star uses adaptive any host",
|
||||||
|
host: "*",
|
||||||
|
explicitHost: true,
|
||||||
|
effectivePub: false,
|
||||||
wantHost: resolveDefaultLauncherAnyHost(),
|
wantHost: resolveDefaultLauncherAnyHost(),
|
||||||
wantPublic: false,
|
wantPublic: false,
|
||||||
wantExplicit: true,
|
wantExplicit: true,
|
||||||
|
|
@ -190,9 +243,33 @@ func TestResolveLauncherBindHost(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveLauncherBindMode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rawHost string
|
||||||
|
hostExplicit bool
|
||||||
|
effectivePub bool
|
||||||
|
wantMode launcherBindMode
|
||||||
|
}{
|
||||||
|
{name: "auto private", rawHost: "", hostExplicit: false, effectivePub: false, wantMode: launcherBindModeAutoPrivate},
|
||||||
|
{name: "auto public", rawHost: "", hostExplicit: false, effectivePub: true, wantMode: launcherBindModeAutoPublic},
|
||||||
|
{name: "explicit localhost", rawHost: "localhost", hostExplicit: true, effectivePub: false, wantMode: launcherBindModeExplicitAdaptiveLocal},
|
||||||
|
{name: "explicit star", rawHost: "*", hostExplicit: true, effectivePub: false, wantMode: launcherBindModeExplicitAdaptiveAny},
|
||||||
|
{name: "explicit literal", rawHost: "0.0.0.0", hostExplicit: true, effectivePub: false, wantMode: launcherBindModeExplicitLiteral},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := resolveLauncherBindMode(tt.rawHost, tt.hostExplicit, tt.effectivePub); got != tt.wantMode {
|
||||||
|
t.Fatalf("resolveLauncherBindMode() = %q, want %q", got, tt.wantMode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLauncherConsoleHosts(t *testing.T) {
|
func TestLauncherConsoleHosts(t *testing.T) {
|
||||||
t.Run("explicit wildcard dedupes localhost and includes loopback ipv6", func(t *testing.T) {
|
t.Run("auto private includes dual loopback hints", func(t *testing.T) {
|
||||||
hosts := launcherConsoleHosts("0.0.0.0", true, false)
|
hosts := launcherConsoleHosts(launcherBindModeAutoPrivate, "localhost", false)
|
||||||
seen := make(map[string]bool, len(hosts))
|
seen := make(map[string]bool, len(hosts))
|
||||||
for _, host := range hosts {
|
for _, host := range hosts {
|
||||||
if seen[host] {
|
if seen[host] {
|
||||||
|
|
@ -211,8 +288,22 @@ func TestLauncherConsoleHosts(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("explicit ipv4 wildcard excludes ipv6 loopback", func(t *testing.T) {
|
||||||
|
hosts := launcherConsoleHosts(launcherBindModeExplicitLiteral, "0.0.0.0", false)
|
||||||
|
seen := make(map[string]bool, len(hosts))
|
||||||
|
for _, host := range hosts {
|
||||||
|
seen[host] = true
|
||||||
|
}
|
||||||
|
if seen["::1"] {
|
||||||
|
t.Fatalf("did not expect ::1 in %#v", hosts)
|
||||||
|
}
|
||||||
|
if !seen["127.0.0.1"] {
|
||||||
|
t.Fatalf("expected 127.0.0.1 in %#v", hosts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("explicit ipv6 host remains visible", func(t *testing.T) {
|
t.Run("explicit ipv6 host remains visible", func(t *testing.T) {
|
||||||
hosts := launcherConsoleHosts("::1", true, false)
|
hosts := launcherConsoleHosts(launcherBindModeExplicitLiteral, "::1", false)
|
||||||
if len(hosts) != 2 {
|
if len(hosts) != 2 {
|
||||||
t.Fatalf("len(hosts) = %d, want 2 (%#v)", len(hosts), hosts)
|
t.Fatalf("len(hosts) = %d, want 2 (%#v)", len(hosts), hosts)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,91 @@ import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ipFamiliesOnce sync.Once
|
||||||
|
hasIPv4 bool
|
||||||
|
hasIPv6 bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func DetectIPFamilies() (bool, bool) {
|
||||||
|
ipFamiliesOnce.Do(func() {
|
||||||
|
if ips, err := net.LookupIP("localhost"); err == nil {
|
||||||
|
for _, ip := range ips {
|
||||||
|
if ip == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ip.To4() != nil {
|
||||||
|
hasIPv4 = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hasIPv6 = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasIPv4 && hasIPv6 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if addrs, err := net.InterfaceAddrs(); err == nil {
|
||||||
|
for _, addr := range addrs {
|
||||||
|
ipnet, ok := addr.(*net.IPNet)
|
||||||
|
if !ok || ipnet.IP == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ipnet.IP.To4() != nil {
|
||||||
|
hasIPv4 = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hasIPv6 = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return hasIPv4, hasIPv6
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "localhost"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SelectAdaptiveAnyHost(hasIPv4, hasIPv6 bool) string {
|
||||||
|
switch {
|
||||||
|
case hasIPv4 && hasIPv6:
|
||||||
|
return "::"
|
||||||
|
case hasIPv6:
|
||||||
|
return "::"
|
||||||
|
case hasIPv4:
|
||||||
|
return "0.0.0.0"
|
||||||
|
default:
|
||||||
|
return "::"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveAdaptiveLoopbackHost() string {
|
||||||
|
hasIPv4, hasIPv6 := DetectIPFamilies()
|
||||||
|
return SelectAdaptiveLoopbackHost(hasIPv4, hasIPv6)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveAdaptiveAnyHost() string {
|
||||||
|
hasIPv4, hasIPv6 := DetectIPFamilies()
|
||||||
|
return SelectAdaptiveAnyHost(hasIPv4, hasIPv6)
|
||||||
|
}
|
||||||
|
|
||||||
// GetPicoclawHome returns the picoclaw home directory.
|
// GetPicoclawHome returns the picoclaw home directory.
|
||||||
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||||
func GetPicoclawHome() string {
|
func GetPicoclawHome() string {
|
||||||
|
|
|
||||||
59
web/backend/utils/runtime_test.go
Normal file
59
web/backend/utils/runtime_test.go
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSelectAdaptiveLoopbackHost(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hasIPv4 bool
|
||||||
|
hasIPv6 bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "dual stack", 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: "localhost"},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 TestSelectAdaptiveAnyHost(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hasIPv4 bool
|
||||||
|
hasIPv6 bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "dual stack", hasIPv4: true, hasIPv6: true, want: "::"},
|
||||||
|
{name: "ipv6 only", hasIPv4: false, hasIPv6: true, want: "::"},
|
||||||
|
{name: "ipv4 only", hasIPv4: true, hasIPv6: false, want: "0.0.0.0"},
|
||||||
|
{name: "fallback", hasIPv4: false, hasIPv6: false, want: "::"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := SelectAdaptiveAnyHost(tt.hasIPv4, tt.hasIPv6); got != tt.want {
|
||||||
|
t.Fatalf("SelectAdaptiveAnyHost(%t, %t) = %q, want %q", tt.hasIPv4, tt.hasIPv6, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAdaptiveHosts(t *testing.T) {
|
||||||
|
loopback := ResolveAdaptiveLoopbackHost()
|
||||||
|
if loopback == "" {
|
||||||
|
t.Fatal("ResolveAdaptiveLoopbackHost() returned empty host")
|
||||||
|
}
|
||||||
|
|
||||||
|
anyHost := ResolveAdaptiveAnyHost()
|
||||||
|
if anyHost == "" {
|
||||||
|
t.Fatal("ResolveAdaptiveAnyHost() returned empty host")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue