Ad- and malware-filtering lists now run to several hundred thousand
names. Loaded as --address lines they cost a lot of memory, and each
query for a name not on the list walks the domain-matching code.

--blocklist=<file> reads such a list as it is published, one name per
line, and keeps only a sorted array of 64-bit hashes of the names, eight
bytes each. A query is answered NXDOMAIN with EDE "Blocked" when a
binary search finds the name or one of its parents. 

The search stops with one label left, so a listed TLD blocks only
itself. With 64-bit hashes a lookup mistakes an unlisted name for a
listed one about once in 2^64 / n tries.

The hash is SipHash-1-3 under a key chosen at random at start-up, so
nobody can craft a list entry that looks harmless but has the hash of a
name it means to block. It is taken a label at a time from the right,
each label hashed with its parent's hash, so checking every parent of a
name hashes each label once and the cost grows linearly with the name.

The check is made on the UDP and TCP paths after the cache, hosts files,
--address and --local, and is skipped for a name with a --server of its
own, so configuration still wins. Blocked names are logged as "blocklist
<name> is NXDOMAIN".

The file is read again on SIGHUP, like --addn-hosts. If it cannot be
read then, the error is logged and the old list is kept.
---
 Makefile           |   2 +-
 man/dnsmasq.8      |  22 ++++-
 src/blocklist.c    | 237 +++++++++++++++++++++++++++++++++++++++++++++
 src/config.h       |  12 ++-
 src/dnsmasq.c      |  16 +++
 src/dnsmasq.h      |  14 ++-
 src/domain-match.c |   7 +-
 src/forward.c      |  30 +++++-
 src/option.c       |  11 +++
 9 files changed, 343 insertions(+), 8 deletions(-)
 create mode 100644 src/blocklist.c

diff --git a/Makefile b/Makefile
index 70309e7..d7e3f48 100644
--- a/Makefile
+++ b/Makefile
@@ -81,7 +81,7 @@ objs = cache.o rfc1035.o util.o option.o forward.o network.o \
        dhcp-common.o outpacket.o radv.o slaac.o auth.o ipset.o pattern.o \
        domain.o dnssec.o blockdata.o tables.o loop.o inotify.o \
        poll.o rrfilter.o edns0.o arp.o crypto.o dump.o ubus.o \
-       metrics.o domain-match.o nftset.o
+       metrics.o domain-match.o nftset.o blocklist.o
 
 hdrs = dnsmasq.h config.h dhcp-protocol.h dhcp6-protocol.h \
        dns-protocol.h radv-protocol.h ip6addr.h metrics.h
diff --git a/man/dnsmasq.8 b/man/dnsmasq.8
index 8923325..d7a2c98 100644
--- a/man/dnsmasq.8
+++ b/man/dnsmasq.8
@@ -613,6 +613,26 @@ Previous versions, configured with (eg) 
--address=/example.com/1.2.3.4 and then
 A would return a NoData answer. From  2.86, the query is sent upstream. To 
restore the pre-2.86 behaviour,
 use the configuration --address=/example.com/1.2.3.4 --local=/example.com/
 .TP
+.B --blocklist=<file>
+Answer NXDOMAIN for the names listed in <file>, one per line, with blank
+lines and anything after a # ignored. This is the format of the domain
+lists published for ad- and malware-filtering, which can be used as they
+are, and it holds them in much less memory than the same names as
+\fB--address\fP lines.
+
+A name matches if it is listed or if any of its parent domains is, so one
+entry covers a domain and its subdomains. The search stops with one label
+left: an entry for a bare TLD blocks only a query for the TLD itself.
+Names answered from a hosts file, by \fB--address\fP or \fB--local\fP,
+or sent to a
+\fB--server\fP given for that domain with an address, are not blocked.
+A \fB--server\fP using # does not exempt a name, since it hands the
+query back to the default servers.
+
+The file is read again on SIGHUP; if it cannot be read then, the old list
+is kept. Available only if dnsmasq
+is compiled with HAVE_BLOCKLIST defined.
+.TP
 .B --ipset=/<domain>[/<domain>...]/<ipset>[,<ipset>...]
 Places the resolved IP addresses of queries for one or more domains in
 the specified Netfilter IP set. If multiple setnames are given, then the
@@ -2402,7 +2422,7 @@ clears its cache and then re-loads
 and 
 .I /etc/ethers 
 and any file given by \fB--dhcp-hostsfile\fP, \fB--dhcp-hostsdir\fP, 
\fB--dhcp-optsfile\fP,
-\fB--dhcp-optsdir\fP, \fB--addn-hosts\fP or \fB--hostsdir\fP.
+\fB--dhcp-optsdir\fP, \fB--addn-hosts\fP, \fB--hostsdir\fP or 
\fB--blocklist\fP.
 The DHCP lease change script is called for all
 existing DHCP leases. If 
 .B
diff --git a/src/blocklist.c b/src/blocklist.c
new file mode 100644
index 0000000..0d4b98c
--- /dev/null
+++ b/src/blocklist.c
@@ -0,0 +1,237 @@
+/* Copyright (c) 2026 Roman Valls Guimera <[email protected]>
+
+   This program is free software; you can redistribute it and/or modify
+   it under the terms of the GNU General Public License as published by
+   the Free Software Foundation; version 2 dated June, 1991, or
+   (at your option) version 3 dated 29 June, 2007.
+
+   This program is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+   GNU General Public License for more details.
+
+   You should have received a copy of the GNU General Public License
+   along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+#include "dnsmasq.h"
+
+#ifdef HAVE_BLOCKLIST
+
+/* Names are hashed with SipHash-1-3 (Aumasson and Bernstein,
+   https://www.aumasson.jp/siphash/) under a random key chosen at
+   start-up. Without the key nobody can work out a name whose hash
+   matches a listed one, and so get a list to block a name it does not
+   show.
+
+   A name is hashed a label at a time from the right: the hash of a
+   label is the hash of its parent's hash followed by the label. The
+   search through a name's parents then hashes each label only once. */
+static u64 key0, key1;
+
+#define ROTL(x, b) (((x) << (b)) | ((x) >> (64 - (b))))
+
+#define SIPROUND                                                       \
+  do {                                                                 \
+    v0 += v1; v1 = ROTL(v1, 13); v1 ^= v0; v0 = ROTL(v0, 32);          \
+    v2 += v3; v3 = ROTL(v3, 16); v3 ^= v2;                             \
+    v0 += v3; v3 = ROTL(v3, 21); v3 ^= v0;                             \
+    v2 += v1; v1 = ROTL(v1, 17); v1 ^= v2; v2 = ROTL(v2, 32);          \
+  } while (0)
+
+/* Add an eight byte word of the message. */
+#define SIPWORD(m)                                                     \
+  do {                                                                 \
+    v3 ^= (m); SIPROUND; v0 ^= (m);                                    \
+  } while (0)
+
+/* SipHash-1-3 of the parent's hash, as eight little-endian bytes,
+   followed by the label. The four constants are SipHash's own: the
+   ASCII of "somepseudorandomlygeneratedbytes". DNS folds the case of
+   ASCII letters only, so this does not use tolower(), which depends on
+   the locale. */
+static u64 hash_label(u64 parent, const char *label, size_t len)
+{
+  u64 v0 = key0 ^ 0x736f6d6570736575ULL;
+  u64 v1 = key1 ^ 0x646f72616e646f6dULL;
+  u64 v2 = key0 ^ 0x6c7967656e657261ULL;
+  u64 v3 = key1 ^ 0x7465646279746573ULL;
+  u64 m = 0;
+  size_t i;
+
+  SIPWORD(parent);
+
+  for (i = 0; i < len; i++)
+    {
+      unsigned char c = label[i];
+
+      if (c >= 'A' && c <= 'Z')
+       c += 'a' - 'A';
+
+      m |= (u64)c << (8 * (i % 8));
+
+      if (i % 8 == 7)
+       {
+         SIPWORD(m);
+         m = 0;
+       }
+    }
+
+  /* The last word carries the length of the message in its top byte. */
+  m |= (u64)(len + 8) << 56;
+  SIPWORD(m);
+
+  /* The "3" of SipHash-1-3: one round per word above, three to finish,
+     so that every bit of the last word reaches every bit of the result
+     before it is used to pick a list entry or a Bloom bit. */
+  v2 ^= 0xff;
+  SIPROUND;
+  SIPROUND;
+  SIPROUND;
+
+  return v0 ^ v1 ^ v2 ^ v3;
+}
+
+static u64 hash_name(const char *name, size_t len)
+{
+  const char *p = name + len;
+  u64 h = 0;
+
+  while (p > name)
+    {
+      const char *label = p;
+
+      while (label > name && label[-1] != '.')
+       label--;
+
+      h = hash_label(h, label, p - label);
+      p = label > name ? label - 1 : name;
+    }
+
+  return h;
+}
+
+static int cmp_hash(const void *a, const void *b)
+{
+  u64 x = *(const u64 *)a, y = *(const u64 *)b;
+
+  return (x > y) - (x < y);
+}
+
+/* Read the --blocklist file: one name per line, ignoring blank lines
+   and anything after a '#', which is the format of the domain lists
+   published for ad-blocking. Only a sorted array of 64 bit hashes is
+   kept, eight bytes a name. Called at start-up, where an error is
+   fatal, and on SIGHUP, where it keeps the old list. */
+void blocklist_init(int reload)
+{
+  char line[MAXDNAME + 2];
+  size_t lines = 0, n = 0;
+  u64 *list = NULL;
+  FILE *f;
+  int err;
+
+  if (!reload)
+    {
+      key0 = rand64();
+      key1 = rand64();
+    }
+
+  if (!(f = fopen(daemon->blocklist_file, "r")))
+    goto fail;
+
+  /* Size the array with a first pass, which splits long lines exactly
+     as the second will. The second stops at that size in case the
+     file grew in between. */
+  while (fgets(line, sizeof(line), f))
+    lines++;
+
+  if (!(list = whine_malloc((lines + 1) * sizeof(u64))))
+    goto fail;
+
+  rewind(f);
+
+  while (n < lines && fgets(line, sizeof(line), f))
+    {
+      char *p = line, *end;
+
+      /* No name is this long, so skip the whole line rather than read
+        what follows as another name. */
+      if (!strchr(line, '\n') && !feof(f))
+       {
+         int c;
+
+         while ((c = getc(f)) != EOF && c != '\n');
+         continue;
+       }
+
+      while (isspace((unsigned char)*p))
+       p++;
+
+      for (end = p; *end && *end != '#' && !isspace((unsigned char)*end); 
end++);
+
+      if (end > p && end[-1] == '.')
+       end--;
+
+      if (end > p)
+       list[n++] = hash_name(p, end - p);
+    }
+
+  if (ferror(f))
+    goto fail;
+
+  fclose(f);
+
+  qsort(list, n, sizeof(u64), cmp_hash);
+  free(daemon->blocklist);
+  daemon->blocklist = list;
+  daemon->blocklist_count = n;
+
+  if (reload)
+    my_syslog(LOG_INFO, _("blocklist %s, %u names"), daemon->blocklist_file, 
daemon->blocklist_count);
+
+  return;
+
+ fail:
+  err = errno;
+  if (f)
+    fclose(f);
+  free(list);
+  errno = err;
+
+  if (!reload)
+    die(_("cannot read blocklist %s: %s"), daemon->blocklist_file, EC_FILE);
+
+  my_syslog(LOG_ERR, _("cannot read blocklist %s: %s, keeping the old list"),
+           daemon->blocklist_file, strerror(errno));
+}
+
+/* Is the name, or any of its parent domains, listed? Parents with one
+   label are not checked, so a listed TLD blocks only a query for
+   itself. */
+int blocklist_match(char *name)
+{
+  char *p = name + strlen(name);
+  u64 h = 0;
+  int labels = 0;
+
+  while (p > name)
+    {
+      char *label = p;
+
+      while (label > name && label[-1] != '.')
+       label--;
+
+      h = hash_label(h, label, p - label);
+
+      if ((++labels > 1 || label == name) &&
+         bsearch(&h, daemon->blocklist, daemon->blocklist_count, sizeof(u64), 
cmp_hash))
+       return 1;
+
+      p = label > name ? label - 1 : name;
+    }
+
+  return 0;
+}
+
+#endif
diff --git a/src/config.h b/src/config.h
index a010c8c..c8a2a38 100644
--- a/src/config.h
+++ b/src/config.h
@@ -139,6 +139,11 @@ HAVE_DNSSEC
 HAVE_DUMPFILE
    include code to dump packets to a libpcap-format file for debugging.
 
+HAVE_BLOCKLIST
+   include code to block the names listed in a file given with
+   --blocklist, using much less memory than the same names as
+   --address lines.
+
 HAVE_LOOP
    include functionality to probe for and remove DNS forwarding loops.
 
@@ -205,6 +210,7 @@ RESOLVFILE
 /* #define HAVE_CONNTRACK */
 /* #define HAVE_DNSSEC */
 /* #define HAVE_NFTSET */
+/* #define HAVE_BLOCKLIST */
 
 /* Default locations for important system files. */
 
@@ -483,6 +489,10 @@ static char *compile_opts =
 #ifndef HAVE_DUMPFILE
 "no-"
 #endif
-"dumpfile";
+"dumpfile "
+#ifndef HAVE_BLOCKLIST
+"no-"
+#endif
+"blocklist";
 
 #endif /* defined(DNSMASQ_COMPILE_OPTS) */
diff --git a/src/dnsmasq.c b/src/dnsmasq.c
index 10d25c1..95817d8 100644
--- a/src/dnsmasq.c
+++ b/src/dnsmasq.c
@@ -417,6 +417,11 @@ int main (int argc, char **argv)
       cache_init();
       blockdata_init();
 
+#ifdef HAVE_BLOCKLIST
+      if (daemon->blocklist_file)
+       blocklist_init(0);
+#endif
+
       /* Scale random socket pool by ftabsize, but
         limit it based on available fds. */
       daemon->numrrand = daemon->ftabsize/2;
@@ -883,6 +888,12 @@ int main (int argc, char **argv)
   
   my_syslog(LOG_INFO, _("compile time options: %s"), compile_opts);
 
+#ifdef HAVE_BLOCKLIST
+  if (daemon->blocklist_file)
+    my_syslog(LOG_INFO, _("blocklist %s, %u names"),
+             daemon->blocklist_file, daemon->blocklist_count);
+#endif
+
   if (chown_warn != 0)
     {
 #if defined(HAVE_LINUX_NETWORK)
@@ -1573,6 +1584,11 @@ static void async_event(int pipe, time_t now)
       {
       case EVENT_RELOAD:
        daemon->soa_sn++; /* Bump zone serial, as it may have changed. */
+
+#ifdef HAVE_BLOCKLIST
+       if (daemon->blocklist_file)
+         blocklist_init(1);
+#endif
        
        /* fall through */
        
diff --git a/src/dnsmasq.h b/src/dnsmasq.h
index ecff18d..02357fd 100644
--- a/src/dnsmasq.h
+++ b/src/dnsmasq.h
@@ -1269,6 +1269,12 @@ extern struct daemon {
   char *timestamp_file;
 #endif
 
+#ifdef HAVE_BLOCKLIST
+  char *blocklist_file;
+  u64 *blocklist;
+  unsigned int blocklist_count;
+#endif
+
   /* globally used stuff for DNS */
   char *packet; /* packet buffer */
   int packet_buff_sz; /* size of above */
@@ -1961,13 +1967,19 @@ void dump_packet_icmp(int mask, void *packet, size_t 
len, union mysockaddr *src,
                      union mysockaddr *dst);
 #endif
 
+/* blocklist.c */
+#ifdef HAVE_BLOCKLIST
+void blocklist_init(int reload);
+int blocklist_match(char *name);
+#endif
+
 /* domain-match.c */
 void build_server_array(void);
 int lookup_domain(char *qdomain, int flags, int *lowout, int *highout);
 int filter_servers(int seed, int flags, int *lowout, int *highout);
 int is_local_answer(time_t now, int first, char *name);
 size_t make_local_answer(int flags, int gotname, size_t size, struct 
dns_header *header,
-                        char *name, size_t limit, int first, int last, int 
ede);
+                        char *name, size_t limit, int first, int last, int 
ede, char *source);
 int server_samegroup(struct server *a, struct server *b);
 #ifdef HAVE_DNSSEC
 int dnssec_server(struct server *server, char *keyname, int is_ds, int 
*firstp, int *lastp);
diff --git a/src/domain-match.c b/src/domain-match.c
index d3ab6b3..0bed6a4 100644
--- a/src/domain-match.c
+++ b/src/domain-match.c
@@ -408,7 +408,7 @@ int is_local_answer(time_t now, int first, char *name)
   return rc;
 }
 
-size_t make_local_answer(int flags, int gotname, size_t size, struct 
dns_header *header, char *name, size_t limit, int first, int last, int ede)
+size_t make_local_answer(int flags, int gotname, size_t size, struct 
dns_header *header, char *name, size_t limit, int first, int last, int ede, 
char *source)
 {
   int trunc = 0, anscount = 0;
   unsigned char *p;
@@ -420,8 +420,11 @@ size_t make_local_answer(int flags, int gotname, size_t 
size, struct dns_header
 
   gotname &= ~(F_QUERY | F_DS);
   
+  /* With a source, log it in place of "config"; F_HOSTS makes
+     log_query() take it from its argument. */
   if (flags & (F_NXDOMAIN | F_NOERR))
-    log_query(flags | gotname | F_NEG | F_CONFIG | F_FORWARD, name, NULL, 
NULL, 0);
+    log_query(flags | gotname | F_NEG | F_FORWARD | (source ? F_HOSTS : 
F_CONFIG),
+             name, NULL, source, 0);
 
   if (flags & F_RCODE)
      {
diff --git a/src/forward.c b/src/forward.c
index f736c30..cfb4685 100644
--- a/src/forward.c
+++ b/src/forward.c
@@ -176,6 +176,7 @@ static void forward_query(int udpfd, union mysockaddr 
*udpaddr,
   int first, last, start = 0;
   int forwarded = 0;
   int ede = EDE_UNSET;
+  char *source = NULL;
   unsigned short rrtype, rrclass;
   unsigned short id = ntohs(header->id); /* Retrieve the id from the new query 
before we overwrite it. */
   unsigned int casediff = 0;
@@ -355,6 +356,18 @@ static void forward_query(int udpfd, union mysockaddr 
*udpaddr,
          flags = 0;
        }
 
+#ifdef HAVE_BLOCKLIST
+      /* A name with a --server of its own is not blocked. */
+      if (!flags && daemon->blocklist_file &&
+         (ede == EDE_NOT_READY || daemon->serverarray[first]->domain_len == 0) 
&&
+         blocklist_match(daemon->namebuff))
+       {
+         flags = F_NXDOMAIN;
+         ede = EDE_BLOCKED;
+         source = "blocklist";
+       }
+#endif
+
       /* don't forward A or AAAA queries for simple names, except the empty 
name */
       if (!flags &&
          option_bool(OPT_NODOTS_LOCAL) &&
@@ -601,7 +614,7 @@ static void forward_query(int udpfd, union mysockaddr 
*udpaddr,
       if (casediff != 0)
        extract_name(header, plen, NULL, (char *)(bitvector ? bitvector : 
&casediff), EXTR_NAME_FLIP, bitvector ? casediff : 1);
              
-      if (!(plen = make_local_answer(flags, gotname, plen, header, 
daemon->namebuff, replylimit, first, last, ede)))
+      if (!(plen = make_local_answer(flags, gotname, plen, header, 
daemon->namebuff, replylimit, first, last, ede, source)))
        return;
       
       if (fwd_flags & FREC_HAS_PHEADER)
@@ -2407,6 +2420,7 @@ void tcp_request(int confd, time_t now, struct iovec 
*bigbuff,
   unsigned int mark = 0;
   int have_mark = 0;
   int first, last, filtered, do_stale = 0;
+  char *source = NULL;
   struct iovec out_iov[2];
   
   bigbuff->iov_base = NULL;
@@ -2617,6 +2631,18 @@ void tcp_request(int confd, time_t now, struct iovec 
*bigbuff,
            flags = is_local_answer(now, first, daemon->namebuff);
          else
            ede = EDE_NOT_READY;
+
+#ifdef HAVE_BLOCKLIST
+         /* A name with a --server of its own is not blocked. */
+         if (!flags && daemon->blocklist_file &&
+             (ede == EDE_NOT_READY || daemon->serverarray[first]->domain_len 
== 0) &&
+             blocklist_match(daemon->namebuff))
+           {
+             flags = F_NXDOMAIN;
+             ede = EDE_BLOCKED;
+             source = "blocklist";
+           }
+#endif
          
          if (!flags && ede == EDE_UNSET)
            {
@@ -2749,7 +2775,7 @@ void tcp_request(int confd, time_t now, struct iovec 
*bigbuff,
       if (m == 0)
        {
          if (!(m = make_local_answer(flags, gotname, size, out_header, 
daemon->namebuff,
-                                     65536, first, last, ede)))
+                                     65536, first, last, ede, source)))
            break;
        }
       else if (ede == EDE_UNSET)
diff --git a/src/option.c b/src/option.c
index 44bd522..8028222 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_BLOCKLIST     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 },
+    { "blocklist", 1, 0, LOPT_BLOCKLIST },
     { 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_BLOCKLIST, ARG_ONE, "<path>", gettext_noop("Block the names listed in 
this file."), NULL },
   { 0, 0, NULL, NULL, NULL }
 }; 
 
@@ -2410,6 +2413,14 @@ static int one_opt(int option, char *arg, char *errstr, 
char *gen_err, int comma
     case LOPT_DUMPMASK:  /* --dumpmask */
       daemon->dump_mask = strtol(arg, NULL, 0);
       break;
+
+    case LOPT_BLOCKLIST:  /* --blocklist */
+#ifdef HAVE_BLOCKLIST
+      daemon->blocklist_file = opt_string_alloc(arg);
+#else
+      ret_err(_("recompile with HAVE_BLOCKLIST defined to enable 
--blocklist"));
+#endif
+      break;
       
 #ifdef HAVE_DHCP      
     case 'l':  /* --dhcp-leasefile */
-- 
2.53.0


_______________________________________________
Dnsmasq-discuss mailing list
[email protected]
https://lists.thekelleys.org.uk/cgi-bin/mailman/listinfo/dnsmasq-discuss

Reply via email to