mirror of
https://github.com/0xERR0R/blocky.git
synced 2026-10-12 02:01:13 +00:00
Client name resolution previously only used the static clientLookup.clients mapping or a reverse-DNS query against a configured clientLookup.upstream. With neither set, the client IP was used as its own name even when Blocky already knew the name from a hosts file or custom DNS entry. ClientNamesResolver now consults Blocky's in-memory reverse sources (custom DNS, hosts files) before the rDNS upstream, performing no network round-trip. Resolution order: static mapping -> local sources -> rDNS upstream -> IP fallback. This also makes the per-client CSV log filename use the resolved hostname.
This commit is contained in:
+17
-2
@@ -558,8 +558,23 @@ DoH URL: `https://blocky.example.com/dns-query/alice` -> request's client name i
|
||||
|
||||
### Resolving client name from IP address
|
||||
|
||||
Blocky uses rDNS to retrieve client's name. To use this feature, you can configure a DNS server for client lookup (
|
||||
typically your router). You can also define client names manually per IP address.
|
||||
Blocky resolves a client's name from its IP address using the first of the following sources that yields a match:
|
||||
|
||||
1. **Custom client name mapping** – names defined manually via `clientLookup.clients` (see below).
|
||||
2. **Local in-memory sources** – reverse (IP → name) entries already known to Blocky from your
|
||||
[custom DNS](#custom-dns) records and [hosts files](#hosts-file). This works automatically, requires no
|
||||
`clientLookup.upstream`, and performs no network lookup. For example, a hosts file entry `192.168.1.11 unifi`
|
||||
makes the client name of `192.168.1.11` resolve to `unifi`.
|
||||
3. **rDNS upstream** – a reverse DNS lookup against the DNS server configured in `clientLookup.upstream`
|
||||
(typically your router).
|
||||
|
||||
If none of these returns a name, the IP address is used as the client name.
|
||||
|
||||
!!! note
|
||||
|
||||
Because client names are also used for [blocking groups](#blocking-and-allowlisting) and
|
||||
[client-specific upstream groups](#upstreams-configuration), enabling local sources (custom DNS / hosts files) can change which
|
||||
group a client matches if a group is keyed on a name now resolved from those sources.
|
||||
|
||||
#### Single name order
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// reverseLookuper resolves host names for an IP from local, in-memory data only
|
||||
// (e.g. hosts files or custom DNS). It performs no network I/O.
|
||||
type reverseLookuper interface {
|
||||
LookupReverse(ip net.IP) []string
|
||||
}
|
||||
|
||||
// ClientNamesResolver tries to determine client name by asking responsible DNS server via rDNS (reverse lookup)
|
||||
type ClientNamesResolver struct {
|
||||
configurable[*config.ClientLookup]
|
||||
@@ -26,11 +32,16 @@ type ClientNamesResolver struct {
|
||||
|
||||
cache cache.ExpiringCache[[]string]
|
||||
externalResolver Resolver
|
||||
reverseLookupers []reverseLookuper
|
||||
}
|
||||
|
||||
// NewClientNamesResolver creates new resolver instance
|
||||
// NewClientNamesResolver creates new resolver instance.
|
||||
//
|
||||
// localReverseLookupers are consulted, in order, for in-memory reverse (IP -> name)
|
||||
// resolution before falling back to the configured rDNS upstream.
|
||||
func NewClientNamesResolver(ctx context.Context,
|
||||
cfg config.ClientLookup, upstreamsCfg config.Upstreams, bootstrap *Bootstrap,
|
||||
localReverseLookupers ...reverseLookuper,
|
||||
) (cr *ClientNamesResolver, err error) {
|
||||
var r Resolver
|
||||
if !cfg.Upstream.IsDefault() {
|
||||
@@ -48,6 +59,7 @@ func NewClientNamesResolver(ctx context.Context,
|
||||
CleanupInterval: time.Hour,
|
||||
}),
|
||||
externalResolver: r,
|
||||
reverseLookupers: localReverseLookupers,
|
||||
}
|
||||
|
||||
return cr, err
|
||||
@@ -112,7 +124,8 @@ func extractClientNamesFromAnswer(answer []dns.RR, fallbackIP net.IP) (clientNam
|
||||
return clientNames
|
||||
}
|
||||
|
||||
// tries to resolve client name from mapping, performs reverse DNS lookup otherwise
|
||||
// tries to resolve client name from mapping, then from local in-memory sources,
|
||||
// and performs a reverse DNS lookup against the configured upstream otherwise
|
||||
func (r *ClientNamesResolver) resolveClientNames(ctx context.Context, ip net.IP) (result []string) {
|
||||
ctx, logger := r.log(ctx)
|
||||
|
||||
@@ -122,6 +135,16 @@ func (r *ClientNamesResolver) resolveClientNames(ctx context.Context, ip net.IP)
|
||||
return result
|
||||
}
|
||||
|
||||
// try local, in-memory reverse sources (hosts file, custom DNS) before any network lookup
|
||||
if names := r.lookupLocalReverse(ip); len(names) > 0 {
|
||||
result = applySingleNameOrder(names, r.cfg.SingleNameOrder)
|
||||
|
||||
logger.WithField("client_names", strings.Join(result, "; ")).
|
||||
Debug("resolved client name(s) from local reverse lookup")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
if r.externalResolver == nil {
|
||||
return []string{ip.String()}
|
||||
}
|
||||
@@ -138,25 +161,42 @@ func (r *ClientNamesResolver) resolveClientNames(ctx context.Context, ip net.IP)
|
||||
}
|
||||
|
||||
clientNames := extractClientNamesFromAnswer(resp.Res.Answer, ip)
|
||||
|
||||
// optional: if singleNameOrder is set, use only one name in the defined order
|
||||
if len(r.cfg.SingleNameOrder) > 0 {
|
||||
for _, i := range r.cfg.SingleNameOrder {
|
||||
if i > 0 && int(i) <= len(clientNames) {
|
||||
result = []string{clientNames[i-1]}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result = clientNames
|
||||
}
|
||||
result = applySingleNameOrder(clientNames, r.cfg.SingleNameOrder)
|
||||
|
||||
logger.WithField("client_names", strings.Join(result, "; ")).Debug("resolved client name(s) from external resolver")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// lookupLocalReverse returns the first non-empty result from the configured local
|
||||
// reverse lookupers (hosts file, custom DNS), or nil if none has a name for the IP.
|
||||
func (r *ClientNamesResolver) lookupLocalReverse(ip net.IP) []string {
|
||||
for _, l := range r.reverseLookupers {
|
||||
if names := l.LookupReverse(ip); len(names) > 0 {
|
||||
return names
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySingleNameOrder reduces the resolved names to a single one following the
|
||||
// configured 1-based order. If order is empty, all names are returned unchanged.
|
||||
// If order is set but no configured index is in range, no name is returned.
|
||||
func applySingleNameOrder(names []string, order []uint) []string {
|
||||
if len(order) == 0 {
|
||||
return names
|
||||
}
|
||||
|
||||
for _, i := range order {
|
||||
if i > 0 && int(i) <= len(names) {
|
||||
return []string{names[i-1]}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ClientNamesResolver) getNameFromIPMapping(ip net.IP, result []string) []string {
|
||||
for name, ips := range r.cfg.ClientnameIPMapping {
|
||||
for _, i := range ips {
|
||||
|
||||
@@ -391,6 +391,123 @@ var _ = Describe("ClientResolver", Label("clientNamesResolver"), func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
Describe("Resolve client name from local reverse lookup", func() {
|
||||
AfterEach(func() {
|
||||
// next resolver will be called
|
||||
m.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
When("a local lookuper has a name for the client IP", func() {
|
||||
BeforeEach(func() {
|
||||
sutConfig = config.ClientLookup{}
|
||||
})
|
||||
JustBeforeEach(func() {
|
||||
sut.reverseLookupers = []reverseLookuper{
|
||||
stubReverseLookuper{"192.168.1.11": {"unifi"}},
|
||||
}
|
||||
})
|
||||
|
||||
It("uses the local name", func() {
|
||||
request := newRequestWithClient("google.de.", dns.Type(dns.TypeA), "192.168.1.11")
|
||||
Expect(sut.Resolve(ctx, request)).
|
||||
Should(SatisfyAll(HaveResponseType(ResponseTypeRESOLVED), HaveReturnCode(dns.RcodeSuccess)))
|
||||
|
||||
Expect(request.ClientNames).Should(ConsistOf("unifi"))
|
||||
})
|
||||
|
||||
It("does not query the external upstream when a local name is found", func() {
|
||||
external := &mockResolver{} // no expectation set: must not be called
|
||||
sut.externalResolver = external
|
||||
|
||||
request := newRequestWithClient("google.de.", dns.Type(dns.TypeA), "192.168.1.11")
|
||||
_, err := sut.Resolve(ctx, request)
|
||||
Expect(err).Should(Succeed())
|
||||
|
||||
Expect(request.ClientNames).Should(ConsistOf("unifi"))
|
||||
external.AssertNotCalled(GinkgoT(), "Resolve", mock.Anything)
|
||||
})
|
||||
})
|
||||
|
||||
When("multiple lookupers are configured", func() {
|
||||
BeforeEach(func() {
|
||||
sutConfig = config.ClientLookup{}
|
||||
})
|
||||
JustBeforeEach(func() {
|
||||
sut.reverseLookupers = []reverseLookuper{
|
||||
stubReverseLookuper{}, // no match
|
||||
stubReverseLookuper{"192.168.1.11": {"unifi"}}, // match
|
||||
}
|
||||
})
|
||||
|
||||
It("uses the first lookuper that returns a name", func() {
|
||||
request := newRequestWithClient("google.de.", dns.Type(dns.TypeA), "192.168.1.11")
|
||||
Expect(sut.Resolve(ctx, request)).
|
||||
Should(SatisfyAll(HaveResponseType(ResponseTypeRESOLVED), HaveReturnCode(dns.RcodeSuccess)))
|
||||
|
||||
Expect(request.ClientNames).Should(ConsistOf("unifi"))
|
||||
})
|
||||
})
|
||||
|
||||
When("a static client mapping also matches", func() {
|
||||
BeforeEach(func() {
|
||||
sutConfig = config.ClientLookup{
|
||||
ClientnameIPMapping: map[string][]net.IP{
|
||||
"static-name": {net.ParseIP("192.168.1.11")},
|
||||
},
|
||||
}
|
||||
})
|
||||
JustBeforeEach(func() {
|
||||
sut.reverseLookupers = []reverseLookuper{
|
||||
stubReverseLookuper{"192.168.1.11": {"local-name"}},
|
||||
}
|
||||
})
|
||||
|
||||
It("prefers the static mapping over the local lookup", func() {
|
||||
request := newRequestWithClient("google.de.", dns.Type(dns.TypeA), "192.168.1.11")
|
||||
Expect(sut.Resolve(ctx, request)).
|
||||
Should(SatisfyAll(HaveResponseType(ResponseTypeRESOLVED), HaveReturnCode(dns.RcodeSuccess)))
|
||||
|
||||
Expect(request.ClientNames).Should(ConsistOf("static-name"))
|
||||
})
|
||||
})
|
||||
|
||||
When("singleNameOrder is set", func() {
|
||||
BeforeEach(func() {
|
||||
sutConfig = config.ClientLookup{SingleNameOrder: []uint{2, 1}}
|
||||
})
|
||||
JustBeforeEach(func() {
|
||||
sut.reverseLookupers = []reverseLookuper{
|
||||
stubReverseLookuper{"192.168.1.11": {"name1", "name2"}},
|
||||
}
|
||||
})
|
||||
|
||||
It("applies the order to the local result", func() {
|
||||
request := newRequestWithClient("google.de.", dns.Type(dns.TypeA), "192.168.1.11")
|
||||
Expect(sut.Resolve(ctx, request)).
|
||||
Should(SatisfyAll(HaveResponseType(ResponseTypeRESOLVED), HaveReturnCode(dns.RcodeSuccess)))
|
||||
|
||||
Expect(request.ClientNames).Should(ConsistOf("name2"))
|
||||
})
|
||||
})
|
||||
|
||||
When("no lookuper has a name and no upstream is defined", func() {
|
||||
BeforeEach(func() {
|
||||
sutConfig = config.ClientLookup{}
|
||||
})
|
||||
JustBeforeEach(func() {
|
||||
sut.reverseLookupers = []reverseLookuper{stubReverseLookuper{}}
|
||||
})
|
||||
|
||||
It("falls back to the client IP", func() {
|
||||
request := newRequestWithClient("google.de.", dns.Type(dns.TypeA), "192.168.1.11")
|
||||
Expect(sut.Resolve(ctx, request)).
|
||||
Should(SatisfyAll(HaveResponseType(ResponseTypeRESOLVED), HaveReturnCode(dns.RcodeSuccess)))
|
||||
|
||||
Expect(request.ClientNames).Should(ConsistOf("192.168.1.11"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Connstruction", func() {
|
||||
When("upstream is invalid", func() {
|
||||
It("errors during construction", func() {
|
||||
@@ -409,3 +526,10 @@ var _ = Describe("ClientResolver", Label("clientNamesResolver"), func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// stubReverseLookuper is an in-memory reverseLookuper for tests, mapping IP string to host names.
|
||||
type stubReverseLookuper map[string][]string
|
||||
|
||||
func (s stubReverseLookuper) LookupReverse(ip net.IP) []string {
|
||||
return s[ip.String()]
|
||||
}
|
||||
|
||||
@@ -84,6 +84,26 @@ func isSupportedType(ip net.IP, question dns.Question) bool {
|
||||
(strings.Contains(ip.String(), ":") && question.Qtype == dns.TypeAAAA)
|
||||
}
|
||||
|
||||
// LookupReverse returns the domain names mapped to the given IP by the custom DNS
|
||||
// configuration, consulting only in-memory data (no network I/O). Returns nil if there is no match.
|
||||
func (r *CustomDNSResolver) LookupReverse(ip net.IP) []string {
|
||||
arpa, err := dns.ReverseAddr(ip.String())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
urls := r.reverseAddresses[arpa]
|
||||
if len(urls) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// return a copy so callers can't mutate the internal mapping
|
||||
names := make([]string, len(urls))
|
||||
copy(names, urls)
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
func (r *CustomDNSResolver) handleReverseDNS(request *model.Request) *model.Response {
|
||||
question := request.Req.Question[0]
|
||||
if question.Qtype == dns.TypePTR {
|
||||
|
||||
@@ -503,6 +503,22 @@ var _ = Describe("CustomDNSResolver", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("LookupReverse", func() {
|
||||
It("returns the mapped domain names for a known IPv4 address", func() {
|
||||
Expect(sut.LookupReverse(net.ParseIP("192.168.143.123"))).
|
||||
Should(ConsistOf("custom.domain", "multiple.ips"))
|
||||
})
|
||||
|
||||
It("returns the mapped domain names for a known IPv6 address", func() {
|
||||
Expect(sut.LookupReverse(net.ParseIP("2001:0db8:85a3:0000:0000:8a2e:0370:7334"))).
|
||||
Should(ConsistOf("ip6.domain", "multiple.ips"))
|
||||
})
|
||||
|
||||
It("returns nil for an unknown IP", func() {
|
||||
Expect(sut.LookupReverse(net.ParseIP("8.8.8.8"))).Should(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delegating to next resolver", func() {
|
||||
When("no mapping for domain exist", func() {
|
||||
It("should delegate to next resolver", func() {
|
||||
|
||||
@@ -75,39 +75,58 @@ func (r *HostsFileResolver) handleReverseDNS(request *model.Request) *model.Resp
|
||||
return nil
|
||||
}
|
||||
|
||||
if r.cfg.FilterLoopback && questionIP.IsLoopback() {
|
||||
hostNames := r.lookupHostNames(questionIP)
|
||||
if len(hostNames) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
hdr := util.CreateHeader(question, r.cfg.HostsTTL.SecondsU32())
|
||||
answers := make([]dns.RR, 0, len(hostNames))
|
||||
|
||||
for _, name := range hostNames {
|
||||
ptr := new(dns.PTR)
|
||||
ptr.Ptr = dns.Fqdn(name)
|
||||
ptr.Hdr = hdr
|
||||
answers = append(answers, ptr)
|
||||
}
|
||||
|
||||
return model.NewResponseWithAnswers(request, answers, model.ResponseTypeHOSTSFILE, "HOSTS FILE")
|
||||
}
|
||||
|
||||
// lookupHostNames returns the host name and its aliases mapped to the given IP by the
|
||||
// loaded hosts files, or nil if there is no match. Loopback addresses are skipped when
|
||||
// FilterLoopback is enabled. Only in-memory data is consulted (no network I/O).
|
||||
func (r *HostsFileResolver) lookupHostNames(ip net.IP) []string {
|
||||
if r.cfg.FilterLoopback && ip.IsLoopback() {
|
||||
// skip the search: we won't find anything
|
||||
return nil
|
||||
}
|
||||
|
||||
// search only in the hosts with an IP version that matches the question
|
||||
hostsData := r.hosts.v4
|
||||
if questionIP.To4() == nil {
|
||||
if ip.To4() == nil {
|
||||
hostsData = r.hosts.v6
|
||||
}
|
||||
|
||||
for host, hostData := range hostsData.hosts {
|
||||
if hostData.IP.Equal(questionIP) {
|
||||
var answers []dns.RR
|
||||
ptr := new(dns.PTR)
|
||||
ptr.Ptr = dns.Fqdn(host)
|
||||
ptr.Hdr = util.CreateHeader(question, r.cfg.HostsTTL.SecondsU32())
|
||||
answers = append(answers, ptr)
|
||||
if hostData.IP.Equal(ip) {
|
||||
names := make([]string, 0, len(hostData.Aliases)+1)
|
||||
names = append(names, host)
|
||||
names = append(names, hostData.Aliases...)
|
||||
|
||||
for _, alias := range hostData.Aliases {
|
||||
ptrAlias := new(dns.PTR)
|
||||
ptrAlias.Ptr = dns.Fqdn(alias)
|
||||
ptrAlias.Hdr = ptr.Hdr
|
||||
answers = append(answers, ptrAlias)
|
||||
}
|
||||
|
||||
return model.NewResponseWithAnswers(request, answers, model.ResponseTypeHOSTSFILE, "HOSTS FILE")
|
||||
return names
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LookupReverse returns the host names mapped to the given IP by the loaded hosts
|
||||
// files, consulting only in-memory data (no network I/O). Returns nil if there is no match.
|
||||
func (r *HostsFileResolver) LookupReverse(ip net.IP) []string {
|
||||
return r.lookupHostNames(ip)
|
||||
}
|
||||
|
||||
func (r *HostsFileResolver) Resolve(ctx context.Context, request *model.Request) (*model.Response, error) {
|
||||
if !r.IsEnabled() {
|
||||
return r.next.Resolve(ctx, request)
|
||||
|
||||
@@ -2,6 +2,7 @@ package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/0xERR0R/blocky/config"
|
||||
@@ -336,6 +337,28 @@ var _ = Describe("HostsFileResolver", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("LookupReverse", func() {
|
||||
It("returns the hostname and its aliases for a known IPv4 address", func() {
|
||||
Expect(sut.LookupReverse(net.ParseIP("10.0.0.1"))).
|
||||
Should(ConsistOf("router0", "router1", "router2"))
|
||||
})
|
||||
|
||||
It("returns the hostname and its aliases for a known IPv6 address", func() {
|
||||
Expect(sut.LookupReverse(net.ParseIP("faaf:faaf:faaf:faaf::1"))).
|
||||
Should(ConsistOf("ipv6host", "ipv6host.local.lan"))
|
||||
})
|
||||
|
||||
It("returns nil for an unknown IP", func() {
|
||||
Expect(sut.LookupReverse(net.ParseIP("8.8.8.8"))).Should(BeNil())
|
||||
})
|
||||
|
||||
When("filterLoopback is true", func() {
|
||||
It("returns nil for a loopback IP", func() {
|
||||
Expect(sut.LookupReverse(net.ParseIP("127.0.0.1"))).Should(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delegating to next resolver", func() {
|
||||
When("no hosts file is provided", func() {
|
||||
It("should delegate to next resolver", func() {
|
||||
|
||||
+5
-2
@@ -388,10 +388,13 @@ func createQueryResolver(
|
||||
) (resolver.ChainedResolver, error) {
|
||||
upstreamTree, utErr := resolver.NewUpstreamTreeResolver(ctx, cfg.Upstreams, bootstrap)
|
||||
blocking, blErr := resolver.NewBlockingResolver(ctx, cfg.Blocking, bootstrap)
|
||||
clientNames, cnErr := resolver.NewClientNamesResolver(ctx, cfg.ClientLookup, cfg.Upstreams, bootstrap)
|
||||
queryLogging, qlErr := resolver.NewQueryLoggingResolver(ctx, cfg.QueryLog)
|
||||
condUpstream, cuErr := resolver.NewConditionalUpstreamResolver(ctx, cfg.Conditional, cfg.Upstreams, bootstrap)
|
||||
customDNS := resolver.NewCustomDNSResolver(cfg.CustomDNS)
|
||||
hostsFile, hfErr := resolver.NewHostsFileResolver(ctx, cfg.HostsFile, bootstrap)
|
||||
// client name resolution consults local reverse sources (custom DNS, hosts file) before the rDNS upstream
|
||||
clientNames, cnErr := resolver.NewClientNamesResolver(
|
||||
ctx, cfg.ClientLookup, cfg.Upstreams, bootstrap, customDNS, hostsFile)
|
||||
decorator := cacheDecorator
|
||||
if !cfg.Caching.IsEnabled() {
|
||||
decorator = nil
|
||||
@@ -423,7 +426,7 @@ func createQueryResolver(
|
||||
resolver.NewEDEResolver(cfg.EDE),
|
||||
queryLogging,
|
||||
resolver.NewMetricsResolver(cfg.Prometheus),
|
||||
resolver.NewCustomDNSResolver(cfg.CustomDNS),
|
||||
customDNS,
|
||||
hostsFile,
|
||||
blocking,
|
||||
dnssecResolver, // DNSSEC validation BEFORE caching - validates all responses before they are cached
|
||||
|
||||
Reference in New Issue
Block a user