#!/usr/bin/env python2 # -*- coding: utf-8 -*- """ Blockstack-client ~~~~~ copyright: (c) 2014-2015 by Halfmoon Labs, Inc. copyright: (c) 2016-2017 by Blockstack.org This file is part of Blockstack-client. Blockstack-client is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Blockstack-client is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Blockstack-client. If not, see . """ import os import sys import atexit if sys.version_info.major != 2: raise Exception("Python 3 is not supported") if sys.version_info.minor < 7: raise Exception("Python 2.7 or greater is required") import traceback import json import argparse # Hack around absolute paths current_dir = os.path.abspath(os.path.dirname(__file__)) parent_dir = os.path.abspath(current_dir + "/../") import time import thread import errno import signal import socket import posixpath import urllib import urllib2 import SocketServer from SimpleHTTPServer import SimpleHTTPRequestHandler import re import jsonschema from jsonschema import ValidationError import requests import random from ConfigParser import SafeConfigParser import keylib import shutil import blockstack_client import blockstack from blockstack import virtualchain_hooks import virtualchain import blockstack_client.schemas as schemas SNAPSHOTS_CONFIG_PATH = os.path.expanduser("~/.blockstack-snapshots/blockstack-snapshots.ini") SNAPSHOT_CHECK_INTERVAL = int(os.environ.get("BLOCKSTACK_SNAPSHOT_CHECK_INTERVAL", 60)) running = True log = blockstack_client.get_logger("blockstack-snapshots") class BlockstackSnapshotHandler(SimpleHTTPRequestHandler): """ Snapshot server request handler. Endpoints are: GET /v1/ping Is this server alive? POST /v1/snapshot Sign a snapshot digest if it is signed by a trusted key server variables: self.server.trusted_public_key self.server.private_key """ JSONRPC_MAX_SIZE = 1024 * 1024 def _send_headers(self, status_code=200, content_type='application/json'): """ Generate and reply headers """ self.send_response(status_code) self.send_header('content-type', content_type) self.send_header('Access-Control-Allow-Origin', '*') # CORS self.end_headers() def _reply_json(self, json_payload, status_code=200): """ Return a JSON-serializable data structure """ self._send_headers(status_code=status_code) json_str = json.dumps(json_payload) self.wfile.write(json_str) def _read_payload(self, maxlen=None): """ Read raw uploaded data. Return the data on success Return None on I/O error, or if maxlen is not None and the number of bytes read is too big """ client_address_str = "{}:{}".format(self.client_address[0], self.client_address[1]) # check length read_len = self.headers.get('content-length', None) if read_len is None: log.error("No content-length given from {}".format(client_address_str)) return None try: read_len = int(read_len) except: log.error("Invalid content-length") return None if maxlen is not None and read_len >= maxlen: log.error("Request from {} is too long ({} >= {})".format(client_address_str, read_len, maxlen)) return None # get the payload request_str = self.rfile.read(read_len) return request_str def _read_json(self, schema=None): """ Read a JSON payload from the requester Return the parsed payload on success Return None on error """ # JSON post? request_type = self.headers.get('content-type', None) client_address_str = "{}:{}".format(self.client_address[0], self.client_address[1]) if request_type != 'application/json': log.error("Invalid request of type {} from {}".format(request_type, client_address_str)) return None request_str = self._read_payload(maxlen=self.JSONRPC_MAX_SIZE) if request_str is None: log.error("Failed to read request") return None # parse the payload request = None try: request = json.loads( request_str ) if schema is not None: jsonschema.validate( request, schema ) except (TypeError, ValueError, ValidationError) as ve: if BLOCKSTACK_DEBUG: log.exception(ve) return None return request def parse_qs(self, qs): """ Parse query string, but enforce one instance of each variable. Return a dict with the variables on success Return None on parse error """ qs_state = urllib2.urlparse.parse_qs(qs) ret = {} for qs_var, qs_value_list in qs_state.items(): if len(qs_value_list) > 1: return None ret[qs_var] = qs_value_list[0] return ret def get_path_and_qs(self): """ Parse and obtain the path and query values. We don't care about fragments. Return {'path': ..., 'qs_values': ...} on success Return {'error': ...} on error """ path_parts = self.path.split("?", 1) if len(path_parts) > 1: qs = path_parts[1].split("#", 1)[0] else: qs = "" path = path_parts[0].split("#", 1)[0] path = posixpath.normpath(urllib.unquote(path)) qs_values = self.parse_qs( qs ) if qs_values is None: return {'error': 'Failed to parse query string'} parts = path.strip('/').split('/') return {'path': path, 'qs_values': qs_values, 'parts': parts} def sign_snapshot( self ): """ Read a snapshot digest, sign it, and return the signature. Return 200 with {'sigb64': sigb64}€D€D€D€D on success Return 401 on invalid request structure Return 403 on invalid signature TODO: if this server is colocated with a blockstack server, then it should verify that independently calculated the same snapshot. Use `block_height` for that. """ snapshot_schema = { 'type': 'object', 'properties': { 'block_height': { 'type': 'integer', }, 'file_hash': { 'type': 'string', 'pattern': schemas.OP_HEX_PATTERN, }, 'sigb64': { 'type': 'string', 'pattern': schemas.OP_BASE64_PATTERN, }, }, 'required': [ 'file_hash', 'sigb64', ], 'additionalProperties': False, } request = self._read_json(schema=snapshot_schema) if request is None: return self._reply_json({'error': 'Invalid snapshot request'}, status_code=401) block_height = request['block_height'] file_hash = request['file_hash'] sigb64 = request['sigb64'] snapshot_info = get_snapshot_hash(self.server.snapshots_dir, block_height) if 'error' in snapshot_info: # nope log.debug("Failed to get snapshot hash for {}: {}".format(block_height, snapshot_info['error'])) self._reply_json({'error': 'Failed to query local snapshot hash'}, status_code=404) # did any of our trusted public keys sign it? valid = False for trusted_public_key in self.server.trusted_public_keys: valid = blockstack_client.verify_digest( file_hash, trusted_public_key, sigb64 ) if valid: break if not valid: return self._reply_json({'error': 'Invalid signature'}, status_code=403) # can sign sigb64 = blockstack_client.sign_digest( file_hash, self.server.private_key ) assert sigb64, "Failed to sign digest" return self._reply_json({'sigb64': sigb64}) def do_POST(self): """ Handle POST request """ path_info = self.get_path_and_qs() if 'error' in path_info: return self._reply_json({'error': 'Invalid request'}, status_code=401) path = path_info['path'] if path == '/v1/snapshots': return self.sign_snapshot() else: return self._reply_json({'error': 'No such method'}, status_code=404) def do_GET(self): """ Handle GET request """ path_info = self.get_path_and_qs() if 'error' in path_info: return self._reply_json({'error': 'Invalid request'}, status_code=401) path = path_info['path'] if path == '/v1/ping': return self._reply_json({'status': 'alive'}) else: return self._reply_json({'error': 'No such method'}, status_code=404) class BlockstackSnapshotServer(SocketServer.TCPServer): """ Snapshot server implementation """ def __init__(self, port, trusted_public_keys, private_key, snapshots_dir ): """ Set up a snapshot server """ SocketServer.TCPServer.__init__(self, ('0.0.0.0', port), BlockstackSnapshotHandler, bind_and_activate=False) self.trusted_public_keys = [keylib.ECPublicKey(pk).to_hex() for pk in trusted_public_keys] self.private_key = keylib.ECPrivateKey(private_key).to_hex() self.snapshots_dir = snapshots_dir log.debug("Set SO_REUSADDR") self.socket.setsockopt( socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 ) self.server_bind() self.server_activate() def make_snapshot(snapshots_dir, private_key, block_number): """ Make a snapshot from the given block height at {snapshots_dir}/snapshot.bsk.{block_number} Symlink it to {snapshots_dir}/snapshot.bsk Return {'status': True} on success Return {'error': ...} on failure """ tmp_snapshot_path = os.path.join(snapshots_dir, '.snapshot.bsk.{}'.format(block_number)) log.debug("Make snapshot for {} in {}".format(block_number, tmp_snapshot_path)) try: res = blockstack.fast_sync_snapshot( tmp_snapshot_path, private_key, block_number ) assert res except Exception as e: log.exception(e) try: os.unlink(tmp_snapshot_path) except: pass return {'error': 'Failed to create snapshot'} # move into position snapshot_path = os.path.join(snapshots_dir, 'snapshot.bsk.{}'.format(block_number)) shutil.move( tmp_snapshot_path, snapshot_path ) # symlink 'snapshot.bsk' to it old_dir = os.getcwd() try: os.chdir(snapshots_dir) if os.path.exists('snapshot.bsk'): os.unlink('snapshot.bsk') # symlink it in os.symlink('snapshot.bsk.{}'.format(block_number), 'snapshot.bsk') except Exception as e: log.exception(e) return {'error': 'Failed to symlink {} to {}'.format('snapshot.bsk.{}'.format(block_number), 'snapshot.bsk')} finally: os.chdir(old_dir) log.debug("Snapshot at {}".format(snapshot_path)) return {'status': True} def get_snapshot_hash(snapshots_dir, block_number): """ Get the expected hash of a snapshot payload Return {'status': True, 'hash': hex hash} on success Return {'error': ...} on failure """ snapshot_path = os.path.join(snapshots_dir, 'snapshot.bsk.{}'.format(block_number)) if not os.path.exists(snapshot_path): return {'error': 'No such snapshot'} snapshot_info = blockstack.fast_sync_inspect_snapshot(snapshot_path) if 'error' in snapshot_info: return {'error': 'Failed to query snapshot'} snapshot_hash = snapshot_info['hash'] return {'status': True, 'hash': snapshot_hash} def snapshot_exists(snapshots_dir, block_number): """ Do we have a snapshot for this block height? """ snapshot_path = os.path.join( snapshots_dir, "snapshot.bsk.{}".format(block_number) ) return os.path.exists(snapshot_path) def find_backup_blocks(working_dir): """ Find the set of block numbers for which we have backups """ backup_blocks = virtualchain.indexer.StateEngine.get_backup_blocks( virtualchain_hooks, working_dir=working_dir ) return list(set(backup_blocks)) def find_snapshot_blocks(snapshots_dir): """ Find the set of block numbers for which we have snapshots """ snapshot_blocks = [] names = os.listdir(snapshots_dir) for name in names: if re.match("^snapshot.bsk.[0-9]+$", name): # what's the block? _, block_num_str = name.rsplit('.', 1) block_num = int(block_num_str) snapshot_blocks.append( block_num ) return list(set(snapshot_blocks)) def snapshotter_thread_main(working_dir, snapshots_dir, private_key, check_running): """ Continuously watch the snapshots directory. Periodically check that we're not running with check_running callable. Meant to be run in a thread. """ def _wait(timeout): deadline = time.time() + timeout while time.time() < deadline: if not check_running(): return False time.sleep(0.5) return True if not os.path.exists(snapshots_dir): os.makedirs(snapshots_dir) block_numbers = find_snapshot_blocks(snapshots_dir) log.debug("Snapshotter start") while check_running(): # find out which block numbers are represented in the backups directory cur_block_numbers = find_backup_blocks(working_dir) log.debug("snapshot blocks: {}".format(",".join(['{}'.format(b) for b in block_numbers]))) log.debug("backup blocks: {}".format(",".join(['{}'.format(b) for b in cur_block_numbers]))) new_block_numbers = [] for block_number in cur_block_numbers: if block_number not in block_numbers: new_block_numbers.append(block_number) if len(new_block_numbers) == 0: # no new blocks res = _wait(SNAPSHOT_CHECK_INTERVAL) if not res: # died break continue # have new snapshots to make. # start with the most recent for new_block in reversed(sorted(new_block_numbers)): res = make_snapshot( snapshots_dir, private_key, new_block ) if 'error' in res: log.error("Failed to make snapshot for {}: {}".format(new_block, res['error'])) else: block_numbers.append(new_block) log.debug("Snapshotter exit") return def read_config(config_path=SNAPSHOTS_CONFIG_PATH): """ Read our config file Return the config dict on success Return {'error': ...} on failure """ parser = SafeConfigParser() parser.read( config_path ) defaults = { 'blockstack-snapshots': { 'private_key': os.path.join( os.path.dirname(config_path), 'public_keys' ), 'public_keys': os.path.join( os.path.dirname(config_path), 'private_key' ), 'snapshots_dir': os.path.join( os.path.dirname(config_path), 'snapshots' ), 'log_file': os.path.join( os.path.dirname(config_path), 'blockstack-snapshots.log' ), 'port': 31128, }, } private_key_path = None public_keys_path = None snapshots_dir = None log_file = None port = None if parser.has_section("blockstack-snapshots"): if parser.has_option('blockstack-snapshots', 'private_key'): private_key_path = parser.get('blockstack-snapshots', 'private_key') if parser.has_option('blockstack-snapshots', 'public_keys'): public_keys_path = parser.get('blockstack-snapshots', 'public_keys') if parser.has_option('blockstack-snapshots', 'snapshots_dir'): snapshots_dir = parser.get('blockstack-snapshots', 'snapshots_dir') if parser.has_option('blockstack-snapshots', 'log_file'): log_file = parser.get('blockstack-snapshots', 'log_file') if parser.has_option('blockstack-snapshots', 'port'): try: port = int(parser.get('blockstack-snapshots', 'port')) except: print >> sys.stderr, "Failed to parse `port` in config file" sys.exit(1) ret = { 'blockstack-snapshots': { 'private_key': private_key_path, 'public_keys': public_keys_path, 'snapshots_dir': snapshots_dir, 'log_file': log_file, 'port': port }, } for sec in ret.keys(): for field in ret[sec].keys(): if ret[sec][field] is None: if defaults[sec][field] is not None: ret[sec][field] = defaults[sec][field] else: del ret[sec][field] return ret def merge_config_args(conf, args): """ Merge CLI arguments into our config dict """ fields = { 'private_key': 'blockstack-snapshots.private_key', 'snapshots': 'blockstack-snapshots.snapshots_dir', 'public_keys': 'blockstack-snapshots.public_keys', 'log_file': 'blockstack-snapshots.log_file', 'port': 'blockstack-snapshots.port' } required = fields.keys() for attr in fields.keys(): attrval = getattr(args, attr, None) if attrval is None: continue parts = fields[attr].split('.') conf_sec = parts[0] conf_attr = parts[1] conf[conf_sec][conf_attr] = attrval return conf def server_wait(snapshots_conf): """ Wait for the server to come up """ delay = 1.0 up = False for i in xrange(0, 5): try: url = 'http://localhost:{}/v1/ping'.format(snapshots_conf['port']) req = requests.get(url) assert req.status_code == 200 up = True break except Exception, e: log.exception(e) log.debug("Failed to ping {}. Try again in {}".format(url, delay)) time.sleep(delay) delay = delay * 2 + random.random() * delay return up def is_running(): global running return running def get_pid_file_path(config_path=SNAPSHOTS_CONFIG_PATH): """ Get the PID file path """ pid_path = os.path.join( os.path.dirname(config_path), 'blockstack-snapshots.pid' ) return pid_path def put_pid_file(config_path=SNAPSHOTS_CONFIG_PATH): """ Put the PID file Return True on success Return False if it already exists """ pid_path = get_pid_file_path(config_path=config_path) if os.path.exists(pid_path): log.debug("PID path already exists: {}".format(pid_path)) return False with open(pid_path, "w") as f: f.write( str(os.getpid()) ) return True def get_pid_from_file(config_path=SNAPSHOTS_CONFIG_PATH): """ Get the PID in the PID file Return None if there is no such file """ pid_path = get_pid_file_path(config_path=config_path) if not os.path.exists(pid_path): return None with open(pid_path, 'r') as f: pid = int(f.read().strip()) return pid def remove_pid_file(config_path=SNAPSHOTS_CONFIG_PATH): """ Remove the PID file Idempotent; succeeds even if it isn't there """ pid_path = get_pid_file_path(config_path=config_path) if os.path.exists(pid_path): os.unlink(pid_path) return True def atexit_cleanup(config_path=SNAPSHOTS_CONFIG_PATH): """ Clean up at exit """ log.debug("Atexit called for {}".format(os.getpid())) remove_pid_file(config_path) def signal_exit(ignored_1, ignored_2): """ Signal handler cleanup """ sys.exit(0) if __name__ == '__main__': virtualchain.setup_virtualchain( virtualchain_hooks ) working_dir = virtualchain.get_working_dir( impl=virtualchain_hooks ) config_path = SNAPSHOTS_CONFIG_PATH argparser = argparse.ArgumentParser(description="blockstack-snapshots version {}".format(blockstack.VERSION)) subparsers = argparser.add_subparsers( dest='action', help='the action to be taken') # --------------------------- parser = subparsers.add_parser( 'start', help='start the snapshot daemon') parser.add_argument( '--debug', action='store_true', help='activate debug output') parser.add_argument( '--config_file', action='store', help='path to the config file (Default is {})'.format(config_path)) parser.add_argument( '--snapshots', action='store', help='the path to the snapshots output directory') parser.add_argument( '--private_key', action='store', help='the path to the private key to sign snapshots') parser.add_argument( '--public_keys', action='store', help='the path to a file with the list of public keys to verify snapshots') parser.add_argument( '--log_file', action='store', help='path to the log file') parser.add_argument( '--port', action='store', type=int, help='the port to listen on') parser.add_argument( '--foreground', action='store_true', help='Run in the foreground. Do not daemonize') # --------------------------- parser = subparsers.add_parser( 'stop', help='stop the snapshot daemon') parser.add_argument( '--config_file', action='store', help='path to the config file (Default is {})'.format(config_path)) parser.add_argument( '--debug', action='store_true', help='activate debug output') # --------------------------- parser = subparsers.add_parser( 'snapshot', help='make a snapshot for each backup') parser.add_argument( '--config_file', action='store', help='path to the config file (Default is {})'.format(config_path)) parser.add_argument( '--snapshots', action='store', help='the path to the snapshots output directory') parser.add_argument( '--private_key', action='store', help='the path to the private key to sign snapshots') parser.add_argument( '--debug', action='store_true', help='activate debug output') args, _ = argparser.parse_known_args() if hasattr(args, 'debug') and args.debug: # re-exec without --debug, but set debug os.environ['BLOCKSTACK_DEBUG'] = "1" new_argv = [] for a in sys.argv: if a != '--debug': new_argv.append(a) os.execv(sys.argv[0], new_argv) config_path = getattr(args, 'config_path', config_path) conf = read_config(config_path=config_path) conf = merge_config_args(conf, args) snapshots_conf = conf['blockstack-snapshots'] if args.action == 'start': # start up snapshots_path = snapshots_conf.get('snapshots_dir', None) private_key_path = snapshots_conf.get('private_key', None) public_keys_path = snapshots_conf.get('public_keys', None) log_file = snapshots_conf.get('log_file', None) port = snapshots_conf.get('port', None) if snapshots_path is None or private_key_path is None or public_keys_path is None or log_file is None or port is None: argparser.print_help() print >> sys.stderr, "\nMissing configuration file information or arguments:\n {}\n".format( ', '.join( filter( lambda x: snapshots_conf.get(x, None) is None, ['snapshots_dir', 'private_key', 'public_keys', 'log_file', 'port'] ) ) ) sys.exit(1) private_key = None trusted_public_keys = [] if not os.path.exists(snapshots_path) or not os.path.isdir(snapshots_path): print >> sys.stderr, "{} does not exist or is not a directory".format(snapshots_path) sys.exit(1) if not os.path.exists(private_key_path): print >> sys.stderr, 'Failed to read {}: no such file or directory'.format(private_key_path) sys.exit(1) if not os.path.exists(public_keys_path): print >> sys.stderr, 'Failed to read {}: no such file or directory'.format(public_keys_path) sys.exit(1) with open(private_key_path) as f: private_key = f.read().strip() with open(public_keys_path) as f: trusted_public_key_list = f.read().strip() trusted_public_keys = trusted_public_key_list.split() try: private_key = keylib.ECPrivateKey(private_key).to_hex() pks = [] for pk_str in trusted_public_keys: pk = keylib.ECPublicKey(pk_str).to_hex() pks.append(pk) trusted_public_keys = pks except: print >> sys.stderr, "Invalid key data" sys.exit(1) if not args.foreground: # is another daemon running? existing_pid = get_pid_from_file(config_path=config_path) if existing_pid is not None: # is it actually running? try: os.kill(existing_pid, 0) print >> sys.stderr, "Another daemon is already running ({})".format(existing_pid) sys.exit(1) except OSError as oe: if oe.errno == errno.ESRCH: print >> sys.stderr, "Removing stale PID file" remove_pid_file(config_path=config_path) else: log.exception(oe) sys.exit(1) except Exception as e: log.exception(e) sys.exit(1) # daemonize res = blockstack_client.daemonize( log_file, child_wait=lambda: server_wait(snapshots_conf) ) if res < 0: print >> sys.stderr, "Failed to start server" sys.exit(1) if res > 0: log.debug("Parent {} forked intermediate child {}".format(os.getpid(), res)) sys.exit(0) # daemon child # put PID file res = put_pid_file(config_path=config_path) if not res: print >> sys.stderr, "Failed to write PID file" sys.exit(1) atexit.register( atexit_cleanup, config_path ) signal.signal(signal.SIGINT, signal_exit ) signal.signal(signal.SIGQUIT, signal_exit ) signal.signal(signal.SIGTERM, signal_exit ) # daemon child continues here # start snapshotter thread thr = thread.start_new_thread( snapshotter_thread_main, (working_dir, snapshots_path, private_key, is_running) ) # start HTTP server srv = BlockstackSnapshotServer( port, trusted_public_keys, private_key, snapshots_path ) try: srv.serve_forever() except Exception as e: log.exception(e) running = False thr.join() sys.exit(0) elif args.action == 'stop': # stop running snapshot server existing_pid = get_pid_from_file(config_path=config_path) if existing_pid is not None: # is it running? try: log.debug("Send SIGTERM to {}".format(existing_pid)) os.kill(existing_pid, signal.SIGTERM) except OSError as oe: if oe.errno == errno.ESRCH: print >> sys.stderr, "No such process" remove_pid_file(config_path=config_path) sys.exit(0) else: log.exception(oe) sys.exit(1) except Exception as e: log.exception(e) sys.exit(1) elif args.action == 'snapshot': # make all snapshots snapshots_path = snapshots_conf.get('snapshots_dir', None) private_key_path = snapshots_conf.get('private_key', None) if snapshots_path is None or private_key_path is None: argparser.print_help() print >> sys.stderr, "\nMissing configuration file information or arguments\n" sys.exit(1) if not os.path.exists(snapshots_path) or not os.path.isdir(snapshots_path): print >> sys.stderr, "{} does not exist or is not a directory" sys.exit(1) if not os.path.exists(private_key_path): print >> sys.stderr, 'Failed to read {}: no such file or directory'.format(private_key_path) sys.exit(1) def _snapshot_running(): global running if running: running = False return True else: return running with open(private_key_path, "r") as f: private_key = f.read().strip() try: private_key = keylib.ECPrivateKey(private_key).to_hex() except Exception as e: print >> sys.stderr, "Invalid key data" sys.exit(1) snapshotter_thread_main(working_dir, snapshots_path, private_key, _snapshot_running) sys.exit(0)