Skip to content

Commit 8f9c962

Browse files
committed
feat: add proxy bypass rules
1 parent 423e382 commit 8f9c962

20 files changed

Lines changed: 569 additions & 46 deletions

DESIGN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2299,6 +2299,7 @@ GeoIP 与订阅的下载都有错误重试的需求。
22992299
* `RESIN_NODE_DNS_UPSTREAMS`:Resin 托管节点域名解析上游,JSON 字符串数组。默认值为 `["https://doh.pub/dns-query","https://dns.alidns.com/dns-query","tls://223.5.5.5?sni=dns.alidns.com","local"]`;设置后完全按数组顺序作为 failover 链。仅作用于内部 sing-box builder 解析节点域名,不影响订阅下载、GeoIP 下载等其他资源下载路径。
23002300
* 支持:`local``udp://host[:port]``tcp://host[:port]``tls://host[:port]?sni=name``quic://host[:port]?sni=name``https://host[:port][/path]?sni=name&bootstrap=local``h3://host[:port][/path]?sni=name&bootstrap=local`
23012301
* 默认端口由传输类型决定:UDP/TCP 为 53,DoT/DoQ 为 853,DoH/H3 为 443;DoH/H3 默认路径为 `/dns-query`
2302+
* `RESIN_PROXY_BYPASS`:不走代理节点的目标规则,默认空。用分号、逗号或换行分隔;命中规则的 HTTP 正向代理、SOCKS5 正向代理与反向代理请求会由 Resin 本机直连目标。支持精确主机、`*`/`?` 通配符、CIDR 网段与 `<local>`(无点号本地域名),例如 `localhost;127.*;10.*;172.16.0.0/12;192.168.*;<local>`
23022303

23032304
日志相关配置:
23042305
* `RESIN_REQUEST_LOG_QUEUE_SIZE`:日志写入队列大小。至少是 RESIN_REQUEST_LOG_QUEUE_FLUSH_BATCH_SIZE 的两倍。默认 8192。

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,8 @@ RESIN_PORT=2260 \
344344

345345
## 🛠️ FAQ
346346

347+
- **Q: How do I let LAN or localhost targets skip proxy nodes?**
348+
- **A**: Set `RESIN_PROXY_BYPASS` to a semicolon/comma/newline-separated rule list. Matching requests are dialed directly by Resin instead of through a proxy node. Example: `RESIN_PROXY_BYPASS="localhost;127.*;10.*;172.16.0.0/12;192.168.*;<local>"`. Supported rules include exact hosts, `*`/`?` wildcards, CIDR ranges, and `<local>` for hostnames without dots.
347349
- **Q: Startup fails with `RESIN_PROXY_TOKEN` undefined?**
348350
- **A**: Even if you do not want a proxy password, you must explicitly set it to empty: `RESIN_PROXY_TOKEN=""`.
349351
- **Q: Startup fails with `RESIN_AUTH_VERSION` undefined?**

README.zh-CN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,8 @@ RESIN_PORT=2260 \
330330

331331
## 🛠️ 常见错误 (FAQ)
332332

333+
- **Q: 如何让内网或本机目标不走代理节点?**
334+
- **A**: 配置 `RESIN_PROXY_BYPASS`,用分号、逗号或换行分隔规则。命中的请求会由 Resin 本机直连目标,而不是通过代理节点。例如:`RESIN_PROXY_BYPASS="localhost;127.*;10.*;172.16.0.0/12;192.168.*;<local>"`。规则支持精确主机、`*`/`?` 通配符、CIDR 网段,以及表示无点号本地域名的 `<local>`
333335
- **Q: 启动失败提示 `RESIN_PROXY_TOKEN` 未定义?**
334336
- **A**: 就算你不打算启用代理密码,也必须显式配置它为空:`RESIN_PROXY_TOKEN=""`
335337
- **Q: 启动失败提示 `RESIN_AUTH_VERSION` 未定义?**

cmd/resin/app_runtime.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ func (a *resinApp) buildNetworkServers(engine *state.StateEngine) error {
416416
MetricsSink: a.metricsManager,
417417
OutboundTransport: outboundTransportCfg,
418418
TransportPool: a.transportPool,
419+
ProxyBypassRules: a.envCfg.ProxyBypassRules,
419420
})
420421

421422
reverseProxy := proxy.NewReverseProxy(proxy.ReverseProxyConfig{
@@ -430,15 +431,17 @@ func (a *resinApp) buildNetworkServers(engine *state.StateEngine) error {
430431
MetricsSink: a.metricsManager,
431432
OutboundTransport: outboundTransportCfg,
432433
TransportPool: a.transportPool,
434+
ProxyBypassRules: a.envCfg.ProxyBypassRules,
433435
})
434436
socks5Inbound := proxy.NewSocks5Inbound(proxy.Socks5InboundConfig{
435-
ProxyToken: a.envCfg.ProxyToken,
436-
AuthVersion: string(a.envCfg.AuthVersion),
437-
Router: a.topoRuntime.router,
438-
Pool: a.topoRuntime.pool,
439-
Health: a.topoRuntime.pool,
440-
Events: proxyEvents,
441-
MetricsSink: a.metricsManager,
437+
ProxyToken: a.envCfg.ProxyToken,
438+
AuthVersion: string(a.envCfg.AuthVersion),
439+
Router: a.topoRuntime.router,
440+
Pool: a.topoRuntime.pool,
441+
Health: a.topoRuntime.pool,
442+
Events: proxyEvents,
443+
MetricsSink: a.metricsManager,
444+
ProxyBypassRules: a.envCfg.ProxyBypassRules,
442445
})
443446

444447
inboundHandler := newInboundMux(

internal/api/handler_system.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type systemEnvConfigResponse struct {
3131
ProxyTransportMaxIdleConns int `json:"proxy_transport_max_idle_conns"`
3232
ProxyTransportMaxIdleConnsPerHost int `json:"proxy_transport_max_idle_conns_per_host"`
3333
ProxyTransportIdleConnTimeout config.Duration `json:"proxy_transport_idle_conn_timeout"`
34+
ProxyBypassRules []string `json:"proxy_bypass_rules"`
3435
RequestLogQueueSize int `json:"request_log_queue_size"`
3536
RequestLogQueueFlushBatchSize int `json:"request_log_queue_flush_batch_size"`
3637
RequestLogQueueFlushInterval config.Duration `json:"request_log_queue_flush_interval"`
@@ -128,6 +129,7 @@ func systemEnvConfigSnapshot(envCfg *config.EnvConfig) *systemEnvConfigResponse
128129
ProxyTransportMaxIdleConns: envCfg.ProxyTransportMaxIdleConns,
129130
ProxyTransportMaxIdleConnsPerHost: envCfg.ProxyTransportMaxIdleConnsPerHost,
130131
ProxyTransportIdleConnTimeout: config.Duration(envCfg.ProxyTransportIdleConnTimeout),
132+
ProxyBypassRules: append([]string(nil), envCfg.ProxyBypassRules...),
131133
RequestLogQueueSize: envCfg.RequestLogQueueSize,
132134
RequestLogQueueFlushBatchSize: envCfg.RequestLogQueueFlushBatchSize,
133135
RequestLogQueueFlushInterval: config.Duration(envCfg.RequestLogQueueFlushInterval),

internal/api/handler_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func newTestServer() *Server {
3838
ProxyTransportMaxIdleConns: 1024,
3939
ProxyTransportMaxIdleConnsPerHost: 64,
4040
ProxyTransportIdleConnTimeout: 90 * time.Second,
41+
ProxyBypassRules: []string{"localhost", "127.*"},
4142
RequestLogQueueSize: 8192,
4243
RequestLogQueueFlushBatchSize: 4096,
4344
RequestLogQueueFlushInterval: 5 * time.Minute,
@@ -335,6 +336,9 @@ func TestSystemEnvConfig_OK(t *testing.T) {
335336
if body["proxy_token_weak"] != false {
336337
t.Errorf("proxy_token_weak: got %v, want false", body["proxy_token_weak"])
337338
}
339+
if rules, ok := body["proxy_bypass_rules"].([]any); !ok || len(rules) != 2 || rules[0] != "localhost" || rules[1] != "127.*" {
340+
t.Errorf("proxy_bypass_rules: got %#v, want [localhost 127.*]", body["proxy_bypass_rules"])
341+
}
338342
if _, ok := body["admin_token"]; ok {
339343
t.Error("admin_token should not be exposed in /system/config/env")
340344
}

internal/config/env.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type EnvConfig struct {
4545
ProxyTransportMaxIdleConns int
4646
ProxyTransportMaxIdleConnsPerHost int
4747
ProxyTransportIdleConnTimeout time.Duration
48+
ProxyBypassRules []string
4849

4950
// Request log
5051
RequestLogQueueSize int
@@ -125,6 +126,7 @@ func LoadEnvConfig() (*EnvConfig, error) {
125126
cfg.ProxyTransportMaxIdleConns = envInt("RESIN_PROXY_TRANSPORT_MAX_IDLE_CONNS", 1024, &errs)
126127
cfg.ProxyTransportMaxIdleConnsPerHost = envInt("RESIN_PROXY_TRANSPORT_MAX_IDLE_CONNS_PER_HOST", 64, &errs)
127128
cfg.ProxyTransportIdleConnTimeout = envDuration("RESIN_PROXY_TRANSPORT_IDLE_CONN_TIMEOUT", 90*time.Second, &errs)
129+
cfg.ProxyBypassRules = envDelimitedStringSlice("RESIN_PROXY_BYPASS", []string{})
128130

129131
// --- Request log ---
130132
cfg.RequestLogQueueSize = envInt("RESIN_REQUEST_LOG_QUEUE_SIZE", 8192, &errs)
@@ -395,6 +397,31 @@ func envStringSlice(key string, defaultVal []string, errs *[]string) []string {
395397
return out
396398
}
397399

400+
func envDelimitedStringSlice(key string, defaultVal []string) []string {
401+
v := os.Getenv(key)
402+
if v == "" {
403+
return defaultVal
404+
}
405+
return splitDelimitedStringSlice(v)
406+
}
407+
408+
func splitDelimitedStringSlice(raw string) []string {
409+
fields := strings.FieldsFunc(raw, func(r rune) bool {
410+
return r == ';' || r == ',' || r == '\n' || r == '\r'
411+
})
412+
out := make([]string, 0, len(fields))
413+
for _, field := range fields {
414+
field = strings.TrimSpace(field)
415+
if field != "" {
416+
out = append(out, field)
417+
}
418+
}
419+
if out == nil {
420+
return []string{}
421+
}
422+
return out
423+
}
424+
398425
func validatePort(name string, value int, errs *[]string) {
399426
if value < 1 || value > 65535 {
400427
*errs = append(*errs, fmt.Sprintf("%s: port must be 1-65535, got %d", name, value))

internal/config/env_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ func TestLoadEnvConfig_Defaults(t *testing.T) {
7373
assertEqual(t, "ProxyTransportMaxIdleConns", cfg.ProxyTransportMaxIdleConns, 1024)
7474
assertEqual(t, "ProxyTransportMaxIdleConnsPerHost", cfg.ProxyTransportMaxIdleConnsPerHost, 64)
7575
assertEqual(t, "ProxyTransportIdleConnTimeout", cfg.ProxyTransportIdleConnTimeout, 90*time.Second)
76+
assertEqual(t, "ProxyBypassRulesLength", len(cfg.ProxyBypassRules), 0)
7677

7778
// Request log
7879
assertEqual(t, "RequestLogQueueSize", cfg.RequestLogQueueSize, 8192)
@@ -116,6 +117,7 @@ func TestLoadEnvConfig_EnvOverrides(t *testing.T) {
116117
envs["RESIN_PROXY_TRANSPORT_MAX_IDLE_CONNS"] = "2048"
117118
envs["RESIN_PROXY_TRANSPORT_MAX_IDLE_CONNS_PER_HOST"] = "128"
118119
envs["RESIN_PROXY_TRANSPORT_IDLE_CONN_TIMEOUT"] = "2m"
120+
envs["RESIN_PROXY_BYPASS"] = "localhost;127.*; 192.168.*\n<local>,10.0.0.0/8"
119121
envs["RESIN_REQUEST_LOG_QUEUE_FLUSH_INTERVAL"] = "10m"
120122
setEnvs(t, envs)
121123

@@ -158,6 +160,12 @@ func TestLoadEnvConfig_EnvOverrides(t *testing.T) {
158160
assertEqual(t, "ProxyTransportMaxIdleConns", cfg.ProxyTransportMaxIdleConns, 2048)
159161
assertEqual(t, "ProxyTransportMaxIdleConnsPerHost", cfg.ProxyTransportMaxIdleConnsPerHost, 128)
160162
assertEqual(t, "ProxyTransportIdleConnTimeout", cfg.ProxyTransportIdleConnTimeout, 2*time.Minute)
163+
assertEqual(t, "ProxyBypassRulesLength", len(cfg.ProxyBypassRules), 5)
164+
assertEqual(t, "ProxyBypassRules[0]", cfg.ProxyBypassRules[0], "localhost")
165+
assertEqual(t, "ProxyBypassRules[1]", cfg.ProxyBypassRules[1], "127.*")
166+
assertEqual(t, "ProxyBypassRules[2]", cfg.ProxyBypassRules[2], "192.168.*")
167+
assertEqual(t, "ProxyBypassRules[3]", cfg.ProxyBypassRules[3], "<local>")
168+
assertEqual(t, "ProxyBypassRules[4]", cfg.ProxyBypassRules[4], "10.0.0.0/8")
161169
if cfg.RequestLogQueueFlushInterval.String() != "10m0s" {
162170
t.Errorf("RequestLogQueueFlushInterval: got %v, want 10m", cfg.RequestLogQueueFlushInterval)
163171
}

internal/proxy/bypass.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package proxy
2+
3+
import (
4+
"net"
5+
"net/netip"
6+
"net/url"
7+
"regexp"
8+
"strconv"
9+
"strings"
10+
)
11+
12+
// TargetBypassMatcher decides whether a target should skip Resin node routing
13+
// and be dialed directly from the Resin process.
14+
type TargetBypassMatcher struct {
15+
rules []targetBypassRule
16+
}
17+
18+
type targetBypassRule struct {
19+
local bool
20+
cidr netip.Prefix
21+
exact string
22+
glob *regexp.Regexp
23+
}
24+
25+
func NewTargetBypassMatcher(patterns []string) *TargetBypassMatcher {
26+
m := &TargetBypassMatcher{}
27+
for _, pattern := range patterns {
28+
if rule, ok := compileTargetBypassRule(pattern); ok {
29+
m.rules = append(m.rules, rule)
30+
}
31+
}
32+
if len(m.rules) == 0 {
33+
return nil
34+
}
35+
return m
36+
}
37+
38+
func (m *TargetBypassMatcher) ShouldBypass(target string) bool {
39+
if m == nil || len(m.rules) == 0 {
40+
return false
41+
}
42+
host := targetHostForBypass(target)
43+
if host == "" {
44+
return false
45+
}
46+
host = strings.ToLower(host)
47+
addr, hasAddr := parseAddrForBypass(host)
48+
for _, rule := range m.rules {
49+
switch {
50+
case rule.local:
51+
if isLocalBypassHost(host) {
52+
return true
53+
}
54+
case rule.cidr.IsValid():
55+
if hasAddr && rule.cidr.Contains(addr) {
56+
return true
57+
}
58+
case rule.glob != nil:
59+
if rule.glob.MatchString(host) {
60+
return true
61+
}
62+
case rule.exact != "":
63+
if host == rule.exact {
64+
return true
65+
}
66+
}
67+
}
68+
return false
69+
}
70+
71+
func compileTargetBypassRule(raw string) (targetBypassRule, bool) {
72+
pattern := strings.TrimSpace(raw)
73+
if pattern == "" {
74+
return targetBypassRule{}, false
75+
}
76+
if strings.EqualFold(pattern, "<local>") {
77+
return targetBypassRule{local: true}, true
78+
}
79+
if prefix, err := netip.ParsePrefix(stripIPv6Zone(strings.Trim(pattern, "[]"))); err == nil {
80+
return targetBypassRule{cidr: prefix}, true
81+
}
82+
83+
pattern = strings.ToLower(targetHostForBypass(pattern))
84+
if pattern == "" {
85+
return targetBypassRule{}, false
86+
}
87+
if strings.ContainsAny(pattern, "*?") {
88+
return targetBypassRule{glob: compileBypassGlob(pattern)}, true
89+
}
90+
return targetBypassRule{exact: pattern}, true
91+
}
92+
93+
func compileBypassGlob(pattern string) *regexp.Regexp {
94+
quoted := regexp.QuoteMeta(pattern)
95+
quoted = strings.ReplaceAll(quoted, `\*`, ".*")
96+
quoted = strings.ReplaceAll(quoted, `\?`, ".")
97+
return regexp.MustCompile("^" + quoted + "$")
98+
}
99+
100+
func targetHostForBypass(target string) string {
101+
target = strings.TrimSpace(target)
102+
if target == "" {
103+
return ""
104+
}
105+
if strings.Contains(target, "://") || strings.HasPrefix(target, "//") {
106+
if u, err := url.Parse(target); err == nil && u.Host != "" {
107+
target = u.Host
108+
}
109+
}
110+
if host, _, err := net.SplitHostPort(target); err == nil {
111+
return strings.Trim(host, "[]")
112+
}
113+
if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
114+
return strings.Trim(target, "[]")
115+
}
116+
if i := strings.LastIndexByte(target, ':'); i > 0 && strings.Count(target, ":") == 1 {
117+
if _, err := strconv.Atoi(target[i+1:]); err == nil {
118+
return strings.Trim(target[:i], "[]")
119+
}
120+
}
121+
return strings.Trim(target, "[]")
122+
}
123+
124+
func parseAddrForBypass(host string) (netip.Addr, bool) {
125+
addr, err := netip.ParseAddr(stripIPv6Zone(host))
126+
return addr, err == nil
127+
}
128+
129+
func stripIPv6Zone(host string) string {
130+
if i := strings.LastIndexByte(host, '%'); i >= 0 {
131+
return host[:i]
132+
}
133+
return host
134+
}
135+
136+
func isLocalBypassHost(host string) bool {
137+
if host == "localhost" {
138+
return true
139+
}
140+
if _, ok := parseAddrForBypass(host); ok {
141+
return false
142+
}
143+
return !strings.Contains(host, ".")
144+
}

internal/proxy/bypass_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package proxy
2+
3+
import "testing"
4+
5+
func TestTargetBypassMatcher_MatchesCommonNoProxyRules(t *testing.T) {
6+
matcher := NewTargetBypassMatcher([]string{
7+
"localhost",
8+
"127.*",
9+
"192.168.*",
10+
"10.0.0.0/8",
11+
"172.16.0.0/12",
12+
"::1",
13+
"<local>",
14+
"*.corp.local",
15+
})
16+
17+
tests := []struct {
18+
target string
19+
want bool
20+
}{
21+
{"localhost:3000", true},
22+
{"127.0.0.1:8080", true},
23+
{"192.168.1.1", true},
24+
{"10.23.45.67:443", true},
25+
{"172.16.0.1:80", true},
26+
{"172.31.255.254", true},
27+
{"172.32.0.1", false},
28+
{"[::1]:8443", true},
29+
{"printer:9100", true},
30+
{"api.corp.local:443", true},
31+
{"api.example.com:443", false},
32+
}
33+
34+
for _, tc := range tests {
35+
t.Run(tc.target, func(t *testing.T) {
36+
if got := matcher.ShouldBypass(tc.target); got != tc.want {
37+
t.Fatalf("ShouldBypass(%q) = %v, want %v", tc.target, got, tc.want)
38+
}
39+
})
40+
}
41+
}
42+
43+
func TestTargetBypassMatcher_EmptyRulesDoesNotMatch(t *testing.T) {
44+
if matcher := NewTargetBypassMatcher(nil); matcher != nil {
45+
t.Fatal("empty rules should return nil matcher")
46+
}
47+
}

0 commit comments

Comments
 (0)