1
0
mirror of https://github.com/samba-team/samba.git synced 2025-01-10 01:18:15 +03:00

samba-tool dns zoneoptions: timestamp manipulation options

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>
This commit is contained in:
Douglas Bagnall 2021-05-27 09:46:02 +12:00 committed by Douglas Bagnall
parent 074f9e1486
commit 2f7aa81a9f
2 changed files with 649 additions and 3 deletions

View 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."""

View 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