fix(sower): resolve the self-target check through the upstream resolvers

v1.7.4 asked the system resolver and was wrong on a deployed client: this
client's own DNS answers its serve address for every domain it proxies (that
is how its clients are steered into it), so "the name resolves to one of my
listeners" is true for every normally proxied domain. In the field that
refused legitimate traffic (SOCKS5 google answered code 2 not-allowed).

The recursion discriminator has to be the name's own record, i.e. what the
relay will act on, so the guard now resolves through the configured upstreams
(which already exclude this client's serve addresses) and caches the answer,
including an empty one, for a minute. Verified against a fake upstream in
tests: a name the upstream points at this client is refused, one it resolves
remotely is proxied.

docs/plans/2026-10-12-self-proxy-target-guard.md records the regression, the
rollback and the deploy pitfall (cat > file leaves 0644, so cp -a installed a
non-executable binary and procd could not start the service).
This commit is contained in:
wweir
2026-10-12 20:11:34 +08:00
parent 2de08e62e4
commit e8388a5cdf
5 changed files with 179 additions and 27 deletions
+1 -1
View File
@@ -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.
@@ -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` 回到本机监听器,那时递归完全不出主机 —— 同样是自持的,只是
不经过隧道。
@@ -59,6 +62,27 @@ finding `f-4d7d5bd2-cee`):
应得 403、`--socks5-hostname` 应被规则拒绝,且两侧连接数不增长;正常代理(google 204、
baidu 200)与 admin 控制台不受影响。
## 现场验证与一次回归(v1.7.4 → v1.7.5)
首版守卫(`v1.7.4`,commit `2de08e6`)用系统解析器做自指判定,部署到 r6c 后现场验证立刻
暴露反例:
- `curl --socks5-hostname 100.64.1.1:1080 https://www.google.com/generate_204` 返回
SOCKS5 码 `2`(not allowed)而不是 204;r6c 的 `logread` 给出根因——
`target is this client's own listener: sower.wweir.fun:80 resolves to 100.64.1.1:80`。
- 同时 `nslookup www.google.com 127.0.0.1` 在 r6c 上返回 `100.64.1.1`:本机 DNS 对被代理
域名回答的就是自己的 serve IP。于是“解析结果是我的地址”对**所有正常被代理域名**都成立,
守卫把整条正常代理路径拒了。这是判定依据错误,不是实现细节错误。
处理:立即把 r6c 回滚到上一个可用二进制(`/usr/sbin/sower.bak-20261012195538`,sha256
`76459b95…`,procd 重启后 pid 变化确认),随后改成向上游解析器求证(`v1.7.5`)并重新部署。
部署坑(已记入 deploy skill):跨机传二进制用 `cat > /tmp/sower.new` 得到的是 `0644`,
`cp -a` 会原样保留该权限,procd 因为不可执行而根本起不来(`/usr/sbin/sower: Permission
Denied`),表现为服务静默 down、LAN DNS 连锁失效。替换后必须 `chmod 755` 并用
`sha256sum` + `pidof` 验证;同时 r6c 的 SSH 在 sower down 时可能连 `home` 跳板的域名解析
都失败,此时改用 IP(`wweir@192.168.1.2` → `root@192.168.1.1`)。
## 未决(不在本次范围)
- `relay.Relay` 只设读超时导致卡死连接永久泄漏(finding `f-1ae16668-b92`),需要在
+5 -4
View File
@@ -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
+48 -13
View File
@@ -1,13 +1,14 @@
package router
import (
"context"
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"
"github.com/miekg/dns"
)
const (
@@ -108,12 +109,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 +124,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",
+99 -7
View File
@@ -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
})
@@ -101,7 +105,7 @@ func TestDialProxyOnlyRefusesUnspecifiedBind(t *testing.T) {
func TestDialProxyOnlyAllowsTargetThatFailsToResolve(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 nil, errors.New("no such host")
})
if _, err := r.DialProxyOnly("tcp", "missing.example.com", 80); err != nil {
@@ -109,6 +113,17 @@ func TestDialProxyOnlyAllowsTargetThatFailsToResolve(t *testing.T) {
}
}
func TestDialProxyOnlyAllowsTargetWithNoAnswer(t *testing.T) {
t.Parallel()
r := newSelfTargetRouter(t, []string{"100.64.1.1:80"}, func(string) ([]net.IP, error) {
return nil, nil
})
if _, err := r.DialProxyOnly("tcp", "nodata.example.com", 80); err != nil {
t.Fatalf("dial after an empty answer: %v", err)
}
}
// DialSmart reaches directDial without passing DialProxyOnly, so the guard has
// to sit on that path too: a name a direct rule matches, or one the access
// probe marks reachable, would otherwise dial this client's own listener in a
@@ -116,7 +131,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 +149,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)
}
}