diff --git a/xCAT-test/dhcptest/README.md b/xCAT-test/dhcptest/README.md index f42fec6ff..248ee27d8 100644 --- a/xCAT-test/dhcptest/README.md +++ b/xCAT-test/dhcptest/README.md @@ -128,6 +128,8 @@ One per line under a single multi-line `assert` key, as `target op value`. | `msgtype` | option 53, by name: `OFFER`, `ACK`, `NAK` | | `yiaddr` `siaddr` `ciaddr` `giaddr` `file` `sname` `xid` `chaddr` | BOOTP header | | `bootfile` | option 67 if the server sent one, else the `file` header | +| `dns_name` | option 81 if the server sent one, else option 12 | +| `fqdn_flags` | option 81's flag letters: `S` `O` `E` `N` | | `option:` / `option:` / bare `` | a DHCP option | | `offers` | how many *servers* answered | | `$step.field` | a field of an earlier reply | @@ -151,6 +153,17 @@ some other loader and for one that names none, which is what "must not be sent its install script" means. Every other operator fails on an absent target, because there is nothing to compare against. +Assert on `dns_name`, not on option 12 or option 81, unless one option in +particular is the point. The two servers answer a client that sent option 81 in +different places -- Kea replies in option 81, ISC under `ignore client-updates` +replies in option 12 only -- and the node reads whichever arrived. This is the +same reason `bootfile` exists. + +`fqdn_flags` is the other half of option 81: `S` says the server will update the +A record, `O` that it overrode what the client asked for, `E` that the name is in +wire format, `N` that no update is wanted. It is absent when the reply carries no +option 81 at all. + Assert on `bootfile`, not on `file`, unless the header itself is the point. Servers genuinely differ — ISC dhcpd fills the BOOTP header, dnsmasq answers in option 67 once the client has asked for it — and firmware reads whichever @@ -173,11 +186,19 @@ Control: `type`, `expect`, `mac`, `xid`, `timeout`, `retries`, Message: `request_options` (55), `vendor_class` (60), `user_class` (77) with `user_class_form` = `raw` or `rfc3004`, `client_arch` (93), `client_ndi` (94), -`client_uuid` (97), `client_id` (61), `hostname` (12), `max_message_size` (57), +`client_uuid` (97), `client_id` (61), `hostname` (12), `fqdn` (81), +`max_message_size` (57), `vendor_specific` (43, hex), `ipxe_options` (175, hex), `requested_address` (50), `server_id` (54), `lease_time` (51), `ciaddr`, `giaddr`, and `option:` for anything else. +`fqdn` takes a name, optionally prefixed with the flags the client is setting: +`fqdn = node01` sends none, `fqdn = S:node01` asks the server to do the update, +`fqdn = N:node01` asks for no update, and `E` among the letters sends the name in +RFC 1035 wire format rather than ASCII. The prefix is read as flags only when +every character before the colon is a flag letter, so a name holding a colon is +still a name. + Step types: `discover`, `request`, `renew`, `rebind`, `release`, `decline`, `inform`, `bootrequest`, `noop`, `sleep`. `expect` is `offer`, `ack`, `nak`, `bootreply`, `any`, or `none` — with `none`, silence is the passing result. @@ -198,7 +219,7 @@ option 53 either, so `msgtype` reads `BOOTREPLY`. | `renew-rebind.conf` | a lease survives RENEW and REBIND | `net` | | `provision-vs-discovery.conf` | a known machine gets its reservation and its own loader; an unknown one gets a pool address | `node_mac`, `node_ip`, `node_loader`, `pool`, `next_server`, `unknown_mac` | | `discovery-bootfile.conf` | an unknown machine is handed a loader too, not just an address | `unknown_mac`, `pool`, `discovery_loader` | -| `netboot-methods.conf` | a node is handed the loader its netboot method names, and a `*NOIP*` port is not answered | `*_mac`, `*_ip`, `*_loader`, `xnba_node`, `petitboot_conf`, `noip_mac` | +| `netboot-methods.conf` | a node is handed the loader its netboot method names, is told its own name whatever it calls itself, and a `*NOIP*` port is not answered | `*_mac`, `*_ip`, `*_loader`, `xnba_node`, `petitboot_conf`, `noip_mac` | | `hierarchy-dhcpserver.conf` | a subnet whose pool belongs to another server ignores unknown MACs but still points known ones at it | `node_mac`, `node_ip`, `delegate`, `unknown_mac` | | `discovery-adoption.conf` | a machine discovered out of the pool is served its own address once defined | `adopt_mac`, `adopt_ip`, `pool` | | `nak-foreign-address.conf` | a REQUEST for an address off this network is refused | `foreign_ip` | diff --git a/xCAT-test/dhcptest/conf/netboot-methods.conf b/xCAT-test/dhcptest/conf/netboot-methods.conf index eca82db39..8e0f77a1b 100644 --- a/xCAT-test/dhcptest/conf/netboot-methods.conf +++ b/xCAT-test/dhcptest/conf/netboot-methods.conf @@ -97,8 +97,25 @@ type = discover mac = %(xnba_mac)s hostname = notthisnode assert = - msgtype == OFFER - option:12 matches ^%(xnba_node)s(\.|$) + msgtype == OFFER + dns_name matches ^%(xnba_node)s(\.|$) + +[scenario node-keeps-its-name-in-fqdn] +description = S-75: A node claiming a name in option 81 is still told its own + +# Option 81 is the other half of the same request and a different code path in +# both servers: a client that sends it is asking to be named, and asking about +# who updates DNS. dns_name is asserted rather than option 81 itself, because +# the two servers answer in different options -- Kea in 81, ISC in 12 -- and +# the node reads whichever arrived. + +[step fqdn-claimed] +type = discover +mac = %(xnba_mac)s +fqdn = notthisnode +assert = + msgtype == OFFER + dns_name matches ^%(xnba_node)s(\.|$) [scenario netboot-pxe] description = S-27: A node with netboot=pxe is handed pxelinux diff --git a/xCAT-test/dhcptest/src/dhcptest_lib/assertions.py b/xCAT-test/dhcptest/src/dhcptest_lib/assertions.py index 40132cbae..c26d10d38 100644 --- a/xCAT-test/dhcptest/src/dhcptest_lib/assertions.py +++ b/xCAT-test/dhcptest/src/dhcptest_lib/assertions.py @@ -23,7 +23,7 @@ META_TARGETS = frozenset(["offers", "attempt"]) #: Targets a client works out from more than one place in the message. Assert #: on these rather than on `file` unless the header itself is the point. -DERIVED_TARGETS = frozenset(["bootfile"]) +DERIVED_TARGETS = frozenset(["bootfile", "fqdn_flags", "dns_name"]) #: Operators that take no value. NULLARY_OPS = frozenset(["present", "absent"]) @@ -176,7 +176,11 @@ def _actual(target, reply, context, extras): if target in DERIVED_TARGETS: if reply is None: return False, None - value = reply.bootfile() + value = { + "fqdn_flags": reply.fqdn_flags, + "dns_name": reply.dns_name, + "bootfile": reply.bootfile, + }[target]() return bool(value), value if target in HEADER_TARGETS: diff --git a/xCAT-test/dhcptest/src/dhcptest_lib/config.py b/xCAT-test/dhcptest/src/dhcptest_lib/config.py index 893685a90..e90e0da78 100644 --- a/xCAT-test/dhcptest/src/dhcptest_lib/config.py +++ b/xCAT-test/dhcptest/src/dhcptest_lib/config.py @@ -43,7 +43,8 @@ STEP_CONTROL_KEYS = frozenset([ #: Keys that put something into the packet. STEP_FIELD_KEYS = frozenset([ "ciaddr", "giaddr", "requested_address", "server_id", "lease_time", - "hostname", "client_id", "vendor_class", "user_class", "user_class_form", + "hostname", "fqdn", "client_id", "vendor_class", "user_class", + "user_class_form", "client_arch", "client_ndi", "client_uuid", "max_message_size", "request_options", "vendor_specific", "ipxe_options", "relay", "bootfile", "sname", diff --git a/xCAT-test/dhcptest/src/dhcptest_lib/model.py b/xCAT-test/dhcptest/src/dhcptest_lib/model.py index 9a26504f4..d2cd2eac5 100644 --- a/xCAT-test/dhcptest/src/dhcptest_lib/model.py +++ b/xCAT-test/dhcptest/src/dhcptest_lib/model.py @@ -112,6 +112,7 @@ class Reply(object): "secs", "flags", "options", + "raw_options", "src_ip", "src_mac", "attempt", @@ -121,7 +122,7 @@ class Reply(object): def __init__(self, msgtype="", xid=0, yiaddr="0.0.0.0", siaddr="0.0.0.0", ciaddr="0.0.0.0", giaddr="0.0.0.0", chaddr="", file="", sname="", secs=0, flags=0, options=None, src_ip="", - src_mac="", attempt=0, raw=None): + src_mac="", attempt=0, raw=None, raw_options=b""): self.msgtype = msgtype self.xid = xid self.yiaddr = yiaddr @@ -134,6 +135,9 @@ class Reply(object): self.secs = secs self.flags = flags self.options = dict(options or {}) + # The option area as it arrived. Option 81 is decoded to its name, so + # the flag bits are only recoverable from here. + self.raw_options = raw_options self.src_ip = src_ip self.src_mac = src_mac self.attempt = attempt @@ -149,7 +153,7 @@ class Reply(object): } #: Fields a client derives from more than one place in the message. - DERIVED_FIELDS = frozenset(["bootfile"]) + DERIVED_FIELDS = frozenset(["bootfile", "fqdn_flags", "dns_name"]) #: Fields that come from an option rather than the BOOTP header. FIELD_OPTIONS = { @@ -177,10 +181,55 @@ class Reply(object): """ return self.options.get(67) or self.file + def dns_name(self): + """The name this reply says the node will be known by. + + Option 81 when the server sent one, option 12 otherwise. The two + backends put it in different places -- Kea answers a client that sent + option 81 with option 81, ISC with `ignore client-updates` sends only + option 12 -- and a scenario about the name should not have to know + which. A client reads whichever arrived, exactly as with `bootfile`. + """ + return self.options.get(81) or self.options.get(12) or "" + + def fqdn_flags(self): + """Option 81's flags as letters -- "SO" -- or "" when it is absent. + + S says the server will update the A record and O that it overrode what + the client asked for. The two together are how a reply says the client + does not get to name itself. + """ + from . import options # local import: avoids a cycle + + raw = self.options.get(81) + if raw is None: + return "" + if isinstance(raw, str): + # decode_value already reduced the option to its name, so the flag + # bits have to come off the packet again. + raw = self._raw_option(81) + if raw is None: + return "" + decoded = options.decode_fqdn(raw) + return options.fqdn_flag_letters(decoded[0]) if decoded else "" + + def _raw_option(self, code): + """The undecoded bytes of one option, straight off the wire.""" + from . import options # local import: avoids a cycle + + blob = self.raw_options + if not blob: + return None + return options.unpack_options(blob).get(code) + def field(self, name): """Resolve one `$step.` reference, or raise KeyError.""" if name == "bootfile": return self.bootfile() + if name == "fqdn_flags": + return self.fqdn_flags() + if name == "dns_name": + return self.dns_name() if name in self.FIELD_OPTIONS: code = self.FIELD_OPTIONS[name] if code not in self.options: @@ -208,7 +257,7 @@ class Reply(object): names.update(cls.DERIVED_FIELDS) names.update( f for f in cls.__slots__ - if f not in ("options", "raw", "attempt") + if f not in ("options", "raw_options", "raw", "attempt") ) return names diff --git a/xCAT-test/dhcptest/src/dhcptest_lib/options.py b/xCAT-test/dhcptest/src/dhcptest_lib/options.py index 229984fcf..3b149f92b 100644 --- a/xCAT-test/dhcptest/src/dhcptest_lib/options.py +++ b/xCAT-test/dhcptest/src/dhcptest_lib/options.py @@ -8,6 +8,7 @@ nothing else. import binascii import ipaddress +import re from .errors import ConfigError @@ -163,6 +164,75 @@ def format_hex(data): return ":".join("%02x" % b for b in bytearray(data)) +#: Option 81's flag bits, in the order RFC 4702 numbers them. S says the server +#: is to update the A record, O that it overrode what the client asked for, E +#: that the name is in wire format rather than ASCII, and N that no update is +#: wanted at all. +FQDN_FLAGS = (("S", 0x01), ("O", 0x02), ("E", 0x04), ("N", 0x08)) + + +def fqdn_flag_letters(flags): + """`0x03` -> `"SO"`. An empty set of flags is `"-"`, so it prints.""" + return "".join(letter for letter, bit in FQDN_FLAGS if flags & bit) or "-" + + +def fqdn_flag_bits(letters): + """`"SO"` -> `0x03`. Unknown letters are a config error, not a silent 0.""" + bits = 0 + known = dict(FQDN_FLAGS) + for letter in str(letters).upper(): + if letter not in known: + raise ConfigError( + "unknown option 81 flag %r: expected any of S, O, E, N" + % (letter,)) + bits |= known[letter] + return bits + + +def encode_fqdn(text): + """Encode a `fqdn` step value into option 81. + + `node01` sends the name with no flags; `S:node01` asks the server to do + the update; `N:node01` asks for none. With E among the flags the name is + sent in RFC 1035 wire format, which is what the option's E bit announces. + """ + text = "" if text is None else str(text).strip() + letters, _, name = text.partition(":") + if not _ or not letters or not re.match(r"\A[SOENsoen]+\Z", letters): + letters, name = "", text + flags = fqdn_flag_bits(letters) + if flags & 0x04: + body = b"" + for label in name.rstrip(".").split("."): + raw = label.encode("utf-8") + if len(raw) > 63: + raise ConfigError("DNS label longer than 63 bytes: %r" % (label,)) + body += bytes([len(raw)]) + raw + body += b"\x00" + else: + body = name.encode("utf-8") + return bytes([flags, 0, 0]) + body + + +def decode_fqdn(raw): + """Option 81 as `(flags, name)`, or None when the bytes are not that. + + Returning None rather than guessing keeps a malformed option printable as + hex instead of asserted against as a misreading. + """ + data = bytes(raw) + if len(data) < 3: + return None + flags = data[0] + body = data[3:] + if flags & 0x04: + names = decode_name_list(body) + if names is None: + return None + return flags, ".".join(names) + return flags, body.decode("utf-8", "replace").rstrip("\x00") + + #: Options carrying a list of DNS names in RFC 1035 wire form, which RFC 3397 #: allows to be compressed against earlier names in the same option. NAME_LIST_OPTIONS = frozenset([119]) @@ -250,6 +320,13 @@ def decode_value(code, raw): names = decode_name_list(raw) if names is not None: return names + if code == 81: + # The name alone, because that is the fact a scenario asserts: a + # server must answer with the node's name and not the client's. + # The flags are reached through the `fqdn_flags` target. + decoded = decode_fqdn(raw) + if decoded is not None: + return decoded[1] return format_hex(raw) return raw @@ -344,6 +421,7 @@ def decode(packet): secs=int(getattr(bootp, "secs", 0)), flags=int(getattr(bootp, "flags", 0)), options=options, + raw_options=blob, src_ip=src_ip, src_mac=src_mac, raw=packet, diff --git a/xCAT-test/dhcptest/src/dhcptest_lib/runner.py b/xCAT-test/dhcptest/src/dhcptest_lib/runner.py index f5bbc4982..3e5bf33c8 100644 --- a/xCAT-test/dhcptest/src/dhcptest_lib/runner.py +++ b/xCAT-test/dhcptest/src/dhcptest_lib/runner.py @@ -82,6 +82,8 @@ def _encode(code, text): for item in text.split(",") if item.strip())) if code in opt.TEXT_OPTIONS: return text.encode("utf-8") + if code == 81: + return opt.encode_fqdn(text) return opt.parse_hex(text) @@ -108,7 +110,8 @@ def build_options(step, session, context, msgtype): simple = [ ("requested_address", 50), ("server_id", 54), ("lease_time", 51), - ("hostname", 12), ("max_message_size", 57), ("vendor_class", 60), + ("hostname", 12), ("fqdn", 81), ("max_message_size", 57), + ("vendor_class", 60), ("client_arch", 93), ("client_ndi", 94), ("client_uuid", 97), ("vendor_specific", 43), ("ipxe_options", 175), ("request_options", 55), diff --git a/xCAT-test/dhcptest/tests/test_assertions.py b/xCAT-test/dhcptest/tests/test_assertions.py index 2d16ad9be..4c6dbd64b 100644 --- a/xCAT-test/dhcptest/tests/test_assertions.py +++ b/xCAT-test/dhcptest/tests/test_assertions.py @@ -155,5 +155,43 @@ class Evaluation(unittest.TestCase): self.assertFalse(result.ok) +class FqdnFlagsTarget(unittest.TestCase): + """`fqdn_flags` is a target of its own, because option 81 holds two facts.""" + + def _reply(self, raw): + from dhcptest_lib import options + blob = options.pack_options([(53, b"\x02"), (81, raw)]) + decoded = dict((code, options.decode_value(code, value)) + for code, value in options.unpack_options(blob).items()) + return Reply(msgtype="OFFER", options=decoded, raw_options=blob) + + def test_the_flags_and_the_name_are_asserted_separately(self): + reply = self._reply(b"\x03\x00\x00node01.cluster.local.") + self.assertTrue(self._ok("fqdn_flags == SO", reply)) + self.assertTrue(self._ok("option:81 == node01.cluster.local.", reply)) + + def test_the_dns_name_prefers_option_81_and_falls_back_to_12(self): + # The two servers put the name in different options. A scenario about + # the name asserts on one target and holds for both. + both = self._reply(b"\x03\x00\x00node01.cluster.local.") + both.options[12] = "node01" + self.assertEqual(both.dns_name(), "node01.cluster.local.") + only12 = Reply(msgtype="OFFER", options={12: "node01"}) + self.assertEqual(only12.dns_name(), "node01") + self.assertTrue(self._ok("dns_name matches ^node01", only12)) + + def test_a_reply_naming_the_node_nowhere_has_no_dns_name(self): + self.assertTrue( + self._ok("dns_name absent", Reply(msgtype="OFFER", options={53: 2}))) + + def test_a_reply_with_no_option_81_has_no_flags(self): + reply = Reply(msgtype="OFFER", options={53: 2}) + self.assertEqual(reply.fqdn_flags(), "") + self.assertTrue(self._ok("fqdn_flags absent", reply)) + + def _ok(self, text, reply): + return assertions.evaluate(assertions.parse(text), reply, {}).ok + + if __name__ == "__main__": unittest.main() diff --git a/xCAT-test/dhcptest/tests/test_options.py b/xCAT-test/dhcptest/tests/test_options.py index 2404d9de9..9f3ed6651 100644 --- a/xCAT-test/dhcptest/tests/test_options.py +++ b/xCAT-test/dhcptest/tests/test_options.py @@ -26,6 +26,59 @@ class OptionNames(unittest.TestCase): self.assertEqual(options.msgtype_name(99), "TYPE-99") +class ClientFqdn(unittest.TestCase): + """Option 81, RFC 4702: who names the node, and who updates DNS.""" + + def test_a_bare_name_is_sent_with_no_flags(self): + self.assertEqual(options.encode_fqdn("node01"), + b"\x00\x00\x00node01") + + def test_flags_are_taken_from_the_prefix(self): + self.assertEqual(options.encode_fqdn("S:node01")[0], 0x01) + self.assertEqual(options.encode_fqdn("N:node01")[0], 0x08) + self.assertEqual(options.encode_fqdn("SO:node01")[0], 0x03) + + def test_a_name_with_a_colon_is_not_read_as_flags(self): + # The prefix is flags only when every character before the colon is a + # flag letter. Otherwise a name is a name. + flags, name = options.decode_fqdn(options.encode_fqdn("host:1")) + self.assertEqual((flags, name), (0, "host:1")) + + def test_a_prefix_that_is_not_all_flag_letters_is_part_of_the_name(self): + # There is no way to tell a typo'd flag from a name that holds a colon, + # so the rule is mechanical: flags only when every character before the + # first colon is one. A mistyped flag then sends a name no server + # answers with, which fails the step rather than passing it quietly. + flags, name = options.decode_fqdn(options.encode_fqdn("SX:node01")) + self.assertEqual((flags, name), (0, "SX:node01")) + + def test_an_unknown_flag_letter_is_refused_when_it_is_read_as_flags(self): + self.assertRaises(ConfigError, options.fqdn_flag_bits, "SX") + + def test_the_e_flag_sends_the_name_in_wire_format(self): + raw = options.encode_fqdn("SE:node01.cluster.local") + self.assertEqual(raw[0], 0x05) + self.assertEqual(raw[3:], b"\x06node01\x07cluster\x05local\x00") + self.assertEqual(options.decode_fqdn(raw), + (0x05, "node01.cluster.local")) + + def test_the_option_decodes_to_the_name_a_scenario_asserts_on(self): + # The flags are reached through `fqdn_flags`, so the option itself is + # the name and `option:81 == node01` reads as it should. + raw = b"\x03\x00\x00node01.cluster.local." + self.assertEqual(options.decode_value(81, raw), + "node01.cluster.local.") + + def test_flag_letters_round_trip(self): + self.assertEqual(options.fqdn_flag_letters(0x03), "SO") + self.assertEqual(options.fqdn_flag_letters(0), "-") + self.assertEqual(options.fqdn_flag_bits("so"), 0x03) + + def test_a_truncated_option_is_left_as_hex(self): + self.assertIsNone(options.decode_fqdn(b"\x03\x00")) + self.assertEqual(options.decode_value(81, b"\x03\x00"), "03:00") + + class OptionArea(unittest.TestCase): def test_pack_then_unpack(self): diff --git a/xCAT-test/dhcptest/tests/test_wire.py b/xCAT-test/dhcptest/tests/test_wire.py index f135f0e32..64eb485a7 100644 --- a/xCAT-test/dhcptest/tests/test_wire.py +++ b/xCAT-test/dhcptest/tests/test_wire.py @@ -36,6 +36,7 @@ user_class_form = rfc3004 request_options = 1, 3, 6, 43, 54, 60, 66, 67 max_message_size = 1260 hostname = probe +fqdn = N:probe.cluster.local expect = offer """ @@ -78,6 +79,14 @@ class RoundTrip(unittest.TestCase): self.assertEqual(options[12], "probe") # text self.assertEqual(options[55], [1, 3, 6, 43, 54, 60, 66, 67]) # list self.assertEqual(options[61], "01:02:00:de:ad:be:ef") # hex + self.assertEqual(options[81], "probe.cluster.local") # fqdn + + def test_the_client_fqdn_carries_the_flags_the_step_asked_for(self): + # "N" is a client saying it wants no DNS update at all. A server xCAT + # configures overrides that, so the step has to be able to send it. + raw = opt.unpack_options(bytes(self.frame["BOOTP"].payload))[81] + self.assertEqual(raw[:3], b"\x08\x00\x00") + self.assertEqual(self.reply.fqdn_flags(), "N") def test_rfc3004_user_class_is_length_prefixed(self): raw = opt.unpack_options(bytes(self.frame["BOOTP"].payload))[77] diff --git a/xCAT-test/unit/dhcp_ddns_policy.t b/xCAT-test/unit/dhcp_ddns_policy.t new file mode 100644 index 000000000..153478c85 --- /dev/null +++ b/xCAT-test/unit/dhcp_ddns_policy.t @@ -0,0 +1,164 @@ +#!/usr/bin/env perl +# +# S-76, S-77 and S-78 of specs/dhcp-wire.md: who performs the DNS update, which +# zones and key it is aimed at, and what a cluster that does not use dynamic DNS +# has instead. All three are marked [config] in the specification, because no +# client can observe them -- a server answers a node correctly and still +# registers the wrong name, or registers nothing at all. +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::Bin/../../xCAT-server/lib"; +use lib "$FindBin::Bin/../../xCAT-server/lib/perl"; +use lib "$FindBin::Bin/../../perl-xCAT"; + +use Test::More; + +$ENV{XCATCFG} ||= 'SQLite:/tmp'; + +my $plugin = "$FindBin::Bin/../../xCAT-server/lib/xcat/plugins/dhcp.pm"; +if ( -f $plugin ) { require $plugin } else { require xCAT_plugin::dhcp } + +BAIL_OUT('xCAT_plugin::dhcp::isc_ddns_zone_statements is missing') + unless defined &xCAT_plugin::dhcp::isc_ddns_zone_statements; + +sub isc_zones { return xCAT_plugin::dhcp::isc_ddns_zone_statements(@_) } + +# --------------------------------------------------------------------------- +# S-77 -- ISC: the forward zone and every reverse zone, each with the key. + +{ + my @lines = isc_zones( + enabled => 1, + domain => 'cluster.local.', + ddnsdomain => undef, + server => '10.0.0.1', + key_name => 'xcat_key', + zones => [ '0.0.10.in-addr.arpa.', '1.0.10.in-addr.arpa.' ], + ); + my $text = join '', @lines; + + like( $text, qr/zone cluster\.local\. \{/, + 'S-77 the forward zone is named' ); + like( $text, qr/zone 0\.0\.10\.in-addr\.arpa\. \{/, + 'S-77 the first reverse zone is named' ); + like( $text, qr/zone 1\.0\.10\.in-addr\.arpa\. \{/, + 'S-77 every reverse zone is named, not just the first' ); + is( scalar( grep { /primary 10\.0\.0\.1; key xcat_key;/ } @lines ), 3, + 'S-77 each of the three zones carries the server and the key' ); +} + +{ + # networks.ddnsdomain overrides site.domain for the forward zone, and is + # also declared so dhcpd qualifies the name it registers. + my @lines = isc_zones( + enabled => 1, + domain => 'cluster.local.', + ddnsdomain => 'rack4.cluster.local', + server => '10.0.0.1', + key_name => 'xcat_key', + zones => [], + ); + my $text = join '', @lines; + like( $text, qr/ddns-domainname "rack4\.cluster\.local";/, + 'S-77 the network ddnsdomain is declared' ); + like( $text, qr/zone rack4\.cluster\.local\. \{/, + 'S-77 and is the forward zone' ); + unlike( $text, qr/zone cluster\.local\. \{/, + 'S-77 the site domain is not named as well' ); +} + +{ + # A zone dhcpd cannot reach, or reaches unsigned, is worse than no zone: + # named refuses the update and nothing on the DHCP side says so. + my $text = join '', isc_zones( + enabled => 1, + domain => 'cluster.local.', + server => '', + key_name => 'xcat_key', + zones => [], + ); + unlike( $text, qr/primary/, + 'S-77 a network with no nameserver writes no primary statement' ); + + $text = join '', isc_zones( + enabled => 1, + domain => 'cluster.local.', + server => '10.0.0.1', + key_name => undef, + zones => [], + ); + unlike( $text, qr/key/, + 'S-77 an unsigned update is not configured either' ); +} + +# --------------------------------------------------------------------------- +# S-78 -- nothing at all when the cluster's DNS is not dynamic. + +{ + is_deeply( + [ + isc_zones( + enabled => 0, + domain => 'cluster.local.', + ddnsdomain => 'rack4.cluster.local', + server => '10.0.0.1', + key_name => 'xcat_key', + zones => ['0.0.10.in-addr.arpa.'], + ) + ], + [], + 'S-78 dnshandler without ddns writes no zone and no key' + ); + + is_deeply( + [ isc_zones( enabled => 1, domain => '', ddnsdomain => '', + server => '10.0.0.1', key_name => 'xcat_key' ) ], + [], + 'S-78 a network with no domain of any kind names no zone' + ); +} + +# --------------------------------------------------------------------------- +# S-76 -- the server updates DNS itself, whatever the client asked for. + +{ + my %intent; + xCAT_plugin::dhcp::kea_apply_ddns_behavior( \%intent ); + + ok( $intent{'ddns-send-updates'}, + 'S-76 Kea is told to send the updates' ); + ok( $intent{'ddns-override-client-update'}, + 'S-76 and to send them for a client that asked to do its own' ); + ok( $intent{'ddns-override-no-update'}, + 'S-76 and for a client that asked for none' ); + ok( $intent{'ddns-qualifying-suffix'}, + 'S-76 the name it registers is qualified with a domain' ); +} + +# ISC says the same thing once, globally, with "ignore client-updates" in the +# preamble both configurations start from. dhcp_omapi_key_config.t asserts that +# statement is there; it is not repeated here. + +# --------------------------------------------------------------------------- +# S-78 -- Kea: with dynamic DNS off there is no D2 intent to write at all. + +{ + local %::XCATSITEVALS = ( dnshandler => 'ddns' ); + ok( xCAT_plugin::dhcp::kea_ddns_enabled(), + 'S-77 dnshandler naming ddns turns the Kea D2 configuration on' ); + + %::XCATSITEVALS = ( dnshandler => 'makedns' ); + ok( !xCAT_plugin::dhcp::kea_ddns_enabled(), + 'S-78 any other dnshandler leaves it off' ); + + %::XCATSITEVALS = (); + ok( !xCAT_plugin::dhcp::kea_ddns_enabled(), + 'S-78 and an unset dnshandler leaves it off' ); + + is( xCAT_plugin::dhcp::kea_build_ddns_intent(), undef, + 'S-78 no D2 intent is built when it is off' ); +} + +done_testing();