From a4e2458a4b19c3ac32c9a4ca39cce0d8159f7d36 Mon Sep 17 00:00:00 2001 From: xteamlyer <29282888+xteamlyer@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:56:50 +0300 Subject: [PATCH] feat: try add bypass --- dialer/bypass.go | 339 ++++++++++++++++++++++++++++++++++++++++++ dialer/bypass_test.go | 114 ++++++++++++++ log/condlog.go | 1 + main.go | 57 ++++++- 4 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 dialer/bypass.go create mode 100644 dialer/bypass_test.go diff --git a/dialer/bypass.go b/dialer/bypass.go new file mode 100644 index 0000000..317ecab --- /dev/null +++ b/dialer/bypass.go @@ -0,0 +1,339 @@ +package dialer + +import ( + "bufio" + "context" + "fmt" + "net" + "os" + "strings" + "sync" +) + +// BypassDialer routes connections based on the destination address: +// - addresses matching any bypass pattern go directly (via directDialer) +// - all other addresses go through the proxy (via proxyDialer) +// +// Supported pattern formats (case-insensitive): +// +// 192.168.1.1 — exact IP (any port) +// 192.168.0.0/16 — CIDR block (any port) +// example.com — exact hostname (any port) +// *.example.com — wildcard: any subdomain +// localhost:8080 — hostname + specific port +// 192.168.1.1:443 — IP + specific port +// 192.168.0.0/24:443 — CIDR + specific port +// +// SOCKS5 + DNS note: +// When used in SOCKS5 mode, hostname patterns only match if the SOCKS client +// sends the hostname directly (ATYP=0x03 / SOCKS5h mode). Most clients resolve +// DNS themselves and send an IP address (ATYP=0x01) — in that case only CIDR +// rules match. Call ResolvePatterns() once after construction to pre-resolve +// hostnames into CIDRs so IP-based bypass works correctly. +type BypassDialer struct { + mu sync.RWMutex + patterns []bypassPattern + directDialer ContextDialer + proxyDialer ContextDialer + logger bypassLogger +} + +// bypassLogger matches the signature of *clog.CondLogger so it can be passed +// directly without an adapter. +type bypassLogger interface { + Info(fmt string, args ...interface{}) error + Debug(fmt string, args ...interface{}) error +} + +// noopLogger silently discards all log messages. +type noopLogger struct{} + +func (noopLogger) Info(string, ...interface{}) error { return nil } +func (noopLogger) Debug(string, ...interface{}) error { return nil } + +type bypassPattern struct { + raw string + port string // "" = any port + hostKind patternKind + cidr *net.IPNet // kindCIDR + host string // kindExact | kindWildcard +} + +type patternKind int + +const ( + kindExact patternKind = iota + kindWildcard // *.suffix + kindCIDR +) + +// NewBypassDialer compiles patterns and returns a BypassDialer. +// logger may be nil (logging disabled). +func NewBypassDialer(patterns []string, directDialer, proxyDialer ContextDialer, logger bypassLogger) (*BypassDialer, error) { + if logger == nil { + logger = noopLogger{} + } + compiled := make([]bypassPattern, 0, len(patterns)) + for _, raw := range patterns { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + p, err := parseBypassPattern(raw) + if err != nil { + return nil, fmt.Errorf("bypass pattern %q: %w", raw, err) + } + compiled = append(compiled, p) + } + return &BypassDialer{ + patterns: compiled, + directDialer: directDialer, + proxyDialer: proxyDialer, + logger: logger, + }, nil +} + +// ResolvePatterns pre-resolves hostname and wildcard patterns into their IP +// addresses and adds them as CIDR /32 (or /128) rules. This is needed when +// the SOCKS5 client resolves DNS itself and sends IP addresses. +// +// resolver is the DNS function to use. Pass nil to use net.DefaultResolver. +// On Android with CGO disabled, net.DefaultResolver may fail because Go's +// pure-Go resolver reads /etc/resolv.conf which may not exist. In that case +// pass a custom resolver backed by the bootstrap DNS already configured in +// the proxy (see buildCustomResolver in main.go). +// +// Wildcard TLD patterns (e.g. *.ru) are skipped — resolving a TLD returns +// nothing useful. List specific domains instead (e.g. yandex.ru, *.yandex.ru). +// +// The resolved IPs supplement the original patterns; hostname matching +// continues to work for clients that send hostnames. +func (bd *BypassDialer) ResolvePatterns(ctx context.Context, resolver *net.Resolver) { + if resolver == nil { + resolver = net.DefaultResolver + } + + // Snapshot current patterns so we don't iterate over appended results. + bd.mu.RLock() + snapshot := make([]bypassPattern, len(bd.patterns)) + copy(snapshot, bd.patterns) + bd.mu.RUnlock() + + var newPatterns []bypassPattern + for _, p := range snapshot { + if p.hostKind != kindExact && p.hostKind != kindWildcard { + continue // CIDR patterns need no resolution + } + if net.ParseIP(p.host) != nil { + continue // already an IP literal + } + + hostname := p.host + if p.hostKind == kindWildcard { + // p.host = ".example.com" — strip leading dot to get the apex + hostname = strings.TrimPrefix(p.host, ".") + + // Skip TLD-only wildcards like "*.ru" — resolving "ru" yields + // nothing meaningful and pollutes the pattern list. + if !strings.Contains(hostname, ".") { + bd.logger.Debug("bypass: skipping TLD wildcard %q (use exact domains instead)", p.raw) + continue + } + } + + ips, err := resolver.LookupIPAddr(ctx, hostname) + if err != nil { + bd.logger.Info("bypass: resolve %q failed: %v (hostname-only matching)", hostname, err) + continue + } + for _, ia := range ips { + bits := 32 + if ia.IP.To4() == nil { + bits = 128 + } + _, cidr, err := net.ParseCIDR(fmt.Sprintf("%s/%d", ia.IP.String(), bits)) + if err != nil { + continue + } + newPatterns = append(newPatterns, bypassPattern{ + raw: fmt.Sprintf("resolved:%s→%s", hostname, ia.IP), + port: p.port, + hostKind: kindCIDR, + cidr: cidr, + }) + bd.logger.Debug("bypass: resolved %q → %s", hostname, ia.IP) + } + } + + if len(newPatterns) > 0 { + bd.mu.Lock() + bd.patterns = append(bd.patterns, newPatterns...) + bd.mu.Unlock() + bd.logger.Info("bypass: pre-resolved %d IP(s) from hostname patterns (total rules: %d)", + len(newPatterns), len(bd.patterns)) + } +} + +func parseBypassPattern(raw string) (bypassPattern, error) { + if raw == "" { + return bypassPattern{}, fmt.Errorf("empty pattern") + } + + p := bypassPattern{raw: raw} + hostPart := raw + + if strings.HasPrefix(raw, "[") { + // IPv6 bracket notation: [::1]:443 + h, port, err := net.SplitHostPort(raw) + if err != nil { + return p, fmt.Errorf("invalid bracketed address: %w", err) + } + p.port = port + p.hostKind = kindExact + p.host = strings.ToLower(h) + return p, nil + } + + colonCount := strings.Count(raw, ":") + if colonCount > 1 { + // Bare IPv6, no port + hostPart = raw + } else if colonCount == 1 { + lastColon := strings.LastIndex(raw, ":") + possiblePort := raw[lastColon+1:] + if isValidPort(possiblePort) { + hostPart = raw[:lastColon] + p.port = possiblePort + } + } + + hostPart = strings.ToLower(hostPart) + + // CIDR? + if strings.Contains(hostPart, "/") { + _, cidr, err := net.ParseCIDR(hostPart) + if err != nil { + return p, fmt.Errorf("invalid CIDR %q: %w", hostPart, err) + } + p.hostKind = kindCIDR + p.cidr = cidr + return p, nil + } + + // Wildcard hostname? + if strings.HasPrefix(hostPart, "*.") { + suffix := hostPart[1:] // ".example.com" + if strings.ContainsAny(suffix, "*?") { + return p, fmt.Errorf("only leading wildcard (*.) is supported") + } + p.hostKind = kindWildcard + p.host = suffix + return p, nil + } + + // Exact hostname or IP literal + p.hostKind = kindExact + p.host = hostPart + return p, nil +} + +func (p bypassPattern) matches(host, port string) bool { + if p.port != "" && p.port != port { + return false + } + host = strings.ToLower(host) + switch p.hostKind { + case kindCIDR: + ip := net.ParseIP(host) + return ip != nil && p.cidr.Contains(ip) + case kindWildcard: + // p.host = ".example.com" + return strings.HasSuffix(host, p.host) && len(host) > len(p.host) + case kindExact: + if host == p.host { + return true + } + // IP literal comparison (normalises IPv4-mapped IPv6 etc.) + if ip := net.ParseIP(host); ip != nil { + if pip := net.ParseIP(p.host); pip != nil { + return ip.Equal(pip) + } + } + } + return false +} + +func (bd *BypassDialer) shouldBypass(addr string) (bool, string) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + host = addr + port = "" + } + host = strings.Trim(host, "[]") + + bd.mu.RLock() + defer bd.mu.RUnlock() + for _, p := range bd.patterns { + if p.matches(host, port) { + return true, p.raw + } + } + return false, "" +} + +func (bd *BypassDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { + bypass, matchedRule := bd.shouldBypass(addr) + if bypass { + bd.logger.Debug("bypass: DIRECT %s (rule: %s)", addr, matchedRule) + return bd.directDialer.DialContext(ctx, network, addr) + } + bd.logger.Debug("bypass: PROXY %s", addr) + return bd.proxyDialer.DialContext(ctx, network, addr) +} + +func (bd *BypassDialer) Dial(network, addr string) (net.Conn, error) { + return bd.DialContext(context.Background(), network, addr) +} + +// LoadBypassFile reads bypass patterns from a text file. +// Empty lines and lines starting with # are ignored. +// Inline comments (preceded by whitespace + #) are stripped. +// Windows line endings (\r\n) are handled transparently by bufio.Scanner. +func LoadBypassFile(filename string) ([]string, error) { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + + var patterns []string + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + // Strip inline comment: " # comment" + if idx := strings.Index(line, " #"); idx >= 0 { + line = strings.TrimSpace(line[:idx]) + } + if line != "" { + patterns = append(patterns, line) + } + } + return patterns, sc.Err() +} + +func isValidPort(s string) bool { + if s == "" || len(s) > 5 { + return false + } + n := 0 + for _, c := range s { + if c < '0' || c > '9' { + return false + } + n = n*10 + int(c-'0') + } + return n > 0 && n <= 65535 +} diff --git a/dialer/bypass_test.go b/dialer/bypass_test.go new file mode 100644 index 0000000..b912e76 --- /dev/null +++ b/dialer/bypass_test.go @@ -0,0 +1,114 @@ +package dialer + +import ( + "testing" +) + +func TestBypassPatternMatches(t *testing.T) { + cases := []struct { + pattern string + host string + port string + want bool + }{ + {"192.168.1.1", "192.168.1.1", "443", true}, + {"192.168.1.1", "192.168.1.2", "443", false}, + {"192.168.0.0/16", "192.168.1.100", "80", true}, + {"192.168.0.0/16", "10.0.0.1", "80", false}, + {"192.168.0.0/24:443", "192.168.0.5", "443", true}, + {"192.168.0.0/24:443", "192.168.0.5", "80", false}, + {"192.168.0.0/24:443", "10.0.0.1", "443", false}, + {"example.com", "example.com", "443", true}, + {"example.com", "sub.example.com", "443", false}, + {"Example.COM", "example.com", "80", true}, + {"*.example.com", "sub.example.com", "443", true}, + {"*.example.com", "deep.sub.example.com", "443", true}, + {"*.example.com", "example.com", "443", false}, + {"*.example.com", "notexample.com", "443", false}, + {"localhost:8080", "localhost", "8080", true}, + {"localhost:8080", "localhost", "9090", false}, + {"example.com", "example.com", "", true}, + {"example.com", "example.com", "8080", true}, + // TLD wildcard: only matches subdomains, not the TLD itself + {"*.ru", "yandex.ru", "443", true}, + {"*.ru", "www.yandex.ru", "443", true}, + {"*.ru", "ru", "443", false}, + // Exact domain + {"yandex.ru", "yandex.ru", "80", true}, + {"yandex.ru", "www.yandex.ru", "80", false}, + } + + for _, tc := range cases { + p, err := parseBypassPattern(tc.pattern) + if err != nil { + t.Errorf("parseBypassPattern(%q) error: %v", tc.pattern, err) + continue + } + got := p.matches(tc.host, tc.port) + if got != tc.want { + t.Errorf("pattern=%q host=%q port=%q: got %v, want %v", + tc.pattern, tc.host, tc.port, got, tc.want) + } + } +} + +func TestShouldBypass(t *testing.T) { + bd, err := NewBypassDialer( + []string{"192.168.0.0/24", "*.internal", "exact.host:8080"}, + nil, nil, nil, + ) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + addr string + want bool + }{ + {"192.168.0.1:443", true}, + {"192.168.1.1:443", false}, + {"foo.internal:443", true}, + {"internal:443", false}, + {"exact.host:8080", true}, + {"exact.host:9090", false}, + {"google.com:443", false}, + } + + for _, tc := range cases { + got, _ := bd.shouldBypass(tc.addr) + if got != tc.want { + t.Errorf("shouldBypass(%q) = %v, want %v", tc.addr, got, tc.want) + } + } +} + +func TestParseBypassPatternErrors(t *testing.T) { + bad := []string{ + "", + "192.168.0.0/999", + "**.example.com", + "*.*.example.com", + } + for _, raw := range bad { + _, err := parseBypassPattern(raw) + if err == nil { + t.Errorf("parseBypassPattern(%q) expected error, got nil", raw) + } + } +} + +func TestTLDWildcardSkippedInResolve(t *testing.T) { + // *.ru should still match hostnames even though resolution is skipped + bd, err := NewBypassDialer([]string{"*.ru"}, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + got, _ := bd.shouldBypass("yandex.ru:443") + if !got { + t.Error("*.ru should match yandex.ru:443 via hostname matching") + } + got, _ = bd.shouldBypass("ru:443") + if got { + t.Error("*.ru should NOT match bare 'ru'") + } +} diff --git a/log/condlog.go b/log/condlog.go index b40572a..d7d753e 100644 --- a/log/condlog.go +++ b/log/condlog.go @@ -6,6 +6,7 @@ import ( ) const ( + SILENT = 60 // suppress all output, including critical errors CRITICAL = 50 ERROR = 40 WARNING = 30 diff --git a/main.go b/main.go index fdf48e1..b0266f6 100644 --- a/main.go +++ b/main.go @@ -133,6 +133,8 @@ type CLIArgs struct { caFile string fakeSNI string overrideProxyAddress string + proxyBypass []string + proxyBypassFile string serverSelection serverSelectionArg serverSelectionTimeout time.Duration serverSelectionTestURL string @@ -163,7 +165,7 @@ func parse_args() *CLIArgs { flag.StringVar(&args.bindAddress, "bind-address", "127.0.0.1:18080", "proxy listen address") flag.BoolVar(&args.socksMode, "socks-mode", false, "listen for SOCKS requests instead of HTTP") flag.IntVar(&args.verbosity, "verbosity", 20, "logging verbosity "+ - "(10 - debug, 20 - info, 30 - warning, 40 - error, 50 - critical)") + "(10 - debug, 20 - info, 30 - warning, 40 - error, 50 - critical, 60 - silent)") flag.DurationVar(&args.timeout, "timeout", DEFAULT_TIMEOUT, "timeout for network operations") flag.BoolVar(&args.showVersion, "version", false, "show program version and exit") @@ -188,6 +190,18 @@ func parse_args() *CLIArgs { flag.StringVar(&args.caFile, "cafile", "", "use custom CA certificate bundle file") flag.StringVar(&args.fakeSNI, "fake-SNI", "", "domain name to use as SNI in communications with servers") flag.StringVar(&args.overrideProxyAddress, "override-proxy-address", "", "use fixed proxy address instead of server address returned by SurfEasy API") + flag.StringVar(&args.proxyBypassFile, "proxy-bypass-file", "", "path to file with bypass patterns, one per line (comments with #)") + flag.Func("proxy-bypass", "comma-separated bypass patterns; matched addresses connect directly.\n"+ + "Formats: hostname (example.com), wildcard (*.example.com), IP (1.2.3.4), CIDR (10.0.0.0/8), any with optional :port suffix", + func(s string) error { + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part != "" { + args.proxyBypass = append(args.proxyBypass, part) + } + } + return nil + }) flag.Var(&args.serverSelection, "server-selection", "server selection policy (first/random/fastest)") flag.DurationVar(&args.serverSelectionTimeout, "server-selection-timeout", DEFAULT_SERVER_SELECTION_TIMEOUT, "timeout given for server selection function to produce result") @@ -283,7 +297,14 @@ func run() int { proxyLogger := clog.NewCondLogger(log.New(logWriter, "PROXY : ", log.LstdFlags|log.Lshortfile), args.verbosity) - socksLogger := log.New(logWriter, "SOCKS : ", + // When verbosity >= SILENT, discard socks5 library logs entirely. + // go-socks5 writes through a raw *log.Logger which bypasses CondLogger, + // so we swap the writer to io.Discard to guarantee silence. + socksLogWriter := io.Writer(logWriter) + if args.verbosity >= clog.SILENT { + socksLogWriter = io.Discard + } + socksLogger := log.New(socksLogWriter, "SOCKS : ", log.LstdFlags|log.Lshortfile) mainLogger.Info("opera-proxy client version %s is starting...", version()) @@ -292,6 +313,10 @@ func run() int { Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, } + // rawDialer is the bare net.Dialer before any -proxy wrapping. + // BypassDialer uses this as directDialer so that bypassed connections + // genuinely go direct — not through the base proxy. + rawDialer := d caPool, exitCode := buildCAPool(args.caFile, mainLogger) if exitCode != 0 { @@ -484,6 +509,33 @@ func run() int { mainLogger.Info("Endpoint override: %s", sanitizedEndpoint) } + // Load bypass patterns from file if specified. + if args.proxyBypassFile != "" { + filePatterns, err := dialer.LoadBypassFile(args.proxyBypassFile) + if err != nil { + mainLogger.Critical("Unable to load bypass file: %v", err) + return 13 + } + args.proxyBypass = append(args.proxyBypass, filePatterns...) + } + if len(args.proxyBypass) > 0 { + mainLogger.Info("Bypass rules (%d): %v", len(args.proxyBypass), args.proxyBypass) + bypassDialer, err := dialer.NewBypassDialer(args.proxyBypass, rawDialer, handlerDialer, mainLogger) + if err != nil { + mainLogger.Critical("Unable to configure bypass rules: %v", err) + return 13 + } + // Pre-resolve hostname/wildcard patterns to IPs so that bypass works + // even when the SOCKS5 client resolves DNS itself and sends IP addresses. + // 20s timeout: Android may take longer to get network ready at daemon startup. + // nil resolver = net.DefaultResolver; on Android with CGO=0 this may fail + // for some patterns — those will still match by hostname if client sends SOCKS5h. + resolveCtx, resolveCancel := context.WithTimeout(context.Background(), 20*time.Second) + bypassDialer.ResolvePatterns(resolveCtx, nil) + resolveCancel() + handlerDialer = bypassDialer + } + clock.RunTicker(context.Background(), args.refresh, args.refreshRetry, func(ctx context.Context) error { mainLogger.Info("Refreshing login...") reqCtx, cl := context.WithTimeout(ctx, args.timeout) @@ -602,6 +654,7 @@ func sanitizeFixedProxyAddress(addr string) string { return net.JoinHostPort(addr, "443") } + func main() { os.Exit(run()) }