Files
rippled/bin/python/ripple/util/ValidatorManifestTest.py
wilsonianb e823e60ca0 Dynamize trusted validator list and quorum (RIPD-1220):
Instead of specifying a static list of trusted validators in the config
or validators file, the configuration can now include trusted validator
list publisher keys.

The trusted validator list and quorum are now reset each consensus
round using the latest validator lists and the list of recent
validations seen. The minimum validation quorum is now only
configurable via the command line.
2017-03-01 11:41:07 -05:00

680 lines
20 KiB
Python
Executable File

#!/usr/bin/env python
"""
Test for setting ephemeral keys for the validator manifest.
"""
from __future__ import (
absolute_import, division, print_function, unicode_literals
)
import argparse
import contextlib
from contextlib import contextmanager
import json
import os
import platform
import shutil
import subprocess
import time
DELAY_WHILE_PROCESS_STARTS_UP = 1.5
ARGS = None
NOT_FOUND = -1 # not in log
ACCEPTED_NEW = 0 # added new manifest
ACCEPTED_UPDATE = 1 # replaced old manifest with new
UNTRUSTED = 2 # don't trust master key
STALE = 3 # seq is too old
REVOKED = 4 # revoked validator key
INVALID = 5 # invalid signature
MANIFEST_ACTION_STR_TO_ID = {
'NotFound': NOT_FOUND, # not found in log
'AcceptedNew': ACCEPTED_NEW,
'AcceptedUpdate': ACCEPTED_UPDATE,
'Untrusted': UNTRUSTED,
'Stale': STALE,
'Revoked': REVOKED,
'Invalid': INVALID,
}
MANIFEST_ACTION_ID_TO_STR = {
v: k for k, v in MANIFEST_ACTION_STR_TO_ID.items()
}
CONF_TEMPLATE = """
[server]
port_rpc
port_peer
port_wss_admin
[port_rpc]
port = {rpc_port}
ip = 127.0.0.1
admin = 127.0.0.1
protocol = https
[port_peer]
port = {peer_port}
ip = 0.0.0.0
protocol = peer
[port_wss_admin]
port = {wss_port}
ip = 127.0.0.1
admin = 127.0.0.1
protocol = wss
[node_size]
medium
[node_db]
type={node_db_type}
path={node_db_path}
open_files=2000
filter_bits=12
cache_mb=256
file_size_mb=8
file_size_mult=2
online_delete=256
advisory_delete=0
[database_path]
{db_path}
[debug_logfile]
{debug_logfile}
[sntp_servers]
time.windows.com
time.apple.com
time.nist.gov
pool.ntp.org
[ips]
r.ripple.com 51235
[ips_fixed]
{sibling_ip} {sibling_port}
[validators]
n949f75evCHwgyP4fPVgaHqNHxUVN15PsJEZ3B3HnXPcPjcZAoy7 RL1
n9MD5h24qrQqiyBC8aeqqCWvpiBiYQ3jxSr91uiDvmrkyHRdYLUj RL2
n9L81uNCaPgtUJfaHh89gmdvXKAmSt5Gdsw2g1iPWaPkAHW5Nm4C RL3
n9KiYM9CgngLvtRCQHZwgC2gjpdaZcCcbt3VboxiNFcKuwFVujzS RL4
n9LdgEtkmGB9E2h3K4Vp7iGUaKuq23Zr32ehxiU8FWY7xoxbWTSA RL5
[validation_seed]
{validation_seed}
#vaidation_public_key: {validation_public_key}
# Other rippled's trusting this validator need this key
[validator_keys]
{all_validator_keys}
[peer_private]
1
[overlay]
expire = 1
auto_connect = 1
[validation_manifest]
{validation_manifest}
[rpc_startup]
{{ "command": "log_level", "severity": "debug" }}
[ssl_verify]
0
"""
# End config template
def static_vars(**kwargs):
def decorate(func):
for k in kwargs:
setattr(func, k, kwargs[k])
return func
return decorate
@static_vars(rpc=5005, peer=51235, wss=6006)
def checkout_port_nums():
"""Returns a tuple of port nums for rpc, peer, and wss_admin"""
checkout_port_nums.rpc += 1
checkout_port_nums.peer += 1
checkout_port_nums.wss += 1
return (
checkout_port_nums.rpc,
checkout_port_nums.peer,
checkout_port_nums.wss
)
def is_windows():
return platform.system() == 'Windows'
def manifest_create():
"""returns dict with keys: 'validator_keys', 'master_secret'"""
to_run = ['python', ARGS.ripple_home + '/bin/python/Manifest.py', 'create']
r = subprocess.check_output(to_run)
result = {}
k = None
for l in r.splitlines():
l = l.strip()
if not l:
continue
elif l == '[validator_keys]':
k = l[1:-1]
elif l == '[master_secret]':
k = l[1:-1]
elif l.startswith('['):
raise ValueError(
'Unexpected key: {} from `manifest create`'.format(l))
else:
if not k:
raise ValueError('Value with no key')
result[k] = l
k = None
if k in result:
raise ValueError('Repeat key from `manifest create`: ' + k)
if len(result) != 2:
raise ValueError(
'Expected 2 keys from `manifest create` but got {} keys instead ({})'.
format(len(result), result))
return result
def sign_manifest(seq, validation_pk, master_secret):
"""returns the signed manifest as a string"""
to_run = ['python', ARGS.ripple_home + '/bin/python/Manifest.py', 'sign',
str(seq), validation_pk, master_secret]
try:
r = subprocess.check_output(to_run)
except subprocess.CalledProcessError as e:
print('Error in sign_manifest: ', e.output)
raise e
result = []
for l in r.splitlines():
l.strip()
if not l or l == '[validation_manifest]':
continue
result.append(l)
return '\n'.join(result)
def get_ripple_exe():
"""Find the rippled executable"""
prefix = ARGS.ripple_home + '/build/'
exe = ['rippled', 'RippleD.exe']
to_test = [prefix + t + '.debug/' + e
for t in ['clang', 'gcc', 'msvc'] for e in exe]
for e in exe:
to_test.append(prefix + '/' + e)
for t in to_test:
if os.path.isfile(t):
return t
class RippledServer(object):
def __init__(self, exe, config_file, server_out):
self.config_file = config_file
self.exe = exe
self.process = None
self.server_out = server_out
self.reinit(config_file)
def reinit(self, config_file):
self.config_file = config_file
self.to_run = [self.exe, '--verbose', '--conf', self.config_file]
@property
def config_root(self):
return os.path.dirname(self.config_file)
@property
def master_secret_file(self):
return self.config_root + '/master_secret.txt'
def startup(self):
if ARGS.verbose:
print('starting rippled:' + self.config_file)
fout = open(self.server_out, 'w')
self.process = subprocess.Popen(
self.to_run, stdout=fout, stderr=subprocess.STDOUT)
def shutdown(self):
if not self.process:
return
fout = open(os.devnull, 'w')
subprocess.Popen(
self.to_run + ['stop'], stdout=fout, stderr=subprocess.STDOUT)
self.process.wait()
self.process = None
def rotate_logfile(self):
if self.server_out == os.devnull:
return
for i in range(100):
backup_name = '{}.{}'.format(self.server_out, i)
if not os.path.exists(backup_name):
os.rename(self.server_out, backup_name)
return
raise ValueError('Could not rotate logfile: {}'.
format(self.server_out))
def validation_create(self):
"""returns dict with keys:
'validation_key', 'validation_public_key', 'validation_seed'
"""
to_run = [self.exe, '-q', '--conf', self.config_file,
'--', 'validation_create']
try:
return json.loads(subprocess.check_output(to_run))['result']
except subprocess.CalledProcessError as e:
print('Error in validation_create: ', e.output)
raise e
@contextmanager
def rippled_server(config_file, server_out=os.devnull):
"""Start a ripple server"""
try:
server = None
server = RippledServer(ARGS.ripple_exe, config_file, server_out)
server.startup()
yield server
finally:
if server:
server.shutdown()
@contextmanager
def pause_server(server, config_file):
"""Shutdown and then restart a ripple server"""
try:
server.shutdown()
server.rotate_logfile()
yield server
finally:
server.reinit(config_file)
server.startup()
def parse_date(d, t):
"""Return the timestamp of a line, or none if the line has no timestamp"""
try:
return time.strptime(d+' '+t, '%Y-%B-%d %H:%M:%S')
except:
return None
def to_dict(l):
"""Given a line of the form Key0: Value0;Key2: Valuue2; Return a dict"""
fields = l.split(';')
result = {}
for f in fields:
if f:
v = f.split(':')
assert len(v) == 2
result[v[0].strip()] = v[1].strip()
return result
def check_ephemeral_key(validator_key,
log_file,
seq,
change_time):
"""
Detect when a server is informed of a validator's ephemeral key change.
`change_time` and `seq` may be None, in which case they are ignored.
"""
manifest_prefix = 'Manifest:'
# a manifest line has the form Manifest: action; Key: value;
# Key can be Pk (public key), Seq, OldSeq,
for l in open(log_file):
sa = l.split()
if len(sa) < 5 or sa[4] != manifest_prefix:
continue
d = to_dict(' '.join(sa[4:]))
# check the seq number and validator_key
if d['Pk'] != validator_key:
continue
if seq is not None and int(d['Seq']) != seq:
continue
if change_time:
t = parse_date(sa[0], sa[1])
if not t or t < change_time:
continue
action = d['Manifest']
return MANIFEST_ACTION_STR_TO_ID[action]
return NOT_FOUND
def check_ephemeral_keys(validator_key,
log_files,
seq,
change_time=None,
timeout_s=60):
result = [NOT_FOUND for i in range(len(log_files))]
if timeout_s < 10:
sleep_time = 1
elif timeout_s < 60:
sleep_time = 5
else:
sleep_time = 10
n = timeout_s//sleep_time
if n == 0:
n = 1
start_time = time.time()
for _ in range(n):
for i, lf in enumerate(log_files):
if result[i] != NOT_FOUND:
continue
result[i] = check_ephemeral_key(validator_key,
lf,
seq,
change_time)
if result[i] != NOT_FOUND:
if all(r != NOT_FOUND for r in result):
return result
else:
server_dir = os.path.basename(os.path.dirname(log_files[i]))
if ARGS.verbose:
print('Check for {}: {}'.format(
server_dir, MANIFEST_ACTION_ID_TO_STR[result[i]]))
tsf = time.time() - start_time
if tsf > 20:
if ARGS.verbose:
print('Waiting for key to propigate: ', tsf)
time.sleep(sleep_time)
return result
def get_validator_key(config_file):
in_validator_keys = False
for l in open(config_file):
sl = l.strip()
if not in_validator_keys and sl == '[validator_keys]':
in_validator_keys = True
continue
if in_validator_keys:
if sl.startswith('['):
raise ValueError('ThisServer validator key not found')
if sl.startswith('#'):
continue
s = sl.split()
if len(s) == 2 and s[1] == 'ThisServer':
return s[0]
def new_config_ephemeral_key(
server, seq, rm_dbs=False, master_secret_file=None):
"""Generate a new ephemeral key, add to config, restart server"""
config_root = server.config_root
config_file = config_root + '/rippled.cfg'
db_dir = config_root + '/db'
if not master_secret_file:
master_secret_file = server.master_secret_file
with open(master_secret_file) as f:
master_secret = f.read()
v = server.validation_create()
signed = sign_manifest(seq, v['validation_public_key'], master_secret)
with pause_server(server, config_file):
if rm_dbs and os.path.exists(db_dir):
shutil.rmtree(db_dir)
os.makedirs(db_dir)
# replace the validation_manifest section with `signed`
bak = config_file + '.bak'
if is_windows() and os.path.isfile(bak):
os.remove(bak)
os.rename(config_file, bak)
in_manifest = False
with open(bak, 'r') as src:
with open(config_file, 'w') as out:
for l in src:
sl = l.strip()
if not in_manifest and sl == '[validation_manifest]':
in_manifest = True
elif in_manifest:
if sl.startswith('[') or sl.startswith('#'):
in_manifest = False
out.write(signed)
out.write('\n\n')
else:
continue
out.write(l)
return (bak, config_file)
def parse_args():
parser = argparse.ArgumentParser(
description=('Create config files for n validators')
)
parser.add_argument(
'--ripple_home', '-r',
default=os.sep.join(os.path.realpath(__file__).split(os.sep)[:-5]),
help=('Root directory of the ripple repo'), )
parser.add_argument('--num_validators', '-n',
default=2,
help=('Number of validators'), )
parser.add_argument('--conf', '-c', help=('rippled config file'), )
parser.add_argument('--out', '-o',
default='test_output',
help=('config root directory'), )
parser.add_argument(
'--existing', '-e',
action='store_true',
help=('use existing config files'), )
parser.add_argument(
'--generate', '-g',
action='store_true',
help=('generate conf files only'), )
parser.add_argument(
'--verbose', '-v',
action='store_true',
help=('verbose status reporting'), )
parser.add_argument(
'--quiet', '-q',
action='store_true',
help=('quiet status reporting'), )
return parser.parse_args()
def get_configs(manifest_seq):
global ARGS
ARGS.ripple_home = os.path.expanduser(ARGS.ripple_home)
n = int(ARGS.num_validators)
if n<2:
raise ValueError(
'Need at least 2 rippled servers. Specified: {}'.format(n))
config_root = ARGS.out
ARGS.ripple_exe = get_ripple_exe()
if not ARGS.ripple_exe:
raise ValueError('No Exe Found')
if ARGS.existing:
return [
os.path.abspath('{}/validator_{}/rippled.cfg'.format(config_root, i))
for i in range(n)
]
initial_config = ARGS.conf
manifests = [manifest_create() for i in range(n)]
port_nums = [checkout_port_nums() for i in range(n)]
with rippled_server(initial_config) as server:
time.sleep(DELAY_WHILE_PROCESS_STARTS_UP)
validations = [server.validation_create() for i in range(n)]
signed_manifests = [sign_manifest(manifest_seq,
v['validation_public_key'],
m['master_secret'])
for m, v in zip(manifests, validations)]
node_db_type = 'RocksDB' if not is_windows() else 'NuDB'
node_db_filename = node_db_type.lower()
config_files = []
for i, (m, v, s) in enumerate(zip(manifests, validations, signed_manifests)):
sibling_index = (i - 1) % len(manifests)
all_validator_keys = '\n'.join([
m['validator_keys'] + ' ThisServer',
manifests[sibling_index]['validator_keys'] + ' NextInRing'])
this_validator_dir = os.path.abspath(
'{}/validator_{}'.format(config_root, i))
db_path = this_validator_dir + '/db'
node_db_path = db_path + '/' + node_db_filename
log_path = this_validator_dir + '/log'
debug_logfile = log_path + '/debug.log'
rpc_port, peer_port, wss_port = port_nums[i]
sibling_ip = '127.0.0.1'
sibling_port = port_nums[sibling_index][1]
d = {
'validation_manifest': s,
'all_validator_keys': all_validator_keys,
'node_db_type': node_db_type,
'node_db_path': node_db_path,
'db_path': db_path,
'debug_logfile': debug_logfile,
'rpc_port': rpc_port,
'peer_port': peer_port,
'wss_port': wss_port,
'sibling_ip': sibling_ip,
'sibling_port': sibling_port,
}
d.update(m)
d.update(v)
for p in [this_validator_dir, db_path, log_path]:
if not os.path.exists(p):
os.makedirs(p)
config_files.append('{}/rippled.cfg'.format(this_validator_dir))
with open(config_files[-1], 'w') as f:
f.write(CONF_TEMPLATE.format(**d))
with open('{}/master_secret.txt'.format(this_validator_dir), 'w') as f:
f.write(m['master_secret'])
return config_files
def update_ephemeral_key(
server, new_seq, log_files,
expected=None, rm_dbs=False, master_secret_file=None,
restore_origional_conf=False, timeout_s=300):
if not expected:
expected = {}
change_time = time.gmtime()
back_conf, new_conf = new_config_ephemeral_key(
server,
new_seq,
rm_dbs,
master_secret_file
)
validator_key = get_validator_key(server.config_file)
start_time = time.time()
ck = check_ephemeral_keys(validator_key,
log_files,
seq=new_seq,
change_time=change_time,
timeout_s=timeout_s)
if ARGS.verbose:
print('Check finished: {} secs.'.format(int(time.time() - start_time)))
all_success = True
for i, r in enumerate(ck):
e = expected.get(i, UNTRUSTED)
server_dir = os.path.basename(os.path.dirname(log_files[i]))
status = 'OK' if e == r else 'FAIL'
print('{}: Server: {} Expected: {} Got: {}'.
format(status, server_dir,
MANIFEST_ACTION_ID_TO_STR[e], MANIFEST_ACTION_ID_TO_STR[r]))
all_success = all_success and (e == r)
if restore_origional_conf:
if is_windows() and os.path.isfile(new_conf):
os.remove(new_conf)
os.rename(back_conf, new_conf)
return all_success
def run_main():
global ARGS
ARGS = parse_args()
manifest_seq = 1
config_files = get_configs(manifest_seq)
if ARGS.generate:
return
if len(config_files) <= 1:
print('Script requires at least 2 servers. Actual #: {}'.
format(len(config_files)))
return
with contextlib.nested(*(rippled_server(c, os.path.dirname(c)+'/log.txt')
for c in config_files)) as servers:
log_files = [os.path.dirname(cf)+'/log.txt' for cf in config_files[1:]]
validator_key = get_validator_key(config_files[0])
start_time = time.time()
ck = check_ephemeral_keys(validator_key,
[log_files[0]],
seq=None,
timeout_s=60)
if ARGS.verbose:
print('Check finished: {} secs.'.format(
int(time.time() - start_time)))
if any(r == NOT_FOUND for r in ck):
print('FAIL: Initial key did not propigate to all servers')
return
manifest_seq += 2
expected = {i: UNTRUSTED for i in range(len(log_files))}
expected[0] = ACCEPTED_UPDATE
if not ARGS.quiet:
print('Testing key update')
kr = update_ephemeral_key(servers[0], manifest_seq, log_files, expected)
if not kr:
print('\nFail: Key Update Test. Exiting')
return
expected = {i: UNTRUSTED for i in range(len(log_files))}
expected[0] = STALE
if not ARGS.quiet:
print('Testing stale key')
kr = update_ephemeral_key(
servers[0], manifest_seq-1, log_files, expected, rm_dbs=True)
if not kr:
print('\nFail: Stale Key Test. Exiting')
return
expected = {i: UNTRUSTED for i in range(len(log_files))}
expected[0] = STALE
if not ARGS.quiet:
print('Testing stale key 2')
kr = update_ephemeral_key(
servers[0], manifest_seq, log_files, expected, rm_dbs=True)
if not kr:
print('\nFail: Stale Key Test. Exiting')
return
expected = {i: UNTRUSTED for i in range(len(log_files))}
expected[0] = REVOKED
if not ARGS.quiet:
print('Testing revoked key')
kr = update_ephemeral_key(
servers[0], 0xffffffff, log_files, expected, rm_dbs=True)
if not kr:
print('\nFail: Revoked Key Text. Exiting')
return
print('\nOK: All tests passed')
if __name__ == '__main__':
run_main()