1510f841c3
Replace boolean bypass logic with bypassDecision enum (decisionBypass/decisionProxy), consolidate four matcher fields into a single patternSet struct for atomic swaps, extract classifyPatterns as a pure function, and remove unused matched() method and slices import. Add 204 lines of tests covering decisions, pattern sets, and group evaluation logic.
534 lines
15 KiB
Go
534 lines
15 KiB
Go
// Package bypass implements address-based routing that decides whether a
|
|
// target address should skip the proxy chain and connect directly.
|
|
//
|
|
// The package provides:
|
|
// - NewBypass: a local bypass that matches addresses against CIDR, IP range,
|
|
// wildcard, and exact-address patterns, with optional periodic reload from
|
|
// file, Redis, or HTTP sources.
|
|
// - BypassGroup: composes multiple bypass rules; whitelist rules use AND
|
|
// logic, blacklist rules use OR logic (evaluated only if whitelist fails).
|
|
// - Plugin-based bypass (gRPC and HTTP) in the plugin sub-package.
|
|
//
|
|
// Matching modes:
|
|
// - Blacklist (whitelist=false): matching addresses bypass the proxy.
|
|
// This is the default.
|
|
// - Whitelist (whitelist=true): only matching addresses bypass the proxy;
|
|
// all other addresses go through the proxy chain.
|
|
package bypass
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-gost/core/bypass"
|
|
"github.com/go-gost/core/logger"
|
|
ctxvalue "github.com/go-gost/x/ctx"
|
|
"github.com/go-gost/x/internal/loader"
|
|
"github.com/go-gost/x/internal/matcher"
|
|
xnet "github.com/go-gost/x/internal/net"
|
|
xlogger "github.com/go-gost/x/logger"
|
|
"github.com/gobwas/glob"
|
|
)
|
|
|
|
// ErrBypass is returned by wrapped connections when the bypass rule
|
|
// determines that the connection should skip the proxy chain.
|
|
var ErrBypass = errors.New("bypass")
|
|
|
|
// options holds the configuration for a localBypass instance.
|
|
type options struct {
|
|
// whitelist toggles between blacklist and whitelist mode.
|
|
// When false (default): matching addresses bypass the proxy.
|
|
// When true: only matching addresses bypass the proxy.
|
|
whitelist bool
|
|
|
|
// matchers holds static patterns provided at construction time
|
|
// (e.g. from config file or command-line arguments).
|
|
matchers []string
|
|
|
|
// fileLoader loads patterns from a file or directory.
|
|
fileLoader loader.Loader
|
|
|
|
// redisLoader loads patterns from a Redis set or key.
|
|
redisLoader loader.Loader
|
|
|
|
// httpLoader loads patterns from an HTTP endpoint.
|
|
httpLoader loader.Loader
|
|
|
|
// period controls the interval between automatic reloads.
|
|
// Values less than 1 second are clamped to 1 second.
|
|
// A value <= 0 disables periodic reload (load once at startup).
|
|
period time.Duration
|
|
|
|
// logger is used for debug and warning messages.
|
|
// Falls back to a no-op logger if nil.
|
|
logger logger.Logger
|
|
}
|
|
|
|
// Option is a functional option for configuring a Bypass instance.
|
|
type Option func(opts *options)
|
|
|
|
// WhitelistOption sets whether the bypass operates in whitelist mode.
|
|
// In whitelist mode, only matching addresses bypass the proxy;
|
|
// all others go through the proxy chain.
|
|
func WhitelistOption(whitelist bool) Option {
|
|
return func(opts *options) {
|
|
opts.whitelist = whitelist
|
|
}
|
|
}
|
|
|
|
// MatchersOption sets the static bypass patterns (CIDR, IP range, wildcard, or address).
|
|
func MatchersOption(matchers []string) Option {
|
|
return func(opts *options) {
|
|
opts.matchers = matchers
|
|
}
|
|
}
|
|
|
|
// ReloadPeriodOption sets the interval between automatic reloads of bypass
|
|
// patterns from external loaders (file, Redis, HTTP).
|
|
func ReloadPeriodOption(period time.Duration) Option {
|
|
return func(opts *options) {
|
|
opts.period = period
|
|
}
|
|
}
|
|
|
|
// FileLoaderOption sets the file loader for reading bypass patterns from a
|
|
// file or directory.
|
|
func FileLoaderOption(fileLoader loader.Loader) Option {
|
|
return func(opts *options) {
|
|
opts.fileLoader = fileLoader
|
|
}
|
|
}
|
|
|
|
// RedisLoaderOption sets the Redis loader for reading bypass patterns from a
|
|
// Redis set or key.
|
|
func RedisLoaderOption(redisLoader loader.Loader) Option {
|
|
return func(opts *options) {
|
|
opts.redisLoader = redisLoader
|
|
}
|
|
}
|
|
|
|
// HTTPLoaderOption sets the HTTP loader for reading bypass patterns from an
|
|
// HTTP endpoint.
|
|
func HTTPLoaderOption(httpLoader loader.Loader) Option {
|
|
return func(opts *options) {
|
|
opts.httpLoader = httpLoader
|
|
}
|
|
}
|
|
|
|
// LoggerOption sets the logger for debug and warning messages.
|
|
func LoggerOption(logger logger.Logger) Option {
|
|
return func(opts *options) {
|
|
opts.logger = logger
|
|
}
|
|
}
|
|
|
|
// bypassDecision represents the outcome of evaluating a bypass rule.
|
|
type bypassDecision int
|
|
|
|
const (
|
|
// decisionBypass means the address should connect directly, skipping the proxy chain.
|
|
decisionBypass bypassDecision = iota
|
|
// decisionProxy means the address should go through the proxy chain.
|
|
decisionProxy
|
|
)
|
|
|
|
func (d bypassDecision) String() string {
|
|
switch d {
|
|
case decisionBypass:
|
|
return "bypass"
|
|
case decisionProxy:
|
|
return "proxy"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// patternSet holds classified pattern matchers for a single bypass rule.
|
|
// All four matcher types are built together by classifyPatterns and swapped
|
|
// atomically under the write lock.
|
|
type patternSet struct {
|
|
cidr matcher.Matcher
|
|
addr matcher.Matcher
|
|
wildcard matcher.Matcher
|
|
ipRange matcher.Matcher
|
|
}
|
|
|
|
// matchAny reports whether addr matches any pattern in the set,
|
|
// trying IP range, address, CIDR, and wildcard matchers in order.
|
|
// Returns false if ps is nil.
|
|
func (ps *patternSet) matchAny(addr string) bool {
|
|
if ps == nil {
|
|
return false
|
|
}
|
|
if ps.ipRange.Match(addr) {
|
|
return true
|
|
}
|
|
if ps.addr.Match(addr) {
|
|
return true
|
|
}
|
|
|
|
host, _, _ := net.SplitHostPort(addr)
|
|
if host == "" {
|
|
host = addr
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil && ps.cidr.Match(host) {
|
|
return true
|
|
}
|
|
|
|
return ps.wildcard.Match(addr)
|
|
}
|
|
|
|
// localBypass is a Bypass that matches addresses against local pattern
|
|
// matchers. Patterns are classified into CIDR, wildcard, IP range, and
|
|
// exact address matchers. Patterns can be loaded from static config,
|
|
// file, Redis, or HTTP sources with optional periodic reload.
|
|
type localBypass struct {
|
|
patterns *patternSet
|
|
options options
|
|
logger logger.Logger
|
|
mu sync.RWMutex
|
|
cancelFunc context.CancelFunc
|
|
}
|
|
|
|
// NewBypass creates and initializes a local Bypass instance.
|
|
//
|
|
// In blacklist mode (the default), addresses matching any pattern bypass the
|
|
// proxy. In whitelist mode (set via WhitelistOption(true)), only matching
|
|
// addresses bypass the proxy and all others go through the chain.
|
|
//
|
|
// If a reload period is configured, patterns from external loaders are
|
|
// refreshed automatically in the background.
|
|
func NewBypass(opts ...Option) bypass.Bypass {
|
|
var options options
|
|
for _, opt := range opts {
|
|
opt(&options)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
p := &localBypass{
|
|
cancelFunc: cancel,
|
|
options: options,
|
|
logger: options.logger,
|
|
}
|
|
if p.logger == nil {
|
|
p.logger = xlogger.Nop()
|
|
}
|
|
|
|
if p.hasLoaders() {
|
|
go p.periodReload(ctx)
|
|
} else {
|
|
_ = p.reload(ctx)
|
|
}
|
|
|
|
return p
|
|
}
|
|
|
|
// periodReload loads patterns immediately, then periodically reloads them
|
|
// from external sources at the configured interval. Returns when ctx is
|
|
// cancelled.
|
|
func (p *localBypass) periodReload(ctx context.Context) error {
|
|
if err := p.reload(ctx); err != nil {
|
|
p.logger.Warnf("reload: %v", err)
|
|
}
|
|
|
|
period := p.options.period
|
|
if period <= 0 {
|
|
return nil
|
|
}
|
|
if period < time.Second {
|
|
period = time.Second
|
|
}
|
|
|
|
ticker := time.NewTicker(period)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
if err := p.reload(ctx); err != nil {
|
|
p.logger.Warnf("reload: %v", err)
|
|
}
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
// reload loads patterns from all configured sources, classifies them into
|
|
// CIDR, wildcard, IP range, and address matchers, then atomically swaps the
|
|
// pattern set under the write lock.
|
|
func (p *localBypass) reload(ctx context.Context) error {
|
|
v, err := p.load(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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
|
|
p.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// hasLoaders reports whether any external data source is configured,
|
|
// which determines whether a background reload goroutine is needed.
|
|
func (p *localBypass) hasLoaders() bool {
|
|
return p.options.fileLoader != nil || p.options.redisLoader != nil || p.options.httpLoader != nil || p.options.period > 0
|
|
}
|
|
|
|
// classifyPatterns sorts raw pattern strings into typed matchers.
|
|
// Invalid wildcard patterns are logged and fall through to address matching.
|
|
func classifyPatterns(patterns []string, log logger.Logger) *patternSet {
|
|
var addrs []string
|
|
var inets []*net.IPNet
|
|
var wildcards []string
|
|
var ipRanges []xnet.IPRange
|
|
|
|
for _, pattern := range patterns {
|
|
if _, inet, err := net.ParseCIDR(pattern); err == nil {
|
|
inets = append(inets, inet)
|
|
continue
|
|
}
|
|
|
|
if strings.ContainsAny(pattern, "*?") {
|
|
if _, err := glob.Compile(pattern); err == nil {
|
|
wildcards = append(wildcards, pattern)
|
|
continue
|
|
}
|
|
log.Warnf("invalid wildcard pattern %q, treating as plain address", pattern)
|
|
}
|
|
|
|
r := xnet.IPRange{}
|
|
if err := r.Parse(pattern); err == nil {
|
|
ipRanges = append(ipRanges, r)
|
|
continue
|
|
}
|
|
|
|
addrs = append(addrs, pattern)
|
|
}
|
|
|
|
return &patternSet{
|
|
cidr: matcher.CIDRMatcher(inets),
|
|
addr: matcher.AddrMatcher(addrs),
|
|
wildcard: matcher.WildcardMatcher(wildcards),
|
|
ipRange: matcher.IPRangeMatcher(ipRanges),
|
|
}
|
|
}
|
|
|
|
// load reads patterns from file, Redis and HTTP loaders if configured.
|
|
func (p *localBypass) load(ctx context.Context) (patterns []string, err error) {
|
|
if p.options.fileLoader != nil {
|
|
if lister, ok := p.options.fileLoader.(loader.Lister); ok {
|
|
list, er := lister.List(ctx)
|
|
if er != nil {
|
|
p.logger.Warnf("file loader: %v", er)
|
|
}
|
|
for _, s := range list {
|
|
if line := p.parseLine(s); line != "" {
|
|
patterns = append(patterns, line)
|
|
}
|
|
}
|
|
} else {
|
|
r, er := p.options.fileLoader.Load(ctx)
|
|
if er != nil {
|
|
p.logger.Warnf("file loader: %v", er)
|
|
}
|
|
if v, _ := p.parsePatterns(r); v != nil {
|
|
patterns = append(patterns, v...)
|
|
}
|
|
}
|
|
}
|
|
if p.options.redisLoader != nil {
|
|
if lister, ok := p.options.redisLoader.(loader.Lister); ok {
|
|
list, er := lister.List(ctx)
|
|
if er != nil {
|
|
p.logger.Warnf("redis loader: %v", er)
|
|
}
|
|
patterns = append(patterns, list...)
|
|
} else {
|
|
r, er := p.options.redisLoader.Load(ctx)
|
|
if er != nil {
|
|
p.logger.Warnf("redis loader: %v", er)
|
|
}
|
|
if v, _ := p.parsePatterns(r); v != nil {
|
|
patterns = append(patterns, v...)
|
|
}
|
|
}
|
|
}
|
|
if p.options.httpLoader != nil {
|
|
r, er := p.options.httpLoader.Load(ctx)
|
|
if er != nil {
|
|
p.logger.Warnf("http loader: %v", er)
|
|
}
|
|
if v, _ := p.parsePatterns(r); v != nil {
|
|
patterns = append(patterns, v...)
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// parsePatterns reads lines from r, strips comments and whitespace, and
|
|
// returns non-empty lines as patterns.
|
|
func (p *localBypass) parsePatterns(r io.Reader) (patterns []string, err error) {
|
|
if r == nil {
|
|
return
|
|
}
|
|
|
|
scanner := bufio.NewScanner(r)
|
|
for scanner.Scan() {
|
|
if line := p.parseLine(scanner.Text()); line != "" {
|
|
patterns = append(patterns, line)
|
|
}
|
|
}
|
|
|
|
err = scanner.Err()
|
|
return
|
|
}
|
|
|
|
func (p *localBypass) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool {
|
|
if addr == "" || p == nil {
|
|
return false
|
|
}
|
|
|
|
decision := p.decide(addr)
|
|
|
|
log := p.logger.WithFields(map[string]any{
|
|
"sid": ctxvalue.SidFromContext(ctx),
|
|
})
|
|
log.Debugf("%s: %s, whitelist: %t", decision, addr, p.options.whitelist)
|
|
|
|
return decision == decisionBypass
|
|
}
|
|
|
|
// 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 {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
|
|
if p.patterns == nil {
|
|
return decisionProxy
|
|
}
|
|
|
|
matched := p.patterns.matchAny(addr)
|
|
|
|
if p.options.whitelist {
|
|
// Whitelist mode: the pattern set specifies addresses that MUST use the proxy.
|
|
// Matched addresses go through the proxy; unmatched addresses bypass.
|
|
if matched {
|
|
return decisionProxy
|
|
}
|
|
return decisionBypass
|
|
}
|
|
// Blacklist mode: the pattern set specifies addresses that should bypass the proxy.
|
|
// Matched addresses bypass; unmatched addresses go through the proxy.
|
|
if matched {
|
|
return decisionBypass
|
|
}
|
|
return decisionProxy
|
|
}
|
|
|
|
func (p *localBypass) IsWhitelist() bool {
|
|
return p.options.whitelist
|
|
}
|
|
|
|
// parseLine removes comments (starting with '#') and trims whitespace.
|
|
func (p *localBypass) parseLine(s string) string {
|
|
if n := strings.IndexByte(s, '#'); n >= 0 {
|
|
s = s[:n]
|
|
}
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
func (p *localBypass) Close() error {
|
|
p.cancelFunc()
|
|
if p.options.fileLoader != nil {
|
|
p.options.fileLoader.Close()
|
|
}
|
|
if p.options.redisLoader != nil {
|
|
p.options.redisLoader.Close()
|
|
}
|
|
if p.options.httpLoader != nil {
|
|
p.options.httpLoader.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// bypassGroup composes multiple Bypass instances into a single rule.
|
|
// Whitelist rules use AND logic (all must match); blacklist rules use
|
|
// OR logic (any match triggers bypass) and are only evaluated when
|
|
// whitelist rules fail.
|
|
type bypassGroup struct {
|
|
bypasses []bypass.Bypass
|
|
}
|
|
|
|
// BypassGroup creates a composite Bypass from multiple rules.
|
|
// Whitelist rules use AND logic (all must match); blacklist rules use
|
|
// OR logic (any match triggers bypass) and are only evaluated when
|
|
// whitelist rules fail.
|
|
func BypassGroup(bypasses ...bypass.Bypass) bypass.Bypass {
|
|
return &bypassGroup{
|
|
bypasses: bypasses,
|
|
}
|
|
}
|
|
|
|
// Contains evaluates all bypass rules in the group with the following logic:
|
|
// - Whitelist rules: ALL must match (AND logic). If any whitelist rule
|
|
// returns false, the combined whitelist result is false.
|
|
// - Blacklist rules: only evaluated if the whitelist result is false.
|
|
// ANY matching blacklist rule triggers a bypass (OR logic).
|
|
func (p *bypassGroup) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool {
|
|
return p.evaluate(ctx, network, addr, opts...) == decisionBypass
|
|
}
|
|
|
|
// evaluate returns the bypass decision for the group by applying two-phase
|
|
// evaluation: whitelist rules use AND logic (all must agree), then blacklist
|
|
// rules use OR logic (any match wins), evaluated only if whitelist fails.
|
|
func (p *bypassGroup) evaluate(ctx context.Context, network, addr string, opts ...bypass.Option) bypassDecision {
|
|
// Phase 1: Whitelist AND — all must agree to bypass.
|
|
hasWhitelist := false
|
|
allWhitelistBypass := true
|
|
for _, bp := range p.bypasses {
|
|
if !bp.IsWhitelist() {
|
|
continue
|
|
}
|
|
hasWhitelist = true
|
|
if !bp.Contains(ctx, network, addr, opts...) {
|
|
allWhitelistBypass = false
|
|
break // short-circuit: one whitelist failure is enough
|
|
}
|
|
}
|
|
if hasWhitelist && allWhitelistBypass {
|
|
return decisionBypass
|
|
}
|
|
|
|
// Phase 2: Blacklist OR — any match triggers bypass.
|
|
for _, bp := range p.bypasses {
|
|
if bp.IsWhitelist() {
|
|
continue
|
|
}
|
|
if bp.Contains(ctx, network, addr, opts...) {
|
|
return decisionBypass
|
|
}
|
|
}
|
|
return decisionProxy
|
|
}
|
|
|
|
// IsWhitelist always returns false for a group, since the group may contain
|
|
// a mix of whitelist and blacklist rules.
|
|
func (p *bypassGroup) IsWhitelist() bool {
|
|
return false
|
|
}
|