From b5ad5e783d6f636d5ca5703919d05fd0119a34fc Mon Sep 17 00:00:00 2001 From: "Wladimir J. van der Laan" Date: Mon, 7 Apr 2014 17:29:36 +0200 Subject: [PATCH] Add Python test for -rpcbind and -rpcallowip Add a new test, `rpcbind_test.py`, that extensively tests the new `-rpcbind` functionality. --- qa/rpc-tests/netutil.py | 134 ++++++++++++++++++++++++++++++ qa/rpc-tests/rpcbind_test.py | 152 +++++++++++++++++++++++++++++++++++ qa/rpc-tests/util.py | 32 +++++++- 3 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 qa/rpc-tests/netutil.py create mode 100755 qa/rpc-tests/rpcbind_test.py diff --git a/qa/rpc-tests/netutil.py b/qa/rpc-tests/netutil.py new file mode 100644 index 0000000000..9bea2e355e --- /dev/null +++ b/qa/rpc-tests/netutil.py @@ -0,0 +1,134 @@ +# Linux network utilities +import sys +import socket +import fcntl +import struct +import array +import os +import binascii + +# Roughly based on http://voorloopnul.com/blog/a-python-netstat-in-less-than-100-lines-of-code/ by Ricardo Pascal +STATE_ESTABLISHED = '01' +STATE_SYN_SENT = '02' +STATE_SYN_RECV = '03' +STATE_FIN_WAIT1 = '04' +STATE_FIN_WAIT2 = '05' +STATE_TIME_WAIT = '06' +STATE_CLOSE = '07' +STATE_CLOSE_WAIT = '08' +STATE_LAST_ACK = '09' +STATE_LISTEN = '0A' +STATE_CLOSING = '0B' + +def get_socket_inodes(pid): + ''' + Get list of socket inodes for process pid. + ''' + base = '/proc/%i/fd' % pid + inodes = [] + for item in os.listdir(base): + target = os.readlink(os.path.join(base, item)) + if target.startswith('socket:'): + inodes.append(int(target[8:-1])) + return inodes + +def _remove_empty(array): + return [x for x in array if x !=''] + +def _convert_ip_port(array): + host,port = array.split(':') + # convert host from mangled-per-four-bytes form as used by kernel + host = binascii.unhexlify(host) + host_out = '' + for x in range(0, len(host)/4): + (val,) = struct.unpack('=I', host[x*4:(x+1)*4]) + host_out += '%08x' % val + + return host_out,int(port,16) + +def netstat(typ='tcp'): + ''' + Function to return a list with status of tcp connections at linux systems + To get pid of all network process running on system, you must run this script + as superuser + ''' + with open('/proc/net/'+typ,'r') as f: + content = f.readlines() + content.pop(0) + result = [] + for line in content: + line_array = _remove_empty(line.split(' ')) # Split lines and remove empty spaces. + tcp_id = line_array[0] + l_addr = _convert_ip_port(line_array[1]) + r_addr = _convert_ip_port(line_array[2]) + state = line_array[3] + inode = int(line_array[9]) # Need the inode to match with process pid. + nline = [tcp_id, l_addr, r_addr, state, inode] + result.append(nline) + return result + +def get_bind_addrs(pid): + ''' + Get bind addresses as (host,port) tuples for process pid. + ''' + inodes = get_socket_inodes(pid) + bind_addrs = [] + for conn in netstat('tcp') + netstat('tcp6'): + if conn[3] == STATE_LISTEN and conn[4] in inodes: + bind_addrs.append(conn[1]) + return bind_addrs + +# from: http://code.activestate.com/recipes/439093/ +def all_interfaces(): + ''' + Return all interfaces that are up + ''' + is_64bits = sys.maxsize > 2**32 + struct_size = 40 if is_64bits else 32 + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + max_possible = 8 # initial value + while True: + bytes = max_possible * struct_size + names = array.array('B', '\0' * bytes) + outbytes = struct.unpack('iL', fcntl.ioctl( + s.fileno(), + 0x8912, # SIOCGIFCONF + struct.pack('iL', bytes, names.buffer_info()[0]) + ))[0] + if outbytes == bytes: + max_possible *= 2 + else: + break + namestr = names.tostring() + return [(namestr[i:i+16].split('\0', 1)[0], + socket.inet_ntoa(namestr[i+20:i+24])) + for i in range(0, outbytes, struct_size)] + +def addr_to_hex(addr): + ''' + Convert string IPv4 or IPv6 address to binary address as returned by + get_bind_addrs. + Very naive implementation that certainly doesn't work for all IPv6 variants. + ''' + if '.' in addr: # IPv4 + addr = [int(x) for x in addr.split('.')] + elif ':' in addr: # IPv6 + sub = [[], []] # prefix, suffix + x = 0 + addr = addr.split(':') + for i,comp in enumerate(addr): + if comp == '': + if i == 0 or i == (len(addr)-1): # skip empty component at beginning or end + continue + x += 1 # :: skips to suffix + assert(x < 2) + else: # two bytes per component + val = int(comp, 16) + sub[x].append(val >> 8) + sub[x].append(val & 0xff) + nullbytes = 16 - len(sub[0]) - len(sub[1]) + assert((x == 0 and nullbytes == 0) or (x == 1 and nullbytes > 0)) + addr = sub[0] + ([0] * nullbytes) + sub[1] + else: + raise ValueError('Could not parse address %s' % addr) + return binascii.hexlify(bytearray(addr)) diff --git a/qa/rpc-tests/rpcbind_test.py b/qa/rpc-tests/rpcbind_test.py new file mode 100755 index 0000000000..a31f8d98ef --- /dev/null +++ b/qa/rpc-tests/rpcbind_test.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python +# Copyright (c) 2014 The Bitcoin Core developers +# Distributed under the MIT/X11 software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +# Test for -rpcbind, as well as -rpcallowip and -rpcconnect + +# Add python-bitcoinrpc to module search path: +import os +import sys +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "python-bitcoinrpc")) + +import json +import shutil +import subprocess +import tempfile +import traceback + +from bitcoinrpc.authproxy import AuthServiceProxy, JSONRPCException +from util import * +from netutil import * + +def run_bind_test(tmpdir, allow_ips, connect_to, addresses, expected): + ''' + Start a node with requested rpcallowip and rpcbind parameters, + then try to connect, and check if the set of bound addresses + matches the expected set. + ''' + expected = [(addr_to_hex(addr), port) for (addr, port) in expected] + base_args = ['-disablewallet', '-nolisten'] + if allow_ips: + base_args += ['-rpcallowip=' + x for x in allow_ips] + binds = ['-rpcbind='+addr for addr in addresses] + nodes = start_nodes(1, tmpdir, [base_args + binds], connect_to) + try: + pid = bitcoind_processes[0].pid + assert_equal(set(get_bind_addrs(pid)), set(expected)) + finally: + stop_nodes(nodes) + wait_bitcoinds() + +def run_allowip_test(tmpdir, allow_ips, rpchost): + ''' + Start a node with rpcwallow IP, and request getinfo + at a non-localhost IP. + ''' + base_args = ['-disablewallet', '-nolisten'] + ['-rpcallowip='+x for x in allow_ips] + nodes = start_nodes(1, tmpdir, [base_args]) + try: + # connect to node through non-loopback interface + url = "http://rt:rt@%s:%d" % (rpchost, START_RPC_PORT,) + node = AuthServiceProxy(url) + node.getinfo() + finally: + node = None # make sure connection will be garbage collected and closed + stop_nodes(nodes) + wait_bitcoinds() + + +def run_test(tmpdir): + assert(sys.platform == 'linux2') # due to OS-specific network stats queries, this test works only on Linux + # find the first non-loopback interface for testing + non_loopback_ip = None + for name,ip in all_interfaces(): + if ip != '127.0.0.1': + non_loopback_ip = ip + break + if non_loopback_ip is None: + assert(not 'This test requires at least one non-loopback IPv4 interface') + print("Using interface %s for testing" % non_loopback_ip) + + # check default without rpcallowip (IPv4 and IPv6 localhost) + run_bind_test(tmpdir, None, '127.0.0.1', [], + [('127.0.0.1', 11100), ('::1', 11100)]) + # check default with rpcallowip (IPv6 any) + run_bind_test(tmpdir, ['127.0.0.1'], '127.0.0.1', [], + [('::0', 11100)]) + # check only IPv4 localhost (explicit) + run_bind_test(tmpdir, ['127.0.0.1'], '127.0.0.1', ['127.0.0.1'], + [('127.0.0.1', START_RPC_PORT)]) + # check only IPv4 localhost (explicit) with alternative port + run_bind_test(tmpdir, ['127.0.0.1'], '127.0.0.1:32171', ['127.0.0.1:32171'], + [('127.0.0.1', 32171)]) + # check only IPv4 localhost (explicit) with multiple alternative ports on same host + run_bind_test(tmpdir, ['127.0.0.1'], '127.0.0.1:32171', ['127.0.0.1:32171', '127.0.0.1:32172'], + [('127.0.0.1', 32171), ('127.0.0.1', 32172)]) + # check only IPv6 localhost (explicit) + run_bind_test(tmpdir, ['[::1]'], '[::1]', ['[::1]'], + [('::1', 11100)]) + # check both IPv4 and IPv6 localhost (explicit) + run_bind_test(tmpdir, ['127.0.0.1'], '127.0.0.1', ['127.0.0.1', '[::1]'], + [('127.0.0.1', START_RPC_PORT), ('::1', START_RPC_PORT)]) + # check only non-loopback interface + run_bind_test(tmpdir, [non_loopback_ip], non_loopback_ip, [non_loopback_ip], + [(non_loopback_ip, START_RPC_PORT)]) + + # Check that with invalid rpcallowip, we are denied + run_allowip_test(tmpdir, [non_loopback_ip], non_loopback_ip) + try: + run_allowip_test(tmpdir, ['1.1.1.1'], non_loopback_ip) + assert(not 'Connection not denied by rpcallowip as expected') + except ValueError: + pass + +def main(): + import optparse + + parser = optparse.OptionParser(usage="%prog [options]") + parser.add_option("--nocleanup", dest="nocleanup", default=False, action="store_true", + help="Leave bitcoinds and test.* datadir on exit or error") + parser.add_option("--srcdir", dest="srcdir", default="../../src", + help="Source directory containing bitcoind/bitcoin-cli (default: %default%)") + parser.add_option("--tmpdir", dest="tmpdir", default=tempfile.mkdtemp(prefix="test"), + help="Root directory for datadirs") + (options, args) = parser.parse_args() + + os.environ['PATH'] = options.srcdir+":"+os.environ['PATH'] + + check_json_precision() + + success = False + nodes = [] + try: + print("Initializing test directory "+options.tmpdir) + if not os.path.isdir(options.tmpdir): + os.makedirs(options.tmpdir) + initialize_chain(options.tmpdir) + + run_test(options.tmpdir) + + success = True + + except AssertionError as e: + print("Assertion failed: "+e.message) + except Exception as e: + print("Unexpected exception caught during testing: "+str(e)) + traceback.print_tb(sys.exc_info()[2]) + + if not options.nocleanup: + print("Cleaning up") + wait_bitcoinds() + shutil.rmtree(options.tmpdir) + + if success: + print("Tests successful") + sys.exit(0) + else: + print("Failed") + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/qa/rpc-tests/util.py b/qa/rpc-tests/util.py index 1d0896a3fb..40f4a1458f 100644 --- a/qa/rpc-tests/util.py +++ b/qa/rpc-tests/util.py @@ -15,6 +15,7 @@ import json import shutil import subprocess import time +import re from bitcoinrpc.authproxy import AuthServiceProxy, JSONRPCException from util import * @@ -112,20 +113,43 @@ def initialize_chain(test_dir): to_dir = os.path.join(test_dir, "node"+str(i)) shutil.copytree(from_dir, to_dir) -def start_nodes(num_nodes, dir): +def _rpchost_to_args(rpchost): + '''Convert optional IP:port spec to rpcconnect/rpcport args''' + if rpchost is None: + return [] + + match = re.match('(\[[0-9a-fA-f:]+\]|[^:]+)(?::([0-9]+))?$', rpchost) + if not match: + raise ValueError('Invalid RPC host spec ' + rpchost) + + rpcconnect = match.group(1) + rpcport = match.group(2) + + if rpcconnect.startswith('['): # remove IPv6 [...] wrapping + rpcconnect = rpcconnect[1:-1] + + rv = ['-rpcconnect=' + rpcconnect] + if rpcport: + rv += ['-rpcport=' + rpcport] + return rv + +def start_nodes(num_nodes, dir, extra_args=None, rpchost=None): # Start bitcoinds, and wait for RPC interface to be up and running: devnull = open("/dev/null", "w+") for i in range(num_nodes): datadir = os.path.join(dir, "node"+str(i)) args = [ "bitcoind", "-datadir="+datadir ] + if extra_args is not None: + args += extra_args[i] bitcoind_processes.append(subprocess.Popen(args)) - subprocess.check_call([ "bitcoin-cli", "-datadir="+datadir, - "-rpcwait", "getblockcount"], stdout=devnull) + subprocess.check_call([ "bitcoin-cli", "-datadir="+datadir] + + _rpchost_to_args(rpchost) + + ["-rpcwait", "getblockcount"], stdout=devnull) devnull.close() # Create&return JSON-RPC connections rpc_connections = [] for i in range(num_nodes): - url = "http://rt:rt@127.0.0.1:%d"%(START_RPC_PORT+i,) + url = "http://rt:rt@%s:%d" % (rpchost or '127.0.0.1', START_RPC_PORT+i,) rpc_connections.append(AuthServiceProxy(url)) return rpc_connections