mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0593934066 | ||
|
|
93ffaec848 | ||
|
|
116d16b98b | ||
|
|
7b2a203cf3 | ||
|
|
8db5288365 |
+1
-1
@@ -138,7 +138,7 @@ For non-transport fallback traffic, it can route by TLS SNI to per-domain upstre
|
||||
- HTTP/HTTPS access probes are cached with an hour-long write TTL through `github.com/maypok86/otter/v2`, keeping repeated smart-routing checks bounded without hiding later reachability changes indefinitely.
|
||||
- Country routing treats `router.country.mmdb` as optional; an empty value disables GeoIP lookup and keeps CIDR-based matching active without startup warnings. A non-empty invalid MMDB path is a startup error.
|
||||
- DNS routing, transparent HTTP/HTTPS forwarding, and smart TCP routing are separate policies. DNS uses conservative explicit proxy rules only; transparent HTTP/HTTPS is proxy-only; SOCKS5 and explicit HTTP proxy traffic use smart routing.
|
||||
- A proxy dial must never re-enter this client's own listeners: both dial entries (`router.DialSmart` for rule/probe-routed traffic and `router.DialProxyOnly` for the tunnel) refuse a target that resolves to one of the addresses the client serves proxy traffic on — the HTTP and HTTPS proxy listeners on every DNS listen address, plus SOCKS5 (`router.ErrSelfProxyTarget`; HTTP answers `403`, SOCKS5 `0x02` not-allowed). Both are guarded because `DialSmart` can reach `directDial` without passing `DialProxyOnly`, and either branch re-enters the proxy. Without the guard one request to a name whose A record is the client's own serve address becomes a self-sustaining chain between the client and its relay (2026-10-12: 601 connections on r6c, and the relay host was OOM-killed), or a local loop that never leaves the host when the target is classified direct. The target is resolved locally for the check because the relay resolves it remotely, and the answer is cached for a minute so a proxied dial does not pay a DNS round trip per connection; a resolution failure is not treated as evidence of self-targeting.
|
||||
- A proxy dial must never re-enter this client's own listeners: both dial entries (`router.DialSmart` for rule/probe-routed traffic and `router.DialProxyOnly` for the tunnel) refuse a target that resolves to one of the addresses the client serves proxy traffic on — the HTTP and HTTPS proxy listeners on every DNS listen address, plus SOCKS5 (`router.ErrSelfProxyTarget`; HTTP answers `403`, SOCKS5 `0x02` not-allowed). Both are guarded because `DialSmart` can reach `directDial` without passing `DialProxyOnly`, and either branch re-enters the proxy. Without the guard one request to a name whose A record is the client's own serve address becomes a self-sustaining chain between the client and its relay (2026-10-12: 601 connections on r6c, and the relay host was OOM-killed), or a local loop that never leaves the host when the target is classified direct. The target is resolved through the client's configured upstream resolvers — never the system resolver, because a deployed client answers its own DNS for every domain it proxies, which would make every proxied dial look like a self target — and the answer is cached for a minute so a proxied dial does not pay a DNS round trip per connection; a resolution failure is not treated as evidence of self-targeting.
|
||||
- Fake site directory mode is loopback-only on port `80` to avoid exposing local static assets directly to the public internet.
|
||||
- `sowerd` prefers the user cache directory for ACME state, but falls back to `/var/cache/sower` so systemd services can start without `HOME`/`XDG_CACHE_HOME` or a config file.
|
||||
- `sowerd` fallback site routing is based only on exact TLS SNI matches; wildcard domains are not supported.
|
||||
|
||||
+7
-1
@@ -217,7 +217,13 @@ func handleHTTPConn(conn net.Conn, r *router.Router, stats *admin.Stats) {
|
||||
rc, err := r.DialProxyOnly("tcp", host, port)
|
||||
if err != nil {
|
||||
slog.Error("dial proxy", "error", err, "host", req.Host, "req", req.URL)
|
||||
stats.RecordProxyError("dial", fmt.Sprintf("%s: %v", hostOnly(req.Host), err))
|
||||
if !stderrors.Is(err, router.ErrBlocked) {
|
||||
stats.RecordProxyError("dial", fmt.Sprintf("%s: %v", hostOnly(req.Host), err))
|
||||
}
|
||||
// Answer the refusal: a client that only sees the connection close cannot
|
||||
// tell a policy refusal (blocked target, this proxy's own listener) from
|
||||
// a broken proxy.
|
||||
writeHTTPProxyError(rereadConn.Stop(), err)
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
@@ -499,3 +499,45 @@ func TestHandleHTTPConnConnectTunnelsToTargetPort(t *testing.T) {
|
||||
_ = upstreamServer.Close()
|
||||
waitForHandler(t, &wg)
|
||||
}
|
||||
|
||||
// A refused proxy request must be answered rather than silently closed: only a
|
||||
// status tells the client that the refusal is policy (this proxy's own
|
||||
// listener, or a blocked target) and not a broken proxy.
|
||||
func TestHandleHTTPConnAnswersRefusedSelfTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
downstreamServer, downstreamClient := net.Pipe()
|
||||
defer downstreamClient.Close()
|
||||
t.Cleanup(func() { _ = downstreamServer.Close() })
|
||||
|
||||
r, err := router.NewRouter(nil, "", "", "", func(_, _ string, _ uint16) (net.Conn, error) {
|
||||
t.Error("proxy dial reached for a self target")
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new router: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
r.SetSelfProxyAddrs("100.64.1.1:80")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
handleHTTPConn(downstreamServer, r, newTestStats(t))
|
||||
}()
|
||||
|
||||
if _, err := downstreamClient.Write([]byte("GET / HTTP/1.1\r\nHost: 100.64.1.1\r\n\r\n")); err != nil {
|
||||
t.Fatalf("write downstream request: %v", err)
|
||||
}
|
||||
_ = downstreamClient.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
response, err := io.ReadAll(downstreamClient)
|
||||
if err != nil {
|
||||
t.Fatalf("read refusal response: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(response), "HTTP/1.1 403 Forbidden") {
|
||||
t.Fatalf("refusal response = %q, want a 403 status", response)
|
||||
}
|
||||
|
||||
waitForHandler(t, &wg)
|
||||
}
|
||||
|
||||
+25
-30
@@ -230,45 +230,40 @@ func TestServeSharedHTTPRoutesAdminAndProxy(t *testing.T) {
|
||||
}
|
||||
loginResp.Body.Close()
|
||||
|
||||
// proxy: origin-form with another Host -> connection closed (blocked)
|
||||
conn0, err := net.Dial("tcp", ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn0.Close()
|
||||
_ = conn0.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
if _, err := io.WriteString(conn0, "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"); err != nil {
|
||||
t.Fatalf("write origin form: %v", err)
|
||||
}
|
||||
if _, err := conn0.Read(make([]byte, 1)); err == nil {
|
||||
t.Fatal("expected proxy path to close the connection")
|
||||
}
|
||||
// proxy: origin-form with another Host -> the proxy path refuses it (the
|
||||
// router blocks example.com), and the admin console does not answer it.
|
||||
assertProxyRefusal(t, ln.Addr().String(), "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
|
||||
|
||||
// proxy: CONNECT -> connection closed
|
||||
conn, err := net.Dial("tcp", ln.Addr().String())
|
||||
// proxy: CONNECT -> proxy refusal
|
||||
assertProxyRefusal(t, ln.Addr().String(), "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n")
|
||||
|
||||
// proxy: absolute-form -> proxy refusal
|
||||
assertProxyRefusal(t, ln.Addr().String(), "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n")
|
||||
}
|
||||
|
||||
// assertProxyRefusal drives one proxy-form request through the shared listener
|
||||
// and requires the proxy path to answer it: the router blocks example.com, so
|
||||
// the client sees a 403 instead of the admin console's response, and a refusal
|
||||
// is a status rather than a silent close so a client can tell policy from a
|
||||
// broken proxy.
|
||||
func assertProxyRefusal(t *testing.T, addr, request string) {
|
||||
t.Helper()
|
||||
|
||||
conn, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
if _, err := io.WriteString(conn, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"); err != nil {
|
||||
t.Fatalf("write connect: %v", err)
|
||||
}
|
||||
if _, err := conn.Read(make([]byte, 1)); err == nil {
|
||||
t.Fatal("expected proxy path to close the connection")
|
||||
if _, err := io.WriteString(conn, request); err != nil {
|
||||
t.Fatalf("write request: %v", err)
|
||||
}
|
||||
|
||||
// proxy: absolute-form -> connection closed
|
||||
conn2, err := net.Dial("tcp", ln.Addr().String())
|
||||
response, err := io.ReadAll(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
t.Fatalf("read proxy refusal: %v", err)
|
||||
}
|
||||
defer conn2.Close()
|
||||
_ = conn2.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
if _, err := io.WriteString(conn2, "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n"); err != nil {
|
||||
t.Fatalf("write absolute: %v", err)
|
||||
}
|
||||
if _, err := conn2.Read(make([]byte, 1)); err == nil {
|
||||
t.Fatal("expected proxy path to close the connection")
|
||||
if !strings.HasPrefix(string(response), "HTTP/1.1 403 Forbidden") {
|
||||
t.Fatalf("proxy refusal = %q, want a 403 status", response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Plan: 拒绝自指代理目标(2026-10-12 r6c↔kr 递归)
|
||||
|
||||
状态:实施中(2026-10-12)。本文为非权威记录;长期规则回写 [ARCHITECTURE.md](../../ARCHITECTURE.md) 的 Design Decisions。
|
||||
状态:已定稿并回写(2026-10-12,v1.7.6)。本文为非权威历史记录;权威规则见 [ARCHITECTURE.md](../../ARCHITECTURE.md) 的 Design Decisions。
|
||||
|
||||
## 问题
|
||||
|
||||
@@ -31,8 +31,11 @@ finding `f-4d7d5bd2-cee`):
|
||||
DNS 监听器不参与:它们不转发,拨过去不可能成链。
|
||||
- `DialSmart` 入口与 `DialProxyOnly` 入口各检查一次 `selfTarget(domain, port)`:只有目标
|
||||
端口命中本机转发端口时才解析(否则直接跳过,零开销);目标是 IP 字面量时直接比较;否则
|
||||
本地解析(远端解析在 relay 侧发生,所以这里只需要知道"那个地址是不是我"),结果按
|
||||
`selfProxyLookupTTL`(1 分钟)缓存,避免每条被代理连接都付一次 DNS 往返。
|
||||
**向配置的上游解析器求证**(`lookupUpstreamIPs`)——绝不能用系统解析器:部署形态的客户端
|
||||
对本机 DNS 被配置成对每一个“要代理的域名”都回答自己的 serve IP(这正是把客户机引到它那里
|
||||
的机制),所以系统解析器的答案对所有被代理域名都是“指向我”,无法区分回环与正常流量;而
|
||||
上游的答案正是 relay 会据以拨号的答案。结果按 `selfProxyLookupTTL`(1 分钟)缓存(含“无
|
||||
答案”的负缓存,避免解析失败的名字每条连接都重付一次超时)。
|
||||
- 两个出口都必须守:`DialSmart` 的 direct 分支(DirectRule 命中,或 `isAccess` 探测把该名字
|
||||
判为可达)会直接 `directDial` 回到本机监听器,那时递归完全不出主机 —— 同样是自持的,只是
|
||||
不经过隧道。
|
||||
@@ -52,12 +55,24 @@ finding `f-4d7d5bd2-cee`):
|
||||
|
||||
## 验证
|
||||
|
||||
- `go vet ./...`、`go test ./...` 全绿;新增用例覆盖:IP 字面量自指、名字解析到自指、
|
||||
非自指(含未服务端口不解析)、未指定地址绑定(`0.0.0.0:443` 匹配任意本地地址)、
|
||||
解析失败放行、决策缓存(同名只解析一次);`cmd/sower` 侧钉住 `selfProxyAddrs` 的集合。
|
||||
- 现场:r6c 升级后,从 tailnet 发 `curl -x http://100.64.1.1:80 http://sower.wweir.fun/`
|
||||
应得 403、`--socks5-hostname` 应被规则拒绝,且两侧连接数不增长;正常代理(google 204、
|
||||
baidu 200)与 admin 控制台不受影响。
|
||||
- 单元测试:`go vet ./...`、`go test ./...` 全绿。守卫用例覆盖 IP 字面量自指、上游解析到自指、
|
||||
上游解析到远端(正常被代理域名必须放行)、未服务端口零解析、未指定地址绑定(`0.0.0.0:443`
|
||||
匹配任意本地地址)、解析失败/空答案放行、决策缓存;`cmd/sower` 侧钉住 `selfProxyAddrs`
|
||||
的集合,以及“拒绝回 403 而不是静默关闭”。
|
||||
- 现场(r6c,v1.7.7 release 资产 `sower-linux-arm64.tar.gz`,sha256 `8cf0a869…`、
|
||||
`vcs.modified=false`):自指 `curl -x http://100.64.1.1:80 http://sower.wweir.fun/`
|
||||
→ `403 Forbidden`;`--socks5-hostname` 自指 → SOCKS5 码 `2`;正常路径 `--socks5-hostname`
|
||||
到 google `204`、`-x http://100.64.1.1:80` 到 baidu `200`、`/api/config` `401`;kr 上 sowerd
|
||||
到 `100.64.1.1` 的连接数为 `0`(修复前峰值 601)。
|
||||
|
||||
### 一次必须记住的回归(v1.7.4 → v1.7.5)
|
||||
|
||||
首版守卫(`v1.7.4`,commit `2de08e6`)用系统解析器判定,现场立刻被推翻:`--socks5-hostname`
|
||||
到 google 返回 SOCKS5 码 `2`,而 `nslookup www.google.com 127.0.0.1` 在 r6c 上返回
|
||||
`100.64.1.1` —— 本机 DNS 按设计对每个被代理域名都回答自己的 serve IP。于是“解析结果是我的
|
||||
地址”对**所有正常被代理域名**都成立,守卫把整条正常代理路径拒了:**这是判定依据错,不是
|
||||
实现细节错**。处理是把 r6c 回滚到上一版可用二进制,改成向上游解析器求证后以 `v1.7.5` 重发
|
||||
(部署期间的权限位与 LAN DNS 连锁坑见 skill `sower-deploy` 的 Pitfalls)。
|
||||
|
||||
## 未决(不在本次范围)
|
||||
|
||||
|
||||
@@ -10,18 +10,13 @@
|
||||
TLS 之下,为每次写入武装 `writeStallTimeout`;`dumpHeapOnSignal`(`heapdump_unix.go`,
|
||||
其他平台 no-op)在 SIGUSR1 时写 heap profile;`GOMEMLIMIT=512MiB` 进入安装器 unit、
|
||||
打包 unit 与 kr drop-in。
|
||||
- 发布:`v1.7.2` 的 Release run 在 windows-amd64/arm64 上失败(`syscall.SIGUSR1` 在
|
||||
Windows 未定义),Attach release assets 被跳过、没有发布任何资产;`06c357c` 把信号
|
||||
处理挪到 `//go:build unix` 文件后以 `v1.7.3` 重新发布(12 个平台构建 + Attach assets 全绿)。
|
||||
- kr 运行的是 **v1.7.3 release 资产**:tarball `sower-linux-amd64.tar.gz` 经
|
||||
`SHA256SUMS` 校验,`sowerd` sha256 `74b34fd9119d45d0d8de2013656a292ed2ffa08ec7e6a2dff6cac9262fd6d428`,
|
||||
`go version -m` 显示 `mod v1.7.3`、`vcs.revision=06c357c`、`vcs.modified=false`;
|
||||
备份 `/usr/local/bin/sowerd.bak-20261012191818`(上一版为本地构建 v1.7.1-4-g28c4f9e,
|
||||
备份 `sowerd.bak-20261012175623`)。
|
||||
- 验证:`:80` 仍 308 跳转;`:443` 回退反代 200;SIGUSR1 产出
|
||||
`/var/cache/sower/sowerd-heap-*.pprof`;用 `transport/sower` 从本机经 kr 中继
|
||||
`www.baidu.com:80` 得到 HTTP/1.0 200(证明新增的写超时不误伤中继);r6c 客户端
|
||||
socks5 到 google 仍 204,其日志自 17:31 起无握手失败;`GOMEMLIMIT=512MiB` 在进程中可见。
|
||||
- 已发布 `v1.7.3`(`v1.7.2` 因 Windows 上 `syscall.SIGUSR1` 未定义而未发布);kr 运行的是
|
||||
该 release 资产的 `sowerd`。
|
||||
- OOM 的**驱动**随后被定位为自指代理递归并在客户端修复(`v1.7.5`/`v1.7.6`,见
|
||||
[2026-10-12-self-proxy-target-guard.md](2026-10-12-self-proxy-target-guard.md));本文件的
|
||||
连接上界是下限保护,不是根因修复。
|
||||
- 验证:`:80` 308 跳转;`:443` 回退反代 200;SIGUSR1 产出 heap profile;`transport/sower`
|
||||
经 kr 中继 `www.baidu.com:80` 得到 HTTP/1.0 200(新增写超时不误伤中继)。
|
||||
|
||||
## 事件
|
||||
|
||||
@@ -78,23 +73,18 @@ kr 的 zram swap 因 `vm.swappiness=0` 从未被使用。
|
||||
(`systemctl kill -s USR1 sowerd`,`go tool pprof -http :0 <file>`),不新增网络面。
|
||||
4. 部署侧:sowerd 单元加 `Environment=GOMEMLIMIT=512MiB`,让失控堆退化服务而不是
|
||||
杀死宿主机。
|
||||
5. 发布工具链:v1.7.1 是唯一用 go1.26.8 构建的制品(现场其余二进制为
|
||||
go1.27.1/1.27.2),而 go1.26 的 Green Tea GC 有公开的内存回归报告
|
||||
(golang/go#77567、#77361)。CI 的 `go-version: ^1.26` 收敛到 `^1.27`。
|
||||
5. 发布工具链:CI 的 `go-version: ^1.26` 收敛到 `^1.27`,与本地/现场构建一致。当时曾怀疑
|
||||
go1.26.8 的 GC 回归(golang/go#77567、#77361)是原因,后证明是自指递归;保留这个改动只是
|
||||
让制品构建环境不再漂移。
|
||||
|
||||
## 未决
|
||||
|
||||
- **驱动已由另一会话定位(2026-10-12 18:44–18:50,board 消息 `m-77507977-64f`)**:任何
|
||||
指名 `sower.wweir.fun` 的代理请求会形成自持链——该名字的公网 A 记录指向 r6c 的
|
||||
tailscale 地址 `100.64.1.1`(r6c 上 sower 监听 80/443/53/1080),`cmd/sower` 无条件下
|
||||
发给 kr,kr 解析同名后回dial 进 r6c:80,再转发,逐跳生成下一跳。实测 r6c 上
|
||||
`100.64.1.1:80` 连接数 155→254→305→495→601(对端全是 kr),且触发它的 curl 退出后
|
||||
仍在增长;kr 上 sowerd 同期 311 个 dial、649 个 fd。这与本次 OOM 的窗口、双向近似
|
||||
对称流量、静默增长、以及“sowerd 一死链就断”全部吻合;18:50 重启 r6c 的 sower 后两侧
|
||||
都回到 0 连接。sowerd 侧的 cap 与写超时只把它**限住**(链在 2048 处停止增长、内存有界),
|
||||
真正的修法是客户端拒绝把请求 dial 回自己的监听地址(另一会话已开高优先级 finding)。
|
||||
触发链是否就是 17:30 那次膨胀的唯一成因仍无直接证据(当时进程已死、无 pprof、sar 缺样),
|
||||
因此上界与 SIGUSR1 dump 的价值是把下一次复现变成可定位事件,而不是宣称已修复根因。
|
||||
- **驱动已定位并修复(见姊妹文件)**:任何指名 `sower.wweir.fun`(公网 A 记录 = r6c 的
|
||||
tailscale 地址 `100.64.1.1`)的代理请求会形成自持链:实测 r6c 上 `100.64.1.1:80` 连接数
|
||||
155→601、kr 上 sowerd 311 个 dial / 649 个 fd,且触发请求退出后仍增长;客户端守卫已在
|
||||
`v1.7.5`/`v1.7.6` 拒绝这类自指拨号。本文件的连接上界因此是下限保护(链在 2048 处停止
|
||||
增长、内存有界);该链是否就是 17:30 那次膨胀的唯一成因仍无直接证据(进程已死、无 pprof、
|
||||
sar 缺样),SIGUSR1 dump 的价值是把下一次复现变成可定位事件。
|
||||
- `relay.Relay` 的写超时缺陷属于 `sower-proxy/conns` 仓库,应在那里修(finding
|
||||
`f-1ae16668-b92`)。sowerd 侧只做上界,不给隧道加全局空闲超时,避免误杀合法但长时间
|
||||
空闲的隧道。
|
||||
|
||||
+4
-5
@@ -231,11 +231,10 @@ bounded cost and a bounded lifetime. These bounds are normative:
|
||||
read with `go tool pprof`). It is the only way to attribute a grown heap to
|
||||
an allocation site, and unlike an HTTP endpoint it adds no network surface.
|
||||
|
||||
The service unit also carries a soft heap ceiling (`GOMEMLIMIT`), so an
|
||||
unbounded heap degrades this service instead of OOM-killing the host: the
|
||||
2026-10-12 OOM on `kr` (970 MiB anon-rss on a 1.6 GiB host) killed the machine,
|
||||
not the service. Historical analysis and the still-unproven trigger live in `docs/plans/`
|
||||
(2026-10-12, sowerd connection bounds; non-authoritative).
|
||||
The service unit also carries a soft heap ceiling (`GOMEMLIMIT`): a heap that
|
||||
grows without bound must degrade this service, not take the host down with it
|
||||
(the 2026-10-12 OOM on `kr` reached 970 MiB anon-rss on a 1.6 GiB host).
|
||||
Motivation and the historical analysis live in `docs/plans/` (non-authoritative).
|
||||
|
||||
## Rejected Directions
|
||||
|
||||
|
||||
+19
-19
@@ -37,7 +37,7 @@ type (
|
||||
// instead of re-entering this proxy (see selfproxy.go).
|
||||
selfProxy atomic.Pointer[selfProxyGuard]
|
||||
selfProxyCache *otter.Cache[string, []net.IP]
|
||||
selfProxyLookup func(ctx context.Context, domain string) ([]net.IP, error)
|
||||
selfProxyLookup func(domain string) ([]net.IP, error)
|
||||
|
||||
dns struct {
|
||||
upstreamDNS string
|
||||
@@ -142,9 +142,10 @@ func NewRouter(serveIPs []string, upstreamDNS, fallbackDNS, mmdbFile string, pro
|
||||
ExpiryCalculator: otter.ExpiryWriting[string, []net.IP](selfProxyLookupTTL),
|
||||
}),
|
||||
}
|
||||
r.selfProxyLookup = func(ctx context.Context, domain string) ([]net.IP, error) {
|
||||
return net.DefaultResolver.LookupIP(ctx, "ip", domain)
|
||||
}
|
||||
// The guard resolves through the configured upstream resolvers, not the
|
||||
// system resolver: a deployed client answers its own DNS for every proxied
|
||||
// domain by design, so a system-resolver answer says nothing (selfproxy.go).
|
||||
r.selfProxyLookup = r.lookupUpstreamIPs
|
||||
|
||||
r.dns.upstreamDNS = upstreamDNS
|
||||
r.dns.fallbackDNS = fallbackDNS
|
||||
@@ -221,15 +222,6 @@ func (r *Router) Dial(network, domain string, port uint16) (net.Conn, error) {
|
||||
|
||||
func (r *Router) DialSmart(network, domain string, port uint16) (net.Conn, error) {
|
||||
ctx := context.Background()
|
||||
addr := net.JoinHostPort(domain, strconv.FormatUint(uint64(port), 10))
|
||||
|
||||
// Guarded here as well as in DialProxyOnly: this path reaches directDial
|
||||
// without passing it (rule-based and probe-based direct decisions), and
|
||||
// dialing a listener this client itself forwards on re-enters the proxy
|
||||
// either way — locally, in that case, since no tunnel is involved.
|
||||
if ip, self := r.selfTarget(domain, port); self {
|
||||
return nil, selfProxyError(domain, port, ip)
|
||||
}
|
||||
|
||||
// 1. rule_based( block > direct > proxy )
|
||||
// 2. detect_based( CN IP || access site )
|
||||
@@ -242,14 +234,14 @@ func (r *Router) DialSmart(network, domain string, port uint16) (net.Conn, error
|
||||
case r.DirectRule.Match(domain):
|
||||
r.observe(RouteDirect, domain)
|
||||
r.observeRuleHit(RouteDirect, domain)
|
||||
return r.directDial(ctx, network, addr)
|
||||
return r.directDial(ctx, network, domain, port)
|
||||
case r.ProxyRule.Match(domain):
|
||||
r.observeRuleHit(RouteProxy, domain)
|
||||
return r.DialProxyOnly(network, domain, port)
|
||||
case r.localSite(ctx, domain), r.isAccess(domain, port):
|
||||
r.observe(RouteDirect, domain)
|
||||
r.observeRuleMiss(domain)
|
||||
return r.directDial(ctx, network, addr)
|
||||
return r.directDial(ctx, network, domain, port)
|
||||
default:
|
||||
r.observeRuleMiss(domain)
|
||||
return r.DialProxyOnly(network, domain, port)
|
||||
@@ -260,9 +252,8 @@ func (r *Router) DialProxyOnly(network, domain string, port uint16) (net.Conn, e
|
||||
if r.ProxyDial == nil {
|
||||
return nil, fmt.Errorf("proxy dialer unavailable")
|
||||
}
|
||||
// Guards every path that reaches the tunnel, explicit proxy requests and
|
||||
// auto-routed traffic alike: a target that resolves to a listener this
|
||||
// client forwards on would be forwarded again, forever.
|
||||
// A target that resolves to a listener this client forwards on would be
|
||||
// forwarded again, forever: the tunnel is one of the two dial exits.
|
||||
if ip, self := r.selfTarget(domain, port); self {
|
||||
return nil, selfProxyError(domain, port, ip)
|
||||
}
|
||||
@@ -275,7 +266,16 @@ func (r *Router) DialProxyOnly(network, domain string, port uint16) (net.Conn, e
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func (r *Router) directDial(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
// directDial is the other way into this client's own listeners: a rule or an
|
||||
// access probe can classify a name that points at one of them as direct, and
|
||||
// that connection re-enters the proxy just the same — locally, since no tunnel
|
||||
// is involved. Both dial exits are therefore guarded, not just the tunnel.
|
||||
func (r *Router) directDial(ctx context.Context, network, domain string, port uint16) (net.Conn, error) {
|
||||
if ip, self := r.selfTarget(domain, port); self {
|
||||
return nil, selfProxyError(domain, port, ip)
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(domain, strconv.FormatUint(uint64(port), 10))
|
||||
start := time.Now()
|
||||
dialer := net.Dialer{Timeout: 5 * time.Second}
|
||||
conn, err := dialer.DialContext(ctx, network, addr)
|
||||
|
||||
+49
-16
@@ -1,13 +1,14 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -15,9 +16,7 @@ const (
|
||||
// self-target check. A proxied dial must not pay a DNS round trip per
|
||||
// connection, and a name that points at this client keeps pointing at it
|
||||
// for far longer than this window.
|
||||
selfProxyLookupTTL = time.Minute
|
||||
// selfProxyLookupTimeout matches localSite's resolution budget.
|
||||
selfProxyLookupTimeout = 2 * time.Second
|
||||
selfProxyLookupTTL = time.Minute
|
||||
selfProxyCacheMaxNames = 1024
|
||||
)
|
||||
|
||||
@@ -108,12 +107,13 @@ func (r *Router) selfTarget(domain string, port uint16) (net.IP, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// selfProxyIPs resolves domain for the self-target check. The relay resolves
|
||||
// the target remotely, so this resolution exists only to answer "is that
|
||||
// address mine?"; its answer is cached, so only a name that is new within
|
||||
// selfProxyLookupTTL costs a lookup. A resolution failure is not evidence of
|
||||
// self-targeting: it returns no addresses and the dial proceeds, leaving the
|
||||
// relay to report its own resolution failure.
|
||||
// selfProxyIPs resolves domain the way the relay will see it: through the
|
||||
// configured upstream resolvers, never through the system resolver. A deployed
|
||||
// client answers its own DNS for every domain it proxies — that is how its
|
||||
// clients are steered into it — so a system-resolver answer is no evidence of
|
||||
// self-targeting, while the upstream answer is what the relay acts on. Only a
|
||||
// name whose own record points at this client (e.g. one published for its
|
||||
// serve address) resolves to a listener here.
|
||||
func (r *Router) selfProxyIPs(domain string) []net.IP {
|
||||
if ip := net.ParseIP(strings.Trim(domain, "[]")); ip != nil {
|
||||
return []net.IP{ip}
|
||||
@@ -122,17 +122,50 @@ func (r *Router) selfProxyIPs(domain string) []net.IP {
|
||||
return ips
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), selfProxyLookupTimeout)
|
||||
defer cancel()
|
||||
|
||||
ips, err := r.selfProxyLookup(ctx, domain)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return nil
|
||||
// A resolution failure is not evidence of self-targeting: it yields no
|
||||
// addresses and the dial proceeds, leaving the relay to report its own
|
||||
// failure. The empty answer is cached too, so a failing lookup is not paid
|
||||
// once per connection.
|
||||
ips, err := r.selfProxyLookup(domain)
|
||||
if err != nil {
|
||||
ips = nil
|
||||
}
|
||||
r.selfProxyCache.Set(domain, ips)
|
||||
return ips
|
||||
}
|
||||
|
||||
// lookupUpstreamIPs asks the configured upstream resolvers for domain's
|
||||
// addresses. The upstream list already excludes this client's own serve
|
||||
// addresses (see buildUpstreamAddrs), so this cannot be answered by this
|
||||
// client's own intercepting DNS.
|
||||
func (r *Router) lookupUpstreamIPs(domain string) ([]net.IP, error) {
|
||||
var (
|
||||
ips []net.IP
|
||||
lastErr error
|
||||
)
|
||||
for _, qtype := range []uint16{dns.TypeA, dns.TypeAAAA} {
|
||||
req := new(dns.Msg)
|
||||
req.SetQuestion(dns.Fqdn(domain), qtype)
|
||||
resp, err := r.Exchange(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
for _, answer := range resp.Answer {
|
||||
switch rr := answer.(type) {
|
||||
case *dns.A:
|
||||
ips = append(ips, rr.A)
|
||||
case *dns.AAAA:
|
||||
ips = append(ips, rr.AAAA)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ips) == 0 && lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// selfProxyError renders a refused proxy dial.
|
||||
func selfProxyError(domain string, port uint16, ip net.IP) error {
|
||||
return fmt.Errorf("%w: %s resolves to %s",
|
||||
|
||||
+100
-14
@@ -1,16 +1,20 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// newSelfTargetRouter builds a router whose proxy dialer hands back an
|
||||
// in-memory pair and whose self-target lookup is stubbed, so the guard runs
|
||||
// without DNS or a network.
|
||||
func newSelfTargetRouter(t *testing.T, selfAddrs []string, resolve func(context.Context, string) ([]net.IP, error)) *Router {
|
||||
// without DNS or a network. A nil resolve keeps the production lookup, which
|
||||
// goes through the router's upstream resolvers.
|
||||
func newSelfTargetRouter(t *testing.T, selfAddrs []string, resolve func(string) ([]net.IP, error)) *Router {
|
||||
t.Helper()
|
||||
|
||||
r, err := NewRouter(nil, "", "", "", func(_, _ string, _ uint16) (net.Conn, error) {
|
||||
@@ -46,7 +50,7 @@ func TestDialProxyOnlyRefusesNameResolvingToSelf(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lookups := 0
|
||||
r := newSelfTargetRouter(t, []string{"100.64.1.1:80"}, func(context.Context, string) ([]net.IP, error) {
|
||||
r := newSelfTargetRouter(t, []string{"100.64.1.1:80"}, func(string) ([]net.IP, error) {
|
||||
lookups++
|
||||
return []net.IP{net.ParseIP("100.64.1.1")}, nil
|
||||
})
|
||||
@@ -67,7 +71,7 @@ func TestDialProxyOnlyAllowsNonSelfTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lookups := 0
|
||||
r := newSelfTargetRouter(t, []string{"100.64.1.1:80", "100.64.1.1:443"}, func(context.Context, string) ([]net.IP, error) {
|
||||
r := newSelfTargetRouter(t, []string{"100.64.1.1:80", "100.64.1.1:443"}, func(string) ([]net.IP, error) {
|
||||
lookups++
|
||||
return []net.IP{net.ParseIP("93.184.216.34")}, nil
|
||||
})
|
||||
@@ -96,16 +100,21 @@ func TestDialProxyOnlyRefusesUnspecifiedBind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A resolution failure says nothing about self-targeting: the dial proceeds so
|
||||
// the relay reports its own resolution result.
|
||||
func TestDialProxyOnlyAllowsTargetThatFailsToResolve(t *testing.T) {
|
||||
// Neither a resolution failure nor an empty answer is evidence of
|
||||
// self-targeting: the dial proceeds so the relay reports its own result, and
|
||||
// the empty decision is cached so a failing lookup is not paid per connection.
|
||||
func TestDialProxyOnlyAllowsTargetWithoutAnAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newSelfTargetRouter(t, []string{"100.64.1.1:80"}, func(context.Context, string) ([]net.IP, error) {
|
||||
return nil, errors.New("no such host")
|
||||
})
|
||||
if _, err := r.DialProxyOnly("tcp", "missing.example.com", 80); err != nil {
|
||||
t.Fatalf("dial after a resolution failure: %v", err)
|
||||
resolvers := map[string]func(string) ([]net.IP, error){
|
||||
"resolution failure": func(string) ([]net.IP, error) { return nil, errors.New("no such host") },
|
||||
"empty answer": func(string) ([]net.IP, error) { return nil, nil },
|
||||
}
|
||||
for name, resolve := range resolvers {
|
||||
r := newSelfTargetRouter(t, []string{"100.64.1.1:80"}, resolve)
|
||||
if _, err := r.DialProxyOnly("tcp", "missing.example.com", 80); err != nil {
|
||||
t.Errorf("%s: dial = %v, want it to proceed", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +125,7 @@ func TestDialProxyOnlyAllowsTargetThatFailsToResolve(t *testing.T) {
|
||||
func TestDialSmartRefusesDirectSelfTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newSelfTargetRouter(t, []string{"100.64.1.1:80"}, func(context.Context, string) ([]net.IP, error) {
|
||||
r := newSelfTargetRouter(t, []string{"100.64.1.1:80"}, func(string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP("100.64.1.1")}, nil
|
||||
})
|
||||
r.DirectRule.Add("self.example.com")
|
||||
@@ -134,3 +143,80 @@ func TestDialSmartRefusesSelfTargetLiteral(t *testing.T) {
|
||||
t.Fatalf("literal self target on the smart path err = %v, want ErrSelfProxyTarget", err)
|
||||
}
|
||||
}
|
||||
|
||||
// startUpstreamDNS serves a fake upstream resolver answering the given names,
|
||||
// so the guard's production lookup can be exercised without the network.
|
||||
func startUpstreamDNS(t *testing.T, answers map[string]net.IP) string {
|
||||
t.Helper()
|
||||
|
||||
conn, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen fake upstream dns: %v", err)
|
||||
}
|
||||
server := &dns.Server{PacketConn: conn, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
|
||||
resp := new(dns.Msg)
|
||||
resp.SetReply(req)
|
||||
if len(req.Question) > 0 {
|
||||
question := req.Question[0]
|
||||
name := strings.ToLower(strings.TrimSuffix(question.Name, "."))
|
||||
if ip, ok := answers[name]; ok {
|
||||
switch {
|
||||
case question.Qtype == dns.TypeA && ip.To4() != nil:
|
||||
resp.Answer = []dns.RR{&dns.A{
|
||||
Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
|
||||
A: ip,
|
||||
}}
|
||||
case question.Qtype == dns.TypeAAAA && ip.To4() == nil:
|
||||
resp.Answer = []dns.RR{&dns.AAAA{
|
||||
Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 60},
|
||||
AAAA: ip,
|
||||
}}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = w.WriteMsg(resp)
|
||||
})}
|
||||
go func() { _ = server.ActivateAndServe() }()
|
||||
t.Cleanup(func() { _ = server.Shutdown() })
|
||||
|
||||
return conn.LocalAddr().String()
|
||||
}
|
||||
|
||||
func newSelfTargetRouterWithUpstream(t *testing.T, selfAddrs []string, answers map[string]net.IP) *Router {
|
||||
t.Helper()
|
||||
|
||||
upstream := startUpstreamDNS(t, answers)
|
||||
r := newSelfTargetRouter(t, selfAddrs, nil)
|
||||
r.dns.upstreamAddrs = []string{upstream}
|
||||
r.dns.upstreamIndex = 0
|
||||
r.dns.refreshAt = time.Now().Add(time.Hour)
|
||||
return r
|
||||
}
|
||||
|
||||
// The guard must ask the upstream resolvers, not the system resolver: this
|
||||
// client answers its own DNS for every domain it proxies, so its own answer
|
||||
// would make every proxied dial look like a self target. A name the upstream
|
||||
// resolves remotely stays proxied.
|
||||
func TestDialProxyOnlyAllowsDomainTheUpstreamResolvesRemotely(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newSelfTargetRouterWithUpstream(t, []string{"100.64.1.1:80", "100.64.1.1:443"},
|
||||
map[string]net.IP{"proxied.example.com": net.ParseIP("93.184.216.34")})
|
||||
|
||||
if _, err := r.DialProxyOnly("tcp", "proxied.example.com", 443); err != nil {
|
||||
t.Fatalf("proxied domain refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A name whose own record points at this client is what the recursion needs,
|
||||
// and the upstream answer is where that shows up.
|
||||
func TestDialProxyOnlyRefusesNameTheUpstreamPointsAtSelf(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newSelfTargetRouterWithUpstream(t, []string{"100.64.1.1:80"},
|
||||
map[string]net.IP{"sower.example.com": net.ParseIP("100.64.1.1")})
|
||||
|
||||
if _, err := r.DialProxyOnly("tcp", "sower.example.com", 80); !errors.Is(err, ErrSelfProxyTarget) {
|
||||
t.Fatalf("self-published name err = %v, want ErrSelfProxyTarget", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user