Redesign the interaction between DNSSEC vaildation and per-domain servers.
authorSimon Kelley <simon@thekelleys.org.uk>
Sun, 2 Feb 2025 20:28:54 +0000 (20:28 +0000)
committerSimon Kelley <simon@thekelleys.org.uk>
Fri, 14 Mar 2025 15:12:45 +0000 (15:12 +0000)
This should just work in all cases now. If the normal chain-of-trust exists into
the delegated domain then whether the domain is signed or not, DNSSEC
validation will function normally. In the case the delgated domain
is an "overlay" on top of the global DNS and no NS and/or DS records
exist connecting it to the global dns, then if the domain is
unsigned the situation will be handled by synthesising a
proof-of-non-existance-of-DS for the domain and queries will be
answered unvalidated; this action will be logged. A signed domain
without chain-of-trust can be validated if a suitable trust-anchor
is provided using --trust-anchor.

Thanks to Uwe Kleine-König for prompting this change, and contributing
valuable insights into what could be improved.

CHANGELOG
man/dnsmasq.8
src/cache.c
src/config.h
src/dnsmasq.c
src/dnssec.c
src/domain-match.c
src/option.c
src/rfc1035.c

index d151fda..320ca0d 100644 (file)
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,19 @@
+version 2.92
+        Redesign the interaction between DNSSEC vaildation and per-domain
+       servers, specified as --server=/<domain>/<ip-address>. This should
+       just work in all cases now. If the normal chain-of-trust exists into
+       the delegated domain then whether the domain is signed or not, DNSSEC
+       validation will function normally. In the case the delgated domain
+       is an "overlay" on top of the global DNS and no NS and/or DS records
+       exist connecting it to the global dns, then if the domain is
+       unsigned the situation will be handled by synthesising a
+       proof-of-non-existance-of-DS for the domain and queries will be
+       answered unvalidated; this action will be logged. A signed domain
+       without chain-of-trust can be validated if a suitable trust-anchor
+       is provided using --trust-anchor. This change should be backwards
+       compatible for all existing working configurations; it extends the
+       space of possible configurations which are functional.
+
 version 2.91
        Fix spurious "resource limit exceeded messages". Thanks to 
        Dominik Derigs for the bug report.
index 0d844c9..f448ed2 100644 (file)
@@ -498,10 +498,7 @@ xxx.internal.thekelleys.org.uk at 192.168.1.1 then giving  the flag
 .B --server=/internal.thekelleys.org.uk/192.168.1.1
 will send all queries for
 internal machines to that nameserver, everything else will go to the
-servers in /etc/resolv.conf. DNSSEC validation is turned off for such
-private nameservers, UNLESS a
-.B --trust-anchor
-is specified for the domain in question. An empty domain specification,
+servers in /etc/resolv.conf. An empty domain specification,
 .B // 
 has the special meaning of "unqualified names only" ie names without any
 dots in them. A non-standard port may be specified as 
@@ -894,12 +891,15 @@ ie capable of returning DNSSEC records with data. If they are not,
 then dnsmasq will not be able to determine the trusted status of
 answers and this means that DNS service will be entirely broken.
 .TP
-.B --trust-anchor=<domain>,[<class>,]<key-tag>,<algorithm>,<digest-type>,<digest>
+.B --trust-anchor=<domain>,[<class>,][<key-tag>,<algorithm>,<digest-type>,<digest>]
 Provide DS records to act a trust anchors for DNSSEC
-validation. Typically these will be the DS record(s) for Key Signing
+validation. The class defaults to IN. Typically these will be the DS record(s) for Key Signing
 key(s) (KSK) of the root zone,
-but trust anchors for limited domains are also possible. The current
-root-zone trust anchors may be downloaded from https://data.iana.org/root-anchors/root-anchors.xml 
+but trust anchors for limited domains are also possible.
+A negative trust anchor (ie. proof that a DS record doesn't exist) may be configured be specifying
+only the name or only the name and class. This can be useful for forcing dnsmasq to treat zones delegated
+using \fB--server=/<domain>/<ip-address>\fP as unsigned. The current
+root-zone trust anchors may be downloaded from https://data.iana.org/root-anchors/root-anchors.xml
 .TP
 .B --dnssec-check-unsigned[=no]
 As a default, dnsmasq checks that unsigned DNS replies are
index 23f948a..2fca17c 100644 (file)
@@ -1448,11 +1448,17 @@ void cache_reload(void)
        cache->flags = F_FORWARD | F_IMMORTAL | F_DS | F_CONFIG | F_NAMEP;
        cache->ttd = daemon->local_ttl;
        cache->name.namep = ds->name;
-       cache->addr.ds.keylen = ds->digestlen;
-       cache->addr.ds.algo = ds->algo;
-       cache->addr.ds.keytag = ds->keytag;
-       cache->addr.ds.digest = ds->digest_type;
        cache->uid = ds->class;
+       if (ds->digestlen != 0)
+         {
+           cache->addr.ds.keylen = ds->digestlen;
+           cache->addr.ds.algo = ds->algo;
+           cache->addr.ds.keytag = ds->keytag;
+           cache->addr.ds.digest = ds->digest_type;
+         }
+       else
+         cache->flags |= F_NEG | F_DNSSECOK | F_NO_RR;
+       
        cache_hash(cache);
        make_non_terminals(cache);
       }
index 1f1eae2..86663c5 100644 (file)
@@ -26,6 +26,7 @@
 #define DNSSEC_LIMIT_SIG_FAIL 20 /* Number of signature that can fail to validate in one answer */
 #define DNSSEC_LIMIT_CRYPTO 200 /* max no. of crypto operations to validate one query. */
 #define DNSSEC_LIMIT_NSEC3_ITERS 150 /* Max. number if iterations allowed in NSEC3 record. */
+#define DNSSEC_ASSUMED_DS_TTL 3600 /* TTL for negative DS records implied by server=/domain/ */
 #define TIMEOUT 10     /* drop UDP queries after TIMEOUT seconds */
 #define SMALL_PORT_RANGE 30 /* If DNS port range is smaller than this, use different allocation. */
 #define FORWARD_TEST 50 /* try all servers every 50 queries */
index c2ef8e4..34845e4 100644 (file)
@@ -930,7 +930,8 @@ int main (int argc, char **argv)
        my_syslog(LOG_INFO, _("DNSSEC signature timestamps not checked until system time valid"));
 
       for (ds = daemon->ds; ds; ds = ds->next)
-       my_syslog(LOG_INFO, _("configured with trust anchor for %s keytag %u"),
+       my_syslog(LOG_INFO,
+                 ds->digestlen == 0 ? _("configured with negative trust anchor for %s") : _("configured with trust anchor for %s keytag %u"),
                  ds->name[0] == 0 ? "<root>" : ds->name, ds->keytag);
     }
 #endif
index 415c4e5..0c2e74f 100644 (file)
@@ -997,49 +997,57 @@ int dnssec_validate_ds(time_t now, struct dns_header *header, size_t plen, char
   unsigned long ttl;
   union all_addr a;
 
+   /* A SERVFAIL answer has been seen to a DS query not at start of authority,
+     so treat it as such and continue to search for a DS or proof of no existence
+     further down the tree. */
+  if (RCODE(header) == SERVFAIL)
+    servfail = neganswer = nons = 1;
+  else
+    rc = dnssec_validate_reply(now, header, plen, name, keyname, NULL, 0, &neganswer, &nons, &neg_ttl, validate_counter);
+  
+  p = (unsigned char *)(header+1);
   if (ntohs(header->qdcount) != 1 ||
-      !(p = skip_name(p, header, plen, 4)))
+      !extract_name(header, plen, &p, name, EXTR_NAME_EXTRACT, 4))
     return STAT_BOGUS;
   
   GETSHORT(qtype, p);
   GETSHORT(qclass, p);
-
+  
   if (qtype != T_DS || qclass != class)
     return STAT_BOGUS;
 
-  /* A SERVFAIL answer has been seen to a DS query not at start of authority,
-     so treat it as such and continue to search for a DS or proof of no existence
-     further down the tree. */
-  if (RCODE(header) == SERVFAIL)
-    servfail = neganswer = nons = 1;
-  else
+  if (!servfail)
     {
-      rc = dnssec_validate_reply(now, header, plen, name, keyname, NULL, 0, &neganswer, &nons, &neg_ttl, validate_counter);
-  
       if (STAT_ISEQUAL(rc, STAT_INSECURE))
        {
-         my_syslog(LOG_WARNING, _("Insecure DS reply received for %s, check domain configuration and upstream DNS server DNSSEC support"), name);
-         log_query(F_NOEXTRA | F_UPSTREAM, name, NULL, "BOGUS DS - not secure", 0);
-         return STAT_BOGUS | DNSSEC_FAIL_INDET;
+         if (lookup_domain(name, F_DOMAINSRV, NULL, NULL))
+           {
+             my_syslog(LOG_INFO, _("Insecure reply received for DS %s, assuming non-DNSSEC domain-specific server."), name);
+             neganswer = 1;
+             nons = 0; /* If we're faking a DS, fake one with an NS. */
+             neg_ttl = DNSSEC_ASSUMED_DS_TTL;
+           }
+         else
+           {
+             my_syslog(LOG_WARNING, _("Insecure DS reply received for %s, check domain configuration and upstream DNS server DNSSEC support"), name);
+             log_query(F_NOEXTRA | F_UPSTREAM, name, NULL, "BOGUS DS - not secure", 0);
+             return STAT_BOGUS | DNSSEC_FAIL_INDET;
+           }
        }
-      
-      p = (unsigned char *)(header+1);
-      if (!extract_name(header, plen, &p, name, EXTR_NAME_EXTRACT, 4))
-       return STAT_BOGUS;
-
-      p += 4; /* qtype, qclass */
-      
-      /* If the key needed to validate the DS is on the same domain as the DS, we'll
-        loop getting nowhere. Stop that now. This can happen of the DS answer comes
-        from the DS's zone, and not the parent zone. */
-      if (STAT_ISEQUAL(rc, STAT_NEED_KEY) && hostname_isequal(name, keyname))
+      else
        {
-         log_query(F_NOEXTRA | F_UPSTREAM, name, NULL, "BOGUS DS", 0);
-         return STAT_BOGUS;
+         if (STAT_ISEQUAL(rc, STAT_NEED_KEY) && hostname_isequal(name, keyname))
+           {
+             /* If the key needed to validate the DS is on the same domain as the DS, we'll
+                loop getting nowhere. Stop that now. This can happen of the DS answer comes
+                from the DS's zone, and not the parent zone. */
+             log_query(F_NOEXTRA | F_UPSTREAM, name, NULL, "BOGUS DS", 0);
+             return STAT_BOGUS;
+           }
+
+         if (!STAT_ISEQUAL(rc, STAT_SECURE))
+           return rc;
        }
-  
-      if (!STAT_ISEQUAL(rc, STAT_SECURE))
-       return rc;
     }
   
   if (!neganswer)
@@ -1129,9 +1137,19 @@ int dnssec_validate_ds(time_t now, struct dns_header *header, size_t plen, char
       /* We only cache validated DS records, DNSSECOK flag hijacked 
         to store presence/absence of NS. */
       if (nons)
-       flags &= ~F_DNSSECOK;
+       {
+         if (lookup_domain(name, F_DOMAINSRV, NULL, NULL))
+           {
+             my_syslog(LOG_WARNING, _("Negative DS reply without NS record received for %s, assuming non-DNSSEC domain-specific server."), name);
+             nons = 0;
+           }
+         else
+           /* We only cache validated DS records, DNSSECOK flag hijacked 
+              to store presence/absence of NS. */
+           flags &= ~F_DNSSECOK;
+       }
     }
-  
+
   cache_start_insert();
   
   /* Use TTL from NSEC for negative cache entries */
@@ -2155,7 +2173,9 @@ int dnssec_validate_reply(time_t now, struct dns_header *header, size_t plen, ch
        /* NXDOMAIN or NODATA reply, unanswered question is (name, qclass, qtype) */
        
        /* For anything other than a DS record, this situation is OK if either
-          the answer is in an unsigned zone, or there's a NSEC records. */
+          the answer is in an unsigned zone, or there's NSEC records.
+          For a DS record, we return INSECURE, which almost always turns
+          into BOGUS in the caller. */
        if ((rc_nsec = prove_non_existence(header, plen, keyname, name, qtype, qclass, NULL, nons, nsec_ttl, validate_counter)) != 0)
          {
            if (rc_nsec & DNSSEC_FAIL_WORK)
@@ -2163,7 +2183,7 @@ int dnssec_validate_reply(time_t now, struct dns_header *header, size_t plen, ch
 
            /* Empty DS without NSECS */
            if (qtype == T_DS)
-             return STAT_BOGUS | rc_nsec;
+             return STAT_INSECURE;
            
            if ((rc_nsec & (DNSSEC_FAIL_NONSEC | DNSSEC_FAIL_NSEC3_ITERS)) &&
                !STAT_ISEQUAL((rc = zone_status(name, qclass, keyname, now)), STAT_SECURE))
index e8204c8..663b3c9 100644 (file)
@@ -346,7 +346,7 @@ int filter_servers(int seed, int flags, int *lowout, int *highout)
                      else
                        {
                          /* --local=/domain/, only return if we don't need a server. */
-                         if (flags & (F_DNSSECOK | F_DOMAINSRV | F_SERVER))
+                         if (flags & (F_DOMAINSRV | F_SERVER))
                            nhigh = i;
                        }
                    }
index 07d66ae..7237410 100644 (file)
@@ -5337,7 +5337,8 @@ err:
        
        new->class = C_IN;
        new->name = NULL;
-
+       new->digestlen = 0;
+       
        if ((comma = split(arg)) && (algo = split(comma)))
          {
            int class = 0;
@@ -5355,29 +5356,37 @@ err:
                algo = split(comma);
              }
          }
-                 
-               if (!comma || !algo || !(digest = split(algo)) || !(keyhex = split(digest)) ||
-           !atoi_check16(comma, &new->keytag) || 
-           !atoi_check8(algo, &new->algo) ||
-           !atoi_check8(digest, &new->digest_type) ||
-           !(new->name = canonicalise_opt(arg)))
+       
+       if (!(new->name = canonicalise_opt(arg)))
          ret_err_free(_("bad trust anchor"), new);
-           
-       /* Upper bound on length */
-       len = (2*strlen(keyhex))+1;
-       new->digest = opt_malloc(len);
-       unhide_metas(keyhex);
-       /* 4034: "Whitespace is allowed within digits" */
-       for (cp = keyhex; *cp; )
-         if (isspace((unsigned char)*cp))
-           for (cp1 = cp; *cp1; cp1++)
-             *cp1 = *(cp1+1);
-         else
-           cp++;
-       if ((new->digestlen = parse_hex(keyhex, (unsigned char *)new->digest, len, NULL, NULL)) == -1)
+
+       if (comma)
          {
-           free(new->name);
-           ret_err_free(_("bad HEX in trust anchor"), new);
+           if (!algo || !(digest = split(algo)) || !(keyhex = split(digest)) ||
+               !atoi_check16(comma, &new->keytag) || 
+               !atoi_check8(algo, &new->algo) ||
+               !atoi_check8(digest, &new->digest_type))
+             {
+               free(new->name);
+               ret_err_free(_("bad trust anchor"), new);
+             }
+           
+           /* Upper bound on length */
+           len = (2*strlen(keyhex))+1;
+           new->digest = opt_malloc(len);
+           unhide_metas(keyhex);
+           /* 4034: "Whitespace is allowed within digits" */
+           for (cp = keyhex; *cp; )
+             if (isspace((unsigned char)*cp))
+               for (cp1 = cp; *cp1; cp1++)
+                 *cp1 = *(cp1+1);
+             else
+               cp++;
+           if ((new->digestlen = parse_hex(keyhex, (unsigned char *)new->digest, len, NULL, NULL)) == -1)
+             {
+               free(new->name);
+               ret_err_free(_("bad HEX in trust anchor"), new);
+             }
          }
        
        new->next = daemon->ds;
index fabfdac..d37cca9 100644 (file)
@@ -1268,9 +1268,7 @@ unsigned int extract_request(struct dns_header *header, size_t qlen, char *name,
     }
 
 #ifdef HAVE_DNSSEC
-  /* F_DNSSECOK as agument to search_servers() inhibits forwarding
-     to servers for domains without a trust anchor. This make the
-     behaviour for DS and DNSKEY queries we forward the same
+  /* Make the behaviour for DS and DNSKEY queries we forward the same
      as for DS and DNSKEY queries we originate. */
   if (option_bool(OPT_DNSSEC_VALID) && (qtype == T_DS || qtype == T_DNSKEY))
     return F_DNSSECOK;