Skip to content

Commit 882e825

Browse files
committed
Merge pull request #3 from joshenders/unstable
Merges unstable branch
2 parents c636752 + 4dbbd6f commit 882e825

File tree

2 files changed

+122
-85
lines changed

2 files changed

+122
-85
lines changed

README.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ ensure that the new provider has imported your zonefile correctly.
99
## Usage
1010

1111
```
12-
usage: dnsdiff [-h] [-V] [-c] [-d SECONDS] -f FILENAME --from-ns NAMESERVER1 --to-ns NAMESERVER2
12+
usage: dnsdiff [-h] [-V] [-c] [-d SECONDS] [-t] -f FILENAME --from-ns
13+
NAMESERVER1[:PORT] --to-ns NAMESERVER2[:PORT]
1314
1415
Options:
1516
-h, --help show this help message and exit
@@ -18,20 +19,23 @@ Options:
1819
-d SECONDS, --delay-max SECONDS
1920
maximum number of seconds of delay to introduce
2021
between each request
22+
-t, --ignore-ttl ignore changes to TTL values
2123
-f FILENAME, --zonefile FILENAME
22-
FILENAME is expected to be a valid zone master file
24+
FILENAME is expected to be a valid zonefile exported
25+
from NAMESERVER1
2326
https://tools.ietf.org/html/rfc1035#section-5
24-
--from-ns NAMESERVER1
27+
--from-ns NAMESERVER1[:PORT]
2528
compare responses to NAMESERVER2
26-
--to-ns NAMESERVER2 compare responses to NAMESERVER1
29+
--to-ns NAMESERVER2[:PORT]
30+
compare responses to NAMESERVER1
2731
```
2832

2933
### Example
3034

3135
```
3236
$ dnsdiff --zonefile example.com.zone --from-ns ns1.example.com --to-ns ns1.cloudflare.com
33-
--- ns1.example.com
34-
+++ ns1.cloudflare.com
37+
--- ns1.example.com 2015-05-24 06:00:40 +0000
38+
+++ ns1.cloudflare.com 2015-05-24 06:00:40 +0000
3539
-example.com. 172800 IN NS ns1.example.com.
3640
-example.com. 172800 IN NS ns2.example.com.
3741
+example.com. 86400 IN NS ns1.cloudflare.com.
@@ -40,7 +44,7 @@ $ dnsdiff --zonefile example.com.zone --from-ns ns1.example.com --to-ns ns1.clou
4044
+example.com. 3600 IN SOA ns1.cloudflare.com. dns.example.com. 2 3600 600 604800 1800
4145
```
4246

43-
## Installation
47+
## Installation (Debian)
4448
### Install system packages
4549

4650
```

dnsdiff

Lines changed: 111 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -9,44 +9,50 @@ import traceback
99

1010
from blessings import Terminal
1111
from random import randint
12-
from time import sleep
12+
from time import sleep, strftime
1313

1414
# TODO: Package properly
15-
VERSION = 'v1.1'
15+
VERSION = 'release-1.2'
1616

17-
def header(ns1_name, ns2_name):
17+
def header(ns1_name, ns1_port, ns2_name, ns2_port):
1818
global seen_header
1919

20+
std_port = socket.getservbyname('domain')
21+
t_stamp = strftime('%Y-%m-%d %H:%M:%S %z')
22+
23+
# http://superuser.com/questions/710019/
2024
if not seen_header:
21-
print("--- %s\n+++ %s" % (ns1_name, ns2_name))
22-
seen_header = True
25+
if ns1_port != std_port or ns2_port != std_port:
26+
print("--- %s:%s\t%s\n+++ %s:%s\t\t%s" %
27+
(ns1_name, ns1_port, t_stamp, ns2_name, ns2_port, t_stamp))
28+
else:
29+
print("--- %s\t%s\n+++ %s\t\t%s" %
30+
(ns1_name, t_stamp, ns2_name, t_stamp))
2331

24-
def added(answer, color=False):
25-
# answer contains multiple records and does not end in newline
26-
answer = '+' + str(answer[0]).replace('\n', '\n+')
32+
seen_header = True
2733

28-
if color:
29-
print(term.green + answer + term.normal)
30-
else:
31-
print(answer)
34+
def added(answer, color=False):
35+
for rrset in answer:
36+
if color:
37+
print(term.green + '+' + rrset + term.normal)
38+
else:
39+
print('+' + rrset)
3240

3341
def removed(answer, color=False):
34-
# answer contains multiple records and does not end in newline
35-
answer = '-' + str(answer[0]).replace('\n', '\n-')
36-
37-
if color:
38-
print(term.red + answer + term.normal)
39-
else:
40-
print(answer)
42+
for rrset in answer:
43+
if color:
44+
print(term.red + '-' + rrset + term.normal)
45+
else:
46+
print('-' + rrset)
4147

42-
def get_dns_response(query, ns_addr, ns_name, rname, rdtype):
48+
def get_response(query, ns_addr, ns_name, rname, rdtype, port):
4349
response = None
4450
successful = False
4551
t = 2
4652

4753
while not successful:
4854
try:
49-
response = dns.query.udp(query, ns_addr, timeout=t)
55+
response = dns.query.udp(query, ns_addr, timeout=t, port=port)
5056
successful = True
5157
except dns.exception.Timeout:
5258
# Retry with backoff
@@ -58,6 +64,32 @@ def get_dns_response(query, ns_addr, ns_name, rname, rdtype):
5864

5965
return response
6066

67+
def extract_rrset(response, ns_name, rdtype, ignore_ttl=False):
68+
# TODO: handle SERVFAIL, REFUSED, NOTIMP, etc?
69+
# Return only well-undestood cases so that this fails loudly
70+
71+
# Successful query response
72+
if (response.rcode() == dns.rcode.NOERROR
73+
and response.answer):
74+
if ignore_ttl:
75+
response.answer[0].ttl = 0
76+
return sorted(str(response.answer[0]).split('\n'))
77+
78+
# rrsets for NS records will be in the AUTHORITY section of the dns.message
79+
# object instead of the ANSWER section
80+
if (rdtype == 'NS'
81+
and response.rcode() == dns.rcode.NOERROR
82+
and not response.answer
83+
and not ns_name in response.authority[0]):
84+
if ignore_ttl:
85+
response.authority[0].ttl = 0
86+
return sorted(str(response.authority[0]).split('\n'))
87+
88+
# AWS Alias record with NXDOMAIN target
89+
if (response.rcode() == dns.rcode.NOERROR
90+
and not response.answer):
91+
return None
92+
6193
def main():
6294
progname = os.path.basename(sys.argv[0])
6395
parser = argparse.ArgumentParser(prog=progname)
@@ -73,26 +105,22 @@ def main():
73105
metavar='SECONDS',
74106
help="maximum number of seconds of delay to introduce" +
75107
" between each request")
76-
'''
77-
# TODO: exclusions
78-
parser.add_argument("-n", "--ignore-ns", action="store_true",
79-
help="Ignore changes to NS records at the apex")
80-
parser.add_argument("-s", "--ignore-soa", action="store_true",
81-
help="Ignore changes to SOA records")
82-
parser.add_argument("-t", "--ignore-ttl", action="store_true",
83-
help="Ignore changes to TTL values")
84-
'''
108+
109+
parser.add_argument("-t", "--ignore-ttl", dest="ttl_flag",
110+
action="store_true",
111+
help="ignore changes to TTL values")
112+
85113
parser.add_argument("-f", "--zonefile", dest="filename", required=True,
86-
help="FILENAME is expected to be a valid zone master" +
87-
" file\n" +
114+
help="FILENAME is expected to be a valid " +
115+
"zonefile exported from NAMESERVER1\n" +
88116
"https://tools.ietf.org/html/rfc1035#section-5")
89117

90118
parser.add_argument("--from-ns", dest="ns1_name", required=True,
91-
metavar="NAMESERVER1",
119+
metavar="NAMESERVER1[:PORT]",
92120
help="compare responses to NAMESERVER2")
93121

94122
parser.add_argument("--to-ns", dest="ns2_name", required=True,
95-
metavar="NAMESERVER2",
123+
metavar="NAMESERVER2[:PORT]",
96124
help="compare responses to NAMESERVER1")
97125

98126
args = parser.parse_args()
@@ -104,13 +132,24 @@ def main():
104132
try:
105133
zone = dns.zone.from_file(args.filename, allow_include=False,
106134
relativize=False)
107-
108135
except dns.exception.DNSException:
109136
sys.stderr.write("%s: Unable to import %s.\n" %
110137
(progname, args.filename))
111138
traceback.print_exc(file=sys.stderr)
112139
sys.exit(1)
113140

141+
# Default port
142+
ns1_port = ns2_port = 53
143+
144+
# Support for alternate port
145+
if ':' in args.ns1_name:
146+
ns1_port = int(args.ns1_name.split(':')[1])
147+
args.ns1_name = args.ns1_name.split(':')[0]
148+
149+
if ':' in args.ns2_name:
150+
ns2_port = int(args.ns2_name.split(':')[1])
151+
args.ns2_name = args.ns2_name.split(':')[0]
152+
114153
# TODO: IPv6 support with socket.getaddrinfo()
115154
ns1_addr = socket.gethostbyname(args.ns1_name)
116155
ns2_addr = socket.gethostbyname(args.ns2_name)
@@ -122,63 +161,57 @@ def main():
122161
rdtype = dns.rdatatype.to_text(rdataset.rdtype)
123162
query = dns.message.make_query(rname, rdtype)
124163

125-
# Attempt to evade rate limits
126-
if args.delay_max:
127-
sleep(randint(0, args.delay_max))
164+
# TODO: Improve accuracy for response pools. Execute each query
165+
# multiple times until most/all records are exposed and gathered.
128166

129-
r1 = get_dns_response(query, ns1_addr, args.ns1_name, rname, rdtype)
130-
if not r1:
131-
sys.stderr.write("%s: SERVFAIL from %s for: %s %s\n" %
132-
(progname, args.ns1_name, rname, rdtype))
167+
r1 = get_response(query, ns1_addr, args.ns1_name, rname, rdtype, ns1_port)
133168

134-
sys.stderr.write("Try using or increasing --delay-max\n" %
135-
(progname, args.ns1_name, rname, rdtype))
169+
if not r1:
170+
sys.stderr.write("%s: Connection timed out to: '%s'\n" %
171+
(progname, args.ns1_name))
172+
sys.stderr.write("Try using or increasing --delay-max\n")
136173
sys.exit(1)
137174

138-
r2 = get_dns_response(query, ns2_addr, args.ns2_name, rname, rdtype)
139-
if not r2:
140-
sys.stderr.write("%s: SERVFAIL from %s for: %s %s\n" %
141-
(progname, args.ns2_name, rname, rdtype))
175+
r2 = get_response(query, ns2_addr, args.ns2_name, rname, rdtype, ns2_port)
142176

143-
sys.stderr.write("Try using or increasing --delay-max\n" %
144-
(progname, args.ns2_name, rname, rdtype))
177+
if not r2:
178+
sys.stderr.write("%s: Connection timed out to: '%s'\n" %
179+
(progname, args.ns2_name))
180+
sys.stderr.write("Try using or increasing --delay-max\n")
145181
sys.exit(1)
146182

147-
# TODO: Fix accuracy in case of round-robin pool. Execute each query
148-
# multiple times until most/all records are exposed. --hard, --hunt,
149-
# or --pool option?
150-
151-
# BUG: .answer is an empty list if NXDOMAIN or NS record. NS record
152-
# will always have empty .answer, check for existance of .authority
153-
# instead, search for --from-ns if necessary
154-
155-
# BUG: cache and sort .answer to avoid false positive for round-robin
156-
# response
157-
158-
if r1.answer and r2.answer:
159-
# Answers from both servers, now compare records
160-
if r1.answer[0] != r2.answer[0]:
161-
header(args.ns1_name, args.ns2_name)
162-
removed(r1.answer, color=args.color_flag)
163-
added(r2.answer, color=args.color_flag)
183+
# FIXME: Please report any issues with answer extraction
184+
a1 = extract_rrset(r1, args.ns1_name, rdtype, ignore_ttl=args.ttl_flag)
185+
a2 = extract_rrset(r2, args.ns2_name, rdtype, ignore_ttl=args.ttl_flag)
186+
187+
if a1 and a2:
188+
# Answers from both servers, compare rrsets
189+
if a1 != a2:
190+
header(args.ns1_name, ns1_port, args.ns2_name, ns2_port)
191+
removed(a1, color=args.color_flag)
192+
added(a2, color=args.color_flag)
164193
# else:
165194
# # Records both exist and match
166195
# pass
167-
elif r1.answer and not r2.answer:
168-
# exists in r1 but NXDOMAIN in r2
169-
header(args.ns1_name, args.ns2_name)
170-
removed(r1.answer, color=args.color_flag)
171-
elif r2.answer and not r1.answer:
172-
# exists in r2 but NXDOMAIN in r1
173-
header(args.ns1_name, args.ns2_name)
174-
added(r2.answer, color=args.color_flag)
196+
elif a1 and not a2:
197+
# Added to ns1 removed from ns2
198+
header(args.ns1_name, ns1_port, args.ns2_name, ns2_port)
199+
removed(a1, color=args.color_flag)
200+
elif a2 and not a1:
201+
# Added to ns2 removed from ns1
202+
header(args.ns1_name, ns1_port, args.ns2_name, ns2_port)
203+
added(a2, color=args.color_flag)
175204
else:
176-
# exists in zonefile but NXDOMAIN on both servers
177-
sys.stderr.write("%s: NXDOMAIN from both nameservers for: %s %s\n" %
178-
(progname, rname, rdtype))
205+
# Exists in zonefile but not on either server
206+
sys.stderr.write("%s: Record \"%s\" exists in zonefile but not on either server.\n" %
207+
(progname, query.question[0]))
179208
sys.stderr.write("Export zonefile from %s and try again\n" %
180209
args.ns1_name)
181210
sys.exit(1)
182211

212+
# Attempt to evade rate limits
213+
if args.delay_max:
214+
sleep(randint(0, args.delay_max))
215+
183216
if __name__ == '__main__':
184217
main()

0 commit comments

Comments
 (0)