Repository
Munin (contrib)
Last change
2020-04-16
Graph Categories
Family
auto
Capabilities
Keywords
Language
Python (3.x)
License
JSON
Authors

bitcoind_

Name

bitcoind_ - Track Bitcoin Server Variables

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.

Magic Markers

#%# family=auto
#%# capabilities=autoconf suggest

License

MIT License

Author

Copyright (C) 2012 Mike Koss

#!/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)