mirror of
https://github.com/xcat2/xcat-core.git
synced 2026-10-22 07:38:08 +00:00
test(dhcp): the DDNS scenarios in the spec have no assertion
specs/dhcp-wire.md gained a feature for the name the cluster's DNS learns -- S-74 to S-78. Four of the five had nothing asserting them, and dhcptest could not send option 81 at all, so the scenario a client uses to claim a name could not be written. dhcptest gains a `fqdn` step field carrying option 81, with the RFC 4702 flags written as a prefix (`S:node01`, `N:node01`, `E` for wire format). It gains two targets: `fqdn_flags` for the flag letters, and `dns_name` for the name the reply says the node will be known by -- option 81 when the server sent one, option 12 otherwise, because Kea answers an option 81 request in option 81 and ISC under `ignore client-updates` answers in option 12 only. That is the same shape as `bootfile`. netboot-methods.conf asserts S-75 twice, once for a name claimed in option 12 and once in option 81. dhcp_ddns_policy.t asserts S-76, S-77 and S-78 on both backends. Checked against kea-dhcp4 2.4.1 and isc-dhcpd 4.4.3-P1 on xcat26-mn: both scenarios fail on a server with no per-node name and pass on one that has it. Four mutations of the code under dhcp_ddns_policy.t are each caught. Signed-off-by: Daniel Hilst <392820+dhilst@users.noreply.github.com>
This commit is contained in:
@@ -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:<num>` / `option:<name>` / bare `<num>` | 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:<n>` 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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.<name>` 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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user