556 lines
18 KiB
Python
556 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
'''
|
|
=head1 NAME
|
|
|
|
tor_
|
|
|
|
=head1 DESCRIPTION
|
|
|
|
Wildcard plugin that gathers some metrics from the Tor daemon
|
|
(https://github.com/daftaupe/munin-tor).
|
|
|
|
Derived from https://github.com/mweinelt/munin-tor
|
|
|
|
This plugin requires the stem library (https://stem.torproject.org/).
|
|
|
|
This plugin requires the GeoIP library (https://www.maxmind.com) for the countries plugin.
|
|
|
|
Available plugins:
|
|
|
|
=over 4
|
|
|
|
=item tor_bandwidth - graph the glabal bandwidth
|
|
|
|
=item tor_connections - graph the number of connexions
|
|
|
|
=item tor_countries - graph the countries represented our connexions
|
|
|
|
=item tor_dormant - graph if tor is dormant or not
|
|
|
|
=item tor_flags - graph the different flags of the relay
|
|
|
|
=item tor_routers - graph the number of routers seen by the relay
|
|
|
|
=item tor_traffic - graph the read/written traffic
|
|
|
|
=back
|
|
|
|
=head2 CONFIGURATION
|
|
|
|
The default configuration is:
|
|
|
|
[tor_*]
|
|
user toranon # or any other user/group that is running tor
|
|
group toranon
|
|
env.torcachefile munin_tor_country_stats.json
|
|
env.torconnectmethod port
|
|
env.torgeoippath /usr/share/GeoIP/GeoIP.dat
|
|
env.tormaxcountries 15
|
|
env.torport 9051
|
|
env.torsocket /var/run/tor/control
|
|
|
|
To make it connect through a socket, you simply need to change C<torconnectmethod>:
|
|
|
|
env.torconnectmethod socket
|
|
|
|
=head1 COPYRIGHT
|
|
|
|
MIT License
|
|
|
|
SPDX-License-Identifier: MIT
|
|
|
|
=head1 AUTHOR
|
|
|
|
Pierre-Alain TORET <pierre-alain.toret@protonmail.com>
|
|
|
|
=head1 MAGIC MARKERS
|
|
|
|
#%# family=auto
|
|
#%# capabilities=autoconf suggest
|
|
|
|
=cut
|
|
'''
|
|
|
|
import collections
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
try:
|
|
import GeoIP
|
|
import stem
|
|
import stem.control
|
|
import stem.connection
|
|
missing_dependency_error = None
|
|
except ImportError as exc:
|
|
# missing dependencies are reported via "autoconf"
|
|
# thus failure is acceptable here
|
|
missing_dependency_error = str(exc)
|
|
|
|
default_torcachefile = 'munin_tor_country_stats.json'
|
|
default_torconnectmethod = 'port'
|
|
default_torgeoippath = '/usr/share/GeoIP/GeoIP.dat'
|
|
default_tormaxcountries = 15
|
|
default_torport = 9051
|
|
default_torsocket = '/var/run/tor/control'
|
|
|
|
|
|
class ConnectionError(Exception):
|
|
"""Error connecting to the controller"""
|
|
|
|
|
|
class AuthError(Exception):
|
|
"""Error authenticating to the controller"""
|
|
|
|
|
|
def authenticate(controller):
|
|
try:
|
|
controller.authenticate()
|
|
return
|
|
except stem.connection.MissingPassword:
|
|
pass
|
|
|
|
try:
|
|
password = os.environ['torpassword']
|
|
except KeyError:
|
|
raise AuthError("Please configure the 'torpassword' "
|
|
"environment variable")
|
|
|
|
try:
|
|
controller.authenticate(password=password)
|
|
except stem.connection.PasswordAuthFailed:
|
|
print("Authentication failed (incorrect password)", file=sys.stderr)
|
|
|
|
|
|
def gen_controller():
|
|
connect_method = os.environ.get('torconnectmethod', default_torconnectmethod)
|
|
if connect_method == 'port':
|
|
return stem.control.Controller.from_port(port=int(os.environ.get('torport',
|
|
default_torport)))
|
|
elif connect_method == 'socket':
|
|
return stem.control.Controller.from_socket_file(path=os.environ.get('torsocket',
|
|
default_torsocket))
|
|
else:
|
|
print("env.torconnectmethod contains an invalid value. "
|
|
"Please specify either 'port' or 'socket'.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
#########################
|
|
# Base Class
|
|
#########################
|
|
|
|
|
|
class TorPlugin(object):
|
|
def __init__(self):
|
|
raise NotImplementedError
|
|
|
|
def conf(self):
|
|
raise NotImplementedError
|
|
|
|
@staticmethod
|
|
def conf_from_dict(graph, labels):
|
|
# header
|
|
for key, val in graph.items():
|
|
print('graph_{} {}'.format(key, val))
|
|
# values
|
|
for label, attributes in labels.items():
|
|
for key, val in attributes.items():
|
|
print('{}.{} {}'.format(label, key, val))
|
|
|
|
@staticmethod
|
|
def get_autoconf_status():
|
|
try:
|
|
import stem
|
|
except ImportError as e:
|
|
return 'no (failed to import the required python module "stem": {})'.format(e)
|
|
try:
|
|
import GeoIP # noqa: F401
|
|
except ImportError as e:
|
|
return 'no (failed to import the required python module "GeoIP": {})'.format(e)
|
|
try:
|
|
with gen_controller() as controller:
|
|
try:
|
|
authenticate(controller)
|
|
return 'yes'
|
|
except stem.connection.AuthenticationFailure as e:
|
|
return 'no (Authentication failed: {})'.format(e)
|
|
except stem.SocketError:
|
|
return 'no (Connection failed)'
|
|
|
|
@staticmethod
|
|
def suggest():
|
|
options = ['bandwidth', 'connections', 'countries', 'dormant', 'flags', 'routers',
|
|
'traffic']
|
|
|
|
for option in options:
|
|
print(option)
|
|
|
|
def fetch(self):
|
|
raise NotImplementedError
|
|
|
|
|
|
##########################
|
|
# Child Classes
|
|
##########################
|
|
|
|
|
|
class TorBandwidth(TorPlugin):
|
|
def __init__(self):
|
|
pass
|
|
|
|
def conf(self):
|
|
graph = {'title': 'Tor observed bandwidth',
|
|
'args': '-l 0 --base 1000',
|
|
'vlabel': 'bytes/s',
|
|
'category': 'network',
|
|
'info': 'estimated capacity based on usage in bytes/s'}
|
|
labels = {'bandwidth': {'label': 'bandwidth', 'min': 0, 'type': 'GAUGE'}}
|
|
|
|
TorPlugin.conf_from_dict(graph, labels)
|
|
|
|
def fetch(self):
|
|
with gen_controller() as controller:
|
|
try:
|
|
authenticate(controller)
|
|
except stem.connection.AuthenticationFailure as e:
|
|
print('Authentication failed ({})'.format(e))
|
|
return
|
|
|
|
# Get fingerprint of our own relay to look up the descriptor for.
|
|
# In Stem 1.3.0 and later, get_server_descriptor() will fetch the
|
|
# relay's own descriptor if no argument is provided, so this will
|
|
# no longer be needed.
|
|
fingerprint = controller.get_info('fingerprint', None)
|
|
if fingerprint is None:
|
|
print("Error while reading fingerprint from Tor daemon", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
response = controller.get_server_descriptor(fingerprint, None)
|
|
if response is None:
|
|
print("Error while getting server descriptor from Tor daemon", file=sys.stderr)
|
|
sys.exit(1)
|
|
print('bandwidth.value {}'.format(response.observed_bandwidth))
|
|
|
|
|
|
class TorConnections(TorPlugin):
|
|
def __init__(self):
|
|
pass
|
|
|
|
def conf(self):
|
|
graph = {'title': 'Tor connections',
|
|
'args': '-l 0 --base 1000',
|
|
'vlabel': 'connections',
|
|
'category': 'network',
|
|
'info': 'OR connections by state'}
|
|
labels = {'new': {'label': 'new', 'min': 0, 'max': 25000, 'type': 'GAUGE'},
|
|
'launched': {'label': 'launched', 'min': 0, 'max': 25000, 'type': 'GAUGE'},
|
|
'connected': {'label': 'connected', 'min': 0, 'max': 25000, 'type': 'GAUGE'},
|
|
'failed': {'label': 'failed', 'min': 0, 'max': 25000, 'type': 'GAUGE'},
|
|
'closed': {'label': 'closed', 'min': 0, 'max': 25000, 'type': 'GAUGE'}}
|
|
|
|
TorPlugin.conf_from_dict(graph, labels)
|
|
|
|
def fetch(self):
|
|
with gen_controller() as controller:
|
|
try:
|
|
authenticate(controller)
|
|
|
|
response = controller.get_info('orconn-status', None)
|
|
if response is None:
|
|
print("No response from Tor daemon in TorConnection.fetch()", file=sys.stderr)
|
|
sys.exit(1)
|
|
else:
|
|
connections = response.split('\n')
|
|
states = dict((state, 0) for state in stem.ORStatus)
|
|
for connection in connections:
|
|
states[connection.rsplit(None, 1)[-1]] += 1
|
|
for state, count in states.items():
|
|
print('{}.value {}'.format(state.lower(), count))
|
|
except stem.connection.AuthenticationFailure as e:
|
|
print('Authentication failed ({})'.format(e))
|
|
|
|
|
|
class TorCountries(TorPlugin):
|
|
def __init__(self):
|
|
# Configure plugin
|
|
self.cache_dir_name = os.environ.get('torcachedir', None)
|
|
if self.cache_dir_name is not None:
|
|
self.cache_dir_name = os.path.join(
|
|
self.cache_dir_name, os.environ.get('torcachefile', default_torcachefile))
|
|
|
|
max_countries = os.environ.get('tormaxcountries', default_tormaxcountries)
|
|
self.max_countries = int(max_countries)
|
|
|
|
geoip_path = os.environ.get('torgeoippath', default_torgeoippath)
|
|
self.geodb = GeoIP.open(geoip_path, GeoIP.GEOIP_MEMORY_CACHE)
|
|
|
|
def conf(self):
|
|
"""Configure plugin"""
|
|
|
|
graph = {'title': 'Tor countries',
|
|
'args': '-l 0 --base 1000',
|
|
'vlabel': 'countries',
|
|
'category': 'network',
|
|
'info': 'OR connections by state'}
|
|
labels = {}
|
|
|
|
countries_num = self.top_countries()
|
|
|
|
for c, v in countries_num:
|
|
labels[c] = {'label': c, 'min': 0, 'max': 25000, 'type': 'GAUGE'}
|
|
|
|
TorPlugin.conf_from_dict(graph, labels)
|
|
|
|
# If needed, create cache file at config time
|
|
if self.cache_dir_name:
|
|
with open(self.cache_dir_name, 'w') as f:
|
|
json.dump(countries_num, f)
|
|
|
|
def fetch(self):
|
|
"""Generate metrics"""
|
|
# Fallback if cache_dir_name is not set, unreadable or any other error
|
|
countries_num = self.top_countries()
|
|
# If possible, read cached data instead of doing the processing twice
|
|
if self.cache_dir_name:
|
|
try:
|
|
with open(self.cache_dir_name) as f:
|
|
countries_num = json.load(f)
|
|
except (IOError, ValueError):
|
|
# use the fallback value above
|
|
pass
|
|
|
|
for c, v in countries_num:
|
|
print("%s.value %d" % (c, v))
|
|
|
|
@staticmethod
|
|
def _gen_ipaddrs_from_statuses(controller):
|
|
"""Generate a sequence of ipaddrs for every network status"""
|
|
for desc in controller.get_network_statuses():
|
|
ipaddr = desc.address
|
|
yield ipaddr
|
|
|
|
@staticmethod
|
|
def simplify(cn):
|
|
"""Simplify country name"""
|
|
cn = cn.replace(' ', '_')
|
|
cn = cn.replace("'", '_')
|
|
cn = cn.split(',', 1)[0]
|
|
return cn
|
|
|
|
def _gen_countries(self, controller):
|
|
"""Generate a sequence of countries for every built circuit"""
|
|
for ipaddr in self._gen_ipaddrs_from_statuses(controller):
|
|
country = self.geodb.country_name_by_addr(ipaddr)
|
|
if country is None:
|
|
yield 'Unknown'
|
|
continue
|
|
|
|
yield self.simplify(country)
|
|
|
|
def top_countries(self):
|
|
"""Build a list of top countries by number of circuits"""
|
|
with gen_controller() as controller:
|
|
try:
|
|
authenticate(controller)
|
|
c = collections.Counter(self._gen_countries(controller))
|
|
return sorted(c.most_common(self.max_countries))
|
|
except stem.connection.AuthenticationFailure as e:
|
|
print('Authentication failed ({})'.format(e))
|
|
return []
|
|
|
|
|
|
class TorDormant(TorPlugin):
|
|
def __init__(self):
|
|
pass
|
|
|
|
def conf(self):
|
|
graph = {'title': 'Tor dormant',
|
|
'args': '-l 0 --base 1000',
|
|
'vlabel': 'dormant',
|
|
'category': 'network',
|
|
'info': 'Is Tor not building circuits because it is idle?'}
|
|
labels = {'dormant': {'label': 'dormant', 'min': 0, 'max': 1, 'type': 'GAUGE'}}
|
|
|
|
TorPlugin.conf_from_dict(graph, labels)
|
|
|
|
def fetch(self):
|
|
with gen_controller() as controller:
|
|
try:
|
|
authenticate(controller)
|
|
|
|
response = controller.get_info('dormant', None)
|
|
if response is None:
|
|
print("Error while reading dormant state from Tor daemon", file=sys.stderr)
|
|
sys.exit(1)
|
|
print('dormant.value {}'.format(response))
|
|
except stem.connection.AuthenticationFailure as e:
|
|
print('Authentication failed ({})'.format(e))
|
|
|
|
|
|
class TorFlags(TorPlugin):
|
|
def __init__(self):
|
|
pass
|
|
|
|
def conf(self):
|
|
graph = {'title': 'Tor relay flags',
|
|
'args': '-l 0 --base 1000',
|
|
'vlabel': 'flags',
|
|
'category': 'network',
|
|
'info': 'Flags active for relay'}
|
|
labels = {flag: {'label': flag, 'min': 0, 'max': 1, 'type': 'GAUGE'} for flag in stem.Flag}
|
|
|
|
TorPlugin.conf_from_dict(graph, labels)
|
|
|
|
def fetch(self):
|
|
with gen_controller() as controller:
|
|
try:
|
|
authenticate(controller)
|
|
except stem.connection.AuthenticationFailure as e:
|
|
print('Authentication failed ({})'.format(e))
|
|
return
|
|
|
|
# Get fingerprint of our own relay to look up the status entry for.
|
|
# In Stem 1.3.0 and later, get_network_status() will fetch the
|
|
# relay's own status entry if no argument is provided, so this will
|
|
# no longer be needed.
|
|
fingerprint = controller.get_info('fingerprint', None)
|
|
if fingerprint is None:
|
|
print("Error while reading fingerprint from Tor daemon", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
response = controller.get_network_status(fingerprint, None)
|
|
if response is None:
|
|
print("Error while getting server descriptor from Tor daemon", file=sys.stderr)
|
|
sys.exit(1)
|
|
for flag in stem.Flag:
|
|
if flag in response.flags:
|
|
print('{}.value 1'.format(flag))
|
|
else:
|
|
print('{}.value 0'.format(flag))
|
|
|
|
|
|
class TorRouters(TorPlugin):
|
|
def __init__(self):
|
|
pass
|
|
|
|
def conf(self):
|
|
graph = {'title': 'Tor routers',
|
|
'args': '-l 0',
|
|
'vlabel': 'routers',
|
|
'category': 'network',
|
|
'info': 'known Tor onion routers'}
|
|
labels = {'routers': {'label': 'routers', 'min': 0, 'type': 'GAUGE'}}
|
|
TorPlugin.conf_from_dict(graph, labels)
|
|
|
|
def fetch(self):
|
|
with gen_controller() as controller:
|
|
try:
|
|
authenticate(controller)
|
|
except stem.connection.AuthenticationFailure as e:
|
|
print('Authentication failed ({})'.format(e))
|
|
return
|
|
response = controller.get_info('ns/all', None)
|
|
if response is None:
|
|
print("Error while reading ns/all from Tor daemon", file=sys.stderr)
|
|
sys.exit(1)
|
|
else:
|
|
routers = response.split('\n')
|
|
onr = 0
|
|
for router in routers:
|
|
if router[0] == "r":
|
|
onr += 1
|
|
|
|
print('routers.value {}'.format(onr))
|
|
|
|
|
|
class TorTraffic(TorPlugin):
|
|
def __init__(self):
|
|
pass
|
|
|
|
def conf(self):
|
|
graph = {'title': 'Tor traffic',
|
|
'args': '-l 0 --base 1024',
|
|
'vlabel': 'bytes/s',
|
|
'category': 'network',
|
|
'info': 'bytes read/written'}
|
|
labels = {'read': {'label': 'read', 'min': 0, 'type': 'DERIVE'},
|
|
'written': {'label': 'written', 'min': 0, 'type': 'DERIVE'}}
|
|
|
|
TorPlugin.conf_from_dict(graph, labels)
|
|
|
|
def fetch(self):
|
|
with gen_controller() as controller:
|
|
try:
|
|
authenticate(controller)
|
|
except stem.connection.AuthenticationFailure as e:
|
|
print('Authentication failed ({})'.format(e))
|
|
return
|
|
|
|
response = controller.get_info('traffic/read', None)
|
|
if response is None:
|
|
print("Error while reading traffic/read from Tor daemon", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print('read.value {}'.format(response))
|
|
|
|
response = controller.get_info('traffic/written', None)
|
|
if response is None:
|
|
print("Error while reading traffic/write from Tor daemon", file=sys.stderr)
|
|
sys.exit(1)
|
|
print('written.value {}'.format(response))
|
|
|
|
|
|
##########################
|
|
# Main
|
|
##########################
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) > 1:
|
|
param = sys.argv[1].lower()
|
|
else:
|
|
param = 'fetch'
|
|
|
|
if param == 'autoconf':
|
|
print(TorPlugin.get_autoconf_status())
|
|
sys.exit()
|
|
elif param == 'suggest':
|
|
TorPlugin.suggest()
|
|
sys.exit()
|
|
else:
|
|
if missing_dependency_error is not None:
|
|
print("Failed to run tor_ due to missing dependency: {}"
|
|
.format(missing_dependency_error), file=sys.stderr)
|
|
sys.exit(1)
|
|
# detect data provider
|
|
if __file__.endswith('_bandwidth'):
|
|
provider = TorBandwidth()
|
|
elif __file__.endswith('_connections'):
|
|
provider = TorConnections()
|
|
elif __file__.endswith('_countries'):
|
|
provider = TorCountries()
|
|
elif __file__.endswith('_dormant'):
|
|
provider = TorDormant()
|
|
elif __file__.endswith('_flags'):
|
|
provider = TorFlags()
|
|
elif __file__.endswith('_routers'):
|
|
provider = TorRouters()
|
|
elif __file__.endswith('_traffic'):
|
|
provider = TorTraffic()
|
|
else:
|
|
print('Unknown plugin name, try "suggest" for a list of possible ones.',
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if param == 'config':
|
|
provider.conf()
|
|
elif param == 'fetch':
|
|
provider.fetch()
|
|
else:
|
|
print('Unknown parameter "{}"'.format(param), file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|