fix(gateway): prefer loopback fallback before wildcard
This commit is contained in:
parent
a86705340f
commit
bf93470977
5 changed files with 167 additions and 2 deletions
|
|
@ -66,6 +66,24 @@ func resolveGatewayListenDecision(
|
||||||
return nil, fmt.Errorf("bind %s:%d failed: %w", host, port, bindErr)
|
return nil, fmt.Errorf("bind %s:%d failed: %w", host, port, bindErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Before widening exposure via wildcard bind, try other common loopback hosts.
|
||||||
|
for _, altHost := range alternativeLoopbackHosts(host) {
|
||||||
|
if err := probeGatewayBind(altHost, port); err == nil {
|
||||||
|
return &gatewayListenDecision{
|
||||||
|
BindHost: altHost,
|
||||||
|
AllowedCIDRs: normalizedCIDRs,
|
||||||
|
AutoFallback: true,
|
||||||
|
FallbackReason: fmt.Sprintf(
|
||||||
|
"loopback bind %s:%d failed, fallback to loopback host %s:%d",
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
altHost,
|
||||||
|
port,
|
||||||
|
),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var discoveredCIDRs []string
|
var discoveredCIDRs []string
|
||||||
if len(normalizedCIDRs) == 0 {
|
if len(normalizedCIDRs) == 0 {
|
||||||
var discoverErr error
|
var discoverErr error
|
||||||
|
|
@ -124,6 +142,27 @@ func resolveGatewayListenDecision(
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func alternativeLoopbackHosts(configuredHost string) []string {
|
||||||
|
configured := strings.TrimSpace(strings.ToLower(configuredHost))
|
||||||
|
candidates := []string{"127.0.0.1", "::1", "localhost"}
|
||||||
|
out := make([]string, 0, len(candidates))
|
||||||
|
seen := make(map[string]struct{}, len(candidates))
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
normalized := strings.TrimSpace(strings.ToLower(candidate))
|
||||||
|
if normalized == configured {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[normalized]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[normalized] = struct{}{}
|
||||||
|
out = append(out, candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func fallbackBindCandidates(configuredLoopbackHost string) []string {
|
func fallbackBindCandidates(configuredLoopbackHost string) []string {
|
||||||
lower := strings.TrimSpace(strings.ToLower(configuredLoopbackHost))
|
lower := strings.TrimSpace(strings.ToLower(configuredLoopbackHost))
|
||||||
if lower == "localhost" {
|
if lower == "localhost" {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesConfiguredCIDRs(t *test
|
||||||
switch host {
|
switch host {
|
||||||
case "127.0.0.1":
|
case "127.0.0.1":
|
||||||
return errors.New("loopback unavailable")
|
return errors.New("loopback unavailable")
|
||||||
|
case "::1", "localhost":
|
||||||
|
return errors.New("alternative loopback unavailable")
|
||||||
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
|
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
|
|
@ -84,6 +86,8 @@ func TestResolveGatewayListenDecisionLoopbackFallbackUsesDiscoveredCIDRs(t *test
|
||||||
switch host {
|
switch host {
|
||||||
case "localhost":
|
case "localhost":
|
||||||
return errors.New("loopback unavailable")
|
return errors.New("loopback unavailable")
|
||||||
|
case "127.0.0.1", "::1":
|
||||||
|
return errors.New("alternative loopback unavailable")
|
||||||
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
|
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
|
|
@ -146,6 +150,8 @@ func TestResolveGatewayListenDecisionLoopbackFailureNoDiscoveredCIDRs(t *testing
|
||||||
switch host {
|
switch host {
|
||||||
case "127.0.0.1":
|
case "127.0.0.1":
|
||||||
return errors.New("loopback unavailable")
|
return errors.New("loopback unavailable")
|
||||||
|
case "::1", "localhost":
|
||||||
|
return errors.New("alternative loopback unavailable")
|
||||||
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
|
case gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
|
|
@ -177,6 +183,8 @@ func TestResolveGatewayListenDecisionLoopbackFallbackIPv6Preferred(t *testing.T)
|
||||||
switch host {
|
switch host {
|
||||||
case "::1":
|
case "::1":
|
||||||
return errors.New("loopback unavailable")
|
return errors.New("loopback unavailable")
|
||||||
|
case "127.0.0.1", "localhost":
|
||||||
|
return errors.New("alternative loopback unavailable")
|
||||||
case gatewayFallbackBindHostV6:
|
case gatewayFallbackBindHostV6:
|
||||||
return nil
|
return nil
|
||||||
case gatewayFallbackBindHostV4:
|
case gatewayFallbackBindHostV4:
|
||||||
|
|
@ -210,6 +218,8 @@ func TestResolveGatewayListenDecisionLoopbackFallbackTriesSecondaryWildcard(t *t
|
||||||
switch host {
|
switch host {
|
||||||
case "::1":
|
case "::1":
|
||||||
return errors.New("loopback unavailable")
|
return errors.New("loopback unavailable")
|
||||||
|
case "127.0.0.1", "localhost":
|
||||||
|
return errors.New("alternative loopback unavailable")
|
||||||
case gatewayFallbackBindHostV6:
|
case gatewayFallbackBindHostV6:
|
||||||
return errors.New("ipv6 wildcard unavailable")
|
return errors.New("ipv6 wildcard unavailable")
|
||||||
case gatewayFallbackBindHostV4:
|
case gatewayFallbackBindHostV4:
|
||||||
|
|
@ -231,6 +241,47 @@ func TestResolveGatewayListenDecisionLoopbackFallbackTriesSecondaryWildcard(t *t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveGatewayListenDecisionLoopbackUsesAlternativeBeforeWildcard(t *testing.T) {
|
||||||
|
origProbe := probeGatewayBind
|
||||||
|
origDiscover := discoverGatewayCIDRs
|
||||||
|
t.Cleanup(func() {
|
||||||
|
probeGatewayBind = origProbe
|
||||||
|
discoverGatewayCIDRs = origDiscover
|
||||||
|
})
|
||||||
|
|
||||||
|
discoverCalled := false
|
||||||
|
probeGatewayBind = func(host string, _ int) error {
|
||||||
|
switch host {
|
||||||
|
case "127.0.0.1":
|
||||||
|
return errors.New("configured loopback unavailable")
|
||||||
|
case "::1":
|
||||||
|
return nil
|
||||||
|
case "localhost", gatewayFallbackBindHostV4, gatewayFallbackBindHostV6:
|
||||||
|
return errors.New("must not be probed after alternative loopback succeeds")
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
discoverGatewayCIDRs = func() ([]string, error) {
|
||||||
|
discoverCalled = true
|
||||||
|
return nil, errors.New("must not be called when alternative loopback succeeds")
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, err := resolveGatewayListenDecision("127.0.0.1", 18790, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveGatewayListenDecision() error = %v", err)
|
||||||
|
}
|
||||||
|
if decision.BindHost != "::1" {
|
||||||
|
t.Fatalf("decision.BindHost = %q, want %q", decision.BindHost, "::1")
|
||||||
|
}
|
||||||
|
if len(decision.AllowedCIDRs) != 0 {
|
||||||
|
t.Fatalf("decision.AllowedCIDRs = %v, want empty", decision.AllowedCIDRs)
|
||||||
|
}
|
||||||
|
if discoverCalled {
|
||||||
|
t.Fatal("discoverGatewayCIDRs() called unexpectedly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCIDRAllowlistMiddleware(t *testing.T) {
|
func TestCIDRAllowlistMiddleware(t *testing.T) {
|
||||||
mw := newCIDRAllowlistMiddleware([]string{"192.168.1.0/24"})
|
mw := newCIDRAllowlistMiddleware([]string{"192.168.1.0/24"})
|
||||||
h, err := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
h, err := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -312,6 +363,27 @@ func TestFallbackBindCandidates(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlternativeLoopbackHosts(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
host string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "configured ipv4", host: "127.0.0.1", want: []string{"::1", "localhost"}},
|
||||||
|
{name: "configured ipv6", host: "::1", want: []string{"127.0.0.1", "localhost"}},
|
||||||
|
{name: "configured localhost", host: "localhost", want: []string{"127.0.0.1", "::1"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := alternativeLoopbackHosts(tt.host)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Fatalf("alternativeLoopbackHosts() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsSafeFallbackIP(t *testing.T) {
|
func TestIsSafeFallbackIP(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ type IPAllowlist struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIPAllowlist parses CIDR rules and constructs an allowlist checker.
|
// NewIPAllowlist parses CIDR rules and constructs an allowlist checker.
|
||||||
// Empty CIDRs means unrestricted policy.
|
// Empty CIDR list means unrestricted policy.
|
||||||
func NewIPAllowlist(allowedCIDRs []string) (*IPAllowlist, error) {
|
func NewIPAllowlist(allowedCIDRs []string) (*IPAllowlist, error) {
|
||||||
if len(allowedCIDRs) == 0 {
|
if len(allowedCIDRs) == 0 {
|
||||||
return &IPAllowlist{}, nil
|
return &IPAllowlist{}, nil
|
||||||
|
|
|
||||||
|
|
@ -280,15 +280,32 @@ func validateConfig(cfg *config.Config) []string {
|
||||||
if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) {
|
if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) {
|
||||||
errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port))
|
errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize and validate gateway allowed CIDRs:
|
||||||
|
// - trim whitespace
|
||||||
|
// - drop empty entries
|
||||||
|
// - canonicalize via ipNet.String()
|
||||||
|
// - deduplicate canonical CIDRs
|
||||||
|
normalizedCIDRs := make([]string, 0, len(cfg.Gateway.AllowedCIDRs))
|
||||||
|
seenCIDRs := make(map[string]struct{}, len(cfg.Gateway.AllowedCIDRs))
|
||||||
for index, cidr := range cfg.Gateway.AllowedCIDRs {
|
for index, cidr := range cfg.Gateway.AllowedCIDRs {
|
||||||
trimmed := strings.TrimSpace(cidr)
|
trimmed := strings.TrimSpace(cidr)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, _, err := net.ParseCIDR(trimmed); err != nil {
|
_, ipNet, err := net.ParseCIDR(trimmed)
|
||||||
|
if err != nil {
|
||||||
errs = append(errs, fmt.Sprintf("gateway.allowed_cidrs[%d] is not a valid CIDR: %v", index, err))
|
errs = append(errs, fmt.Sprintf("gateway.allowed_cidrs[%d] is not a valid CIDR: %v", index, err))
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
canonical := ipNet.String()
|
||||||
|
if _, exists := seenCIDRs[canonical]; exists {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
seenCIDRs[canonical] = struct{}{}
|
||||||
|
normalizedCIDRs = append(normalizedCIDRs, canonical)
|
||||||
|
}
|
||||||
|
cfg.Gateway.AllowedCIDRs = normalizedCIDRs
|
||||||
|
|
||||||
// Pico channel: token required when enabled
|
// Pico channel: token required when enabled
|
||||||
if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token.String() == "" {
|
if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token.String() == "" {
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,43 @@ func TestHandlePatchConfig_RejectsInvalidGatewayAllowedCIDRs(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_NormalizesGatewayAllowedCIDRs(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"gateway": {
|
||||||
|
"allowed_cidrs": [" 192.168.1.20/24 ", "", "192.168.1.0/24", " 10.0.0.0/8 ", " "]
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"192.168.1.0/24", "10.0.0.0/8"}
|
||||||
|
if len(cfg.Gateway.AllowedCIDRs) != len(want) {
|
||||||
|
t.Fatalf("len(gateway.allowed_cidrs) = %d, want %d", len(cfg.Gateway.AllowedCIDRs), len(want))
|
||||||
|
}
|
||||||
|
for i, cidr := range want {
|
||||||
|
if cfg.Gateway.AllowedCIDRs[i] != cidr {
|
||||||
|
t.Fatalf("gateway.allowed_cidrs[%d] = %q, want %q", i, cfg.Gateway.AllowedCIDRs[i], cidr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// setupPicoEnabledEnv creates a test environment with Pico channel enabled and
|
// setupPicoEnabledEnv creates a test environment with Pico channel enabled and
|
||||||
// its token stored only in .security.yml (not in the JSON payload).
|
// its token stored only in .security.yml (not in the JSON payload).
|
||||||
func setupPicoEnabledEnv(t *testing.T) (string, func()) {
|
func setupPicoEnabledEnv(t *testing.T) (string, func()) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue