1 # Copyright 2018-2020, Damian Johnson and The Tor Project
2 # See LICENSE for licensing information
5 Directories that provide `relay descriptor information
6 <../tutorials/mirror_mirror_on_the_wall.html>`_. At a very high level tor works
9 1. Volunteer starts a new tor relay, during which it sends a `server
10 descriptor <descriptor/server_descriptor.html>`_ to each of the directory
13 2. Each hour the directory authorities make a `vote
14 <descriptor/networkstatus.html>`_ that says who they think the active
15 relays are in the network and some attributes about them.
17 3. The directory authorities send each other their votes, and compile that
18 into the `consensus <descriptor/networkstatus.html>`_. This document is very
19 similar to the votes, the only difference being that the majority of the
20 authorities agree upon and sign this document. The idividual relay entries
21 in the vote or consensus is called `router status entries
22 <descriptor/router_status_entry.html>`_.
24 4. Tor clients (people using the service) download the consensus from an
25 authority, fallback, or other mirror to determine who the active relays in
26 the network are. They then use this to construct circuits and use the
31 Directory - Relay we can retrieve descriptor information from
32 | |- from_cache - Provides cached information bundled with Stem.
33 | +- from_remote - Downloads the latest directory information from tor.
35 |- Authority - Tor directory authority
36 +- Fallback - Mirrors that can be used instead of the authorities
38 .. versionadded:: 1.7.0
51 from stem
.util
import connection
, str_tools
, tor_tools
52 from typing
import Any
, Callable
, Dict
, Iterator
, List
, Mapping
, Optional
, Pattern
, Sequence
, Tuple
, Union
54 GITWEB_AUTHORITY_URL
= 'https://gitweb.torproject.org/tor.git/plain/src/app/config/auth_dirs.inc'
55 GITWEB_FALLBACK_URL
= 'https://gitweb.torproject.org/tor.git/plain/src/app/config/fallback_dirs.inc'
56 FALLBACK_CACHE_PATH
= os
.path
.join(os
.path
.dirname(__file__
), 'cached_fallbacks.cfg')
58 AUTHORITY_NAME
= re
.compile('"(\\S+) orport=(\\d+) .*"')
59 AUTHORITY_V3IDENT
= re
.compile('"v3ident=([\\dA-F]{40}) "')
60 AUTHORITY_IPV6
= re
.compile('"ipv6=\\[([\\da-f:]+)\\]:(\\d+) "')
61 AUTHORITY_ADDR
= re
.compile('"([\\d\\.]+):(\\d+) ([\\dA-F ]{49})",')
63 FALLBACK_DIV
= '/* ===== */'
64 FALLBACK_MAPPING
= re
.compile('/\\*\\s+(\\S+)=(\\S*)\\s+\\*/')
66 FALLBACK_ADDR
= re
.compile('"([\\d\\.]+):(\\d+) orport=(\\d+) id=([\\dA-F]{40}).*')
67 FALLBACK_NICKNAME
= re
.compile('/\\* nickname=(\\S+) \\*/')
68 FALLBACK_EXTRAINFO
= re
.compile('/\\* extrainfo=([0-1]) \\*/')
69 FALLBACK_IPV6
= re
.compile('" ipv6=\\[([\\da-f:]+)\\]:(\\d+)"')
72 def _match_with(lines
: Sequence
[str], regexes
: Sequence
[Pattern
], required
: Optional
[Sequence
[Pattern
]] = None) -> Dict
[Pattern
, Union
[str, List
[str]]]:
74 Scans the given content against a series of regex matchers, providing back a
75 mapping of regexes to their capture groups. This maping is with the value if
76 the regex has just a single capture group, and a tuple otherwise.
78 :param lines: text to parse
79 :param regexes: regexes to match against
80 :param required: matches that must be in the content
82 :returns: **dict** mapping matchers against their capture groups
84 :raises: **ValueError** if a required match is not present
90 for matcher
in regexes
:
91 m
= matcher
.search(str_tools
._to
_unicode
(line
))
94 match_groups
= m
.groups()
95 matches
[matcher
] = match_groups
if len(match_groups
) > 1 else match_groups
[0]
98 for required_matcher
in required
:
99 if required_matcher
not in matches
:
100 raise ValueError('Failed to parse mandatory data from:\n\n%s' % '\n'.join(lines
))
105 def _directory_entries(lines
: List
[str], pop_section_func
: Callable
[[List
[str]], List
[str]], regexes
: Sequence
[Pattern
], required
: Optional
[Sequence
[Pattern
]] = None) -> Iterator
[Dict
[Pattern
, Union
[str, List
[str]]]]:
106 next_section
= pop_section_func(lines
)
109 yield _match_with(next_section
, regexes
, required
)
110 next_section
= pop_section_func(lines
)
113 class Directory(object):
115 Relay we can contact for descriptor information.
117 Our :func:`~stem.directory.Directory.from_cache` and
118 :func:`~stem.directory.Directory.from_remote` functions key off a
119 different identifier based on our subclass...
121 * :class:`~stem.directory.Authority` keys off the nickname.
122 * :class:`~stem.directory.Fallback` keys off fingerprints.
124 This is because authorities are highly static and canonically known by their
125 names, whereas fallbacks vary more and don't necessarily have a nickname to
128 :var str address: IPv4 address of the directory
129 :var int or_port: port on which the relay services relay traffic
130 :var int dir_port: port on which directory information is available
131 :var str fingerprint: relay fingerprint
132 :var str nickname: relay nickname
133 :var tuple orport_v6: **(address, port)** tuple for the directory's IPv6
134 ORPort, or **None** if it doesn't have one
137 def __init__(self
, address
: str, or_port
: Union
[int, str], dir_port
: Union
[int, str], fingerprint
: str, nickname
: str, orport_v6
: Tuple
[str, int]) -> None:
138 identifier
= '%s (%s)' % (fingerprint
, nickname
) if nickname
else fingerprint
140 if not connection
.is_valid_ipv4_address(address
):
141 raise ValueError('%s has an invalid IPv4 address: %s' % (identifier
, address
))
142 elif not connection
.is_valid_port(or_port
):
143 raise ValueError('%s has an invalid ORPort: %s' % (identifier
, or_port
))
144 elif not connection
.is_valid_port(dir_port
):
145 raise ValueError('%s has an invalid DirPort: %s' % (identifier
, dir_port
))
146 elif not tor_tools
.is_valid_fingerprint(fingerprint
):
147 raise ValueError('%s has an invalid fingerprint: %s' % (identifier
, fingerprint
))
148 elif nickname
and not tor_tools
.is_valid_nickname(nickname
):
149 raise ValueError('%s has an invalid nickname: %s' % (fingerprint
, nickname
))
152 if not isinstance(orport_v6
, tuple) or len(orport_v6
) != 2:
153 raise ValueError('%s orport_v6 should be a two value tuple: %s' % (identifier
, str(orport_v6
)))
154 elif not connection
.is_valid_ipv6_address(orport_v6
[0]):
155 raise ValueError('%s has an invalid IPv6 address: %s' % (identifier
, orport_v6
[0]))
156 elif not connection
.is_valid_port(orport_v6
[1]):
157 raise ValueError('%s has an invalid IPv6 port: %s' % (identifier
, orport_v6
[1]))
159 self
.address
= address
160 self
.or_port
= int(or_port
)
161 self
.dir_port
= int(dir_port
)
162 self
.fingerprint
= fingerprint
163 self
.nickname
= nickname
164 self
.orport_v6
= (orport_v6
[0], int(orport_v6
[1])) if orport_v6
else None
167 def from_cache() -> Dict
[str, Any
]:
169 Provides cached Tor directory information. This information is hardcoded
170 into Tor and occasionally changes, so the information provided by this
171 method may not necessarily match the latest version of tor.
173 .. versionadded:: 1.5.0
175 .. versionchanged:: 1.7.0
176 Support added to the :class:`~stem.directory.Authority` class.
178 :returns: **dict** of **str** identifiers to
179 :class:`~stem.directory.Directory` instances
182 raise NotImplementedError('Unsupported Operation: this should be implemented by the Directory subclass')
185 def from_remote(timeout
: int = 60) -> Dict
[str, Any
]:
187 Reads and parses tor's directory data `from gitweb.torproject.org <https://gitweb.torproject.org/>`_.
188 Note that while convenient, this reliance on GitWeb means you should alway
189 call with a fallback, such as...
194 authorities = stem.directory.Authority.from_remote()
196 authorities = stem.directory.Authority.from_cache()
198 .. versionadded:: 1.5.0
200 .. versionchanged:: 1.7.0
201 Support added to the :class:`~stem.directory.Authority` class.
203 :param timeout: seconds to wait before timing out the request
205 :returns: **dict** of **str** identifiers to their
206 :class:`~stem.directory.Directory`
208 :raises: **OSError** if unable to retrieve the fallback directories
211 raise NotImplementedError('Unsupported Operation: this should be implemented by the Directory subclass')
213 def __hash__(self
) -> int:
214 return stem
.util
._hash
_attr
(self
, 'address', 'or_port', 'dir_port', 'fingerprint', 'nickname', 'orport_v6')
216 def __eq__(self
, other
: Any
) -> bool:
217 return hash(self
) == hash(other
) if isinstance(other
, Directory
) else False
219 def __ne__(self
, other
: Any
) -> bool:
220 return not self
== other
223 class Authority(Directory
):
225 Tor directory authority, a special type of relay `hardcoded into tor
226 <https://gitweb.torproject.org/tor.git/plain/src/or/auth_dirs.inc>`_
227 to enumerate the relays in the network.
229 .. versionchanged:: 1.7.0
230 Added the orport_v6 attribute.
232 :var str v3ident: identity key fingerprint used to sign votes and consensus
235 def __init__(self
, address
: Optional
[str] = None, or_port
: Optional
[Union
[int, str]] = None, dir_port
: Optional
[Union
[int, str]] = None, fingerprint
: Optional
[str] = None, nickname
: Optional
[str] = None, orport_v6
: Optional
[Tuple
[str, int]] = None, v3ident
: Optional
[str] = None) -> None:
236 super(Authority
, self
).__init
__(address
, or_port
, dir_port
, fingerprint
, nickname
, orport_v6
)
238 if v3ident
and not tor_tools
.is_valid_fingerprint(v3ident
):
239 identifier
= '%s (%s)' % (fingerprint
, nickname
) if nickname
else fingerprint
240 raise ValueError('%s has an invalid v3ident: %s' % (identifier
, v3ident
))
242 self
.v3ident
= v3ident
245 def from_cache() -> Dict
[str, 'stem.directory.Authority']:
246 return dict(DIRECTORY_AUTHORITIES
)
249 def from_remote(timeout
: int = 60) -> Dict
[str, 'stem.directory.Authority']:
251 lines
= str_tools
._to
_unicode
(urllib
.request
.urlopen(GITWEB_AUTHORITY_URL
, timeout
= timeout
).read()).splitlines()
254 raise OSError('no content')
256 exc
, stacktrace
= sys
.exc_info()[1:3]
257 message
= "Unable to download tor's directory authorities from %s: %s" % (GITWEB_AUTHORITY_URL
, exc
)
258 raise stem
.DownloadFailed(GITWEB_AUTHORITY_URL
, exc
, stacktrace
, message
)
260 # Entries look like...
262 # "moria1 orport=9101 "
263 # "v3ident=D586D18309DED4CD6D57C18FDB97EFA96D330566 "
264 # "128.31.0.39:9131 9695 DFC3 5FFE B861 329B 9F1A B04C 4639 7020 CE31",
269 for matches
in _directory_entries(lines
, Authority
._pop
_section
, (AUTHORITY_NAME
, AUTHORITY_V3IDENT
, AUTHORITY_IPV6
, AUTHORITY_ADDR
), required
= (AUTHORITY_NAME
, AUTHORITY_ADDR
)):
270 nickname
, or_port
= matches
.get(AUTHORITY_NAME
) # type: ignore
271 address
, dir_port
, fingerprint
= matches
.get(AUTHORITY_ADDR
) # type: ignore
273 results
[nickname
] = Authority(
277 fingerprint
= fingerprint
.replace(' ', ''),
279 orport_v6
= matches
.get(AUTHORITY_IPV6
), # type: ignore
280 v3ident
= matches
.get(AUTHORITY_V3IDENT
), # type: ignore
282 except ValueError as exc
:
283 raise OSError(str(exc
))
288 def _pop_section(lines
: List
[str]) -> List
[str]:
290 Provides the next authority entry.
296 section_lines
.append(lines
.pop(0))
298 while lines
and lines
[0].startswith(' '):
299 section_lines
.append(lines
.pop(0))
303 def __hash__(self
) -> int:
304 return stem
.util
._hash
_attr
(self
, 'v3ident', parent
= Directory
, cache
= True)
306 def __eq__(self
, other
: Any
) -> bool:
307 return hash(self
) == hash(other
) if isinstance(other
, Authority
) else False
309 def __ne__(self
, other
: Any
) -> bool:
310 return not self
== other
313 class Fallback(Directory
):
315 Particularly stable relays tor can instead of authorities when
316 bootstrapping. These relays are `hardcoded in tor
317 <https://gitweb.torproject.org/tor.git/tree/src/or/fallback_dirs.inc>`_.
319 For example, the following checks the performance of tor's fallback directories...
324 from stem.descriptor.remote import get_consensus
325 from stem.directory import Fallback
327 for fallback in Fallback.from_cache().values():
329 get_consensus(endpoints = [(fallback.address, fallback.dir_port)]).run()
330 print('Downloading the consensus took %0.2f from %s' % (time.time() - start, fallback.fingerprint))
335 Downloading the consensus took 5.07 from 0AD3FA884D18F89EEA2D89C019379E0E7FD94417
336 Downloading the consensus took 3.59 from C871C91489886D5E2E94C13EA1A5FDC4B6DC5204
337 Downloading the consensus took 4.16 from 74A910646BCEEFBCD2E874FC1DC997430F968145
340 .. versionadded:: 1.5.0
342 .. versionchanged:: 1.7.0
343 Added the has_extrainfo and header attributes which are part of
344 the `second version of the fallback directories
345 <https://lists.torproject.org/pipermail/tor-dev/2017-December/012721.html>`_.
347 :var bool has_extrainfo: **True** if the relay should be able to provide
348 extrainfo descriptors, **False** otherwise.
349 :var collections.OrderedDict header: metadata about the fallback directory file this originated from
352 def __init__(self
, address
: Optional
[str] = None, or_port
: Optional
[Union
[int, str]] = None, dir_port
: Optional
[Union
[int, str]] = None, fingerprint
: Optional
[str] = None, nickname
: Optional
[str] = None, has_extrainfo
: bool = False, orport_v6
: Optional
[Tuple
[str, int]] = None, header
: Optional
[Mapping
[str, str]] = None) -> None:
353 super(Fallback
, self
).__init
__(address
, or_port
, dir_port
, fingerprint
, nickname
, orport_v6
)
354 self
.has_extrainfo
= has_extrainfo
355 self
.header
= collections
.OrderedDict(header
) if header
else collections
.OrderedDict()
358 def from_cache(path
: str = FALLBACK_CACHE_PATH
) -> Dict
[str, 'stem.directory.Fallback']:
359 conf
= stem
.util
.conf
.Config()
361 headers
= collections
.OrderedDict([(k
.split('.', 1)[1], conf
.get(k
)) for k
in conf
.keys() if k
.startswith('header.')])
365 for fingerprint
in set([key
.split('.')[0] for key
in conf
.keys()]):
366 if fingerprint
in ('tor_commit', 'stem_commit', 'header'):
371 for attr_name
in ('address', 'or_port', 'dir_port', 'nickname', 'has_extrainfo', 'orport6_address', 'orport6_port'):
372 key
= '%s.%s' % (fingerprint
, attr_name
)
373 attr
[attr_name
] = conf
.get(key
)
375 if not attr
[attr_name
] and attr_name
not in ('nickname', 'has_extrainfo', 'orport6_address', 'orport6_port'):
376 raise OSError("'%s' is missing from %s" % (key
, FALLBACK_CACHE_PATH
))
378 if attr
['orport6_address'] and attr
['orport6_port']:
379 orport_v6
= (attr
['orport6_address'], int(attr
['orport6_port']))
383 results
[fingerprint
] = Fallback(
384 address
= attr
['address'],
385 or_port
= int(attr
['or_port']),
386 dir_port
= int(attr
['dir_port']),
387 fingerprint
= fingerprint
,
388 nickname
= attr
['nickname'],
389 has_extrainfo
= attr
['has_extrainfo'] == 'true',
390 orport_v6
= orport_v6
,
397 def from_remote(timeout
: int = 60) -> Dict
[str, 'stem.directory.Fallback']:
399 lines
= str_tools
._to
_unicode
(urllib
.request
.urlopen(GITWEB_FALLBACK_URL
, timeout
= timeout
).read()).splitlines()
402 raise OSError('no content')
404 exc
, stacktrace
= sys
.exc_info()[1:3]
405 message
= "Unable to download tor's fallback directories from %s: %s" % (GITWEB_FALLBACK_URL
, exc
)
406 raise stem
.DownloadFailed(GITWEB_FALLBACK_URL
, exc
, stacktrace
, message
)
410 if lines
[0] != '/* type=fallback */':
411 raise OSError('%s does not have a type field indicating it is fallback directory metadata' % GITWEB_FALLBACK_URL
)
415 for line
in Fallback
._pop
_section
(lines
):
416 mapping
= FALLBACK_MAPPING
.match(line
)
419 header
[mapping
.group(1)] = mapping
.group(2)
421 raise OSError('Malformed fallback directory header line: %s' % line
)
423 Fallback
._pop
_section
(lines
) # skip human readable comments
425 # Entries look like...
427 # "5.9.110.236:9030 orport=9001 id=0756B7CD4DFC8182BE23143FAC0642F515182CEB"
428 # " ipv6=[2a01:4f8:162:51e2::2]:9001"
429 # /* nickname=rueckgrat */
435 for matches
in _directory_entries(lines
, Fallback
._pop
_section
, (FALLBACK_ADDR
, FALLBACK_NICKNAME
, FALLBACK_EXTRAINFO
, FALLBACK_IPV6
), required
= (FALLBACK_ADDR
,)):
436 address
, dir_port
, or_port
, fingerprint
= matches
[FALLBACK_ADDR
] # type: ignore
438 results
[fingerprint
] = Fallback(
440 or_port
= int(or_port
),
441 dir_port
= int(dir_port
),
442 fingerprint
= fingerprint
,
443 nickname
= matches
.get(FALLBACK_NICKNAME
), # type: ignore
444 has_extrainfo
= matches
.get(FALLBACK_EXTRAINFO
) == '1',
445 orport_v6
= matches
.get(FALLBACK_IPV6
), # type: ignore
448 except ValueError as exc
:
449 raise OSError(str(exc
))
454 def _pop_section(lines
: List
[str]) -> List
[str]:
456 Provides lines up through the next divider. This excludes lines with just a
457 comma since they're an artifact of these being C strings.
465 while lines
and line
!= FALLBACK_DIV
:
466 if line
.strip() != ',':
467 section_lines
.append(line
)
474 def _write(fallbacks
: Dict
[str, 'stem.directory.Fallback'], tor_commit
: str, stem_commit
: str, headers
: Mapping
[str, str], path
: str = FALLBACK_CACHE_PATH
) -> None:
476 Persists fallback directories to a location in a way that can be read by
479 :param fallbacks: mapping of fingerprints to their fallback directory
480 :param tor_commit: tor commit the fallbacks came from
481 :param stem_commit: stem commit the fallbacks came from
482 :param headers: metadata about the file these came from
483 :param path: location fallbacks will be persisted to
486 conf
= stem
.util
.conf
.Config()
487 conf
.set('tor_commit', tor_commit
)
488 conf
.set('stem_commit', stem_commit
)
490 for k
, v
in headers
.items():
491 conf
.set('header.%s' % k
, v
)
493 for directory
in sorted(fallbacks
.values(), key
= lambda x
: x
.fingerprint
):
494 fingerprint
= directory
.fingerprint
495 conf
.set('%s.address' % fingerprint
, directory
.address
)
496 conf
.set('%s.or_port' % fingerprint
, str(directory
.or_port
))
497 conf
.set('%s.dir_port' % fingerprint
, str(directory
.dir_port
))
498 conf
.set('%s.nickname' % fingerprint
, directory
.nickname
)
499 conf
.set('%s.has_extrainfo' % fingerprint
, 'true' if directory
.has_extrainfo
else 'false')
501 if directory
.orport_v6
:
502 conf
.set('%s.orport6_address' % fingerprint
, str(directory
.orport_v6
[0]))
503 conf
.set('%s.orport6_port' % fingerprint
, str(directory
.orport_v6
[1]))
507 def __hash__(self
) -> int:
508 return stem
.util
._hash
_attr
(self
, 'has_extrainfo', 'header', parent
= Directory
, cache
= True)
510 def __eq__(self
, other
: Any
) -> bool:
511 return hash(self
) == hash(other
) if isinstance(other
, Fallback
) else False
513 def __ne__(self
, other
: Any
) -> bool:
514 return not self
== other
517 def _fallback_directory_differences(previous_directories
: Mapping
[str, 'stem.directory.Fallback'], new_directories
: Mapping
[str, 'stem.directory.Fallback']) -> str:
519 Provides a description of how fallback directories differ.
524 added_fp
= set(new_directories
.keys()).difference(previous_directories
.keys())
525 removed_fp
= set(previous_directories
.keys()).difference(new_directories
.keys())
528 directory
= new_directories
[fp
]
529 orport_v6
= '%s:%s' % directory
.orport_v6
if directory
.orport_v6
else '[none]'
532 '* Added %s as a new fallback directory:' % directory
.fingerprint
,
533 ' address: %s' % directory
.address
,
534 ' or_port: %s' % directory
.or_port
,
535 ' dir_port: %s' % directory
.dir_port
,
536 ' nickname: %s' % directory
.nickname
,
537 ' has_extrainfo: %s' % directory
.has_extrainfo
,
538 ' orport_v6: %s' % orport_v6
,
542 for fp
in removed_fp
:
543 lines
.append('* Removed %s as a fallback directory' % fp
)
545 for fp
in new_directories
:
546 if fp
in added_fp
or fp
in removed_fp
:
547 continue # already discussed these
549 previous_directory
= previous_directories
[fp
]
550 new_directory
= new_directories
[fp
]
552 if previous_directory
!= new_directory
:
553 for attr
in ('address', 'or_port', 'dir_port', 'fingerprint', 'orport_v6', 'header'):
554 old_attr
= getattr(previous_directory
, attr
)
555 new_attr
= getattr(new_directory
, attr
)
557 if old_attr
!= new_attr
:
558 lines
.append('* Changed the %s of %s from %s to %s' % (attr
, fp
, old_attr
, new_attr
))
560 return '\n'.join(lines
)
563 DIRECTORY_AUTHORITIES
= {
566 address
= '128.31.0.39',
569 fingerprint
= '9695DFC35FFEB861329B9F1AB04C46397020CE31',
570 v3ident
= 'D586D18309DED4CD6D57C18FDB97EFA96D330566',
574 address
= '86.59.21.38',
577 fingerprint
= '847B1F850344D7876491A54892F904934E4EB85D',
578 orport_v6
= ('2001:858:2:2:aabb:0:563b:1526', 443),
579 v3ident
= '14C131DFC5C6F93646BE72FA1401C02A8DF2E8B4',
583 address
= '45.66.33.45',
586 fingerprint
= '7EA6EAD6FD83083C538F44038BBFA077587DD755',
587 v3ident
= 'E8A9C45EDE6D711294FADF8E7951F4DE6CA56B58',
589 'gabelmoo': Authority(
590 nickname
= 'gabelmoo',
591 address
= '131.188.40.189',
594 fingerprint
= 'F2044413DAC2E02E3D6BCF4735A19BCA1DE97281',
595 orport_v6
= ('2001:638:a000:4140::ffff:189', 443),
596 v3ident
= 'ED03BB616EB2F60BEC80151114BB25CEF515B226',
598 'dannenberg': Authority(
599 nickname
= 'dannenberg',
600 address
= '193.23.244.244',
603 orport_v6
= ('2001:678:558:1000::244', 443),
604 fingerprint
= '7BE683E65D48141321C5ED92F075C55364AC7123',
605 v3ident
= '0232AF901C31A04EE9848595AF9BB7620D4C5B2E',
607 'maatuska': Authority(
608 nickname
= 'maatuska',
609 address
= '171.25.193.9',
612 fingerprint
= 'BD6A829255CB08E66FBE7D3748363586E46B3810',
613 orport_v6
= ('2001:67c:289c::9', 80),
614 v3ident
= '49015F787433103580E3B66A1707A00E60F2D15B',
616 'Faravahar': Authority(
617 nickname
= 'Faravahar',
618 address
= '154.35.175.225',
621 fingerprint
= 'CF6D0AAFB385BE71B8E111FC5CFF4B47923733BC',
622 v3ident
= 'EFCBE720AB3A82B99F9E953CD5BF50F7EEFC7B97',
624 'longclaw': Authority(
625 nickname
= 'longclaw',
626 address
= '199.58.81.140',
629 fingerprint
= '74A910646BCEEFBCD2E874FC1DC997430F968145',
630 v3ident
= '23D15D965BC35114467363C165C4F724B64B4F66',
634 address
= '204.13.164.118',
637 fingerprint
= '24E2F139121D4394C54B5BCC368B3B411857C413',
638 orport_v6
= ('2620:13:4000:6000::1000:118', 443),
639 v3ident
= '27102BC123E7AF1D4741AE047E160C91ADC76B21',
643 address
= '66.111.2.131',
646 fingerprint
= 'BA44A889E64B93FAA2B114E02C2A279A8555C533',
647 v3ident
= None, # does not vote in the consensus