]> git.ipfire.org Git - thirdparty/samba.git/commitdiff
samba-tool dns zoneoptions: timestamp manipulation options
authorDouglas Bagnall <douglas.bagnall@catalyst.net.nz>
Wed, 26 May 2021 21:46:02 +0000 (09:46 +1200)
committerDouglas Bagnall <dbagnall@samba.org>
Wed, 2 Jun 2021 03:56:36 +0000 (03:56 +0000)
There was a bug in Samba before 4.9 that marked all records intended
to be static with a current timestamp, and all records intended to be
dynamic with a zero timestamp. This was exactly the opposite of
correct behaviour.

It follows that a domain which has been upgraded past 4.9, but on
which aging is not enabled, records intended to be static will have a
timestamp from before the upgrade date (unless their nodes have
suffered a DNS update, which due to another bug, will change the
timestmap). The following command will make these truly static:

$ samba-tool dns zoneoptions --mark-old-records-static=2018-07-23 -U...

where '2018-07-23' should be replaced by the approximate date of the
upgrade beyond 4.9.

It seems riskier making blanket conversions of static records into
dynamic records, but there are sometimes useful patterns in the names
given to machines that we can exploit. For example, if there is a
group of machines with names like 'desktop-123' that are all supposed
to using dynamic DNS, the adminstrator can go

$ samba-tool dns zoneoptions --mark-records-dynamic-regex='desktop-\d+'

and there's a --mark-records-static-regex for symmetry.

These options are deliberately long and cumbersome to type, so people
have a chance to think before they get to the end. We also introduce a
'--dry-run' (or '-n') option so they can inspect the likely results
before going ahead.

*NOTE* ageing will still not work properly after this commit, due to
other bugs that will be fixed in other commits.

Signed-off-by: Douglas Bagnall <douglas.bagnall@catalyst.net.nz>
Reviewed-by: Andrew Bartlett <abartlet@samba.org>
python/samba/netcmd/dns.py
python/samba/tests/samba_tool/dnscmd.py

index 75f66e41ed0b5ad48cb676d009d314bb69fa33c6..46295823503c914ace186362785e0acaf11f6b75 100644 (file)
@@ -23,8 +23,12 @@ from socket import inet_ntop
 from socket import AF_INET
 from socket import AF_INET6
 import struct
+import time
+import ldb
+from samba.ndr import ndr_unpack, ndr_pack
+import re
 
-from samba import remove_dc
+from samba import remove_dc, dsdb_dns
 from samba.samdb import SamDB
 from samba.auth import system_session
 
@@ -452,6 +456,14 @@ class cmd_zoneoptions(Command):
         Option('--client-version', help='Client Version',
                default='longhorn', metavar='w2k|dotnet|longhorn',
                choices=['w2k', 'dotnet', 'longhorn'], dest='cli_ver'),
+        Option('--mark-old-records-static',
+               help="Make records older than this (YYYY-MM-DD) static"),
+        Option('--mark-records-static-regex', metavar="REGEXP",
+               help="Make records matching this regular expression static"),
+        Option('--mark-records-dynamic-regex', metavar="REGEXP",
+               help="Make records matching this regular expression dynamic"),
+        Option('-n', '--dry-run', action='store_true',
+               help="Don't change anything, say what would happen"),
     ]
 
     integer_properties = []
@@ -476,7 +488,11 @@ class cmd_zoneoptions(Command):
                          integer_properties)
 
     def run(self, server, zone, cli_ver, sambaopts=None, credopts=None,
-            versionopts=None, **kwargs):
+            versionopts=None, dry_run=False,
+            mark_old_records_static=None,
+            mark_records_static_regex=None,
+            mark_records_dynamic_regex=None,
+            **kwargs):
         self.lp = sambaopts.get_loadparm()
         self.creds = credopts.get_credentials(self.lp)
         dns_conn = dns_connect(server, self.lp, self.creds)
@@ -496,6 +512,9 @@ class cmd_zoneoptions(Command):
             name_param = dnsserver.DNS_RPC_NAME_AND_PARAM()
             name_param.dwParam = v
             name_param.pszNodeName = k
+            if dry_run:
+                print(f"would set {k} to {v} for {zone}", file=self.outf)
+                continue
             try:
                 dns_conn.DnssrvOperation2(client_version,
                                           0,
@@ -510,6 +529,187 @@ class cmd_zoneoptions(Command):
 
             print(f"Set {k} to {v}", file=self.outf)
 
+        # We don't want to allow more than one of these --mark-*
+        # options at a time, as they are sensitive to ordering and
+        # the order is not documented.
+        n_mark_options = 0
+        for x in (mark_old_records_static,
+                  mark_records_static_regex,
+                  mark_records_dynamic_regex):
+            if x is not None:
+                n_mark_options += 1
+
+        if n_mark_options > 1:
+            raise CommandError("Multiple --mark-* options will not work\n")
+
+        if mark_old_records_static is not None:
+            self.mark_old_records_static(server, zone,
+                                         mark_old_records_static,
+                                         dry_run)
+
+        if mark_records_static_regex is not None:
+            self.mark_records_static_regex(server,
+                                           zone,
+                                           mark_records_static_regex,
+                                           dry_run)
+
+        if mark_records_dynamic_regex is not None:
+            self.mark_records_dynamic_regex(server,
+                                            zone,
+                                            mark_records_dynamic_regex,
+                                            dry_run)
+
+
+    def _get_dns_nodes(self, server, zone_name):
+        samdb = SamDB(url="ldap://%s" % server,
+                      session_info=system_session(),
+                      credentials=self.creds, lp=self.lp)
+
+        zone_dn = (f"DC={zone_name},CN=MicrosoftDNS,DC=DomainDNSZones,"
+                   f"{samdb.get_default_basedn()}")
+
+        nodes = samdb.search(base=zone_dn,
+                             scope=ldb.SCOPE_SUBTREE,
+                             expression=("(&(objectClass=dnsNode)"
+                                         "(!(dNSTombstoned=TRUE)))"),
+                             attrs=["dnsRecord", "name"])
+        return samdb, nodes
+
+    def mark_old_records_static(self, server, zone_name, date_string, dry_run):
+        try:
+            ts = time.strptime(date_string, "%Y-%m-%d")
+            t = time.mktime(ts)
+        except ValueError as e:
+            raise CommandError(f"Invalid date {date_string}: should be YYY-MM-DD")
+        threshold = dsdb_dns.unix_to_dns_timestamp(int(t))
+
+        samdb, nodes = self._get_dns_nodes(server, zone_name)
+
+        for node in nodes:
+            if "dnsRecord" not in node:
+                continue
+
+            values = list(node["dnsRecord"])
+            changes = 0
+            for i, v in enumerate(values):
+                rec = ndr_unpack(dnsp.DnssrvRpcRecord, v)
+                if rec.dwTimeStamp < threshold and rec.dwTimeStamp != 0:
+                    rec.dwTimeStamp = 0
+                    values[i] = ndr_pack(rec)
+                    changes += 1
+
+            if changes == 0:
+                continue
+
+            name = node["name"][0].decode()
+
+            if dry_run:
+                print(f"would make {changes}/{len(values)} records static "
+                      f"on {name}.{zone_name}.", file=self.outf)
+                continue
+
+            msg = ldb.Message.from_dict(samdb,
+                                        {'dn': node.dn,
+                                         'dnsRecord': values
+                                        },
+                                        ldb.FLAG_MOD_REPLACE)
+            samdb.modify(msg)
+            print(f"made {changes}/{len(values)} records static on "
+                  f"{name}.{zone_name}.", file=self.outf)
+
+    def mark_records_static_regex(self, server, zone_name, regex, dry_run):
+        """Make the records of nodes with matching names static.
+        """
+        r = re.compile(regex)
+        samdb, nodes = self._get_dns_nodes(server, zone_name)
+
+        for node in nodes:
+            name = node["name"][0].decode()
+            if not r.search(name):
+                continue
+            if "dnsRecord" not in node:
+                continue
+
+            values = list(node["dnsRecord"])
+            if len(values) == 0:
+                continue
+
+            changes = 0
+            for i, v in enumerate(values):
+                rec = ndr_unpack(dnsp.DnssrvRpcRecord, v)
+                if rec.dwTimeStamp != 0:
+                    rec.dwTimeStamp = 0
+                    values[i] = ndr_pack(rec)
+                    changes += 1
+
+            if changes == 0:
+                continue
+
+            if dry_run:
+                print(f"would make {changes}/{len(values)} records static "
+                      f"on {name}.{zone_name}.", file=self.outf)
+                continue
+
+            msg = ldb.Message.from_dict(samdb,
+                                        {'dn': node.dn,
+                                         'dnsRecord': values
+                                        },
+                                        ldb.FLAG_MOD_REPLACE)
+            samdb.modify(msg)
+            print(f"made {changes}/{len(values)} records static on "
+                  f"{name}.{zone_name}.", file=self.outf)
+
+    def mark_records_dynamic_regex(self, server, zone_name, regex, dry_run):
+        """Make the records of nodes with matching names dynamic, with a
+        current timestamp. In this case we only adjust the A, AAAA,
+        and TXT records.
+        """
+        r = re.compile(regex)
+        samdb, nodes = self._get_dns_nodes(server, zone_name)
+        now = time.time()
+        dns_timestamp = dsdb_dns.unix_to_dns_timestamp(int(now))
+        safe_wtypes = {
+            dnsp.DNS_TYPE_A,
+            dnsp.DNS_TYPE_AAAA,
+            dnsp.DNS_TYPE_TXT
+        }
+
+        for node in nodes:
+            name = node["name"][0].decode()
+            if not r.search(name):
+                continue
+            if "dnsRecord" not in node:
+                continue
+
+            values = list(node["dnsRecord"])
+            if len(values) == 0:
+                continue
+
+            changes = 0
+            for i, v in enumerate(values):
+                rec = ndr_unpack(dnsp.DnssrvRpcRecord, v)
+                if rec.wType in safe_wtypes and rec.dwTimeStamp == 0:
+                    rec.dwTimeStamp = dns_timestamp
+                    values[i] = ndr_pack(rec)
+                    changes += 1
+
+            if changes == 0:
+                continue
+
+            if dry_run:
+                print(f"would make {changes}/{len(values)} records dynamic "
+                      f"on {name}.{zone_name}.", file=self.outf)
+                continue
+
+            msg = ldb.Message.from_dict(samdb,
+                                        {'dn': node.dn,
+                                         'dnsRecord': values
+                                        },
+                                        ldb.FLAG_MOD_REPLACE)
+            samdb.modify(msg)
+            print(f"made {changes}/{len(values)} records dynamic on "
+                  f"{name}.{zone_name}.", file=self.outf)
+
 
 class cmd_zoneinfo(Command):
     """Query for zone information."""
index 5486ad4d05ffe7bd71193264c380031a5e17961a..6b9e7bb1c677a8051834672fba1aefa9045d99c2 100644 (file)
@@ -24,6 +24,8 @@ from samba.samdb import SamDB
 from samba.ndr import ndr_unpack, ndr_pack
 from samba.dcerpc import dnsp
 from samba.tests.samba_tool.base import SambaToolCmdTest
+import time
+from samba import dsdb_dns
 
 
 class DnsCmdTestCase(SambaToolCmdTest):
@@ -137,6 +139,23 @@ class DnsCmdTestCase(SambaToolCmdTest):
                                           self.creds_string)
         self.assertCmdSuccess(result, out, err)
 
+    def get_all_records(self, zone_name):
+        zone_dn = (f"DC={zone_name},CN=MicrosoftDNS,DC=DomainDNSZones,"
+                   f"{self.samdb.get_default_basedn()}")
+
+        expression = "(&(objectClass=dnsNode)(!(dNSTombstoned=TRUE)))"
+
+        nodes = self.samdb.search(base=zone_dn, scope=ldb.SCOPE_SUBTREE,
+                                  expression=expression,
+                                  attrs=["dnsRecord", "name"])
+
+        record_map = {}
+        for node in nodes:
+            name = node["name"][0].decode()
+            record_map[name] = list(node["dnsRecord"])
+
+        return record_map
+
     def get_record_from_db(self, zone_name, record_name):
         zones = self.samdb.search(base="DC=DomainDnsZones,%s"
                                   % self.samdb.get_default_basedn(),
@@ -909,7 +928,7 @@ class DnsCmdTestCase(SambaToolCmdTest):
                               "Failed to print zoneinfo")
         self.assertTrue(out != '')
 
-    def test_zoneoptions(self):
+    def test_zoneoptions_aging(self):
         for options, vals, error in (
                 (['--aging=1'], {'fAging': 'TRUE'}, False),
                 (['--aging=0'], {'fAging': 'FALSE'}, False),
@@ -961,3 +980,430 @@ class DnsCmdTestCase(SambaToolCmdTest):
             for k, v in vals.items():
                 self.assertIn(k, info)
                 self.assertEqual(v, info[k])
+
+
+    def ldap_add_node_with_records(self, name, records):
+        dn = (f"DC={name},DC={self.zone},CN=MicrosoftDNS,DC=DomainDNSZones,"
+              f"{self.samdb.get_default_basedn()}")
+
+        dns_records = []
+        for r in records:
+            rec = dnsp.DnssrvRpcRecord()
+            rec.wType = r.get('wType', dnsp.DNS_TYPE_A)
+            rec.rank = dnsp.DNS_RANK_ZONE
+            rec.dwTtlSeconds = 900
+            rec.dwTimeStamp = r.get('dwTimeStamp', 0)
+            rec.data = r.get('data', '10.10.10.10')
+            dns_records.append(ndr_pack(rec))
+
+        msg = ldb.Message.from_dict(self.samdb,
+                                    {'dn': dn,
+                                     "objectClass": ["top", "dnsNode"],
+                                     'dnsRecord': dns_records
+                                    })
+        self.samdb.add(msg)
+
+    def get_timestamp_map(self):
+        re_wtypes = (dnsp.DNS_TYPE_A,
+                     dnsp.DNS_TYPE_AAAA,
+                     dnsp.DNS_TYPE_TXT)
+
+        t = time.time()
+        now = dsdb_dns.unix_to_dns_timestamp(int(t))
+
+        records = self.get_all_records(self.zone)
+        tsmap = {}
+        for k, recs in records.items():
+            m = []
+            tsmap[k] = m
+            for rec in recs:
+                r = ndr_unpack(dnsp.DnssrvRpcRecord, rec)
+                timestamp = r.dwTimeStamp
+                if abs(timestamp - now) < 3:
+                    timestamp = 'nowish'
+
+                if r.wType in re_wtypes:
+                    m.append(('R', timestamp))
+                else:
+                    m.append(('-', timestamp))
+
+        return tsmap
+
+
+    def test_zoneoptions_mark_records(self):
+        self.maxDiff = 10000
+        # We need a number of records to work with, so we'll use part
+        # of our known good records list, using three different names
+        # to test the regex. All these records will be static.
+        for dnstype in self.good_records:
+            for record in self.good_records[dnstype][:2]:
+                self.runsubcmd("dns", "add",
+                               os.environ["SERVER"],
+                               self.zone, "frobitz",
+                               dnstype, record,
+                               self.creds_string)
+                self.runsubcmd("dns", "add",
+                               os.environ["SERVER"],
+                               self.zone, "weergly",
+                               dnstype, record,
+                               self.creds_string)
+                self.runsubcmd("dns", "add",
+                               os.environ["SERVER"],
+                               self.zone, "snizle",
+                               dnstype, record,
+                               self.creds_string)
+
+        # and we also want some that aren't static, and some mixed
+        # static/dynamic records.
+        # timestamps are in hours since 1601; now ~= 3.7 million
+        for ts in (0, 100, 10 ** 6, 10 ** 7):
+            name = f"ts-{ts}"
+            self.ldap_add_node_with_records(name, [{"dwTimeStamp": ts}])
+
+        recs = []
+        for ts in (0, 100, 10 ** 6, 10 ** 7):
+            addr = f'10.{(ts >> 16) & 255}.{(ts >> 8) & 255}.{ts & 255}'
+            recs.append({"dwTimeStamp": ts, "data": addr})
+
+        self.ldap_add_node_with_records("ts-multi", recs)
+
+        # get the state of ALL records.
+        # then we make assertions about the diffs, keeping track of
+        # the current state.
+
+        tsmap = self.get_timestamp_map()
+
+
+
+        for options, diff, output_substrings, error in (
+                # --mark-old-records-static
+                # --mark-records-static-regex
+                # --mark-records-dynamic-regex
+                (
+                    ['--mark-old-records-static=1971-13-04'],
+                    {},
+                    [],
+                    "bad date"
+                ),
+                (
+                    # using --dry-run, should be no change, but output.
+                    ['--mark-old-records-static=1971-03-04', '--dry-run'],
+                    {},
+                    [
+                        "would make 1/1 records static on ts-1000000.zone.",
+                        "would make 1/1 records static on ts-100.zone.",
+                        "would make 2/4 records static on ts-multi.zone.",
+                    ],
+                    False
+                ),
+                (
+                    # timestamps < ~ 3.25 million are now static
+                    ['--mark-old-records-static=1971-03-04'],
+                    {
+                        'ts-100': [('R', 0)],
+                        'ts-1000000': [('R', 0)],
+                        'ts-multi': [('R', 0), ('R', 0), ('R', 0), ('R', 10000000)]
+                    },
+                    [
+                        "made 1/1 records static on ts-1000000.zone.",
+                        "made 1/1 records static on ts-100.zone.",
+                        "made 2/4 records static on ts-multi.zone.",
+                    ],
+                    False
+                ),
+                (
+                    # no change, old records already static
+                    ['--mark-old-records-static=1972-03-04'],
+                    {},
+                    [],
+                    False
+                ),
+                (
+                    # no change, samba-tool added records already static
+                    ['--mark-records-static-regex=sniz'],
+                    {},
+                    [],
+                    False
+                ),
+                (
+                    # snizle has 2 A, 2 AAAA, 10 fancy, and 2 TXT records, in
+                    # that order.
+                    # the A, AAAA, and TXT recrods should be dynamic
+                    ['--mark-records-dynamic-regex=sniz'],
+                    {'snizle': [('R', 'nowish'),
+                                ('R', 'nowish'),
+                                ('R', 'nowish'),
+                                ('R', 'nowish'),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('R', 'nowish'),
+                                ('R', 'nowish')]
+                    },
+                    ['made 6/16 records dynamic on snizle.zone.'],
+                    False
+                ),
+                (
+                    # This regex should catch snizle, weergly, and ts-*
+                    # but we're doing dry-run so no change
+                    ['--mark-records-dynamic-regex=[sw]', '-n'],
+                    {},
+                    ['would make 3/4 records dynamic on ts-multi.zone.',
+                     'would make 1/1 records dynamic on ts-0.zone.',
+                     'would make 1/1 records dynamic on ts-1000000.zone.',
+                     'would make 6/16 records dynamic on weergly.zone.',
+                     'would make 1/1 records dynamic on ts-100.zone.'
+                    ],
+                    False
+                ),
+                (
+                    # This regex should catch snizle and frobitz
+                    # but snizle has already been changed.
+                    ['--mark-records-dynamic-regex=z'],
+                    {'frobitz': [('R', 'nowish'),
+                                 ('R', 'nowish'),
+                                 ('R', 'nowish'),
+                                 ('R', 'nowish'),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('R', 'nowish'),
+                                 ('R', 'nowish')]
+                    },
+                    ['made 6/16 records dynamic on frobitz.zone.'],
+                    False
+                ),
+                (
+                    # This regex should catch snizle, frobitz, and
+                    # ts-multi. Note that the 1e7 ts-multi record is
+                    # alreay dynamic and doesn't change.
+                    ['--mark-records-dynamic-regex=[i]'],
+                    {'ts-multi': [('R', 'nowish'),
+                                  ('R', 'nowish'),
+                                  ('R', 'nowish'),
+                                  ('R', 10000000)]
+                    },
+                    ['made 3/4 records dynamic on ts-multi.zone.'],
+                    False
+                ),
+                (
+                    # matches no records
+                    ['--mark-records-dynamic-regex=^aloooooo[qw]+'],
+                    {},
+                    [],
+                    False
+                ),
+                (
+                    # This should be an error, as only one --mark-*
+                    # argument is allowed at a time
+                    ['--mark-records-dynamic-regex=.',
+                     '--mark-records-static-regex=.',
+                    ],
+                    {},
+                    [],
+                    True
+                ),
+                (
+                    # This should also be an error
+                    ['--mark-old-records-static=1997-07-07',
+                     '--mark-records-static-regex=.',
+                    ],
+                    {},
+                    [],
+                    True
+                ),
+                (
+                    # This should not be an error. --aging and refresh
+                    # options can be mixed with --mark ones.
+                    ['--mark-old-records-static=1997-07-07',
+                     '--aging=0',
+                    ],
+                    {},
+                    ['Set Aging to 0'],
+                    False
+                ),
+                (
+                    # This regex should catch weergly, but all the
+                    # records are already static,
+                    ['--mark-records-static-regex=wee'],
+                    {},
+                    [],
+                    False
+                ),
+                (
+                    # Make frobitz static again.
+                    ['--mark-records-static-regex=obi'],
+                    {'frobitz': [('R', 0),
+                                 ('R', 0),
+                                 ('R', 0),
+                                 ('R', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('R', 0),
+                                 ('R', 0)]
+                    },
+                    ['made 6/16 records static on frobitz.zone.'],
+                    False
+                ),
+                (
+                    # would make almost everything static, but --dry-run
+                    ['--mark-old-records-static=2222-03-04', '--dry-run'],
+                    {},
+                    [
+                        'would make 6/16 records static on snizle.zone.',
+                        'would make 3/4 records static on ts-multi.zone.'
+                    ],
+                    False
+                ),
+                (
+                    # make everything static
+                    ['--mark-records-static-regex=.'],
+                     {'snizle': [('R', 0),
+                                 ('R', 0),
+                                 ('R', 0),
+                                 ('R', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('R', 0),
+                                 ('R', 0)],
+                      'ts-10000000': [('R', 0)],
+                      'ts-multi': [('R', 0), ('R', 0), ('R', 0), ('R', 0)]
+                     },
+                    [
+                        'made 4/4 records static on ts-multi.zone.',
+                        'made 1/1 records static on ts-10000000.zone.',
+                        'made 6/16 records static on snizle.zone.',
+                    ],
+                    False
+                ),
+                (
+                    # make everything dynamic that can be
+                    ['--mark-records-dynamic-regex=.'],
+                    {'frobitz': [('R', 'nowish'),
+                                 ('R', 'nowish'),
+                                 ('R', 'nowish'),
+                                 ('R', 'nowish'),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('R', 'nowish'),
+                                 ('R', 'nowish')],
+                     'snizle': [('R', 'nowish'),
+                                ('R', 'nowish'),
+                                ('R', 'nowish'),
+                                ('R', 'nowish'),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('-', 0),
+                                ('R', 'nowish'),
+                                ('R', 'nowish')],
+                     'ts-0': [('R', 'nowish')],
+                     'ts-100': [('R', 'nowish')],
+                     'ts-1000000': [('R', 'nowish')],
+                     'ts-10000000': [('R', 'nowish')],
+                     'ts-multi': [('R', 'nowish'),
+                                  ('R', 'nowish'),
+                                  ('R', 'nowish'),
+                                  ('R', 'nowish')],
+                     'weergly': [('R', 'nowish'),
+                                 ('R', 'nowish'),
+                                 ('R', 'nowish'),
+                                 ('R', 'nowish'),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('-', 0),
+                                 ('R', 'nowish'),
+                                 ('R', 'nowish')]
+                    },
+                    [
+                        'made 4/4 records dynamic on ts-multi.zone.',
+                        'made 6/16 records dynamic on snizle.zone.',
+                        'made 1/1 records dynamic on ts-0.zone.',
+                        'made 1/1 records dynamic on ts-1000000.zone.',
+                        'made 1/1 records dynamic on ts-10000000.zone.',
+                        'made 1/1 records dynamic on ts-100.zone.',
+                        'made 6/16 records dynamic on frobitz.zone.',
+                        'made 6/16 records dynamic on weergly.zone.',
+                    ],
+                    False
+                ),
+            ):
+            result, out, err = self.runsubcmd("dns",
+                                              "zoneoptions",
+                                              os.environ["SERVER"],
+                                              self.zone,
+                                              self.creds_string,
+                                              *options)
+            if error:
+                self.assertCmdFail(result, f"zoneoptions should fail ({error})")
+            else:
+                self.assertCmdSuccess(result,
+                                      out,
+                                      err,
+                                      "zoneoptions shouldn't fail")
+
+            new_tsmap = self.get_timestamp_map()
+
+            # same keys, always
+            self.assertEqual(sorted(new_tsmap), sorted(tsmap))
+            changes = {}
+            for k in tsmap:
+                if tsmap[k] != new_tsmap[k]:
+                    changes[k] = new_tsmap[k]
+
+            self.assertEqual(diff, changes)
+
+            for s in output_substrings:
+                self.assertIn(s, out)
+            tsmap = new_tsmap