feat(router): answer internal PTR lookups locally, keep external DNS unexposed

Parse in-addr.arpa/ip6.arpa query names back to IPs and answer
NXDOMAIN locally when the target is an internal range (RFC1918,
CGNAT 100.64/10, link-local, loopback, IPv6 ULA) and no internal
DNS server is among the upstreams. Reverse lookups still forward
when an internal DNS server can answer them, and public reverse
names keep the existing forwarding path.
This commit is contained in:
wweir
2026-08-11 21:02:41 +08:00
parent 683b1da33f
commit b4d92703ce
3 changed files with 353 additions and 0 deletions
+1
View File
@@ -80,6 +80,7 @@ For non-transport fallback traffic, it can route by TLS SNI to per-domain upstre
Proxy-routed domains return local A/AAAA records, suppress HTTPS/SVCB and other non-address metadata locally, and never leak proxy-matched names to direct upstream DNS.
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.
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.
+104
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"net"
"strconv"
"strings"
"time"
@@ -42,6 +43,15 @@ func (r *Router) ServeDNS(w dns.ResponseWriter, req *dns.Msg) {
return
}
// Internal reverse lookups (RFC1918, CGNAT, link-local, loopback, ULA)
// have no data on public upstream DNS and would leak internal network
// layout there. Answer NXDOMAIN locally unless an internal DNS server is
// among the upstreams and can answer authoritatively.
if arpaIP, ok := parseReverseName(domain); ok && isInternalIP(arpaIP) && !r.dnsUpstreamIsInternal() {
_ = w.WriteMsg(r.dnsFail(req, dns.RcodeNameError))
return
}
if !dnsRuleMatch(r.DirectRule, routeDomains) && dnsRuleMatch(r.ProxyRule, routeDomains) {
switch {
case isAddressQuestion(qtype):
@@ -135,6 +145,100 @@ func isAddressQuestion(qtype uint16) bool {
return qtype == dns.TypeA || qtype == dns.TypeAAAA
}
// parseReverseName resolves an in-addr.arpa or ip6.arpa query name back to
// the IP it represents. It returns ok=false for incomplete zone names (e.g.
// "168.192.in-addr.arpa.") and malformed names, which keep the default
// forwarding behavior.
func parseReverseName(domain string) (net.IP, bool) {
name := strings.ToLower(strings.TrimSuffix(domain, "."))
switch {
case strings.HasSuffix(name, ".in-addr.arpa"):
labels := strings.Split(strings.TrimSuffix(name, ".in-addr.arpa"), ".")
if len(labels) != net.IPv4len {
return nil, false
}
var octets [net.IPv4len]byte
for i := range labels {
v, err := strconv.Atoi(labels[net.IPv4len-1-i]) // reversed: name lists least-significant octet first
if err != nil || v < 0 || v > 255 {
return nil, false
}
octets[i] = byte(v)
}
return net.IPv4(octets[0], octets[1], octets[2], octets[3]).To4(), true
case strings.HasSuffix(name, ".ip6.arpa"):
nibbles := strings.Split(strings.TrimSuffix(name, ".ip6.arpa"), ".")
if len(nibbles) != net.IPv6len*2 {
return nil, false
}
ip := make(net.IP, net.IPv6len)
for i, h := range nibbles {
v, err := strconv.ParseUint(h, 16, 8)
if err != nil {
return nil, false
}
// Nibbles are ordered least-significant byte first, low nibble
// first within each byte (matching dns.ReverseAddr output).
n := len(nibbles) - 1 - i
if n%2 == 0 {
ip[n/2] |= byte(v) << 4
} else {
ip[n/2] |= byte(v)
}
}
return ip, true
default:
return nil, false
}
}
// isInternalIP reports whether ip belongs to a private or local-only range:
// RFC1918 / IPv6 ULA (net.IP.IsPrivate), CGNAT shared address space
// (RFC 6598), link-local unicast, loopback, and the unspecified address.
func isInternalIP(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() {
return true
}
if v4 := ip.To4(); v4 != nil {
// CGNAT 100.64.0.0/10
return v4[0] == 100 && v4[1]&0xC0 == 0x40
}
return false
}
// dnsUpstreamIsInternal reports whether any configured or discovered DNS
// upstream is an internal address. Internal reverse lookups are only
// forwarded when an internal DNS server can answer them.
func (r *Router) dnsUpstreamIsInternal() bool {
r.dns.Lock()
defer r.dns.Unlock()
if len(r.dns.upstreamAddrs) > 0 {
for _, addr := range r.dns.upstreamAddrs {
if isInternalHost(addr) {
return true
}
}
return false
}
return isInternalHost(r.dns.upstreamDNS) || isInternalHost(r.dns.fallbackDNS)
}
// isInternalHost reports whether host (with optional port) is an internal IP.
func isInternalHost(hostport string) bool {
host := hostport
if h, _, err := net.SplitHostPort(hostport); err == nil {
host = h
}
return isInternalIP(net.ParseIP(strings.Trim(host, "[]")))
}
func dnsRuleMatch(rule interface{ Match(string) bool }, domains []string) bool {
for _, domain := range domains {
if rule.Match(domain) {
+248
View File
@@ -0,0 +1,248 @@
package router
import (
"net"
"sync/atomic"
"testing"
"github.com/miekg/dns"
)
func TestParseReverseName(t *testing.T) {
t.Parallel()
ip, ok := parseReverseName("1.2.3.4.in-addr.arpa.")
if !ok || !ip.Equal(net.ParseIP("4.3.2.1")) {
t.Fatalf("parse 1.2.3.4.in-addr.arpa: ip=%v ok=%v", ip, ok)
}
if ip, ok := parseReverseName("4.3.2.1.IN-ADDR.ARPA."); !ok || !ip.Equal(net.ParseIP("1.2.3.4")) {
t.Fatalf("case-insensitive parse: ip=%v ok=%v", ip, ok)
}
if ip, ok := parseReverseName("8.8.8.8.in-addr.arpa."); !ok || !ip.Equal(net.ParseIP("8.8.8.8")) {
t.Fatalf("parse public reverse name: ip=%v ok=%v", ip, ok)
}
v6 := net.ParseIP("fd00::1")
arpa, err := dns.ReverseAddr(v6.String())
if err != nil {
t.Fatalf("reverse addr: %v", err)
}
ip, ok = parseReverseName(arpa)
if !ok || !ip.Equal(v6) {
t.Fatalf("parse %s: ip=%v ok=%v", arpa, ip, ok)
}
for _, name := range []string{
"168.192.in-addr.arpa.", // incomplete zone name
"example.com.", // not a reverse name
"256.1.2.3.in-addr.arpa.", // octet out of range
"1.2.3.in-addr.arpa.", // too few octets
"1.2.3.4.5.in-addr.arpa.", // too many octets
"g.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa.", // invalid hex nibble
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa.", // 31 nibbles
} {
if ip, ok := parseReverseName(name); ok {
t.Fatalf("expected %q to be rejected, got %v", name, ip)
}
}
}
func TestIsInternalIP(t *testing.T) {
t.Parallel()
internal := []string{
"10.0.0.1", "172.16.0.1", "172.31.255.255", "192.168.1.1",
"100.64.0.1", "100.127.255.254", // CGNAT
"169.254.1.1", // link-local unicast
"127.0.0.1", // loopback
"0.0.0.0", // unspecified
"fc00::1", "fd00::1", // IPv6 ULA
"fe80::1", // IPv6 link-local
"::1", // IPv6 loopback
}
for _, s := range internal {
if !isInternalIP(net.ParseIP(s)) {
t.Errorf("expected %s to be internal", s)
}
}
public := []string{
"8.8.8.8", "1.1.1.1", "223.5.5.5",
"100.63.255.255", "100.128.0.1", // CGNAT boundaries
"192.0.2.1", // TEST-NET-1
"224.0.0.1", // multicast
"2400:3200::1", "2001:4860:4860::8888",
}
for _, s := range public {
if isInternalIP(net.ParseIP(s)) {
t.Errorf("expected %s to be public", s)
}
}
}
func TestDNSUpstreamIsInternal(t *testing.T) {
t.Parallel()
r := newTestRouter(t, nil, "", "", "", nil)
r.dns.upstreamAddrs = []string{"8.8.8.8:53", "223.5.5.5:53"}
if r.dnsUpstreamIsInternal() {
t.Fatal("public upstreams reported as internal")
}
r.dns.upstreamAddrs = []string{"8.8.8.8:53", "192.168.1.1:53"}
if !r.dnsUpstreamIsInternal() {
t.Fatal("internal upstream not reported")
}
r.dns.upstreamAddrs = nil
r.dns.upstreamDNS = "192.168.1.1"
r.dns.fallbackDNS = "223.5.5.5"
if !r.dnsUpstreamIsInternal() {
t.Fatal("internal configured upstream not reported")
}
r.dns.upstreamDNS = ""
r.dns.fallbackDNS = "223.5.5.5"
if r.dnsUpstreamIsInternal() {
t.Fatal("public fallback reported as internal")
}
}
func TestServeDNSInternalReverseLookup(t *testing.T) {
t.Parallel()
t.Run("public upstream answers NXDOMAIN locally", func(t *testing.T) {
r := newTestRouter(t, []string{"127.0.0.2"}, "", "223.5.5.5", "", nil)
r.dns.upstreamAddrs = []string{"8.8.8.8:53"}
r.dns.upstreamIndex = 0
req := new(dns.Msg)
req.SetQuestion("1.1.168.192.in-addr.arpa.", dns.TypePTR)
writer := &mockDNSWriter{localAddr: &net.UDPAddr{IP: net.ParseIP("127.0.0.2"), Port: 53}}
r.ServeDNS(writer, req)
if writer.msg == nil {
t.Fatal("expected response")
}
if writer.msg.Rcode != dns.RcodeNameError {
t.Fatalf("expected NXDOMAIN, got %s", dns.RcodeToString[writer.msg.Rcode])
}
if len(writer.msg.Answer) != 0 {
t.Fatalf("expected no answer, got %v", writer.msg.Answer)
}
})
t.Run("internal upstream forwards to it", func(t *testing.T) {
var upstreamQueries atomic.Int32
upstreamAddr := startUDPTestDNSServer(t, dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
upstreamQueries.Add(1)
resp := new(dns.Msg)
resp.SetReply(req)
resp.Answer = []dns.RR{&dns.PTR{
Hdr: dns.RR_Header{Name: req.Question[0].Name, Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: 60},
Ptr: "host.lan.",
}}
_ = w.WriteMsg(resp)
}))
r := newTestRouter(t, []string{"127.0.0.2"}, "", "223.5.5.5", "", nil)
r.dns.upstreamAddrs = []string{upstreamAddr} // 127.0.0.1:port counts as internal
r.dns.upstreamIndex = 0
req := new(dns.Msg)
req.SetQuestion("1.1.168.192.in-addr.arpa.", dns.TypePTR)
writer := &mockDNSWriter{localAddr: &net.UDPAddr{IP: net.ParseIP("127.0.0.2"), Port: 53}}
r.ServeDNS(writer, req)
if upstreamQueries.Load() != 1 {
t.Fatalf("expected forward to internal upstream, got %d queries", upstreamQueries.Load())
}
if writer.msg == nil || writer.msg.Rcode != dns.RcodeSuccess {
t.Fatalf("expected NOERROR response, got %+v", writer.msg)
}
if len(writer.msg.Answer) != 1 {
t.Fatalf("expected PTR answer, got %v", writer.msg.Answer)
}
if ptr, ok := writer.msg.Answer[0].(*dns.PTR); !ok || ptr.Ptr != "host.lan." {
t.Fatalf("unexpected answer: %v", writer.msg.Answer[0])
}
})
t.Run("public reverse name still forwards", func(t *testing.T) {
var upstreamQueries atomic.Int32
upstreamAddr := startUDPTestDNSServer(t, dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
upstreamQueries.Add(1)
resp := new(dns.Msg)
resp.SetReply(req)
_ = w.WriteMsg(resp)
}))
r := newTestRouter(t, []string{"127.0.0.2"}, "", "223.5.5.5", "", nil)
r.dns.upstreamAddrs = []string{upstreamAddr}
r.dns.upstreamIndex = 0
req := new(dns.Msg)
req.SetQuestion("4.2.2.4.in-addr.arpa.", dns.TypePTR)
writer := &mockDNSWriter{localAddr: &net.UDPAddr{IP: net.ParseIP("127.0.0.2"), Port: 53}}
r.ServeDNS(writer, req)
if upstreamQueries.Load() != 1 {
t.Fatalf("expected public reverse lookup forwarded, got %d queries", upstreamQueries.Load())
}
})
t.Run("ipv6 internal reverse with public upstream answers NXDOMAIN locally", func(t *testing.T) {
r := newTestRouter(t, []string{"127.0.0.2"}, "", "223.5.5.5", "", nil)
r.dns.upstreamAddrs = []string{"8.8.8.8:53"}
r.dns.upstreamIndex = 0
arpa, err := dns.ReverseAddr("fd00::1")
if err != nil {
t.Fatalf("reverse addr: %v", err)
}
req := new(dns.Msg)
req.SetQuestion(arpa, dns.TypePTR)
writer := &mockDNSWriter{localAddr: &net.UDPAddr{IP: net.ParseIP("127.0.0.2"), Port: 53}}
r.ServeDNS(writer, req)
if writer.msg == nil {
t.Fatal("expected response")
}
if writer.msg.Rcode != dns.RcodeNameError {
t.Fatalf("expected NXDOMAIN, got %s", dns.RcodeToString[writer.msg.Rcode])
}
})
t.Run("ipv6 internal reverse with internal upstream forwards", func(t *testing.T) {
var upstreamQueries atomic.Int32
upstreamAddr := startUDPTestDNSServer(t, dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
upstreamQueries.Add(1)
resp := new(dns.Msg)
resp.SetReply(req)
_ = w.WriteMsg(resp)
}))
r := newTestRouter(t, []string{"127.0.0.2"}, "", "223.5.5.5", "", nil)
r.dns.upstreamAddrs = []string{upstreamAddr}
r.dns.upstreamIndex = 0
arpa, err := dns.ReverseAddr("fe80::1")
if err != nil {
t.Fatalf("reverse addr: %v", err)
}
req := new(dns.Msg)
req.SetQuestion(arpa, dns.TypePTR)
writer := &mockDNSWriter{localAddr: &net.UDPAddr{IP: net.ParseIP("127.0.0.2"), Port: 53}}
r.ServeDNS(writer, req)
if upstreamQueries.Load() != 1 {
t.Fatalf("expected forward to internal upstream, got %d queries", upstreamQueries.Load())
}
})
}