diff --git a/CHANGELOG.md b/CHANGELOG.md index 367a9940..2328d259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,12 @@ and this project adheres to ## [Unreleased] ### Added +- `ipset` subdomain matching, just like `dnsmasq` does ([#2179]). - Client ID support for DNS-over-HTTPS, DNS-over-QUIC, and DNS-over-TLS ([#1383]). - `$dnsrewrite` modifier for filters ([#2102]). @@ -31,6 +32,7 @@ and this project adheres to [#1361]: https://github.com/AdguardTeam/AdGuardHome/issues/1361 [#1383]: https://github.com/AdguardTeam/AdGuardHome/issues/1383 [#2102]: https://github.com/AdguardTeam/AdGuardHome/issues/2102 +[#2179]: https://github.com/AdguardTeam/AdGuardHome/issues/2179 [#2302]: https://github.com/AdguardTeam/AdGuardHome/issues/2302 [#2304]: https://github.com/AdguardTeam/AdGuardHome/issues/2304 [#2305]: https://github.com/AdguardTeam/AdGuardHome/issues/2305 diff --git a/go.mod b/go.mod index a4f86658..aea6a6ee 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/AdguardTeam/urlfilter v0.14.2 github.com/NYTimes/gziphandler v1.1.1 github.com/ameshkov/dnscrypt/v2 v2.0.1 + github.com/digineo/go-ipset/v2 v2.2.1 github.com/fsnotify/fsnotify v1.4.9 github.com/go-ping/ping v0.0.0-20201115131931-3300c582a663 github.com/gobuffalo/envy v1.9.0 // indirect @@ -19,6 +20,7 @@ require ( github.com/karrick/godirwalk v1.16.1 // indirect github.com/lucas-clemente/quic-go v0.19.3 github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7 + github.com/mdlayher/netlink v1.1.2-0.20201013204415-ded538f7f4be github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 github.com/miekg/dns v1.1.35 github.com/rogpeppe/go-internal v1.6.2 // indirect @@ -26,6 +28,7 @@ require ( github.com/sirupsen/logrus v1.7.0 // indirect github.com/spf13/cobra v1.1.1 // indirect github.com/stretchr/testify v1.6.1 + github.com/ti-mo/netfilter v0.4.0 github.com/u-root/u-root v7.0.0+incompatible go.etcd.io/bbolt v1.3.5 golang.org/x/crypto v0.0.0-20201217014255-9d1352758620 diff --git a/go.sum b/go.sum index b298768b..995400c9 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/digineo/go-ipset/v2 v2.2.1 h1:k6skY+0fMqeUjjeWO/m5OuWPSZUAn7AucHMnQ1MX77g= +github.com/digineo/go-ipset/v2 v2.2.1/go.mod h1:wBsNzJlZlABHUITkesrggFnZQtgW5wkqw1uo8Qxe0VU= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= @@ -223,6 +225,7 @@ github.com/joomcode/errorx v1.0.3/go.mod h1:eQzdtdlNyN7etw6YCS4W4+lu442waxZYw5yv github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw= github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ= github.com/jsimonetti/rtnetlink v0.0.0-20201009170750-9c6f07d100c1/go.mod h1:hqoO/u39cqLeBLebZ8fWdE96O7FxrAsRYhnVOdgHxok= +github.com/jsimonetti/rtnetlink v0.0.0-20201110080708-d2c240429e6c h1:7cpGGTQO6+OuYQWkueqeXuErSjs1NZtpALpv1x7Mq4g= github.com/jsimonetti/rtnetlink v0.0.0-20201110080708-d2c240429e6c/go.mod h1:huN4d1phzjhlOsNIjFsw2SVRbwIHj3fJDMEU2SDPTmg= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= @@ -269,10 +272,14 @@ github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7 h1:lez6TS6aAau+8wXUP3G9I3TGlmPFEq2CTxBaRqY6AGE= github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7/go.mod h1:U6ZQobyTjI/tJyq2HG+i/dfSoFUt8/aZCM+GKtmFk/Y= +github.com/mdlayher/netlink v0.0.0-20190313131330-258ea9dff42c/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA= github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA= github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M= github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY= +github.com/mdlayher/netlink v1.1.1 h1:VqG+Voq9V4uZ+04vjIrcSCWDpf91B1xxbP4QBUmUJE8= github.com/mdlayher/netlink v1.1.1/go.mod h1:WTYpFb/WTvlRJAyKhZL5/uy69TDDpHHu2VZmb2XgV7o= +github.com/mdlayher/netlink v1.1.2-0.20201013204415-ded538f7f4be h1:7JeFwhE5SIdgKRd0qnqjOYJxY8AML8x/j+/qvFZ8R+c= +github.com/mdlayher/netlink v1.1.2-0.20201013204415-ded538f7f4be/go.mod h1:WTYpFb/WTvlRJAyKhZL5/uy69TDDpHHu2VZmb2XgV7o= github.com/mdlayher/raw v0.0.0-20190606142536-fef19f00fc18/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 h1:aFkJ6lx4FPip+S+Uw4aTegFMct9shDvP+79PsSxpm3w= github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065/go.mod h1:7EpbotpCmVZcu+KCX4g9WaRNuu11uyhiW7+Le1dKawg= @@ -411,6 +418,9 @@ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/ti-mo/netfilter v0.2.0/go.mod h1:8GbBGsY/8fxtyIdfwy29JiluNcPK4K7wIT+x42ipqUU= +github.com/ti-mo/netfilter v0.4.0 h1:rTN1nBYULDmMfDeBHZpKuNKX/bWEXQUhe02a/10orzg= +github.com/ti-mo/netfilter v0.4.0/go.mod h1:V54q75mUx8CNA2JnFl+wv9iZ5+JP9nCcRlaFS5OZSRM= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/u-root/u-root v7.0.0+incompatible h1:u+KSS04pSxJGI5E7WE4Bs9+Zd75QjFv+REkjy/aoAc8= github.com/u-root/u-root v7.0.0+incompatible/go.mod h1:RYkpo8pTHrNjW08opNd/U6p/RJE7K0D8fXO0d47+3YY= @@ -506,6 +516,7 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381 h1:VXak5I6aEWmAXeQjA+QSZzlgN golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201016165138-7b1cca2348c0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11 h1:lwlPPsmjDKK0J6eG6xDWd5XPehI0R024zxjDnw3esPA= @@ -540,6 +551,7 @@ golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190322080309-f49334f85ddc/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190418153312-f0ce4c0180be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -567,6 +579,7 @@ golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201017003518-b09fb700fbb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201112073958-5cba982894dd h1:5CtCZbICpIOFdgO940moixOPjc0178IU44m4EjOO5IY= golang.org/x/sys v0.0.0-20201112073958-5cba982894dd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/dnsforward/dnsforward.go b/internal/dnsforward/dnsforward.go index 0ada0640..b3449439 100644 --- a/internal/dnsforward/dnsforward.go +++ b/internal/dnsforward/dnsforward.go @@ -108,6 +108,12 @@ func (s *Server) Close() { s.stats = nil s.queryLog = nil s.dnsProxy = nil + + err := s.ipset.Close() + if err != nil { + log.Error("closing ipset: %s", err) + } + s.Unlock() } @@ -190,11 +196,14 @@ func (s *Server) Prepare(config *ServerConfig) error { // Initialize IPSET configuration // -- - s.ipset.init(s.conf.IPSETList) + err := s.ipset.init(s.conf.IPSETList) + if err != nil { + return err + } // Prepare DNS servers settings // -- - err := s.prepareUpstreamSettings() + err = s.prepareUpstreamSettings() if err != nil { return err } diff --git a/internal/dnsforward/ipset.go b/internal/dnsforward/ipset.go deleted file mode 100644 index 7421edda..00000000 --- a/internal/dnsforward/ipset.go +++ /dev/null @@ -1,142 +0,0 @@ -package dnsforward - -import ( - "net" - "strings" - "sync" - - "github.com/AdguardTeam/AdGuardHome/internal/util" - "github.com/AdguardTeam/golibs/log" - "github.com/miekg/dns" -) - -type ipsetCtx struct { - ipsetList map[string][]string // domain -> []ipset_name - ipsetCache map[[4]byte]bool // cache for IP[] to prevent duplicate calls to ipset program - ipsetMutex *sync.Mutex - ipset6Cache map[[16]byte]bool // cache for IP[] to prevent duplicate calls to ipset program - ipset6Mutex *sync.Mutex -} - -// Convert configuration settings to an internal map -// DOMAIN[,DOMAIN].../IPSET1_NAME[,IPSET2_NAME]... -func (c *ipsetCtx) init(ipsetConfig []string) { - c.ipsetList = make(map[string][]string) - c.ipsetCache = make(map[[4]byte]bool) - c.ipsetMutex = &sync.Mutex{} - c.ipset6Cache = make(map[[16]byte]bool) - c.ipset6Mutex = &sync.Mutex{} - - for _, it := range ipsetConfig { - it = strings.TrimSpace(it) - hostsAndNames := strings.Split(it, "/") - if len(hostsAndNames) != 2 { - log.Debug("IPSET: invalid value %q", it) - continue - } - - ipsetNames := strings.Split(hostsAndNames[1], ",") - if len(ipsetNames) == 0 { - log.Debug("IPSET: invalid value %q", it) - continue - } - bad := false - for i := range ipsetNames { - ipsetNames[i] = strings.TrimSpace(ipsetNames[i]) - if len(ipsetNames[i]) == 0 { - bad = true - break - } - } - if bad { - log.Debug("IPSET: invalid value %q", it) - continue - } - - hosts := strings.Split(hostsAndNames[0], ",") - for _, host := range hosts { - host = strings.TrimSpace(host) - host = strings.ToLower(host) - if len(host) == 0 { - log.Debug("IPSET: invalid value %q", it) - continue - } - c.ipsetList[host] = ipsetNames - } - } - log.Debug("IPSET: added %d hosts", len(c.ipsetList)) -} - -func (c *ipsetCtx) getIP(rr dns.RR) net.IP { - switch a := rr.(type) { - case *dns.A: - var ip4 [4]byte - copy(ip4[:], a.A.To4()) - c.ipsetMutex.Lock() - defer c.ipsetMutex.Unlock() - _, found := c.ipsetCache[ip4] - if found { - return nil // this IP was added before - } - c.ipsetCache[ip4] = false - return a.A - - case *dns.AAAA: - var ip6 [16]byte - copy(ip6[:], a.AAAA) - c.ipset6Mutex.Lock() - defer c.ipset6Mutex.Unlock() - _, found := c.ipset6Cache[ip6] - if found { - return nil // this IP was added before - } - c.ipset6Cache[ip6] = false - return a.AAAA - - default: - return nil - } -} - -// Add IP addresses of the specified in configuration domain names to an ipset list -func (c *ipsetCtx) process(ctx *dnsContext) (rc resultCode) { - req := ctx.proxyCtx.Req - if !(req.Question[0].Qtype == dns.TypeA || - req.Question[0].Qtype == dns.TypeAAAA) || - !ctx.responseFromUpstream { - return resultCodeSuccess - } - - host := req.Question[0].Name - host = strings.TrimSuffix(host, ".") - host = strings.ToLower(host) - ipsetNames, found := c.ipsetList[host] - if !found { - return resultCodeSuccess - } - - log.Debug("IPSET: found ipsets %v for host %s", ipsetNames, host) - - for _, it := range ctx.proxyCtx.Res.Answer { - ip := c.getIP(it) - if ip == nil { - continue - } - - ipStr := ip.String() - for _, name := range ipsetNames { - code, out, err := util.RunCommand("ipset", "add", name, ipStr) - if err != nil { - log.Info("IPSET: %s(%s) -> %s: %s", host, ipStr, name, err) - continue - } - if code != 0 { - log.Info("IPSET: ipset add: code:%d output:%q", code, out) - continue - } - log.Debug("IPSET: added %s(%s) -> %s", host, ipStr, name) - } - } - - return resultCodeSuccess -} diff --git a/internal/dnsforward/ipset_linux.go b/internal/dnsforward/ipset_linux.go new file mode 100644 index 00000000..b62744f7 --- /dev/null +++ b/internal/dnsforward/ipset_linux.go @@ -0,0 +1,369 @@ +// +build linux + +package dnsforward + +import ( + "fmt" + "net" + "strings" + "sync" + + "github.com/AdguardTeam/AdGuardHome/internal/agherr" + "github.com/AdguardTeam/golibs/log" + "github.com/digineo/go-ipset/v2" + "github.com/mdlayher/netlink" + "github.com/miekg/dns" + "github.com/ti-mo/netfilter" +) + +// TODO(a.garipov): Cover with unit tests as well as document how to test it +// manually. The original PR by @dsheets on Github contained an integration +// test, but unfortunately I didn't have the time to properly refactor it and +// check it in. +// +// See https://github.com/AdguardTeam/AdGuardHome/issues/2611. + +// ipsetProps contains one Linux Netfilter ipset properties. +type ipsetProps struct { + name string + family netfilter.ProtoFamily +} + +// ipsetCtx is the Linux Netfilter ipset context. +type ipsetCtx struct { + // mu protects all properties below. + mu *sync.Mutex + + nameToIpset map[string]ipsetProps + domainToIpsets map[string][]ipsetProps + + addedIPs map[[16]byte]struct{} + + ipv4Conn *ipset.Conn + ipv6Conn *ipset.Conn +} + +// dialNetfilter establishes connections to Linux's netfilter module. +func (c *ipsetCtx) dialNetfilter(config *netlink.Config) (err error) { + // The kernel API does not actually require two sockets but package + // github.com/digineo/go-ipset does. + // + // TODO(a.garipov): Perhaps we can ditch package ipset altogether and + // just use packages netfilter and netlink. + c.ipv4Conn, err = ipset.Dial(netfilter.ProtoIPv4, config) + if err != nil { + return fmt.Errorf("dialing v4: %w", err) + } + + c.ipv6Conn, err = ipset.Dial(netfilter.ProtoIPv6, config) + if err != nil { + return fmt.Errorf("dialing v6: %w", err) + } + + return nil +} + +// ipsetProps returns the properties of an ipset with the given name. +func (c *ipsetCtx) ipsetProps(name string) (set ipsetProps, err error) { + // The family doesn't seem to matter when we use a header query, so + // query only the IPv4 one. + // + // TODO(a.garipov): Find out if this is a bug or a feature. + res, err := c.ipv4Conn.Header(name) + if err != nil { + return set, err + } + + if res == nil || res.Family == nil { + return set, agherr.Error("empty response or no family data") + } + + family := netfilter.ProtoFamily(res.Family.Value) + if family != netfilter.ProtoIPv4 && family != netfilter.ProtoIPv6 { + return set, fmt.Errorf("unexpected ipset family %s", family) + } + + return ipsetProps{ + name: name, + family: family, + }, nil +} + +// ipsets returns currently known ipsets. +func (c *ipsetCtx) ipsets(names []string) (sets []ipsetProps, err error) { + for _, name := range names { + set, ok := c.nameToIpset[name] + if ok { + sets = append(sets, set) + + continue + } + + var err error + set, err = c.ipsetProps(name) + if err != nil { + return nil, fmt.Errorf("querying ipset %q: %w", name, err) + } + + c.nameToIpset[name] = set + sets = append(sets, set) + } + + return sets, nil +} + +// parseIpsetConfig parses one ipset configuration string. +func parseIpsetConfig(cfgStr string) (hosts, ipsetNames []string, err error) { + cfgStr = strings.TrimSpace(cfgStr) + hostsAndNames := strings.Split(cfgStr, "/") + if len(hostsAndNames) != 2 { + return nil, nil, fmt.Errorf("invalid value %q: expected one slash", cfgStr) + } + + hosts = strings.Split(hostsAndNames[0], ",") + ipsetNames = strings.Split(hostsAndNames[1], ",") + + if len(ipsetNames) == 0 { + log.Info("ipset: resolutions for %q will not be stored", hosts) + + return nil, nil, nil + } + + for i := range ipsetNames { + ipsetNames[i] = strings.TrimSpace(ipsetNames[i]) + if len(ipsetNames[i]) == 0 { + return nil, nil, fmt.Errorf("invalid value %q: empty ipset name", cfgStr) + } + } + + for i := range hosts { + hosts[i] = strings.TrimSpace(hosts[i]) + hosts[i] = strings.ToLower(hosts[i]) + if len(hosts[i]) == 0 { + log.Info("ipset: root catchall in %q", ipsetNames) + } + } + + return hosts, ipsetNames, nil +} + +// init initializes the ipset context. It is not safe for concurrent use. +// +// TODO(a.garipov): Rewrite into a simple constructor? +func (c *ipsetCtx) init(ipsetConfig []string) (err error) { + c.mu = &sync.Mutex{} + c.nameToIpset = make(map[string]ipsetProps) + c.domainToIpsets = make(map[string][]ipsetProps) + c.addedIPs = make(map[[16]byte]struct{}) + + err = c.dialNetfilter(&netlink.Config{}) + if err != nil { + return fmt.Errorf("ipset: dialing netfilter: %w", err) + } + + for i, cfgStr := range ipsetConfig { + var hosts, ipsetNames []string + hosts, ipsetNames, err = parseIpsetConfig(cfgStr) + if err != nil { + return fmt.Errorf("ipset: config line at index %d: %w", i, err) + } + + var ipsets []ipsetProps + ipsets, err = c.ipsets(ipsetNames) + if err != nil { + return fmt.Errorf("ipset: getting ipsets config line at index %d: %w", i, err) + } + + for _, host := range hosts { + c.domainToIpsets[host] = append(c.domainToIpsets[host], ipsets...) + } + } + + log.Debug("ipset: added %d domains for %d ipsets", len(c.domainToIpsets), len(c.nameToIpset)) + + return nil +} + +// Close closes the Linux Netfilter connections. +func (c *ipsetCtx) Close() (err error) { + var errors []error + err = c.ipv4Conn.Close() + if err != nil { + errors = append(errors, err) + } + + err = c.ipv6Conn.Close() + if err != nil { + errors = append(errors, err) + } + + if len(errors) != 0 { + return agherr.Many("closing ipsets", errors...) + } + + return nil +} + +// ipFromRR returns an IP address from a DNS resource record. +func ipFromRR(rr dns.RR) (ip net.IP) { + switch a := rr.(type) { + case *dns.A: + return a.A + case *dns.AAAA: + return a.AAAA + default: + return nil + } +} + +// lookupHost find the ipsets for the host, taking subdomain wildcards into +// account. +func (c *ipsetCtx) lookupHost(host string) (sets []ipsetProps) { + // Search for matching ipset hosts starting with most specific + // subdomain. We could use a trie here but the simple, inefficient + // solution isn't that expensive. ~75 % for 10 subdomains vs 0, but + // still sub-microsecond on a Core i7. + // + // TODO(a.garipov): Re-add benchmarks from the original PR. + for i := 0; i != -1; i++ { + host = host[i:] + sets = c.domainToIpsets[host] + if sets != nil { + return sets + } + + i = strings.Index(host, ".") + } + + // Check the root catch-all one. + return c.domainToIpsets[""] +} + +// addIPs adds the IP addresses for the host to the ipset. set must be same +// family as set's family. +func (c *ipsetCtx) addIPs(host string, set ipsetProps, ips []net.IP) (err error) { + if len(ips) == 0 { + return + } + + entries := make([]*ipset.Entry, 0, len(ips)) + for _, ip := range ips { + entries = append(entries, ipset.NewEntry(ipset.EntryIP(ip))) + } + + var conn *ipset.Conn + switch set.family { + case netfilter.ProtoIPv4: + conn = c.ipv4Conn + case netfilter.ProtoIPv6: + conn = c.ipv6Conn + default: + return fmt.Errorf("unexpected family %s for ipset %q", set.family, set.name) + } + + err = conn.Add(set.name, entries...) + if err != nil { + return fmt.Errorf("adding %q%s to ipset %q: %w", host, ips, set.name, err) + } + + log.Debug("ipset: added %s%s to ipset %s", host, ips, set.name) + + return nil +} + +// skipIpsetProcessing returns true when the ipset processing can be skipped for +// this request. +func (c *ipsetCtx) skipIpsetProcessing(ctx *dnsContext) (ok bool) { + if len(c.domainToIpsets) == 0 || ctx == nil || !ctx.responseFromUpstream { + return true + } + + req := ctx.proxyCtx.Req + if req == nil || len(req.Question) == 0 { + return true + } + + qt := req.Question[0].Qtype + return qt != dns.TypeA && qt != dns.TypeAAAA && qt != dns.TypeANY +} + +// process adds the resolved IP addresses to the domain's ipsets, if any. +func (c *ipsetCtx) process(ctx *dnsContext) (rc resultCode) { + if c == nil { + return resultCodeSuccess + } + + c.mu.Lock() + defer c.mu.Unlock() + + if c.skipIpsetProcessing(ctx) { + log.Debug("ipset: skipped processing for request") + + return resultCodeSuccess + } + + req := ctx.proxyCtx.Req + host := req.Question[0].Name + host = strings.TrimSuffix(host, ".") + host = strings.ToLower(host) + sets := c.lookupHost(host) + if len(sets) == 0 { + return resultCodeSuccess + } + + log.Debug("ipset: found ipsets %+v for host %s", sets, host) + + if ctx.proxyCtx.Res == nil { + return resultCodeSuccess + } + + ans := ctx.proxyCtx.Res.Answer + l := len(ans) + v4s := make([]net.IP, 0, l) + v6s := make([]net.IP, 0, l) + for _, rr := range ans { + ip := ipFromRR(rr) + if ip == nil { + continue + } + + var iparr [16]byte + copy(iparr[:], ip.To16()) + if _, added := c.addedIPs[iparr]; added { + continue + } + + if ip.To4() == nil { + v6s = append(v6s, ip) + + continue + } + + v4s = append(v4s, ip) + } + + var err error +setLoop: + for _, set := range sets { + switch set.family { + case netfilter.ProtoIPv4: + err = c.addIPs(host, set, v4s) + if err != nil { + break setLoop + } + case netfilter.ProtoIPv6: + err = c.addIPs(host, set, v6s) + if err != nil { + break setLoop + } + default: + err = fmt.Errorf("unexpected family %s for ipset %q", set.family, set.name) + break setLoop + } + } + if err != nil { + log.Error("ipset: adding host ips: %s", err) + } + + return resultCodeSuccess +} diff --git a/internal/dnsforward/ipset_others.go b/internal/dnsforward/ipset_others.go new file mode 100644 index 00000000..1b1c2e1b --- /dev/null +++ b/internal/dnsforward/ipset_others.go @@ -0,0 +1,26 @@ +// +build !linux + +package dnsforward + +import ( + "github.com/AdguardTeam/golibs/log" +) + +type ipsetCtx struct{} + +// init initializes the ipset context. +func (c *ipsetCtx) init(ipsetConfig []string) (err error) { + if len(ipsetConfig) != 0 { + log.Info("ipset: only available on linux") + } + + return nil +} + +// process adds the resolved IP addresses to the domain's ipsets, if any. +func (c *ipsetCtx) process(_ *dnsContext) (rc resultCode) { + return resultCodeSuccess +} + +// Close closes the Linux Netfilter connections. +func (c *ipsetCtx) Close() (_ error) { return nil } diff --git a/internal/dnsforward/ipset_test.go b/internal/dnsforward/ipset_test.go deleted file mode 100644 index f08e4d08..00000000 --- a/internal/dnsforward/ipset_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package dnsforward - -import ( - "testing" - - "github.com/AdguardTeam/dnsproxy/proxy" - "github.com/miekg/dns" - "github.com/stretchr/testify/assert" -) - -func TestIPSET(t *testing.T) { - s := Server{} - s.conf.IPSETList = append(s.conf.IPSETList, "HOST.com/name") - s.conf.IPSETList = append(s.conf.IPSETList, "host2.com,host3.com/name23") - s.conf.IPSETList = append(s.conf.IPSETList, "host4.com/name4,name41") - c := ipsetCtx{} - c.init(s.conf.IPSETList) - - assert.Equal(t, "name", c.ipsetList["host.com"][0]) - assert.Equal(t, "name23", c.ipsetList["host2.com"][0]) - assert.Equal(t, "name23", c.ipsetList["host3.com"][0]) - assert.Equal(t, "name4", c.ipsetList["host4.com"][0]) - assert.Equal(t, "name41", c.ipsetList["host4.com"][1]) - - _, ok := c.ipsetList["host0.com"] - assert.False(t, ok) - - ctx := &dnsContext{ - srv: &s, - } - ctx.proxyCtx = &proxy.DNSContext{} - ctx.proxyCtx.Req = &dns.Msg{ - Question: []dns.Question{ - { - Name: "host.com.", - Qtype: dns.TypeA, - }, - }, - } - assert.Equal(t, resultCodeSuccess, c.process(ctx)) -}