Files
picoclaw/pkg/netbind/netbind.go
T
lc6464 d4d652b455 feat(host): complete launcher and gateway multi-host binding support
- add shared netbind planning for strict tcp4/tcp6 bind semantics
- support launcher/gateway host env overrides and launcher-to-gateway forwarding
- cover host binding and forwarding with network and subprocess env tests
2026-04-14 14:04:36 +08:00

581 lines
13 KiB
Go

package netbind
import (
"context"
"errors"
"fmt"
"net"
"strconv"
"strings"
"sync"
)
type DefaultMode int
const (
DefaultLoopback DefaultMode = iota
DefaultAny
)
type groupKind int
const (
groupAdaptiveLoopback groupKind = iota
groupAdaptiveAny
groupExact
)
type exactBinding struct {
host string
network string
v6Only bool
}
type bindGroup struct {
kind groupKind
allowIPv4 bool
allowIPv6 bool
exact exactBinding
}
type Plan struct {
groups []bindGroup
ProbeHost string
}
type OpenResult struct {
Listeners []net.Listener
BindHosts []string
Port string
ProbeHost string
}
type tokenKind int
const (
tokenName tokenKind = iota
tokenLocalhost
tokenStar
tokenIPv4
tokenIPv6
tokenIPv4Any
tokenIPv6Any
)
type hostToken struct {
kind tokenKind
canonical string
key string
}
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)
}
func IsLoopbackHost(host string) bool {
host = strings.TrimSpace(host)
if host == "" {
return false
}
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(strings.Trim(host, "[]"))
return ip != nil && ip.IsLoopback()
}
func IsUnspecifiedHost(host string) bool {
host = strings.TrimSpace(host)
if host == "" {
return false
}
ip := net.ParseIP(strings.Trim(host, "[]"))
return ip != nil && ip.IsUnspecified()
}
func NormalizeHostInput(raw string) (string, error) {
tokens, err := parseHostTokens(raw)
if err != nil {
return "", err
}
parts := make([]string, 0, len(tokens))
for _, token := range tokens {
parts = append(parts, token.canonical)
}
return strings.Join(parts, ","), nil
}
func BuildPlan(raw string, defaultMode DefaultMode) (Plan, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return buildDefaultPlan(defaultMode), nil
}
tokens, err := parseHostTokens(raw)
if err != nil {
return Plan{}, err
}
for _, token := range tokens {
if token.kind == tokenStar {
return Plan{
groups: []bindGroup{{kind: groupAdaptiveAny}},
ProbeHost: ResolveAdaptiveLoopbackHost(),
}, nil
}
}
hasIPv4Any := false
hasIPv6Any := false
for _, token := range tokens {
switch token.kind {
case tokenIPv4Any:
hasIPv4Any = true
case tokenIPv6Any:
hasIPv6Any = true
}
}
allowLocalhostIPv4 := !hasIPv4Any
allowLocalhostIPv6 := !hasIPv6Any
groups := make([]bindGroup, 0, len(tokens))
seenExact := make(map[string]struct{}, len(tokens))
addedLocalhost := false
for _, token := range tokens {
switch token.kind {
case tokenLocalhost:
if addedLocalhost || (!allowLocalhostIPv4 && !allowLocalhostIPv6) {
continue
}
groups = append(groups, bindGroup{
kind: groupAdaptiveLoopback,
allowIPv4: allowLocalhostIPv4,
allowIPv6: allowLocalhostIPv6,
})
addedLocalhost = true
case tokenIPv4Any:
key := "exact:tcp4:0.0.0.0"
if _, ok := seenExact[key]; ok {
continue
}
seenExact[key] = struct{}{}
groups = append(groups, bindGroup{
kind: groupExact,
exact: exactBinding{
host: "0.0.0.0",
network: "tcp4",
},
})
case tokenIPv6Any:
key := "exact:tcp6:::"
if _, ok := seenExact[key]; ok {
continue
}
seenExact[key] = struct{}{}
groups = append(groups, bindGroup{
kind: groupExact,
exact: exactBinding{
host: "::",
network: "tcp6",
v6Only: true,
},
})
case tokenIPv4:
if hasIPv4Any {
continue
}
key := "exact:tcp4:" + strings.ToLower(token.canonical)
if _, ok := seenExact[key]; ok {
continue
}
seenExact[key] = struct{}{}
groups = append(groups, bindGroup{
kind: groupExact,
exact: exactBinding{
host: token.canonical,
network: "tcp4",
},
})
case tokenIPv6:
if hasIPv6Any {
continue
}
key := "exact:tcp6:" + strings.ToLower(token.canonical)
if _, ok := seenExact[key]; ok {
continue
}
seenExact[key] = struct{}{}
groups = append(groups, bindGroup{
kind: groupExact,
exact: exactBinding{
host: token.canonical,
network: "tcp6",
v6Only: true,
},
})
case tokenName:
key := "exact:tcp:" + token.key
if _, ok := seenExact[key]; ok {
continue
}
seenExact[key] = struct{}{}
groups = append(groups, bindGroup{
kind: groupExact,
exact: exactBinding{
host: token.canonical,
network: "tcp",
},
})
}
}
plan := Plan{groups: groups}
plan.ProbeHost = probeHostForGroups(groups)
return plan, nil
}
func OpenPlan(plan Plan, port string) (OpenResult, error) {
if port == "" {
return OpenResult{}, errors.New("port cannot be empty")
}
selectedPort := port
listeners := make([]net.Listener, 0, len(plan.groups))
bindHosts := make([]string, 0, len(plan.groups))
bindSeen := make(map[string]struct{}, len(plan.groups))
closeAll := func() {
for _, ln := range listeners {
_ = ln.Close()
}
}
for _, group := range plan.groups {
groupListeners, groupHosts, actualPort, err := openGroup(group, selectedPort)
if err != nil {
closeAll()
return OpenResult{}, err
}
if selectedPort == "0" && actualPort != "" {
selectedPort = actualPort
}
listeners = append(listeners, groupListeners...)
for _, host := range groupHosts {
key := strings.ToLower(host)
if _, ok := bindSeen[key]; ok {
continue
}
bindSeen[key] = struct{}{}
bindHosts = append(bindHosts, host)
}
}
return OpenResult{
Listeners: listeners,
BindHosts: bindHosts,
Port: selectedPort,
ProbeHost: plan.ProbeHost,
}, nil
}
func buildDefaultPlan(defaultMode DefaultMode) Plan {
switch defaultMode {
case DefaultAny:
return Plan{
groups: []bindGroup{{kind: groupAdaptiveAny}},
ProbeHost: ResolveAdaptiveLoopbackHost(),
}
default:
return Plan{
groups: []bindGroup{{
kind: groupAdaptiveLoopback,
allowIPv4: true,
allowIPv6: true,
}},
ProbeHost: ResolveAdaptiveLoopbackHost(),
}
}
}
func probeHostForGroups(groups []bindGroup) string {
hasIPv4Any := false
hasIPv6Any := false
for _, group := range groups {
if group.kind == groupAdaptiveLoopback {
switch {
case group.allowIPv4 && group.allowIPv6:
return ResolveAdaptiveLoopbackHost()
case group.allowIPv6:
return "::1"
case group.allowIPv4:
return "127.0.0.1"
}
}
if group.kind == groupAdaptiveAny {
return ResolveAdaptiveLoopbackHost()
}
if group.kind != groupExact {
continue
}
switch group.exact.host {
case "0.0.0.0":
hasIPv4Any = true
case "::":
hasIPv6Any = true
}
}
switch {
case hasIPv4Any && hasIPv6Any:
return ResolveAdaptiveLoopbackHost()
case hasIPv6Any:
return "::1"
case hasIPv4Any:
return "127.0.0.1"
}
for _, group := range groups {
if group.kind == groupExact {
return group.exact.host
}
}
return ResolveAdaptiveLoopbackHost()
}
func parseHostTokens(raw string) ([]hostToken, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, errors.New("host cannot be empty")
}
parts := strings.Split(raw, ",")
tokens := make([]hostToken, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
token, err := parseHostToken(part)
if err != nil {
return nil, err
}
if _, ok := seen[token.key]; ok {
continue
}
seen[token.key] = struct{}{}
tokens = append(tokens, token)
}
if len(tokens) == 0 {
return nil, errors.New("host cannot be empty")
}
return tokens, nil
}
func parseHostToken(raw string) (hostToken, error) {
host := strings.TrimSpace(raw)
if host == "" {
return hostToken{}, errors.New("host list contains an empty entry")
}
if host == "*" {
return hostToken{kind: tokenStar, canonical: "*", key: "*"}, nil
}
if strings.EqualFold(host, "localhost") {
return hostToken{kind: tokenLocalhost, canonical: "localhost", key: "localhost"}, nil
}
trimmed := strings.Trim(host, "[]")
if ip := net.ParseIP(trimmed); ip != nil {
if ip4 := ip.To4(); ip4 != nil {
canonical := ip4.String()
kind := tokenIPv4
if ip4.IsUnspecified() {
kind = tokenIPv4Any
}
return hostToken{kind: kind, canonical: canonical, key: canonical}, nil
}
canonical := ip.String()
kind := tokenIPv6
if ip.IsUnspecified() {
kind = tokenIPv6Any
}
return hostToken{kind: kind, canonical: canonical, key: strings.ToLower(canonical)}, nil
}
return hostToken{
kind: tokenName,
canonical: host,
key: strings.ToLower(host),
}, nil
}
func openGroup(group bindGroup, port string) ([]net.Listener, []string, string, error) {
switch group.kind {
case groupAdaptiveLoopback:
return openAdaptiveLoopbackGroup(group.allowIPv6, group.allowIPv4, port)
case groupAdaptiveAny:
return openAdaptiveAnyGroup(port)
case groupExact:
ln, actualPort, err := openExactListener(group.exact, port)
if err != nil {
return nil, nil, "", err
}
return []net.Listener{ln}, []string{group.exact.host}, actualPort, nil
default:
return nil, nil, "", fmt.Errorf("unsupported bind group kind: %d", group.kind)
}
}
func openAdaptiveLoopbackGroup(allowIPv6, allowIPv4 bool, port string) ([]net.Listener, []string, string, error) {
if allowIPv6 && allowIPv4 {
if ln6, actualPort, err6 := openExactListener(exactBinding{host: "::1", network: "tcp6", v6Only: true}, port); err6 == nil {
if ln4, _, err4 := openExactListener(exactBinding{host: "127.0.0.1", network: "tcp4"}, actualPort); err4 == nil {
return []net.Listener{ln6, ln4}, []string{"::1", "127.0.0.1"}, actualPort, nil
}
_ = ln6.Close()
}
}
if allowIPv6 {
ln6, actualPort, err := openExactListener(exactBinding{host: "::1", network: "tcp6", v6Only: true}, port)
if err == nil {
return []net.Listener{ln6}, []string{"::1"}, actualPort, nil
}
}
if allowIPv4 {
ln4, actualPort, err := openExactListener(exactBinding{host: "127.0.0.1", network: "tcp4"}, port)
if err == nil {
return []net.Listener{ln4}, []string{"127.0.0.1"}, actualPort, nil
}
}
return nil, nil, "", fmt.Errorf("failed to open adaptive localhost listener on port %s", port)
}
func openAdaptiveAnyGroup(port string) ([]net.Listener, []string, string, error) {
// Intentionally bind tcp/:: here. Go's compatibility layer handles dual-stack
// wildcard binding where the platform supports it, while tcp4 remains the
// fallback for IPv4-only environments.
if ln, actualPort, err := openExactListener(exactBinding{host: "::", network: "tcp"}, port); err == nil {
return []net.Listener{ln}, []string{"::"}, actualPort, nil
}
ln4, actualPort, err := openExactListener(exactBinding{host: "0.0.0.0", network: "tcp4"}, port)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to open adaptive any-host listener on port %s", port)
}
return []net.Listener{ln4}, []string{"0.0.0.0"}, actualPort, nil
}
func openExactListener(binding exactBinding, port string) (net.Listener, string, error) {
listenConfig := net.ListenConfig{}
if binding.network == "tcp6" && binding.v6Only {
listenConfig.Control = applyIPv6OnlyControl(true)
}
ln, err := listenConfig.Listen(context.Background(), binding.network, net.JoinHostPort(binding.host, port))
if err != nil {
return nil, "", err
}
actualPort, err := listenerPort(ln)
if err != nil {
_ = ln.Close()
return nil, "", err
}
return ln, actualPort, nil
}
func listenerPort(ln net.Listener) (string, error) {
addr, ok := ln.Addr().(*net.TCPAddr)
if ok {
return strconv.Itoa(addr.Port), nil
}
_, port, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
return "", err
}
return port, nil
}