diff --git a/bypass/bypass.go b/bypass/bypass.go index 8e300723..065c97f8 100644 --- a/bypass/bypass.go +++ b/bypass/bypass.go @@ -47,6 +47,12 @@ type options struct { // When true: only matching addresses bypass the proxy. whitelist bool + // network restricts this bypass to a specific network protocol. + // When set, the incoming network must match before address matchers + // are evaluated. Recognized values include "tcp" and "udp". + // An empty value disables the network check. + network string + // matchers holds static patterns provided at construction time // (e.g. from config file or command-line arguments). matchers []string @@ -82,6 +88,15 @@ func WhitelistOption(whitelist bool) Option { } } +// NetworkOption restricts this bypass to a specific network protocol. +// When set, Contains returns false when the incoming network does not +// match, regardless of address matchers. Accepts values like "tcp" or "udp". +func NetworkOption(network string) Option { + return func(opts *options) { + opts.network = network + } +} + // MatchersOption sets the static bypass patterns (CIDR, IP range, wildcard, or address). func MatchersOption(matchers []string) Option { return func(opts *options) { @@ -272,10 +287,10 @@ func (p *localBypass) reload(ctx context.Context) error { patterns := append(p.options.matchers, v...) p.logger.Debugf("load items %d", len(patterns)) - ps := classifyPatterns(patterns, p.logger) - p.mu.Lock() - p.patterns = ps + if len(patterns) > 0 { + p.patterns = classifyPatterns(patterns, p.logger) + } p.mu.Unlock() return nil @@ -402,7 +417,7 @@ func (p *localBypass) Contains(ctx context.Context, network, addr string, opts . return false } - decision := p.decide(addr) + decision := p.decide(network, addr) log := p.logger.WithFields(map[string]any{ "sid": ctxvalue.SidFromContext(ctx), @@ -414,11 +429,22 @@ func (p *localBypass) Contains(ctx context.Context, network, addr string, opts . // decide returns the bypass decision for the given address, applying the // whitelist or blacklist mode to the pattern match result. -func (p *localBypass) decide(addr string) bypassDecision { +func (p *localBypass) decide(network string, addr string) bypassDecision { p.mu.RLock() defer p.mu.RUnlock() + if p.options.network != "" && p.options.network != network { + return decisionProxy + } + if p.patterns == nil { + if p.options.network != "" { + if p.options.whitelist { + return decisionProxy + } + return decisionBypass + } + return decisionProxy } diff --git a/bypass/bypass_test.go b/bypass/bypass_test.go index 23d2475c..f21d9fb2 100644 --- a/bypass/bypass_test.go +++ b/bypass/bypass_test.go @@ -288,32 +288,32 @@ func TestMatched_IP(t *testing.T) { b := newSyncedBypass(MatchersOption([]string{"192.168.1.1"})) defer b.Close() - assert.Equal(t, decisionBypass, b.decide("192.168.1.1")) - assert.Equal(t, decisionProxy, b.decide("10.0.0.1")) + assert.Equal(t, decisionBypass, b.decide("", "192.168.1.1")) + assert.Equal(t, decisionProxy, b.decide("", "10.0.0.1")) } func TestMatched_CIDR(t *testing.T) { b := newSyncedBypass(MatchersOption([]string{"10.0.0.0/8"})) defer b.Close() - assert.Equal(t, decisionBypass, b.decide("10.0.0.1")) - assert.Equal(t, decisionProxy, b.decide("192.168.1.1")) + assert.Equal(t, decisionBypass, b.decide("", "10.0.0.1")) + assert.Equal(t, decisionProxy, b.decide("", "192.168.1.1")) } func TestMatched_Wildcard(t *testing.T) { b := newSyncedBypass(MatchersOption([]string{"*.example.com"})) defer b.Close() - assert.Equal(t, decisionBypass, b.decide("foo.example.com")) - assert.Equal(t, decisionProxy, b.decide("foo.other.com")) + assert.Equal(t, decisionBypass, b.decide("", "foo.example.com")) + assert.Equal(t, decisionProxy, b.decide("", "foo.other.com")) } func TestMatched_IPRange(t *testing.T) { b := newSyncedBypass(MatchersOption([]string{"10.0.0.1-10.0.0.100"})) defer b.Close() - assert.Equal(t, decisionBypass, b.decide("10.0.0.50")) - assert.Equal(t, decisionProxy, b.decide("10.0.0.200")) + assert.Equal(t, decisionBypass, b.decide("", "10.0.0.50")) + assert.Equal(t, decisionProxy, b.decide("", "10.0.0.200")) } // --- reload tests --- @@ -683,7 +683,20 @@ func TestNewBypass_ReloadError(t *testing.T) { // --- Network parameter ignored --- -func TestContains_NetworkIgnoredForMatching(t *testing.T) { +// --- Network option tests --- + +func TestNetworkOption(t *testing.T) { + var opts options + NetworkOption("tcp")(&opts) + assert.Equal(t, "tcp", opts.network) + + NetworkOption("")(&opts) + assert.Equal(t, "", opts.network) +} + +// --- Network pre-filter tests --- + +func TestContains_NetworkNotSet_BehavesAsBefore(t *testing.T) { b := newSyncedBypass(MatchersOption([]string{"192.168.1.1"})) defer b.Close() @@ -692,6 +705,173 @@ func TestContains_NetworkIgnoredForMatching(t *testing.T) { assert.True(t, b.Contains(context.Background(), "ip4", "192.168.1.1")) } +func TestContains_NetworkMatch_BlacklistBypass(t *testing.T) { + b := newSyncedBypass( + NetworkOption("tcp"), + MatchersOption([]string{"192.168.1.1"}), + ) + defer b.Close() + + // Network matches and addr matches → bypass + assert.True(t, b.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestContains_NetworkMatch_BlacklistPass(t *testing.T) { + b := newSyncedBypass( + NetworkOption("tcp"), + MatchersOption([]string{"192.168.1.1"}), + ) + defer b.Close() + + // Network matches but addr does not match → proxy + assert.False(t, b.Contains(context.Background(), "tcp", "10.0.0.1")) +} + +func TestContains_NetworkMatch_WhitelistProxy(t *testing.T) { + b := newSyncedBypass( + WhitelistOption(true), + NetworkOption("udp"), + MatchersOption([]string{"192.168.1.1"}), + ) + defer b.Close() + + // Network matches and addr matches → in whitelist, addr goes through proxy + assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1")) +} + +func TestContains_NetworkMatch_WhitelistBypass(t *testing.T) { + b := newSyncedBypass( + WhitelistOption(true), + NetworkOption("udp"), + MatchersOption([]string{"192.168.1.1"}), + ) + defer b.Close() + + // Network matches but addr does not match → whitelist bypass + assert.True(t, b.Contains(context.Background(), "udp", "10.0.0.1")) +} + +func TestContains_NetworkMismatch_ReturnsFalse(t *testing.T) { + b := newSyncedBypass( + NetworkOption("tcp"), + MatchersOption([]string{"192.168.1.1"}), + ) + defer b.Close() + + // Network does not match → bypass returns false regardless of addr + assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1")) + assert.False(t, b.Contains(context.Background(), "udp", "")) + assert.False(t, b.Contains(context.Background(), "sctp", "192.168.1.1")) +} + +func TestContains_NetworkMismatch_EmptyAddr(t *testing.T) { + b := newSyncedBypass( + NetworkOption("tcp"), + MatchersOption([]string{"192.168.1.1"}), + ) + defer b.Close() + + // addr is empty → early return false + assert.False(t, b.Contains(context.Background(), "tcp", "")) +} + +func TestContains_NetworkMismatch_WhitelistReturnsFalse(t *testing.T) { + b := newSyncedBypass( + WhitelistOption(true), + NetworkOption("tcp"), + MatchersOption([]string{"192.168.1.1"}), + ) + defer b.Close() + + // Network does not match → bypass returns false even in whitelist mode + assert.False(t, b.Contains(context.Background(), "udp", "10.0.0.1")) + assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1")) +} + +func TestContains_PatternSetPassesThroughNetworkFilter(t *testing.T) { + b := newSyncedBypass( + NetworkOption("tcp"), + MatchersOption([]string{"192.168.1.1", "10.0.0.0/8", "*.example.com"}), + ) + defer b.Close() + + // Network matches → pattern set is evaluated + assert.True(t, b.Contains(context.Background(), "tcp", "10.0.0.1")) + assert.True(t, b.Contains(context.Background(), "tcp", "foo.example.com")) + assert.False(t, b.Contains(context.Background(), "tcp", "8.8.8.8")) +} + +// --- Network-only tests (no address matchers) --- + +func TestNetworkOnly_Blacklist_BypassesMatchingNetwork(t *testing.T) { + b := newSyncedBypass(NetworkOption("tcp")) + defer b.Close() + + // Network matches → bypass + assert.True(t, b.Contains(context.Background(), "tcp", "192.168.1.1")) + assert.True(t, b.Contains(context.Background(), "tcp", "10.0.0.1")) +} + +func TestNetworkOnly_Blacklist_DoesNotBypassNonMatching(t *testing.T) { + b := newSyncedBypass(NetworkOption("tcp")) + defer b.Close() + + // Network does not match → not bypassed + assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1")) +} + +func TestNetworkOnly_Whitelist_ProxiesMatchingNetwork(t *testing.T) { + b := newSyncedBypass( + WhitelistOption(true), + NetworkOption("tcp"), + ) + defer b.Close() + + // In whitelist mode, matched network → proxy (not bypassed) + assert.False(t, b.Contains(context.Background(), "tcp", "192.168.1.1")) + assert.False(t, b.Contains(context.Background(), "tcp", "10.0.0.1")) +} + +func TestNetworkOnly_Whitelist_NonMatchingNotAffected(t *testing.T) { + b := newSyncedBypass( + WhitelistOption(true), + NetworkOption("tcp"), + ) + defer b.Close() + + // network: tcp 是作用域限定,非 TCP 流量不受影响 + assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1")) +} + +func TestNetworkOnly_NoNetwork_BehavesAsBefore(t *testing.T) { + b := newSyncedBypass() + defer b.Close() + + // No network configured, no matchers → never bypass + assert.False(t, b.Contains(context.Background(), "tcp", "192.168.1.1")) + assert.False(t, b.Contains(context.Background(), "udp", "192.168.1.1")) +} + +func TestNetworkOnly_EmptyAddr_ReturnsFalse(t *testing.T) { + b := newSyncedBypass(NetworkOption("tcp")) + defer b.Close() + + assert.False(t, b.Contains(context.Background(), "tcp", "")) +} + +func TestNetworkOption_DoesNotAffectGroupBypass(t *testing.T) { + b := newSyncedBypass( + NetworkOption("tcp"), + MatchersOption([]string{"192.168.1.1"}), + ) + defer b.Close() + + // bypassGroup embeds the same localBypass and respects the network filter + g := BypassGroup(b) + assert.False(t, g.Contains(context.Background(), "udp", "192.168.1.1")) + assert.True(t, g.Contains(context.Background(), "tcp", "192.168.1.1")) +} + // --- Debug logging doesn't panic --- func TestContains_DenyLogs(t *testing.T) { @@ -916,33 +1096,33 @@ func TestDecide_BlacklistMatched(t *testing.T) { b := newSyncedBypass(MatchersOption([]string{"192.168.1.1"})) defer b.Close() - assert.Equal(t, decisionBypass, b.decide("192.168.1.1")) + assert.Equal(t, decisionBypass, b.decide("", "192.168.1.1")) } func TestDecide_BlacklistNotMatched(t *testing.T) { b := newSyncedBypass(MatchersOption([]string{"192.168.1.1"})) defer b.Close() - assert.Equal(t, decisionProxy, b.decide("10.0.0.1")) + assert.Equal(t, decisionProxy, b.decide("", "10.0.0.1")) } func TestDecide_WhitelistMatched(t *testing.T) { b := newSyncedBypass(WhitelistOption(true), MatchersOption([]string{"192.168.1.1"})) defer b.Close() - assert.Equal(t, decisionProxy, b.decide("192.168.1.1")) + assert.Equal(t, decisionProxy, b.decide("", "192.168.1.1")) } func TestDecide_WhitelistNotMatched(t *testing.T) { b := newSyncedBypass(WhitelistOption(true), MatchersOption([]string{"192.168.1.1"})) defer b.Close() - assert.Equal(t, decisionBypass, b.decide("10.0.0.1")) + assert.Equal(t, decisionBypass, b.decide("", "10.0.0.1")) } func TestDecide_NilPatterns(t *testing.T) { lb := &localBypass{logger: xlogger.Nop()} - assert.Equal(t, decisionProxy, lb.decide("anything")) + assert.Equal(t, decisionProxy, lb.decide("", "anything")) } // --- evaluate (bypassGroup) tests --- diff --git a/config/config.go b/config/config.go index 3bf3e326..306dd40e 100644 --- a/config/config.go +++ b/config/config.go @@ -175,6 +175,7 @@ type BypassConfig struct { // Deprecated: use whitelist instead Reverse bool `yaml:",omitempty" json:"reverse,omitempty"` Whitelist bool `yaml:",omitempty" json:"whitelist,omitempty"` + Network string `yaml:",omitempty" json:"network,omitempty"` Matchers []string `yaml:",omitempty" json:"matchers,omitempty"` Reload time.Duration `yaml:",omitempty" json:"reload,omitempty"` File *FileLoader `yaml:",omitempty" json:"file,omitempty"` diff --git a/config/parsing/bypass/parse.go b/config/parsing/bypass/parse.go index 1416c1c2..1d3ddefa 100644 --- a/config/parsing/bypass/parse.go +++ b/config/parsing/bypass/parse.go @@ -50,6 +50,7 @@ func ParseBypass(cfg *config.BypassConfig) bypass.Bypass { opts := []xbypass.Option{ xbypass.MatchersOption(cfg.Matchers), xbypass.WhitelistOption(cfg.Reverse || cfg.Whitelist), + xbypass.NetworkOption(cfg.Network), xbypass.ReloadPeriodOption(cfg.Reload), xbypass.LoggerOption(logger.Default().WithFields(map[string]any{ "kind": "bypass",