This repository has been archived by the owner on May 22, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 2
/
sync-zone.py
224 lines (197 loc) · 6.9 KB
/
sync-zone.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# Standard library modules
import argparse
import json
import sys
from collections import OrderedDict
# Local modules
import mythic
import rfcparser
import zone_validate
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest = 'command_name')
# parser for the 'sync' command
parser_sync = subparsers.add_parser('sync', help = 'sync zone file')
parser_sync.add_argument(
"-n",
"--dry-run",
help="dry run: check commands but do not perform any actions",
action="store_false",
dest="perform_sync",
)
parser_sync.add_argument(
"-q",
"--quiet",
help="don't print anything except for errors",
action="store_true",
dest="quiet",
)
parser_sync.add_argument(
"--include-dangerous-records",
help="allow modification of all dangerous records",
action="store_true",
dest="include_dangerous",
)
parser_sync.add_argument(
"--strict", help="perform stricter checking", action="store_true"
)
parser_sync.add_argument(
"--diffs",
help="only delete/add records if there is a change",
action="store_true",
)
parser_sync.add_argument(
"--credentials-file", help="path to credentials file", required=True
)
parser_sync.add_argument(
"--zone", help="name of zone (e.g. example.org)", required=True
)
parser_sync.add_argument(
"--rfc-file", help="path to bind-style zone file", required=True
)
parser_validate = subparsers.add_parser('validate', help = 'validate zone file')
parser_validate.add_argument(
"--include-dangerous-records",
help="allow modification of all dangerous records",
action="store_true",
dest="include_dangerous",
)
parser_validate.add_argument(
"--strict", help="perform stricter checking", action="store_true"
)
parser_validate.add_argument(
"--zone", help="name of zone (e.g. example.org)", required=True
)
parser_validate.add_argument(
"--rfc-file", help="path to bind-style zone file", required=True
)
args = parser.parse_args()
try:
with open(args.rfc_file) as f:
zone = rfcparser.RFCParser(f)
zone_records = zone.records(
"ADD", include_dangerous=args.include_dangerous
)
if args.zone != zone.domain():
print("Zonefile origin domain is not for specified zone")
print(args.zone, "!=", zone.domain())
sys.exit(1)
except rfcparser.RFCParserError as err:
if err.line:
print(f"Error: {err.message} in {err.line}")
else:
print(f"Error: {err.message}")
sys.exit(1)
# Validate all new zone records
for zone_record in zone_records:
if not zone_validate.validate_zone_record(zone_record, args.strict):
print("The following record failed validation:")
print(zone_record)
sys.exit(1)
# If we only want to validate the records, stop now before any API
# requests are made and authentication is required
if args.command_name == 'validate':
sys.exit(0)
with open(args.credentials_file) as f:
credentials = json.load(f)
try:
api = mythic.MythicAPI(args.zone, credentials[args.zone])
except mythic.APIError as err:
print("* Error: {}".format(err.message))
sys.exit(2)
except KeyError as err:
print("* Error: {} not in credentials".format(err.args[0]))
sys.exit(2)
# Get all the existing records
list_response = api.list()
# rstrip needed as Mythic adds a trailing space to the LIST responses
list_records = [l.rstrip() for l in list_response.text.splitlines()]
# collapse repeated whitespace into single space [MB178014]
list_records = [" ".join(l.split()) for l in list_records]
# remove extra quoted quotes in returned [MB178014]
list_records = [l.replace('\\"', '') for l in list_records]
# Create DELETE [record] commands for all existing records returned by LIST,
# except NS and SOA records
# This is an ordered dict of lists. The side effect of this is to
# group together records with the same hostname type combination.
delete_records = OrderedDict()
origins = ["@", args.zone "."]
for list_record in list_records:
record_parts = list_record.split()
record_name, record_ttl, record_type, *_ = record_parts
dangerous = False
if record_type == "SOA":
# Deleting SOA records may break the zone
dangerous = True
elif record_type == "NS" and record_name in origins:
# Deleting origin NS records may break the zone
dangerous = True
if dangerous and args.include_dangerous:
print("* Warning: deleting dangerous record:")
print(list_record)
if not dangerous or args.include_dangerous:
delete_records.setdefault((record_name, record_type), []).append(
list_record
)
if record_type == "ANAME":
# This works because of the ordering of the records
# that we get from Mythic
for tuple in [(record_name, "A"), (record_name, "AAAA")]:
if tuple in delete_records:
del delete_records[tuple]
add_records = OrderedDict()
other_commands = []
for zone_record in zone_records:
if not zone_validate.skip_zone_record(zone_record):
record_parts = zone_record.split()
command, record_name, record_ttl, record_type, *_ = record_parts
if command == "ADD":
add_records.setdefault((record_name, record_type), []).append(
" ".join(record_parts[1:])
)
else:
other_commands.append(" ".join(zone_record.split()))
add_commands = []
for key, add_record_list in add_records.items():
adding = True
if args.diffs:
if key in delete_records:
if sorted(add_record_list) == sorted(delete_records[key]):
# If we would be ADD-ing exactly what we are DELETE-ing
# don't do either.
adding = False
del delete_records[key]
if adding:
for add_record in add_record_list:
add_commands.append("ADD " add_record)
delete_commands = []
for delete_record_list in delete_records.values():
for delete_record in delete_record_list:
delete_commands.append("DELETE " delete_record)
# Send all the commands in one transaction
sync_commands = []
sync_commands.extend(delete_commands)
sync_commands.extend(add_commands)
sync_commands.extend(other_commands)
if not args.quiet:
for cmd in sync_commands:
print(cmd)
if args.perform_sync:
sync_response = api.call(sync_commands)
if sync_response.status_code == 200:
if not args.quiet:
print(sync_response.text)
responses = sync_response.text.splitlines()
# This assumes the API replies in same order as requested
error = False
for a, b in zip(sync_commands, responses):
if a != b:
print('* Mismatch: "{}" -> "{}"'.format(a, b))
error = True
if error:
sys.exit(1)
else:
print("* Error:", sync_response.status_code, sync_response.reason)
print(sync_response.text)
sys.exit(1)
else:
print("* Dry run: no action taken")