3 # This program is free software. It comes without any warranty, to
4 # the extent permitted by applicable law. You can redistribute it
5 # and/or modify it under the terms of the Do What The Fuck You Want
6 # To Public License, Version 2, as published by Sam Hocevar. See
7 # http://sam.zoy.org/wtfpl/COPYING for more details.
13 from optparse
import OptionParser
, OptionGroup
16 from abc
import abstractmethod
18 class BaseFilter(object):
20 def accept(self
, relay
):
23 def load(self
, relays
):
26 class RunningFilter(BaseFilter
):
27 def accept(self
, relay
):
28 return relay
['running']
30 class FamilyFilter(BaseFilter
):
31 def __init__(self
, family
, all_relays
):
32 self
._family
_fingerprint
= None
33 self
._family
_nickname
= None
34 self
._family
_relays
= []
36 for relay
in all_relays
:
37 if len(family
) == 40 and relay
['fingerprint'] == family
:
40 if len(family
) < 20 and 'Named' in relay
['flags'] and relay
['nickname'] == family
:
44 self
._family
_fingerprint
= '$%s' % found_relay
['fingerprint']
45 if 'Named' in found_relay
['flags']:
46 self
._family
_nickname
= found_relay
['nickname']
47 self
._family
_relays
= [self
._family
_fingerprint
] + found_relay
.get('family', [])
49 def accept(self
, relay
):
50 fingerprint
= '$%s' % relay
['fingerprint']
51 mentions
= [fingerprint
] + relay
.get('family', [])
52 # Only show families as accepted by consensus (mutually listed relays)
53 listed
= fingerprint
in self
._family
_relays
54 listed
= listed
or 'Named' in relay
['flags'] and relay
['nickname'] in self
._family
_relays
55 mentioned
= self
._family
_fingerprint
in mentions
56 mentioned
= mentioned
or self
._family
_nickname
in mentions
57 if listed
and mentioned
:
61 class CountryFilter(BaseFilter
):
62 def __init__(self
, countries
=[]):
63 self
._countries
= [x
.lower() for x
in countries
]
65 def accept(self
, relay
):
66 return relay
.get('country', None) in self
._countries
68 class ASFilter(BaseFilter
):
69 def __init__(self
, as_sets
=[]):
70 self
._as
_sets
= [x
if not x
.isdigit() else "AS" + x
for x
in as_sets
]
72 def accept(self
, relay
):
73 return relay
.get('as_number', None) in self
._as
_sets
75 class ExitFilter(BaseFilter
):
76 def accept(self
, relay
):
77 return relay
.get('exit_probability', -1) > 0.0
79 class GuardFilter(BaseFilter
):
80 def accept(self
, relay
):
81 return relay
.get('guard_probability', -1) > 0.0
83 class FastExitFilter(BaseFilter
):
84 def __init__(self
, bandwidth_rate
, advertised_bandwidth
, ports
, inverse
=False):
85 self
.bandwidth_rate
= bandwidth_rate
86 self
.advertised_bandwidth
= advertised_bandwidth
88 self
.inverse
= inverse
90 def accept(self
, relay
):
91 if relay
.get('bandwidth_rate', -1) < self
.bandwidth_rate
:
93 if relay
.get('advertised_bandwidth', -1) < self
.advertised_bandwidth
:
95 relevant_ports
= set(self
.ports
)
96 summary
= relay
.get('exit_policy_summary', {})
97 if 'accept' in summary
:
98 portlist
= summary
['accept']
99 elif 'reject' in summary
:
100 portlist
= summary
['reject']
106 ports
.extend(range(int(p
.split('-')[0]),
107 int(p
.split('-')[1]) + 1))
110 policy_ports
= set(ports
)
111 if 'accept' in summary
and not relevant_ports
.issubset(policy_ports
):
113 if 'reject' in summary
and not relevant_ports
.isdisjoint(policy_ports
):
115 return not self
.inverse
117 class RelayStats(object):
118 def __init__(self
, options
):
120 self
._filters
= self
._create
_filters
(options
)
121 self
._get
_group
= self
._get
_group
_function
(options
)
127 self
._data
= json
.load(file(os
.path
.join(os
.path
.dirname(os
.path
.abspath(__file__
)), 'details.json')))
136 relays
= self
.data
['relays']
137 for f
in self
._filters
:
139 relays
= filter(f
.accept
, relays
)
142 self
.add_relay(relay
)
145 def _create_filters(self
, options
):
147 if not options
.inactive
:
148 filters
.append(RunningFilter())
150 filters
.append(FamilyFilter(options
.family
, self
.data
['relays']))
152 filters
.append(CountryFilter(options
.country
))
154 filters
.append(ASFilter(options
.ases
))
155 if options
.exits_only
:
156 filters
.append(ExitFilter())
157 if options
.guards_only
:
158 filters
.append(GuardFilter())
159 if options
.fast_exits_only
:
160 filters
.append(FastExitFilter(95 * 125 * 1024, 5000 * 1024, [80, 443, 554, 1755], False))
163 def _get_group_function(self
, options
):
164 if options
.by_country
and options
.by_as
:
165 return lambda relay
: (relay
.get('country', None), relay
.get('as_number', None))
166 elif options
.by_country
:
167 return lambda relay
: relay
.get('country', None)
169 return lambda relay
: relay
.get('as_number', None)
171 return lambda relay
: relay
.get('fingerprint')
173 def add_relay(self
, relay
):
174 key
= self
._get
_group
(relay
)
175 if key
not in self
._relays
:
176 self
._relays
[key
] = []
177 self
._relays
[key
].append(relay
)
179 def format_and_sort_groups(self
, grouped_relays
, by_country
=False, by_as_number
=False, links
=False):
180 formatted_groups
= {}
181 for group
in grouped_relays
.values():
182 group_weights
= (0, 0, 0, 0, 0)
185 weights
= (relay
.get('consensus_weight_fraction', 0),
186 relay
.get('advertised_bandwidth_fraction', 0),
187 relay
.get('guard_probability', 0),
188 relay
.get('middle_probability', 0),
189 relay
.get('exit_probability', 0))
190 group_weights
= tuple(sum(x
) for x
in zip(group_weights
, weights
))
191 nickname
= relay
['nickname']
192 fingerprint
= relay
['fingerprint'] if not links
else "https://atlas.torproject.org/#details/%s" % relay
['fingerprint']
193 exit
= 'Exit' if 'Exit' in set(relay
['flags']) else '-'
194 guard
= 'Guard' if 'Guard' in set(relay
['flags']) else '-'
195 country
= relay
.get('country', '')
196 as_number
= relay
.get('as_number', '')
197 as_name
= relay
.get('as_name', '')
199 if by_country
or by_as_number
:
200 nickname
= "(%d relays)" % relays_in_group
204 if by_country
and not by_as_number
:
207 if by_as_number
and not by_country
:
210 format_string
= "%8.4f%% %8.4f%% %8.4f%% %8.4f%% %8.4f%% %-19s %-78s %-4s %-5s %-2s %-9s %s"
212 format_string
= "%8.4f%% %8.4f%% %8.4f%% %8.4f%% %8.4f%% %-19s %-40s %-4s %-5s %-2s %-9s %s"
213 formatted_group
= format_string
% (
214 group_weights
[0] * 100.0,
215 group_weights
[1] * 100.0,
216 group_weights
[2] * 100.0,
217 group_weights
[3] * 100.0,
218 group_weights
[4] * 100.0,
219 nickname
, fingerprint
,
220 exit
, guard
, country
, as_number
, as_name
)
221 formatted_groups
[formatted_group
] = group_weights
222 sorted_groups
= sorted(formatted_groups
.iteritems(), key
=operator
.itemgetter(1))
223 sorted_groups
.reverse()
226 def print_groups(self
, sorted_groups
, count
=10, by_country
=False, by_as_number
=False, short
=False, links
=False):
229 output_string
.append(" CW adv_bw P_guard P_middle P_exit Nickname Link Exit Guard CC AS_num AS_name"[:short
])
231 output_string
.append(" CW adv_bw P_guard P_middle P_exit Nickname Fingerprint Exit Guard CC AS_num AS_name"[:short
])
232 if count
< 0: count
= len(sorted_groups
)
233 for formatted_group
, weight
in sorted_groups
[:count
]:
234 output_string
.append(formatted_group
[:short
])
235 if len(sorted_groups
) > count
:
236 if by_country
and by_as_number
:
237 type = "countries and ASes"
244 other_weights
= (0, 0, 0, 0, 0)
245 for _
, weights
in sorted_groups
[count
:]:
246 other_weights
= tuple(sum(x
) for x
in zip(other_weights
, weights
))
247 output_string
.append("%8.4f%% %8.4f%% %8.4f%% %8.4f%% %8.4f%% (%d other %s)" % (
248 other_weights
[0] * 100.0, other_weights
[1] * 100.0,
249 other_weights
[2] * 100.0, other_weights
[3] * 100.0,
250 other_weights
[4] * 100.0, len(sorted_groups
) - count
, type))
251 selection_weights
= (0, 0, 0, 0, 0)
252 for _
, weights
in sorted_groups
:
253 selection_weights
= tuple(sum(x
) for x
in zip(selection_weights
, weights
))
254 if len(sorted_groups
) > 1 and selection_weights
[0] < 0.999:
255 output_string
.append("%8.4f%% %8.4f%% %8.4f%% %8.4f%% %8.4f%% (total in selection)" % (
256 selection_weights
[0] * 100.0, selection_weights
[1] * 100.0,
257 selection_weights
[2] * 100.0, selection_weights
[3] * 100.0,
258 selection_weights
[4] * 100.0))
261 def create_option_parser():
262 parser
= OptionParser()
263 parser
.add_option("-d", "--download", action
="store_true",
264 help="download details.json from Onionoo service")
265 group
= OptionGroup(parser
, "Filtering options")
266 group
.add_option("-i", "--inactive", action
="store_true", default
=False,
267 help="include relays in selection that aren't currently running")
268 group
.add_option("-a", "--as", dest
="ases", action
="append",
269 help="select only relays from autonomous system number AS",
271 group
.add_option("-c", "--country", action
="append",
272 help="select only relays from country with code CC", metavar
="CC")
273 group
.add_option("-e", "--exits-only", action
="store_true",
274 help="select only relays suitable for exit position")
275 group
.add_option("-f", "--family", action
="store", type="string", metavar
="RELAY",
276 help="select family by fingerprint or nickname (for named relays)")
277 group
.add_option("-g", "--guards-only", action
="store_true",
278 help="select only relays suitable for guard position")
279 group
.add_option("-x", "--fast-exits-only", action
="store_true",
280 help="select only 100+ Mbit/s exits allowing ports 80, 443, 554, and 1755")
281 parser
.add_option_group(group
)
282 group
= OptionGroup(parser
, "Grouping options")
283 group
.add_option("-A", "--by-as", action
="store_true", default
=False,
284 help="group relays by AS")
285 group
.add_option("-C", "--by-country", action
="store_true", default
=False,
286 help="group relays by country")
287 parser
.add_option_group(group
)
288 group
= OptionGroup(parser
, "Display options")
289 group
.add_option("-l", "--links", action
="store_true",
290 help="display links to the Atlas service instead of fingerprints")
291 group
.add_option("-t", "--top", type="int", default
=10, metavar
="NUM",
292 help="display only the top results (default: %default; -1 for all)")
293 group
.add_option("-s", "--short", action
="store_true",
294 help="cut the length of the line output at 70 chars")
295 parser
.add_option_group(group
)
298 def download_details_file():
299 url
= urllib
.urlopen('https://onionoo.torproject.org/details?type=relay')
300 details_file
= open(os
.path
.join(os
.path
.dirname(os
.path
.abspath(__file__
)), 'details.json'), 'w')
301 details_file
.write(url
.read())
305 if '__main__' == __name__
:
306 parser
= create_option_parser()
307 (options
, args
) = parser
.parse_args()
309 parser
.error("Did not understand positional argument(s), use options instead.")
311 if options
.family
and not re
.match(r
'^[A-F0-9]{40}$', options
.family
) and not re
.match(r
'^[A-Za-z0-9]{1,19}$', options
.family
):
312 parser
.error("Not a valid fingerprint or nickname: %s" % options
.family
)
314 download_details_file()
315 print "Downloaded details.json. Re-run without --download option."
318 if not os
.path
.exists(os
.path
.join(os
.path
.dirname(os
.path
.abspath(__file__
)), 'details.json')):
319 parser
.error("Did not find details.json. Re-run with --download.")
321 stats
= RelayStats(options
)
322 sorted_groups
= stats
.format_and_sort_groups(stats
.relays
,
323 by_country
=options
.by_country
,
324 by_as_number
=options
.by_as
,
326 output_string
= stats
.print_groups(sorted_groups
, options
.top
,
327 by_country
=options
.by_country
,
328 by_as_number
=options
.by_as
,
329 short
=70 if options
.short
else None,
331 print '\n'.join(output_string
)