feat: resolve client names from hosts file and custom DNS (#1964) (#2069)

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:
Dimitri Herzog
2026-05-22 11:50:58 +02:00
committed by GitHub
parent f457ec90d5
commit fdcf351add
8 changed files with 295 additions and 35 deletions
+17 -2
View File
@@ -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
+55 -15
View File
@@ -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 {
+124
View File
@@ -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()]
}
+20
View File
@@ -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 {
+16
View File
@@ -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() {
+35 -16
View File
@@ -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)
+23
View File
@@ -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
View File
@@ -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