asmap-tool.py raw
1 #!/usr/bin/env python3
2 # Copyright (c) 2022 Pieter Wuille
3 # Distributed under the MIT software license, see the accompanying
4 # file LICENSE or http://www.opensource.org/licenses/mit-license.php.
5
6 import argparse
7 import sys
8 import ipaddress
9 import json
10 import math
11 from collections import defaultdict
12
13 import asmap
14
15 def load_file(input_file):
16 try:
17 contents = input_file.read()
18 except OSError as err:
19 sys.exit(f"Input file '{input_file.name}' cannot be read: {err.strerror}.")
20 try:
21 bin_asmap = asmap.ASMap.from_binary(contents)
22 except ValueError:
23 bin_asmap = None
24 txt_error = None
25 entries = None
26 try:
27 txt_contents = str(contents, encoding="utf-8")
28 except UnicodeError:
29 txt_error = "invalid UTF-8"
30 txt_contents = None
31 if txt_contents is not None:
32 entries = []
33 for line in txt_contents.split("\n"):
34 idx = line.find('#')
35 if idx >= 0:
36 line = line[:idx]
37 line = line.lstrip(' ').rstrip(' \t\r\n')
38 if len(line) == 0:
39 continue
40 fields = line.split(' ')
41 if len(fields) != 2:
42 txt_error = f"unparseable line '{line}'"
43 entries = None
44 break
45 prefix, asn = fields
46 if len(asn) <= 2 or asn[:2] != "AS" or any(c < '0' or c > '9' for c in asn[2:]):
47 txt_error = f"invalid ASN '{asn}'"
48 entries = None
49 break
50 try:
51 net = ipaddress.ip_network(prefix)
52 except ValueError:
53 txt_error = f"invalid network '{prefix}'"
54 entries = None
55 break
56 entries.append((asmap.net_to_prefix(net), int(asn[2:])))
57 if entries is not None and bin_asmap is not None and len(contents) > 0:
58 sys.exit(f"Input file '{input_file.name}' is ambiguous.")
59 if entries is not None:
60 state = asmap.ASMap()
61 state.update_multi(entries)
62 return state
63 if bin_asmap is not None:
64 return bin_asmap
65 sys.exit(f"Input file '{input_file.name}' is neither a valid binary asmap file nor valid text input ({txt_error}).")
66
67
68 def save_binary(output_file, state, fill):
69 contents = state.to_binary(fill=fill)
70 try:
71 output_file.write(contents)
72 output_file.close()
73 except OSError as err:
74 sys.exit(f"Output file '{output_file.name}' cannot be written to: {err.strerror}.")
75
76 def save_text(output_file, state, fill, overlapping):
77 for prefix, asn in state.to_entries(fill=fill, overlapping=overlapping):
78 net = asmap.prefix_to_net(prefix)
79 try:
80 print(f"{net} AS{asn}", file=output_file)
81 except OSError as err:
82 sys.exit(f"Output file '{output_file.name}' cannot be written to: {err.strerror}.")
83 try:
84 output_file.close()
85 except OSError as err:
86 sys.exit(f"Output file '{output_file.name}' cannot be written to: {err.strerror}.")
87
88 def main():
89 parser = argparse.ArgumentParser(description="Tool for performing various operations on textual and binary asmap files.")
90 subparsers = parser.add_subparsers(title="valid subcommands", dest="subcommand")
91
92 parser_encode = subparsers.add_parser("encode", help="convert asmap data to binary format")
93 parser_encode.add_argument('-f', '--fill', dest="fill", default=False, action="store_true",
94 help="permit reassigning undefined network ranges arbitrarily to reduce size")
95 parser_encode.add_argument('infile', nargs='?', type=argparse.FileType('rb'), default=sys.stdin.buffer,
96 help="input asmap file (text or binary); default is stdin")
97 parser_encode.add_argument('outfile', nargs='?', type=argparse.FileType('wb'), default=sys.stdout.buffer,
98 help="output binary asmap file; default is stdout")
99
100 parser_decode = subparsers.add_parser("decode", help="convert asmap data to text format")
101 parser_decode.add_argument('-f', '--fill', dest="fill", default=False, action="store_true",
102 help="permit reassigning undefined network ranges arbitrarily to reduce length")
103 parser_decode.add_argument('-n', '--nonoverlapping', dest="overlapping", default=True, action="store_false",
104 help="output strictly non-overall ping network ranges (increases output size)")
105 parser_decode.add_argument('infile', nargs='?', type=argparse.FileType('rb'), default=sys.stdin.buffer,
106 help="input asmap file (text or binary); default is stdin")
107 parser_decode.add_argument('outfile', nargs='?', type=argparse.FileType('w'), default=sys.stdout,
108 help="output text file; default is stdout")
109
110 parser_diff = subparsers.add_parser("diff", help="compute the difference between two asmap files")
111 parser_diff.add_argument('-i', '--ignore-unassigned', dest="ignore_unassigned", default=False, action="store_true",
112 help="ignore unassigned ranges in the first input (useful when second input is filled)")
113 parser_diff.add_argument('infile1', type=argparse.FileType('rb'),
114 help="first file to compare (text or binary)")
115 parser_diff.add_argument('infile2', type=argparse.FileType('rb'),
116 help="second file to compare (text or binary)")
117
118 parser_diff_addrs = subparsers.add_parser("diff_addrs",
119 help="compute difference between two asmap files for a set of addresses")
120 parser_diff_addrs.add_argument('-s', '--show-addresses', dest="show_addresses", default=False, action="store_true",
121 help="include reassigned addresses in the output")
122 parser_diff_addrs.add_argument("infile1", type=argparse.FileType("rb"),
123 help="first file to compare (text or binary)")
124 parser_diff_addrs.add_argument("infile2", type=argparse.FileType("rb"),
125 help="second file to compare (text or binary)")
126 parser_diff_addrs.add_argument("addrs_file", type=argparse.FileType("r"),
127 help="address file containing getnodeaddresses output to use in the comparison "
128 "(make sure to set the count parameter to zero to get all node addresses, "
129 "e.g. 'bitcoin-cli getnodeaddresses 0 > addrs.json')")
130 args = parser.parse_args()
131 if args.subcommand is None:
132 parser.print_help()
133 elif args.subcommand == "encode":
134 if args.outfile.isatty():
135 sys.exit("Not much use in writing binary to a TTY. Please specify an output file or pipe output to another process.")
136 state = load_file(args.infile)
137 save_binary(args.outfile, state, fill=args.fill)
138 elif args.subcommand == "decode":
139 state = load_file(args.infile)
140 save_text(args.outfile, state, fill=args.fill, overlapping=args.overlapping)
141 elif args.subcommand == "diff":
142 state1 = load_file(args.infile1)
143 state2 = load_file(args.infile2)
144 ipv4_changed = 0
145 ipv4_entries_changed = 0
146 ipv6_changed = 0
147 ipv6_entries_changed = 0
148 for prefix, old_asn, new_asn in state1.diff(state2):
149 if args.ignore_unassigned and old_asn == 0:
150 continue
151 net = asmap.prefix_to_net(prefix)
152 if isinstance(net, ipaddress.IPv4Network):
153 ipv4_changed += net.num_addresses
154 ipv4_entries_changed += 1
155 elif isinstance(net, ipaddress.IPv6Network):
156 ipv6_changed += net.num_addresses
157 ipv6_entries_changed += 1
158 if new_asn == 0:
159 print(f"# {net} was AS{old_asn}")
160 elif old_asn == 0:
161 print(f"{net} AS{new_asn} # was unassigned")
162 else:
163 print(f"{net} AS{new_asn} # was AS{old_asn}")
164 ipv4_change_str = "" if ipv4_changed == 0 else f" (2^{math.log2(ipv4_changed):.2f})"
165 ipv6_change_str = "" if ipv6_changed == 0 else f" (2^{math.log2(ipv6_changed):.2f})"
166
167 print(
168 f"""# Summary
169 IPv4: {ipv4_entries_changed} entries with {ipv4_changed}{ipv4_change_str} addresses changed
170 IPv6: {ipv6_entries_changed} entries with {ipv6_changed}{ipv6_change_str} addresses changed"""
171 )
172 elif args.subcommand == "diff_addrs":
173 state1 = load_file(args.infile1)
174 state2 = load_file(args.infile2)
175 address_info = json.load(args.addrs_file)
176 addrs = {a["address"] for a in address_info if a["network"] in ["ipv4", "ipv6"]}
177 reassignments = defaultdict(list)
178 for addr in addrs:
179 net = ipaddress.ip_network(addr)
180 prefix = asmap.net_to_prefix(net)
181 old_asn = state1.lookup(prefix)
182 new_asn = state2.lookup(prefix)
183 if new_asn != old_asn:
184 reassignments[(old_asn, new_asn)].append(addr)
185 reassignments = sorted(reassignments.items(), key=lambda item: len(item[1]), reverse=True)
186 num_reassignment_type = defaultdict(int)
187 for (old_asn, new_asn), reassigned_addrs in reassignments:
188 num_reassigned = len(reassigned_addrs)
189 num_reassignment_type[(bool(old_asn), bool(new_asn))] += num_reassigned
190 old_asn_str = f"AS{old_asn}" if old_asn else "unassigned"
191 new_asn_str = f"AS{new_asn}" if new_asn else "unassigned"
192 opt = ": " + ", ".join(reassigned_addrs) if args.show_addresses else ""
193 print(f"{num_reassigned} address(es) reassigned from {old_asn_str} to {new_asn_str}{opt}")
194 num_reassignments = sum(len(addrs) for _, addrs in reassignments)
195 share = num_reassignments / len(addrs) if len(addrs) > 0 else 0
196 print(f"Summary: {num_reassignments:,} ({share:.2%}) of {len(addrs):,} addresses were reassigned "
197 f"(migrations={num_reassignment_type[True, True]}, assignments={num_reassignment_type[False, True]}, "
198 f"unassignments={num_reassignment_type[True, False]})")
199 else:
200 parser.print_help()
201 sys.exit("No command provided.")
202
203 if __name__ == '__main__':
204 main()
205