mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
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:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user