feat: try add bypass

This commit is contained in:
xteamlyer
2026-04-29 13:56:50 +03:00
committed by Alexey71
parent 677c32bba1
commit a4e2458a4b
4 changed files with 509 additions and 2 deletions
+339
View File
@@ -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
}