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 class RunningFilter(BaseFilter
):
24 def accept(self
, relay
):
25 return relay
['running']
27 class FamilyFilter(BaseFilter
):
28 def __init__(self
, family
, all_relays
):
29 self
._family
_fingerprint
= None
30 self
._family
_nickname
= None
31 self
._family
_relays
= []
33 for relay
in all_relays
:
34 if len(family
) == 40 and relay
['fingerprint'] == family
:
37 if len(family
) < 20 and 'Named' in relay
['flags'] and relay
['nickname'] == family
:
41 self
._family
_fingerprint
= '$%s' % found_relay
['fingerprint']
42 if 'Named' in found_relay
['flags']:
43 self
._family
_nickname
= found_relay
['nickname']
44 self
._family
_relays
= [self
._family
_fingerprint
] + found_relay
.get('family', [])
46 def accept(self
, relay
):
47 fingerprint
= '$%s' % relay
['fingerprint']
48 mentions
= [fingerprint
] + relay
.get('family', [])
49 if fingerprint
in self
._family
_relays
:
51 if 'Named' in relay
['flags'] and relay
['nickname'] in self
._family
_relays
:
53 if self
._family
_fingerprint
in mentions
:
55 if self
._family
_nickname
in mentions
:
59 class CountryFilter(BaseFilter
):
60 def __init__(self
, countries
=[]):
61 self
._countries
= [x
.lower() for x
in countries
]
63 def accept(self
, relay
):
64 return relay
.get('country', None) in self
._countries
66 class ASFilter(BaseFilter
):
67 def __init__(self
, as_sets
=[]):
68 self
._as
_sets
= [x
if not x
.isdigit() else "AS" + x
for x
in as_sets
]
70 def accept(self
, relay
):
71 return relay
.get('as_number', None) in self
._as
_sets
73 class ExitFilter(BaseFilter
):
74 def accept(self
, relay
):
75 return relay
.get('exit_probability', -1) > 0.0
77 class GuardFilter(BaseFilter
):
78 def accept(self
, relay
):
79 return relay
.get('guard_probability', -1) > 0.0
81 class FastExitFilter(BaseFilter
):
82 def accept(self
, relay
):
83 if relay
.get('bandwidth_rate', -1) < 12500 * 1024:
85 if relay
.get('advertised_bandwidth', -1) < 5000 * 1024:
87 relevant_ports
= set([80, 443, 554, 1755])
88 summary
= relay
.get('exit_policy_summary', {})
89 if 'accept' in summary
:
90 portlist
= summary
['accept']
91 elif 'reject' in summary
:
92 portlist
= summary
['reject']
98 ports
.extend(range(int(p
.split('-')[0]),
99 int(p
.split('-')[1]) + 1))
102 policy_ports
= set(ports
)
103 if 'accept' in summary
and not relevant_ports
.issubset(policy_ports
):
105 if 'reject' in summary
and not relevant_ports
.isdisjoint(policy_ports
):
109 class RelayStats(object):
110 def __init__(self
, options
):
112 self
._filters
= self
._create
_filters
(options
)
113 self
._get
_group
= self
._get
_group
_function
(options
)
119 self
._data
= json
.load(file('details.json'))
128 for relay
in self
.data
['relays']:
130 for f
in self
._filters
:
131 if not f
.accept(relay
):
135 self
.add_relay(relay
)
138 def _create_filters(self
, options
):
140 if not options
.inactive
:
141 filters
.append(RunningFilter())
143 filters
.append(FamilyFilter(options
.family
, self
.data
['relays']))
145 filters
.append(CountryFilter(options
.country
))
147 filters
.append(ASFilter(options
.ases
))
148 if options
.exits_only
:
149 filters
.append(ExitFilter())
150 if options
.guards_only
:
151 filters
.append(GuardFilter())
152 if options
.fast_exits_only
:
153 filters
.append(FastExitFilter())
156 def _get_group_function(self
, options
):
157 if options
.by_country
and options
.by_as
:
158 return lambda relay
: (relay
.get('country', None), relay
.get('as_number', None))
159 elif options
.by_country
:
160 return lambda relay
: relay
.get('country', None)
162 return lambda relay
: relay
.get('as_number', None)
164 return lambda relay
: relay
.get('fingerprint')
166 def add_relay(self
, relay
):
167 key
= self
._get
_group
(relay
)
168 if key
not in self
._relays
:
169 self
._relays
[key
] = []
170 self
._relays
[key
].append(relay
)
172 def format_and_sort_groups(self
, grouped_relays
, by_country
=False, by_as_number
=False, links
=False):
173 formatted_groups
= {}
174 for group
in grouped_relays
.values():
175 group_weights
= (0, 0, 0, 0, 0)
178 weights
= (relay
.get('consensus_weight_fraction', 0),
179 relay
.get('advertised_bandwidth_fraction', 0),
180 relay
.get('guard_probability', 0),
181 relay
.get('middle_probability', 0),
182 relay
.get('exit_probability', 0))
183 group_weights
= tuple(sum(x
) for x
in zip(group_weights
, weights
))
184 nickname
= relay
['nickname']
185 fingerprint
= relay
['fingerprint'] if not links
else "https://atlas.torproject.org/#details/%s" % relay
['fingerprint']
186 exit
= 'Exit' if 'Exit' in set(relay
['flags']) else ''
187 guard
= 'Guard' if 'Guard' in set(relay
['flags']) else ''
188 country
= relay
.get('country', '')
189 as_number
= relay
.get('as_number', '')
190 as_name
= relay
.get('as_name', '')
192 if by_country
or by_as_number
:
193 nickname
= "(%d relays)" % relays_in_group
197 if by_country
and not by_as_number
:
200 if by_as_number
and not by_country
:
203 format_string
= "%8.4f%% %8.4f%% %8.4f%% %8.4f%% %8.4f%% %-19s %-78s %-4s %-5s %-2s %-9s %s"
205 format_string
= "%8.4f%% %8.4f%% %8.4f%% %8.4f%% %8.4f%% %-19s %-40s %-4s %-5s %-2s %-9s %s"
206 formatted_group
= format_string
% (
207 group_weights
[0] * 100.0,
208 group_weights
[1] * 100.0,
209 group_weights
[2] * 100.0,
210 group_weights
[3] * 100.0,
211 group_weights
[4] * 100.0,
212 nickname
, fingerprint
,
213 exit
, guard
, country
, as_number
, as_name
)
214 formatted_groups
[formatted_group
] = group_weights
215 sorted_groups
= sorted(formatted_groups
.iteritems(), key
=operator
.itemgetter(1))
216 sorted_groups
.reverse()
219 def print_groups(self
, sorted_groups
, count
=10, by_country
=False, by_as_number
=False, short
=False, links
=False):
221 print " CW adv_bw P_guard P_middle P_exit Nickname Link Exit Guard CC AS_num AS_name"[:short
]
223 print " CW adv_bw P_guard P_middle P_exit Nickname Fingerprint Exit Guard CC AS_num AS_name"[:short
]
224 if count
< 0: count
= len(sorted_groups
)
225 for formatted_group
, weight
in sorted_groups
[:count
]:
226 print formatted_group
[:short
]
227 if len(sorted_groups
) > count
:
228 if by_country
and by_as_number
:
229 type = "countries and ASes"
236 other_weights
= (0, 0, 0, 0, 0)
237 for _
, weights
in sorted_groups
[count
:]:
238 other_weights
= tuple(sum(x
) for x
in zip(other_weights
, weights
))
239 print "%8.4f%% %8.4f%% %8.4f%% %8.4f%% %8.4f%% (%d other %s)" % (
240 other_weights
[0] * 100.0, other_weights
[1] * 100.0,
241 other_weights
[2] * 100.0, other_weights
[3] * 100.0,
242 other_weights
[4] * 100.0, len(sorted_groups
) - count
, type)
243 selection_weights
= (0, 0, 0, 0, 0)
244 for _
, weights
in sorted_groups
:
245 selection_weights
= tuple(sum(x
) for x
in zip(selection_weights
, weights
))
246 if len(sorted_groups
) > 1 and selection_weights
[0] < 0.999:
247 print "%8.4f%% %8.4f%% %8.4f%% %8.4f%% %8.4f%% (total in selection)" % (
248 selection_weights
[0] * 100.0, selection_weights
[1] * 100.0,
249 selection_weights
[2] * 100.0, selection_weights
[3] * 100.0,
250 selection_weights
[4] * 100.0)
252 def create_option_parser():
253 parser
= OptionParser()
254 parser
.add_option("-d", "--download", action
="store_true",
255 help="download details.json from Onionoo service")
256 group
= OptionGroup(parser
, "Filtering options")
257 group
.add_option("-i", "--inactive", action
="store_true", default
=False,
258 help="include relays in selection that aren't currently running")
259 group
.add_option("-a", "--as", dest
="ases", action
="append",
260 help="select only relays from autonomous system number AS",
262 group
.add_option("-c", "--country", action
="append",
263 help="select only relays from country with code CC", metavar
="CC")
264 group
.add_option("-e", "--exits-only", action
="store_true",
265 help="select only relays suitable for exit position")
266 group
.add_option("-f", "--family", action
="store", type="string", metavar
="RELAY",
267 help="select family by fingerprint or nickname (for named relays)")
268 group
.add_option("-g", "--guards-only", action
="store_true",
269 help="select only relays suitable for guard position")
270 group
.add_option("-x", "--fast-exits-only", action
="store_true",
271 help="select only 100+ Mbit/s exits allowing ports 80, 443, 554, and 1755")
272 parser
.add_option_group(group
)
273 group
= OptionGroup(parser
, "Grouping options")
274 group
.add_option("-A", "--by-as", action
="store_true", default
=False,
275 help="group relays by AS")
276 group
.add_option("-C", "--by-country", action
="store_true", default
=False,
277 help="group relays by country")
278 parser
.add_option_group(group
)
279 group
= OptionGroup(parser
, "Display options")
280 group
.add_option("-l", "--links", action
="store_true",
281 help="display links to the Atlas service instead of fingerprints")
282 group
.add_option("-t", "--top", type="int", default
=10, metavar
="NUM",
283 help="display only the top results (default: %default; -1 for all)")
284 group
.add_option("-s", "--short", action
="store_true",
285 help="cut the length of the line output at 70 chars")
286 parser
.add_option_group(group
)
289 def download_details_file():
290 url
= urllib
.urlopen('https://onionoo.torproject.org/details?type=relay')
291 details_file
= open("details.json", 'w')
292 details_file
.write(url
.read())
296 if '__main__' == __name__
:
297 parser
= create_option_parser()
298 (options
, args
) = parser
.parse_args()
300 parser
.error("Did not understand positional argument(s), use options instead.")
302 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
):
303 parser
.error("Not a valid fingerprint or nickname: %s" % options
.family
)
305 download_details_file()
306 print "Downloaded details.json. Re-run without --download option."
309 if not os
.path
.exists('details.json'):
310 parser
.error("Did not find details.json. Re-run with --download.")
312 stats
= RelayStats(options
)
313 sorted_groups
= stats
.format_and_sort_groups(stats
.relays
,
314 by_country
=options
.by_country
,
315 by_as_number
=options
.by_as
,
317 stats
.print_groups(sorted_groups
, options
.top
,
318 by_country
=options
.by_country
,
319 by_as_number
=options
.by_as
,
320 short
=70 if options
.short
else None,