[Dnsmasq-discuss] [PATCH 1/2] Add `--local-hosts` to stop forwarding queries for local names
Dominik Derigs
git at dl6er.de
Sun Oct 4 11:44:21 UTC 2026
From: DL6ER <dl6er at dl6er.de>
A name which only exists locally answers from local data for the types it
has and is forwarded for every other type. `/etc/hosts` with a single A
record therefore sends the matching AAAA, MX and HTTPS queries upstream,
and if the public zone of the same name exists, the client is handed the
public answer for a name the administrator defined locally. Resolvers
send AAAA alongside A, so this happens on nearly every lookup.
For `--address=` this was the documented behaviour until 2.86, and the
man page offers `--local=/domain/` to restore it. That works on whole
domains: it makes dnsmasq authoritative for the zone, so every name in it
which is not configured locally answers NXDOMAIN. It is therefore not
usable for a domain which also exists publicly, which is exactly the
split-DNS case where local records are wanted. `/etc/hosts`, DHCP and
`--host-record` names were never covered either way.
`--local-hosts` is the per-name equivalent. A query is answered locally
as an empty NOERROR when dnsmasq holds a record for that exact name and
no record of the queried type, and is forwarded unchanged otherwise. It
is the same hook `--domain-needed` uses in `forward_query()` and
`tcp_request()`, with `check_for_local_name()` as the test.
The test is deliberately narrower than `check_for_local_domain()` in
three ways, each of which would otherwise capture a name that is not
ours:
- `cache_find_non_terminal()` matches answers cached from upstream, so a
name whose A we happened to cache would stop forwarding its AAAA.
`cache_find_local()` requires `F_HOSTS`, `F_DHCP` or `F_CONFIG`.
- Both it and the configured-record lists match a name because a record
exists *below* it, which is right for proving a non-terminal exists but
not for claiming the name. An `--mx-host` for `mail.example.com` must
not stop `example.com` resolving. Hence the exact-match flag on
`check_for_local_config()` and the empty-non-terminal test from
`make_non_terminals()` in `cache_find_local()`.
- DS and DNSKEY are excluded, both as query types and as records. An
address record says nothing about the zone's keys, so answering them
locally would assert an absence dnsmasq cannot prove, and a resolver
validating behind us would get a negative DS answer carrying no SOA to
reason from.
`lookup_domain(name, F_CONFIG, ...)` covers `--address=`, which is a
server entry rather than a cache record.
The option is off by default and changes nothing when unset.
Signed-off-by: DL6ER <dl6er at dl6er.de>
---
man/dnsmasq.8 | 20 ++++++++++++++++++++
src/cache.c | 23 +++++++++++++++++++++++
src/dnsmasq.h | 5 ++++-
src/forward.c | 16 ++++++++++++++++
src/option.c | 3 +++
src/rfc1035.c | 39 +++++++++++++++++++++++++++++----------
6 files changed, 95 insertions(+), 11 deletions(-)
diff --git a/man/dnsmasq.8 b/man/dnsmasq.8
index 8923325..c61cc79 100644
--- a/man/dnsmasq.8
+++ b/man/dnsmasq.8
@@ -503,6 +503,26 @@ Tells dnsmasq to never forward A or AAAA queries for plain names, without dots
or domain parts, to upstream nameservers. If the name is not known
from /etc/hosts or DHCP then a "not found" answer is returned.
.TP
+.B --local-hosts
+Tells dnsmasq to never forward queries for a name it holds a record for, even
+when the query is for a type that record does not cover. Without this, a name
+in /etc/hosts with only an A record has its AAAA and MX queries sent upstream,
+and the client gets whatever the public zone of the same name says. With it,
+those queries are answered from the local data alone, as an empty NOERROR.
+
+Only the exact names dnsmasq was configured with are affected: /etc/hosts,
+\fB--host-record\fP, \fB--cname\fP, \fB--address\fP, \fB--mx-host\fP,
+\fB--txt-record\fP, \fB--srv-host\fP, \fB--ptr-record\fP,
+\fB--interface-name\fP and DHCP leases. Names merely cached from upstream do
+not count, and neither do other names in the same domain, which keep being
+forwarded as usual. This makes it a per-name alternative to \fB--local\fP,
+which takes over a whole domain and answers NXDOMAIN for every name in it that
+is not configured locally.
+
+DS and DNSKEY queries are always forwarded, whatever local records the name
+has. An address record says nothing about the keys of the zone the name sits
+in, so answering them locally would claim knowledge dnsmasq does not have.
+.TP
.B \-S, --local, --server=[/[<domain>]/[domain/]][<server>[#<port>]][@<interface>][@<source-ip>[#<port>]]
Specify upstream servers directly. Setting this flag does
not suppress reading of /etc/resolv.conf, use \fB--no-resolv\fP to do that. If one or more
diff --git a/src/cache.c b/src/cache.c
index c20b738..cbcf575 100644
--- a/src/cache.c
+++ b/src/cache.c
@@ -1193,6 +1193,29 @@ int cache_find_non_terminal(char *name, time_t now)
return 0;
}
+/* Is there a locally configured record holding data for exactly this name?
+ Unlike cache_find_non_terminal(), an answer cached from upstream does not
+ count: only /etc/hosts, --host-record, --cname, --dns-rr and DHCP do.
+ Empty non-terminals are excluded with the same test make_non_terminals()
+ uses, so a name is not claimed merely because a record exists below it,
+ and so are DS and DNSKEY, which say nothing about the name's own data. */
+int cache_find_local(char *name, time_t now)
+{
+ struct crec *crecp;
+
+ for (crecp = *hash_bucket(name); crecp; crecp = crecp->hash_next)
+ if (!is_outdated_cname_pointer(crecp) &&
+ !is_expired(now, crecp) &&
+ (crecp->flags & F_FORWARD) &&
+ (crecp->flags & (F_HOSTS | F_DHCP | F_CONFIG)) &&
+ (crecp->flags & (F_IPV4 | F_IPV6 | F_CNAME | F_RR)) &&
+ !(crecp->flags & F_NXDOMAIN) &&
+ hostname_isequal(name, cache_get_name(crecp)))
+ return 1;
+
+ return 0;
+}
+
struct crec *cache_find_by_name(struct crec *crecp, char *name, time_t now, unsigned int prot)
{
struct crec *ans;
diff --git a/src/dnsmasq.h b/src/dnsmasq.h
index ecff18d..66a2da2 100644
--- a/src/dnsmasq.h
+++ b/src/dnsmasq.h
@@ -296,7 +296,8 @@ struct event_desc {
#define OPT_LEASEQUERY 77
#define OPT_LOG_ONLY_FAILED 78
#define OPT_LOG_MALLOC 79
-#define OPT_LAST 80
+#define OPT_LOCAL_HOSTS 80
+#define OPT_LAST 81
#define OPTION_BITS (sizeof(unsigned int)*8)
#define OPTION_SIZE ( (OPT_LAST/OPTION_BITS)+((OPT_LAST%OPTION_BITS)!=0) )
@@ -1374,6 +1375,7 @@ void next_uid(struct crec *crecp);
void log_query(unsigned int flags, char *name, union all_addr *addr, char *arg, unsigned short type);
char *record_source(unsigned int index);
int cache_find_non_terminal(char *name, time_t now);
+int cache_find_local(char *name, time_t now);
struct crec *cache_find_by_addr(struct crec *crecp,
union all_addr *addr, time_t now,
unsigned int prot);
@@ -1446,6 +1448,7 @@ int check_for_bogus_wildcard(struct dns_header *header, size_t qlen, char *name,
time_t now);
int check_for_ignored_address(struct dns_header *header, size_t qlen);
int check_for_local_domain(char *name, time_t now);
+int check_for_local_name(char *name, time_t now);
size_t resize_packet(struct dns_header *header, size_t plen,
unsigned char *pheader, size_t hlen);
int add_resource_record(struct dns_header *header, char *limit, int *truncp,
diff --git a/src/forward.c b/src/forward.c
index f736c30..9df981d 100644
--- a/src/forward.c
+++ b/src/forward.c
@@ -362,6 +362,15 @@ static void forward_query(int udpfd, union mysockaddr *udpaddr,
!strchr(daemon->namebuff, '.') &&
strlen(daemon->namebuff) != 0)
flags = check_for_local_domain(daemon->namebuff, now) ? F_NOERR : F_NXDOMAIN;
+
+ /* A name we hold a record for is ours: don't ask upstream about the
+ types that record does not cover. DS and DNSKEY are not ours to
+ answer, an address record says nothing about the zone's keys. */
+ if (!flags &&
+ option_bool(OPT_LOCAL_HOSTS) &&
+ !(gotname & F_DNSSECOK) &&
+ check_for_local_name(daemon->namebuff, now))
+ flags = F_NOERR;
/* Configured answer. */
if (flags || ede == EDE_NOT_READY)
@@ -2626,6 +2635,13 @@ void tcp_request(int confd, time_t now, struct iovec *bigbuff,
!strchr(daemon->namebuff, '.') &&
strlen(daemon->namebuff) != 0)
flags = check_for_local_domain(daemon->namebuff, now) ? F_NOERR : F_NXDOMAIN;
+ /* A name we hold a record for is ours: don't ask upstream about the
+ types that record does not cover. DS and DNSKEY are not ours
+ to answer, an address record says nothing about the keys. */
+ else if (option_bool(OPT_LOCAL_HOSTS) &&
+ !(gotname & F_DNSSECOK) &&
+ check_for_local_name(daemon->namebuff, now))
+ flags = F_NOERR;
else
{
master = daemon->serverarray[first];
diff --git a/src/option.c b/src/option.c
index 44bd522..a682f09 100644
--- a/src/option.c
+++ b/src/option.c
@@ -201,6 +201,7 @@ struct myoption {
#define LOPT_LEASEQUERY 389
#define LOPT_SPLIT_RELAY 390
#define LOPT_LOG_MALLOC 391
+#define LOPT_LOCAL_HOSTS 392
#ifdef HAVE_GETOPT_LONG
static const struct option opts[] =
@@ -403,6 +404,7 @@ static const struct myoption opts[] =
{ "max-tcp-connections", 1, 0, LOPT_MAX_PROCS },
{ "leasequery", 2, 0, LOPT_LEASEQUERY },
{ "log-malloc", 0, 0, LOPT_LOG_MALLOC },
+ { "local-hosts", 0, 0, LOPT_LOCAL_HOSTS },
{ NULL, 0, 0, 0 }
};
@@ -612,6 +614,7 @@ static struct {
{ LOPT_CACHE_RR, ARG_DUP, "<RR-type>", gettext_noop("Cache this DNS resource record type."), NULL },
{ LOPT_MAX_PROCS, ARG_ONE, "<integer>", gettext_noop("Maximum number of concurrent tcp connections."), NULL },
{ LOPT_LOG_MALLOC, OPT_LOG_MALLOC, NULL, gettext_noop("Log memory allocation for debugging."), NULL },
+ { LOPT_LOCAL_HOSTS, OPT_LOCAL_HOSTS, NULL, gettext_noop("Do NOT forward queries for names which have a local record."), NULL },
{ 0, 0, NULL, NULL, NULL }
};
diff --git a/src/rfc1035.c b/src/rfc1035.c
index 633aa5b..9318c1c 100644
--- a/src/rfc1035.c
+++ b/src/rfc1035.c
@@ -1295,8 +1295,15 @@ void setup_reply(struct dns_header *header, unsigned int flags, int ede)
}
}
-/* check if name matches local names ie from /etc/hosts or DHCP or local mx names. */
-int check_for_local_domain(char *name, time_t now)
+/* Does name match a locally configured record, ie an MX, TXT, SRV, NAPTR, PTR
+ or interface-name? With exact set, only the name itself counts; otherwise a
+ record below it does too, which makes name an existing non-terminal. */
+static int local_config_match(char *name, char *record, int exact)
+{
+ return exact ? hostname_isequal(name, record) : !!hostname_issubdomain(name, record);
+}
+
+static int check_for_local_config(char *name, int exact)
{
struct mx_srv_record *mx;
struct txt_record *txt;
@@ -1305,28 +1312,25 @@ int check_for_local_domain(char *name, time_t now)
struct naptr *naptr;
for (naptr = daemon->naptr; naptr; naptr = naptr->next)
- if (hostname_issubdomain(name, naptr->name))
+ if (local_config_match(name, naptr->name, exact))
return 1;
for (mx = daemon->mxnames; mx; mx = mx->next)
- if (hostname_issubdomain(name, mx->name))
+ if (local_config_match(name, mx->name, exact))
return 1;
for (txt = daemon->txt; txt; txt = txt->next)
- if (hostname_issubdomain(name, txt->name))
+ if (local_config_match(name, txt->name, exact))
return 1;
for (intr = daemon->int_names; intr; intr = intr->next)
- if (hostname_issubdomain(name, intr->name))
+ if (local_config_match(name, intr->name, exact))
return 1;
for (ptr = daemon->ptr; ptr; ptr = ptr->next)
- if (hostname_issubdomain(name, ptr->name))
+ if (local_config_match(name, ptr->name, exact))
return 1;
- if (cache_find_non_terminal(name, now))
- return 1;
-
if (is_name_synthetic(F_IPV4, name, NULL) ||
is_name_synthetic(F_IPV6, name, NULL))
return 1;
@@ -1334,6 +1338,21 @@ int check_for_local_domain(char *name, time_t now)
return 0;
}
+/* check if name matches local names ie from /etc/hosts or DHCP or local mx names. */
+int check_for_local_domain(char *name, time_t now)
+{
+ return check_for_local_config(name, 0) || cache_find_non_terminal(name, now);
+}
+
+/* As above, but for --local-hosts, so an answer cached from upstream must not
+ count: the question is whether this name is one we were configured with. */
+int check_for_local_name(char *name, time_t now)
+{
+ return check_for_local_config(name, 1) ||
+ cache_find_local(name, now) ||
+ lookup_domain(name, F_CONFIG, NULL, NULL);
+}
+
static int check_bad_address(struct dns_header *header, size_t qlen, struct bogus_addr *baddr, char *name, unsigned long *ttlp)
{
unsigned char *p;
--
2.43.0
More information about the Dnsmasq-discuss
mailing list