#!/usr/bin/env python3 """=cut =head1 NAME bitcoind_ - Track Bitcoin Server Variables =head1 CONFIGURATION You need to be able to authenticate to the bitcoind server to issue rpc's. This plugin supports two ways to do that: 1) In /etc/munin/plugin-conf.d/bitcoin.conf place: [bitcoind_*] user your-username env.bitcoin_configfile /home/your-username/.bitcoin/bitcoin.conf Then be sure that the file referenced above (typically: $HOME/.bitcoin/bitcoin.conf) has the correct authentication info: rpcconnect, rpcport, rpcuser, rpcpassword 2) Place your bitcoind authentication directly in /etc/munin/plugin-conf.d/bitcoin.conf [bitcoind_*] env.rpcport 8332 env.rpcconnect 127.0.0.1 env.rpcuser your-username-here env.rpcpassword your-password-here To install all available graphs: sudo munin-node-configure --libdir=. --suggest --shell | sudo bash Leave out the "| bash" to get a list of commands you can select from to install individual graphs. =head1 MAGIC MARKERS #%# family=auto #%# capabilities=autoconf suggest =head1 LICENSE MIT License =head1 AUTHOR Copyright (C) 2012 Mike Koss =cut""" import base64 import json import os import re import sys import time import urllib.error import urllib.request DEBUG = os.getenv('MUNIN_DEBUG') == '1' def _get_version(info): # v0.15.2 version is represented as 150200 return info['version'] // 10000 def _rpc_get_initial_info(connection): (info, connect_error) = connection.getnetworkinfo() if connect_error: if isinstance(connect_error, urllib.error.HTTPError) and connect_error.code == 404: # getinfo RPC exists in version <= 0.15 (info, connect_error) = connection.getinfo() if connect_error: return (None, None, connect_error) else: return (None, None, connect_error) # pass all other not-404 errors return (info, _get_version(info), None) def _rpc_get_balance(info, minor_version, connection): # see https://github.com/bitcoin/bitcoin/blob/239d199667888e5d60309f15a38eed4d3afe56c4/ # doc/release-notes/release-notes-0.19.0.1.md#new-rpcs if minor_version >= 19: # we use getbalance*s* (plural) as old getbalance is being deprecated, # and we have to calculate total balance (owned and watch-only) manually now. (result, error) = connection.getbalances() total = sum(result[wallet_mode]['trusted'] for wallet_mode in ('mine', 'watchonly') if wallet_mode in result) info['balance'] = total return info else: (result, error) = connection.getbalance() info['balance'] = result return info def main(): # getinfo variable is read from command name - probably the sym-link name. request_var = sys.argv[0].split('_', 1)[1] or 'balance' command = sys.argv[1] if len(sys.argv) > 1 else None request_labels = {'balance': ('Wallet Balance', 'BTC'), 'connections': ('Peer Connections', 'Connections'), 'transactions': ("Transactions", "Transactions", ('confirmed', 'waiting')), 'block_age': ("Last Block Age", "Seconds"), 'difficulty': ("Difficulty", ""), } labels = request_labels[request_var] if len(labels) < 3: line_labels = [request_var] else: line_labels = labels[2] if command == 'suggest': for var_name in request_labels.keys(): print(var_name) return True if command == 'config': print('graph_category htc') print('graph_title Bitcoin %s' % labels[0]) print('graph_vlabel %s' % labels[1]) for label in line_labels: print('%s.label %s' % (label, label)) return True # Munin should send connection options via environment vars bitcoin_options = get_env_options('rpcconnect', 'rpcport', 'rpcuser', 'rpcpassword') bitcoin_options.rpcconnect = bitcoin_options.get('rpcconnect', '127.0.0.1') bitcoin_options.rpcport = bitcoin_options.get('rpcport', '8332') error = None if bitcoin_options.get('rpcuser') is None: conf_file = os.getenv("bitcoin_configfile") if not conf_file: error = "Missing environment settings: rpcuser/rcpassword or bitcoin_configfile" elif not os.path.exists(conf_file): error = "Configuration file does not exist: {}".format(conf_file) else: bitcoin_options = parse_conf(conf_file) if not error: try: bitcoin_options.require('rpcuser', 'rpcpassword') except KeyError as exc: error = str(exc).strip("'") if not error: bitcoin = ServiceProxy('http://%s:%s' % (bitcoin_options.rpcconnect, bitcoin_options.rpcport), username=bitcoin_options.rpcuser, password=bitcoin_options.rpcpassword) (info, minor_version, connect_error) = _rpc_get_initial_info(bitcoin) if connect_error: error = "Could not connect to Bitcoin server: {}".format(connect_error) if command == 'autoconf': if error: print('no ({})'.format(error)) else: print('yes') return True if error: print(error, file=sys.stderr) return False if request_var == 'balance': info = _rpc_get_balance(info, minor_version, bitcoin) if request_var in ('transactions', 'block_age'): (info, error) = bitcoin.getblockchaininfo() (info, error) = bitcoin.getblock(info['bestblockhash']) info['block_age'] = int(time.time()) - info['time'] info['confirmed'] = len(info['tx']) if request_var == 'difficulty': (info, error) = bitcoin.getblockchaininfo() if request_var == 'transactions': (memory_pool, error) = bitcoin.getmempoolinfo() info['waiting'] = memory_pool['size'] for label in line_labels: print("%s.value %s" % (label, info[label])) return True def parse_conf(filename): """ Bitcoin config file parser. """ options = Options() re_line = re.compile(r'^\s*([^#]*)\s*(#.*)?$') re_setting = re.compile(r'^(.*)\s*=\s*(.*)$') try: with open(filename) as file: for line in file.readlines(): line = re_line.match(line).group(1).strip() m = re_setting.match(line) if m is None: continue (var, value) = (m.group(1), m.group(2).strip()) options[var] = value except OSError: # the config file may be missing pass return options def get_env_options(*vars): options = Options() for var in vars: value = os.getenv(var) if value is not None: options[var] = os.getenv(var) return options class Options(dict): """A dict that allows for object-like property access syntax.""" def __getattr__(self, name): try: return self[name] except KeyError: raise AttributeError(name) def require(self, *names): missing = [] for name in names: if self.get(name) is None: missing.append(name) if len(missing) > 0: raise KeyError("Missing required setting{}: {}." .format('s' if len(missing) > 1 else '', ', '.join(missing))) class ServiceProxy: """ Proxy for a JSON-RPC web service. Calls to a function attribute generates a JSON-RPC call to the host service. If a callback keyword arg is included, the call is processed as an asynchronous request. Each call returns (result, error) tuple. """ def __init__(self, url, username=None, password=None): self.url = url self.id = 0 self.username = username self.password = password def __getattr__(self, method): self.id += 1 return Proxy(self, method, id=self.id) class Proxy: def __init__(self, service, method, id=None): self.service = service self.method = method self.id = id def __call__(self, *args): if DEBUG: arg_strings = [json.dumps(arg) for arg in args] print("Calling %s(%s) @ %s" % (self.method, ', '.join(arg_strings), self.service.url)) data = { 'method': self.method, 'params': args, 'id': self.id, } request = urllib.request.Request(self.service.url, json.dumps(data).encode()) if self.service.username: auth_string = '%s:%s' % (self.service.username, self.service.password) auth_b64 = base64.urlsafe_b64encode(auth_string.encode()).decode() request.add_header('Authorization', 'Basic %s' % auth_b64) try: body = urllib.request.urlopen(request).read() except urllib.error.URLError as e: return (None, e) if DEBUG: print('RPC Response (%s): %s' % (self.method, json.dumps(json.loads(body), indent=4))) try: data = json.loads(body) except ValueError as e: return (None, e.message) # TODO: Check that id matches? return (data['result'], data['error']) def get_json_url(url): request = urllib.request.Request(url) body = urllib.request.urlopen(request).read() data = json.loads(body) return data if __name__ == "__main__": sys.exit(0 if main() else 1)