mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
feat(router): attribute DNS stats via EDNS client subnet, strip ECS upstream
This commit is contained in:
@@ -81,6 +81,7 @@ For non-transport fallback traffic, it can route by TLS SNI to per-domain upstre
|
||||
Direct upstream DNS failures fall back only for retryable upstream service errors; when no fallback succeeds, the last upstream DNS response code is returned as-is.
|
||||
Service discovery names are matched against both the full query name and the base domain only for service record types.
|
||||
Reverse lookups (PTR) for internal ranges (RFC1918, CGNAT 100.64/10, link-local, loopback, IPv6 ULA) are answered with NXDOMAIN locally unless an internal DNS server is among the upstreams, so internal layout never leaks to public DNS and internal reverse resolution still works when an internal DNS server is configured.
|
||||
Client attribution for DNS statistics prefers the EDNS Client Subnet (ECS) address when a forwarding resolver (e.g. dnsmasq --add-subnet) carries the real client in the query, falling back to the transport source address; ECS is stripped before any query is forwarded upstream so client subnets never reach public resolvers.
|
||||
9. For DNS-mode transparent HTTP traffic, parse the target host from the request line and always forward through the upstream proxy. For DNS-mode transparent HTTPS traffic, peek the TLS ClientHello to extract SNI and always forward through the upstream proxy.
|
||||
These transparent `80/443` listeners are second-stage proxy-only handlers for domains already mapped to local proxy IPs by DNS; they do not run smart routing again.
|
||||
HTTPS transparent proxying reads only the TLS ClientHello, then replays the untouched bytes to the selected upstream; it must not complete or terminate TLS locally.
|
||||
|
||||
+1
-1
@@ -418,7 +418,7 @@ type dnsStatsHandler struct {
|
||||
|
||||
func (h dnsStatsHandler) ServeDNS(w dns.ResponseWriter, req *dns.Msg) {
|
||||
if len(req.Question) == 1 {
|
||||
h.stats.RecordDNS(req.Question[0].Name, admin.ClientIPOf(w.RemoteAddr()))
|
||||
h.stats.RecordDNS(req.Question[0].Name, router.ClientIPOf(req, w.RemoteAddr()))
|
||||
}
|
||||
h.Handler.ServeDNS(w, req)
|
||||
}
|
||||
|
||||
+46
-1
@@ -636,6 +636,7 @@ func (r *Router) exchangeUDP(req *dns.Msg, addr string) (*dns.Msg, error) {
|
||||
if msg.IsEdns0() == nil {
|
||||
msg.SetEdns0(dnsUDPSize, false)
|
||||
}
|
||||
stripECS(msg)
|
||||
|
||||
client := dns.Client{
|
||||
Net: "udp",
|
||||
@@ -647,14 +648,58 @@ func (r *Router) exchangeUDP(req *dns.Msg, addr string) (*dns.Msg, error) {
|
||||
}
|
||||
|
||||
func (r *Router) exchangeTCP(req *dns.Msg, addr string) (*dns.Msg, error) {
|
||||
msg := req.Copy()
|
||||
stripECS(msg)
|
||||
client := dns.Client{
|
||||
Net: "tcp",
|
||||
Timeout: dnsTimeout,
|
||||
}
|
||||
resp, _, err := client.Exchange(req.Copy(), addr)
|
||||
resp, _, err := client.Exchange(msg, addr)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// ClientIPOf returns the client IP to attribute a DNS query to. Queries
|
||||
// forwarded by a local resolver (e.g. dnsmasq --add-subnet) carry the real
|
||||
// client address in the EDNS Client Subnet option; when present it takes
|
||||
// precedence over the transport source address, which would otherwise be the
|
||||
// resolver itself.
|
||||
func ClientIPOf(req *dns.Msg, remote net.Addr) string {
|
||||
if opt := req.IsEdns0(); opt != nil {
|
||||
for _, o := range opt.Option {
|
||||
if sub, ok := o.(*dns.EDNS0_SUBNET); ok && sub.Address != nil {
|
||||
if ip := sub.Address.String(); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if remote == nil {
|
||||
return ""
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(remote.String()); err == nil {
|
||||
return host
|
||||
}
|
||||
return remote.String()
|
||||
}
|
||||
|
||||
// stripECS removes the EDNS Client Subnet option before a query is forwarded
|
||||
// upstream. The subnet is added by a forwarding resolver (dnsmasq
|
||||
// --add-subnet) and is only meaningful between that resolver and this proxy;
|
||||
// forwarding it to public resolvers would leak the real client network.
|
||||
func stripECS(msg *dns.Msg) {
|
||||
opt := msg.IsEdns0()
|
||||
if opt == nil {
|
||||
return
|
||||
}
|
||||
kept := opt.Option[:0]
|
||||
for _, o := range opt.Option {
|
||||
if _, isECS := o.(*dns.EDNS0_SUBNET); !isECS {
|
||||
kept = append(kept, o)
|
||||
}
|
||||
}
|
||||
opt.Option = kept
|
||||
}
|
||||
|
||||
func (r *Router) isServeIP(raw string) bool {
|
||||
ip := net.ParseIP(strings.Trim(raw, "[]"))
|
||||
if ip == nil {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func ecsMsg(addr string, family uint16, netmask uint8) *dns.Msg {
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion("example.com.", dns.TypeA)
|
||||
opt := new(dns.OPT)
|
||||
opt.Hdr.Name = "."
|
||||
opt.Hdr.Rrtype = dns.TypeOPT
|
||||
opt.Option = append(opt.Option, &dns.EDNS0_SUBNET{
|
||||
Code: dns.EDNS0SUBNET,
|
||||
Family: family,
|
||||
SourceNetmask: netmask,
|
||||
SourceScope: 0,
|
||||
Address: net.ParseIP(addr),
|
||||
})
|
||||
msg.Extra = append(msg.Extra, opt)
|
||||
return msg
|
||||
}
|
||||
|
||||
func TestClientIPOfPrefersECS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
remote := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5353}
|
||||
|
||||
// Queries forwarded by dnsmasq --add-subnet carry the real client in
|
||||
// ECS; it must win over the transport source (the resolver itself).
|
||||
if got := ClientIPOf(ecsMsg("192.168.1.42", 1, 32), remote); got != "192.168.1.42" {
|
||||
t.Fatalf("ECS v4: got %q", got)
|
||||
}
|
||||
if got := ClientIPOf(ecsMsg("fd00::1", 2, 128), remote); got != "fd00::1" {
|
||||
t.Fatalf("ECS v6: got %q", got)
|
||||
}
|
||||
|
||||
// No ECS: fall back to the transport remote address.
|
||||
plain := new(dns.Msg)
|
||||
plain.SetQuestion("example.com.", dns.TypeA)
|
||||
if got := ClientIPOf(plain, remote); got != "127.0.0.1" {
|
||||
t.Fatalf("fallback: got %q", got)
|
||||
}
|
||||
if got := ClientIPOf(plain, nil); got != "" {
|
||||
t.Fatalf("nil remote: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripECS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// ECS-only query: OPT section left empty.
|
||||
req := ecsMsg("192.168.1.42", 1, 32)
|
||||
stripECS(req)
|
||||
if opt := req.IsEdns0(); opt == nil || len(opt.Option) != 0 {
|
||||
t.Fatalf("strip all: opt=%v options=%d", opt, len(opt.Option))
|
||||
}
|
||||
|
||||
// ECS alongside another EDNS option: only ECS is removed.
|
||||
req = ecsMsg("192.168.1.42", 1, 32)
|
||||
req.IsEdns0().Option = append(req.IsEdns0().Option, &dns.EDNS0_NSID{Code: dns.EDNS0NSID, Nsid: "test"})
|
||||
stripECS(req)
|
||||
opt := req.IsEdns0()
|
||||
if opt == nil || len(opt.Option) != 1 {
|
||||
t.Fatalf("strip one: opt=%v options=%d", opt, len(opt.Option))
|
||||
}
|
||||
if _, ok := opt.Option[0].(*dns.EDNS0_NSID); !ok {
|
||||
t.Fatalf("unexpected remaining option: %#v", opt.Option[0])
|
||||
}
|
||||
|
||||
// No EDNS at all: no-op.
|
||||
plain := new(dns.Msg)
|
||||
plain.SetQuestion("example.com.", dns.TypeA)
|
||||
stripECS(plain)
|
||||
if plain.IsEdns0() != nil {
|
||||
t.Fatal("no-op query gained an OPT record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeStripsECSUpstream(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var seen atomic.Bool
|
||||
upstreamAddr := startUDPTestDNSServer(t, dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
|
||||
if opt := req.IsEdns0(); opt != nil {
|
||||
for _, o := range opt.Option {
|
||||
if _, isECS := o.(*dns.EDNS0_SUBNET); isECS {
|
||||
t.Errorf("upstream received ECS option")
|
||||
}
|
||||
}
|
||||
}
|
||||
seen.Store(true)
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(req)
|
||||
m.Answer = append(m.Answer, &dns.A{
|
||||
Hdr: dns.RR_Header{Name: req.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
|
||||
A: net.ParseIP("1.2.3.4"),
|
||||
})
|
||||
_ = w.WriteMsg(m)
|
||||
}))
|
||||
|
||||
r := newTestRouter(t, nil, "", "223.5.5.5", "", nil)
|
||||
r.dns.upstreamAddrs = []string{upstreamAddr}
|
||||
r.dns.upstreamIndex = 0
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
req := ecsMsg("192.168.1.42", 1, 32)
|
||||
resp, err := r.Exchange(req)
|
||||
if err != nil {
|
||||
t.Fatalf("exchange: %v", err)
|
||||
}
|
||||
if !seen.Load() {
|
||||
t.Fatal("upstream handler never invoked")
|
||||
}
|
||||
if len(resp.Answer) != 1 {
|
||||
t.Fatalf("unexpected answer count: %d", len(resp.Answer))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user