Commit 4eba717192 for bind

commit 4eba7171927d1620b8c0a91dbbc2b08f939685af
Author: Evan Hunt <each@isc.org>
Date:   Wed Mar 25 22:17:37 2026 -0700

    NS checks are no longer fatal by default

    A zone which is delegated using DELEG is not required to have
    an NS RRset at its apex.  The absence of such an RRset now generates
    a warning by default, rather than a hard failure.

    This behavior can be controlled using the new "check-ns" option, which
    can be set to fail, warn, or ignore.

diff --git a/bin/check/check-tool.c b/bin/check/check-tool.c
index 7932f577cd..4301183a74 100644
--- a/bin/check/check-tool.c
+++ b/bin/check/check-tool.c
@@ -79,10 +79,9 @@ bool dochecksrv = false;
 bool docheckns = false;
 #endif /* if CHECK_LOCAL */
 bool docheckrpz = false;
-dns_zoneopt_t zone_options = DNS_ZONEOPT_CHECKNS | DNS_ZONEOPT_FATALNS |
-			     DNS_ZONEOPT_CHECKMX | DNS_ZONEOPT_CHECKDUPRR |
-			     DNS_ZONEOPT_CHECKSPF | DNS_ZONEOPT_MANYERRORS |
-			     DNS_ZONEOPT_CHECKNAMES |
+dns_zoneopt_t zone_options = DNS_ZONEOPT_CHECKNS | DNS_ZONEOPT_CHECKMX |
+			     DNS_ZONEOPT_CHECKDUPRR | DNS_ZONEOPT_CHECKSPF |
+			     DNS_ZONEOPT_MANYERRORS | DNS_ZONEOPT_CHECKNAMES |
 			     DNS_ZONEOPT_CHECKINTEGRITY |
 #if CHECK_SIBLING
 			     DNS_ZONEOPT_CHECKSIBLING |
diff --git a/bin/include/defaultconfig.h b/bin/include/defaultconfig.h
index e8d474b1f1..655e57a16f 100644
--- a/bin/include/defaultconfig.h
+++ b/bin/include/defaultconfig.h
@@ -127,6 +127,7 @@ options {\n\
 	check-names primary fail;\n\
 	check-names response ignore;\n\
 	check-names secondary warn;\n\
+	check-ns warn;\n\
 	check-spf warn;\n\
 	check-svcb yes;\n\
 	clients-per-query 10;\n\
diff --git a/bin/named/server.c b/bin/named/server.c
index 143ff506a2..fb8ac9f908 100644
--- a/bin/named/server.c
+++ b/bin/named/server.c
@@ -5925,6 +5925,33 @@ create_view(const cfg_obj_t *vconfig, dns_viewlist_t *viewlist,
 	return ISC_R_SUCCESS;
 }

+static void
+configure_zone_nscheck(const cfg_obj_t *options, const cfg_obj_t *vconfig,
+		       dns_zone_t *zone) {
+	const cfg_obj_t *modeobj = NULL;
+	const cfg_obj_t *voptions = NULL;
+	const char *mode;
+
+	if (vconfig) {
+		voptions = cfg_tuple_get(vconfig, "options");
+	}
+	(void)named_config_findopt(voptions, options, "check-ns", &modeobj);
+	mode = cfg_obj_asstring(modeobj);
+
+	if (strcmp(mode, "fail") == 0) {
+		dns_zone_setoption(zone, DNS_ZONEOPT_CHECKNS, true);
+		dns_zone_setoption(zone, DNS_ZONEOPT_FATALNS, true);
+	} else if (strcmp(mode, "warn") == 0) {
+		dns_zone_setoption(zone, DNS_ZONEOPT_CHECKNS, true);
+		dns_zone_setoption(zone, DNS_ZONEOPT_FATALNS, false);
+	} else if (strcmp(mode, "ignore") == 0) {
+		dns_zone_setoption(zone, DNS_ZONEOPT_CHECKNS, false);
+		dns_zone_setoption(zone, DNS_ZONEOPT_FATALNS, false);
+	} else {
+		UNREACHABLE();
+	}
+}
+
 /*
  * Configure or reconfigure a zone.
  */
@@ -6235,8 +6262,7 @@ configure_zone(const cfg_obj_t *config, const cfg_obj_t *zconfig,
 		}
 	}

-	dns_zone_setoption(zone, DNS_ZONEOPT_CHECKNS, true);
-	dns_zone_setoption(zone, DNS_ZONEOPT_FATALNS, true);
+	configure_zone_nscheck(options, vconfig, zone);

 	if (zone_is_catz) {
 		dns_zone_catz_enable(zone, view->catzs);
diff --git a/bin/tests/system/checkns/ns1/example.db b/bin/tests/system/checkns/ns1/example.db
new file mode 100644
index 0000000000..1fd7600f66
--- /dev/null
+++ b/bin/tests/system/checkns/ns1/example.db
@@ -0,0 +1,2 @@
+@		30	IN	SOA	ns1 hostmaster 2017052201 3600 600 604800 30
+a		300	IN	A	10.53.0.5
diff --git a/bin/tests/system/checkns/ns1/named.conf.j2 b/bin/tests/system/checkns/ns1/named.conf.j2
new file mode 100644
index 0000000000..ecec846b0e
--- /dev/null
+++ b/bin/tests/system/checkns/ns1/named.conf.j2
@@ -0,0 +1,31 @@
+options {
+	{% include_indented "_common/options.conf.j2" %}
+};
+
+{% include "_common/controls.conf.j2" %}
+
+view vwarn {
+	match-clients { @ns.ip@; };
+	zone "example" {
+		type primary;
+		file "example.db";
+	};
+};
+
+view vignore {
+	match-clients { 10.53.0.2; };
+	check-ns ignore;
+	zone "example2" {
+		type primary;
+		file "example.db";
+	};
+};
+
+view vfail {
+	match-clients { 10.53.0.3; };
+	check-ns fail;
+	zone "example3" {
+		type primary;
+		file "example.db";
+	};
+};
diff --git a/bin/tests/system/checkns/tests_checkns.py b/bin/tests/system/checkns/tests_checkns.py
new file mode 100644
index 0000000000..252f1f7f9f
--- /dev/null
+++ b/bin/tests/system/checkns/tests_checkns.py
@@ -0,0 +1,35 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+
+import isctest
+
+
+def test_checkns(ns1):
+    with ns1.watch_log_from_start() as watcher:
+        watcher.wait_for_line("all zones loaded")
+
+        assert "zone example/IN/vwarn: has no NS records" in ns1.log
+        assert "example/IN/vwarn: loaded" in ns1.log
+        msg = isctest.query.create("a.example", "A")
+        res = isctest.query.udp(msg, ns1.ip, source=ns1.ip)
+        isctest.check.noerror(res)
+
+        assert "example2/IN/vignore: loaded" in ns1.log
+        assert "has no NS records (example2)" not in ns1.log
+        msg = isctest.query.create("a.example2", "A")
+        res = isctest.query.udp(msg, ns1.ip, source="10.53.0.2")
+        isctest.check.noerror(res)
+
+        assert "zone example3/IN/vfail: has no NS records" in ns1.log
+        msg = isctest.query.create("a.example3", "A")
+        res = isctest.query.udp(msg, ns1.ip, source="10.53.0.3")
+        isctest.check.servfail(res)
diff --git a/bin/tests/system/checkzone/tests.sh b/bin/tests/system/checkzone/tests.sh
index 115bffdb52..9d380e0519 100644
--- a/bin/tests/system/checkzone/tests.sh
+++ b/bin/tests/system/checkzone/tests.sh
@@ -444,5 +444,15 @@ n=$((n + 1))
 if [ $ret != 0 ]; then echo_i "failed"; fi
 status=$((status + ret))

+echo_i "Checking for no NS records warning ($n)"
+ret=0
+$CHECKZONE example zones/warn.no-apex-ns.db >test.out.$n || ret=1
+grep "zone example/IN: has no NS records" test.out.$n >/dev/null || ret=1
+grep "OK" test.out.$n >/dev/null || ret=1
+$CHECKZONE -n fail example zones/warn.no-apex-ns.db >/dev/null && ret=1
+n=$((n + 1))
+if [ $ret != 0 ]; then echo_i "failed"; fi
+status=$((status + ret))
+
 echo_i "exit status: $status"
 [ $status -eq 0 ] || exit 1
diff --git a/bin/tests/system/checkzone/zones/warn.no-apex-ns.db b/bin/tests/system/checkzone/zones/warn.no-apex-ns.db
new file mode 100644
index 0000000000..8451e14bdd
--- /dev/null
+++ b/bin/tests/system/checkzone/zones/warn.no-apex-ns.db
@@ -0,0 +1,18 @@
+; Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+;
+; SPDX-License-Identifier: MPL-2.0
+;
+; This Source Code Form is subject to the terms of the Mozilla Public
+; License, v. 2.0.  If a copy of the MPL was not distributed with this
+; file, you can obtain one at https://mozilla.org/MPL/2.0/.
+;
+; See the COPYRIGHT file distributed with this work for additional
+; information regarding copyright ownership.
+
+@		30	IN	SOA	ns1 hostmaster 2017052201 3600 600 604800 30
+ns1		3600	IN	AAAA	fd92:7065:b8e:ffff::1
+ns2		3600	IN	AAAA	fd92:7065:b8e:ffff::2
+ns3		3600	IN	AAAA	fd92:7065:b8e:ffff::4
+ns4		3600	IN	AAAA	fd92:7065:b8e:ffff::4
+dualstack	300	IN	AAAA	2001:db8::1
+dualstack	300	IN	A	10.53.0.5
diff --git a/doc/arm/reference.rst b/doc/arm/reference.rst
index 115685a3d9..50c0c90776 100644
--- a/doc/arm/reference.rst
+++ b/doc/arm/reference.rst
@@ -2654,6 +2654,14 @@ Boolean Options
    default is to ``warn``. Other possible values are ``fail`` and
    ``ignore``.

+.. namedconf:statement:: check-ns
+   :tags: zone
+   :short: Checks existence and correctness of NS records.
+
+   This checks whether a zone has an NS record at its apex, and whether
+   NS records appear to refer to IP addresses. The default is to ``warn``.
+   Other possible values are ``fail`` and ``ignore``.
+
 .. namedconf:statement:: check-wildcard
    :tags: zone
    :short: Checks for non-terminal wildcards.
diff --git a/doc/misc/hint.zoneopt b/doc/misc/hint.zoneopt
index 260db7fb5f..dba0106bb5 100644
--- a/doc/misc/hint.zoneopt
+++ b/doc/misc/hint.zoneopt
@@ -1,6 +1,7 @@
 zone <string> [ <class> ] {
 	type hint;
 	check-names ( fail | warn | ignore );
+	check-ns ( fail | warn | ignore );
 	file <quoted_string>;
 	template <string>;
 };
diff --git a/doc/misc/mirror.zoneopt b/doc/misc/mirror.zoneopt
index 78ce7a5044..0e976ae38f 100644
--- a/doc/misc/mirror.zoneopt
+++ b/doc/misc/mirror.zoneopt
@@ -7,6 +7,7 @@ zone <string> [ <class> ] {
 	allow-update-forwarding { <address_match_element>; ... };
 	also-notify [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... };
 	check-names ( fail | warn | ignore );
+	check-ns ( fail | warn | ignore );
 	database <string>;
 	file <quoted_string>;
 	ixfr-from-differences <boolean>;
diff --git a/doc/misc/options b/doc/misc/options
index eadf175850..0885933ced 100644
--- a/doc/misc/options
+++ b/doc/misc/options
@@ -91,6 +91,7 @@ options {
 	check-mx ( fail | warn | ignore );
 	check-mx-cname ( fail | warn | ignore );
 	check-names ( primary | master | secondary | slave | response ) ( fail | warn | ignore ); // may occur multiple times
+	check-ns ( fail | warn | ignore );
 	check-sibling <boolean>;
 	check-spf ( warn | ignore );
 	check-srv-cname ( fail | warn | ignore );
@@ -384,6 +385,7 @@ template <string> {
 	check-mx ( fail | warn | ignore );
 	check-mx-cname ( fail | warn | ignore );
 	check-names ( fail | warn | ignore );
+	check-ns ( fail | warn | ignore );
 	check-sibling <boolean>;
 	check-spf ( warn | ignore );
 	check-srv-cname ( fail | warn | ignore );
@@ -504,6 +506,7 @@ view <string> [ <class> ] {
 	check-mx ( fail | warn | ignore );
 	check-mx-cname ( fail | warn | ignore );
 	check-names ( primary | master | secondary | slave | response ) ( fail | warn | ignore ); // may occur multiple times
+	check-ns ( fail | warn | ignore );
 	check-sibling <boolean>;
 	check-spf ( warn | ignore );
 	check-srv-cname ( fail | warn | ignore );
diff --git a/doc/misc/primary.zoneopt b/doc/misc/primary.zoneopt
index 3bdbf9ee65..c9224553de 100644
--- a/doc/misc/primary.zoneopt
+++ b/doc/misc/primary.zoneopt
@@ -10,6 +10,7 @@ zone <string> [ <class> ] {
 	check-mx ( fail | warn | ignore );
 	check-mx-cname ( fail | warn | ignore );
 	check-names ( fail | warn | ignore );
+	check-ns ( fail | warn | ignore );
 	check-sibling <boolean>;
 	check-spf ( warn | ignore );
 	check-srv-cname ( fail | warn | ignore );
diff --git a/doc/misc/secondary.zoneopt b/doc/misc/secondary.zoneopt
index 631ee731c3..c6a91e91f5 100644
--- a/doc/misc/secondary.zoneopt
+++ b/doc/misc/secondary.zoneopt
@@ -7,6 +7,7 @@ zone <string> [ <class> ] {
 	allow-update-forwarding { <address_match_element>; ... };
 	also-notify [ port <integer> ] [ source ( <ipv4_address> | * ) ] [ source-v6 ( <ipv6_address> | * ) ] { ( <server-list> | <ipv4_address> [ port <integer> ] | <ipv6_address> [ port <integer> ] ) [ key <string> ] [ tls <string> ]; ... };
 	check-names ( fail | warn | ignore );
+	check-ns ( fail | warn | ignore );
 	checkds ( explicit | <boolean> );
 	database <string>;
 	dlz <string>;
diff --git a/doc/misc/stub.zoneopt b/doc/misc/stub.zoneopt
index 4d25095484..8df5d77306 100644
--- a/doc/misc/stub.zoneopt
+++ b/doc/misc/stub.zoneopt
@@ -3,6 +3,7 @@ zone <string> [ <class> ] {
 	allow-query { <address_match_element>; ... };
 	allow-query-on { <address_match_element>; ... };
 	check-names ( fail | warn | ignore );
+	check-ns ( fail | warn | ignore );
 	database <string>;
 	file <quoted_string>;
 	forward ( first | only );
diff --git a/lib/isccfg/namedconf.c b/lib/isccfg/namedconf.c
index 446bf4e8f9..7331c7b870 100644
--- a/lib/isccfg/namedconf.c
+++ b/lib/isccfg/namedconf.c
@@ -2369,6 +2369,7 @@ static cfg_clausedef_t view_clauses[] = {
 	{ "catalog-zones", &cfg_type_catz, 0, NULL },
 	{ "check-names", &cfg_type_checknames, CFG_CLAUSEFLAG_MULTI,
 	  checknames_merge },
+	{ "check-ns", &cfg_type_checkmode, 0, NULL },
 	{ "cleaning-interval", NULL, CFG_CLAUSEFLAG_ANCIENT, NULL },
 	{ "clients-per-query", &cfg_type_uint32, 0, NULL },
 	{ "deny-answer-addresses", &cfg_type_denyaddresses, 0, NULL },
@@ -2799,6 +2800,10 @@ static cfg_clausedef_t zone_only_clauses[] = {
 	  CFG_ZONE_PRIMARY | CFG_ZONE_SECONDARY | CFG_ZONE_MIRROR |
 		  CFG_ZONE_HINT | CFG_ZONE_STUB,
 	  NULL },
+	{ "check-ns", &cfg_type_checkmode,
+	  CFG_ZONE_PRIMARY | CFG_ZONE_SECONDARY | CFG_ZONE_MIRROR |
+		  CFG_ZONE_HINT | CFG_ZONE_STUB,
+	  NULL },
 	{ "checkds", &cfg_type_checkdstype,
 	  CFG_ZONE_PRIMARY | CFG_ZONE_SECONDARY, NULL },
 	{ "database", &cfg_type_astring,