mirror of
https://github.com/alexgo-io/stacks-puppet-node.git
synced 2026-03-30 16:45:26 +08:00
573 lines
16 KiB
Python
573 lines
16 KiB
Python
#!/usr/bin/env python
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Blockstack
|
|
~~~~~
|
|
copyright: (c) 2014-2015 by Halfmoon Labs, Inc.
|
|
copyright: (c) 2016-2017 by Blockstack.org
|
|
|
|
This file is part of Blockstack
|
|
|
|
Blockstack 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 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. If not, see <http://www.gnu.org/licenses/>.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
import json
|
|
import datetime
|
|
import traceback
|
|
import time
|
|
import math
|
|
import random
|
|
import shutil
|
|
import tempfile
|
|
import binascii
|
|
import copy
|
|
import threading
|
|
import errno
|
|
import base64
|
|
import keylib
|
|
import subprocess
|
|
import urllib
|
|
import hashlib
|
|
|
|
import virtualchain
|
|
import blockstack_client
|
|
|
|
log = virtualchain.get_logger("blockstack-server")
|
|
|
|
import pybitcoin
|
|
|
|
import nameset as blockstack_state_engine
|
|
import nameset.virtualchain_hooks as virtualchain_hooks
|
|
|
|
import config
|
|
|
|
from .b40 import *
|
|
from .config import *
|
|
from .scripts import *
|
|
from .hashing import *
|
|
from .storage import *
|
|
|
|
from .nameset import *
|
|
from .operations import *
|
|
|
|
def snapshot_peek_number( fd, off ):
|
|
"""
|
|
Read the last 8 bytes of fd
|
|
and interpret it as an int.
|
|
"""
|
|
# read number of 8 bytes
|
|
fd.seek( off - 8, os.SEEK_SET )
|
|
value_hex = fd.read(8)
|
|
if len(value_hex) != 8:
|
|
return None
|
|
try:
|
|
value = int(value_hex, 16)
|
|
except ValueError:
|
|
return None
|
|
|
|
return value
|
|
|
|
|
|
def snapshot_peek_sigb64( fd, off, bytelen ):
|
|
"""
|
|
Read the last :bytelen bytes of
|
|
fd and interpret it as a base64-encoded
|
|
string
|
|
"""
|
|
fd.seek( off - bytelen, os.SEEK_SET )
|
|
sigb64 = fd.read(bytelen)
|
|
if len(sigb64) != bytelen:
|
|
return None
|
|
|
|
try:
|
|
base64.b64decode(sigb64)
|
|
except:
|
|
return None
|
|
|
|
return sigb64
|
|
|
|
|
|
def fast_sync_sign_snapshot( snapshot_path, private_key, first=False ):
|
|
"""
|
|
Append a signature to the end of a snapshot path
|
|
with the given private key.
|
|
|
|
If first is True, then don't expect the signature trailer.
|
|
|
|
Return True on success
|
|
Return False on error
|
|
"""
|
|
|
|
if not os.path.exists(snapshot_path):
|
|
log.error("No such file or directory: {}".format(snapshot_path))
|
|
return False
|
|
|
|
file_size = 0
|
|
payload_size = 0
|
|
write_offset = 0
|
|
try:
|
|
sb = os.stat(snapshot_path)
|
|
file_size = sb.st_size
|
|
assert file_size > 8
|
|
except Exception as e:
|
|
log.exception(e)
|
|
return False
|
|
|
|
num_sigs = 0
|
|
snapshot_hash = None
|
|
with open(snapshot_path, 'r+') as f:
|
|
|
|
if not first:
|
|
info = fast_sync_inspect(f)
|
|
if 'error' in info:
|
|
log.error("Failed to inspect {}: {}".format(snapshot_path, info['error']))
|
|
return False
|
|
|
|
num_sigs = len(info['signatures'])
|
|
write_offset = info['sig_append_offset']
|
|
payload_size = info['payload_size']
|
|
|
|
else:
|
|
# no one has signed yet.
|
|
write_offset = file_size
|
|
num_sigs = 0
|
|
payload_size = file_size
|
|
|
|
# hash the file and sign the (bin-encoded) hash
|
|
privkey_hex = keylib.ECPrivateKey(private_key).to_hex()
|
|
hash_hex = blockstack_client.storage.get_file_hash( f, hashlib.sha256, fd_len=payload_size )
|
|
sigb64 = blockstack_client.keys.sign_digest( hash_hex, privkey_hex, hashfunc=hashlib.sha256 )
|
|
|
|
if os.environ.get("BLOCKSTACK_TEST") == "1":
|
|
log.debug("Signed {} with {} to make {}".format(hash_hex, keylib.ECPrivateKey(private_key).public_key().to_hex(), sigb64))
|
|
|
|
# append
|
|
f.seek(write_offset, os.SEEK_SET)
|
|
f.write(sigb64)
|
|
f.write('{:08x}'.format(len(sigb64)))
|
|
|
|
# append number of signatures
|
|
num_sigs += 1
|
|
f.write('{:08x}'.format(num_sigs))
|
|
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
|
|
return True
|
|
|
|
|
|
def fast_sync_snapshot( export_path, private_key, block_number ):
|
|
"""
|
|
Export all the local state for fast-sync.
|
|
If block_number is given, then the name database
|
|
at that particular block number will be taken.
|
|
|
|
The exported tarball will be signed with the given private key,
|
|
and the signature will be appended to the end of the file.
|
|
|
|
Return True if we succeed
|
|
Return False if not
|
|
"""
|
|
|
|
db_paths = None
|
|
found = True
|
|
tmpdir = None
|
|
namedb_path = None
|
|
|
|
def _cleanup(path):
|
|
try:
|
|
# shutil.rmtree(path)
|
|
print 'rm -rf {}'.format(path)
|
|
except Exception, e:
|
|
log.exception(e)
|
|
log.error("Failed to clear directory {}".format(path))
|
|
|
|
|
|
def _log_backup(path):
|
|
sb = None
|
|
try:
|
|
sb = os.stat(path)
|
|
except Exception, e:
|
|
log.exception(e)
|
|
log.error("Failed to stat {}".format(path))
|
|
return False
|
|
|
|
log.debug("Back up {} ({} bytes)".format(path, sb.st_size))
|
|
|
|
|
|
def _copy_paths(src_paths, dest_dir):
|
|
for db_path in src_paths:
|
|
dest_path = os.path.join(dest_dir, os.path.basename(db_path))
|
|
try:
|
|
_log_backup(db_path)
|
|
shutil.copy(db_path, dest_path)
|
|
except Exception, e:
|
|
log.exception(e)
|
|
log.error("Failed to copy {} to {}".format(db_path, dest_path))
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
# ugly hack to work around the lack of a `nonlocal` keyword in Python 2.x
|
|
def _zonefile_copy_progress_outer():
|
|
def inner(src, names):
|
|
for name in names:
|
|
if name == 'zonefile.txt':
|
|
inner.zonefile_count += 1
|
|
if inner.zonefile_count % 100 == 0:
|
|
log.debug("{} zone files copied".format(inner.zonefile_count))
|
|
|
|
return []
|
|
|
|
inner.zonefile_count = 0
|
|
return inner
|
|
|
|
_zonefile_copy_progress = _zonefile_copy_progress_outer()
|
|
|
|
# make sure we have the apppriate tools
|
|
tools = ['tar', 'bzip2', 'mv', 'sqlite3']
|
|
for tool in tools:
|
|
rc = os.system("which {} > /dev/null".format(tool))
|
|
if rc != 0:
|
|
log.error("'{}' command not found".format(tool))
|
|
return False
|
|
|
|
working_dir = virtualchain.get_working_dir()
|
|
if not os.path.exists(working_dir):
|
|
log.error("No such directory {}".format(working_dir))
|
|
return False
|
|
|
|
if block_number is None:
|
|
# last backup
|
|
all_blocks = BlockstackDB.get_backup_blocks( virtualchain_hooks )
|
|
if len(all_blocks) == 0:
|
|
log.error("No backups available")
|
|
return False
|
|
|
|
block_number = max(all_blocks)
|
|
|
|
log.debug("Snapshot from block {}".format(block_number))
|
|
|
|
# use a backup database
|
|
db_paths = BlockstackDB.get_backup_paths( block_number, virtualchain_hooks )
|
|
|
|
for p in db_paths:
|
|
if not os.path.exists(p):
|
|
log.error("Missing file: '%s'" % p)
|
|
found = False
|
|
|
|
if not found:
|
|
return False
|
|
|
|
try:
|
|
tmpdir = tempfile.mkdtemp(prefix='.blockstack-export-')
|
|
except Exception, e:
|
|
log.exception(e)
|
|
return False
|
|
|
|
# copying from backups
|
|
backups_path = os.path.join(tmpdir, "backups")
|
|
try:
|
|
os.makedirs(backups_path)
|
|
except Exception, e:
|
|
log.exception(e)
|
|
log.error("Failed to make directory {}".format(backups_path))
|
|
_cleanup(tmpdir)
|
|
return False
|
|
|
|
rc = _copy_paths(db_paths, backups_path)
|
|
if not rc:
|
|
_cleanup(tmpdir)
|
|
return False
|
|
|
|
# copy over atlasdb
|
|
atlasdb_path = os.path.join(working_dir, "atlas.db")
|
|
dest_path = os.path.join(tmpdir, "atlas.db")
|
|
_log_backup(atlasdb_path)
|
|
rc = sqlite3_backup(atlasdb_path, dest_path)
|
|
if not rc:
|
|
_cleanup(tmpdir)
|
|
return False
|
|
|
|
# copy over zone files
|
|
zonefiles_path = os.path.join(working_dir, "zonefiles")
|
|
dest_path = os.path.join(tmpdir, "zonefiles")
|
|
try:
|
|
shutil.copytree(zonefiles_path, dest_path, ignore=_zonefile_copy_progress)
|
|
except Exception, e:
|
|
log.exception(e)
|
|
log.error('Failed to copy {} to {}'.format(zonefiles_path, dest_path))
|
|
return False
|
|
|
|
# compress
|
|
export_path = os.path.abspath(export_path)
|
|
cmd = "cd '{}' && tar cf 'snapshot.tar' * && bzip2 'snapshot.tar' && mv 'snapshot.tar.bz2' '{}'".format(tmpdir, export_path)
|
|
log.debug("Compressing: {}".format(cmd))
|
|
rc = os.system(cmd)
|
|
if rc != 0:
|
|
log.error("Failed to compress {}. Exit code {}. Command: \"{}\"".format(tmpdir, rc, cmd))
|
|
_cleanup(tmpdir)
|
|
return False
|
|
|
|
log.debug("Wrote {} bytes".format(os.stat(export_path).st_size))
|
|
|
|
rc = fast_sync_sign_snapshot( export_path, private_key, first=True )
|
|
if not rc:
|
|
log.error("Failed to sign snapshot {}".format(export_path))
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def fast_sync_fetch( import_url ):
|
|
"""
|
|
Get the data for an import snapshot.
|
|
Store it to a temporary path
|
|
Return the path on success
|
|
Return None on error
|
|
"""
|
|
try:
|
|
fd, tmppath = tempfile.mkstemp(prefix='.blockstack-fast-sync-')
|
|
except Exception, e:
|
|
log.exception(e)
|
|
return None
|
|
|
|
try:
|
|
path, headers = urllib.urlretrieve(import_url, tmppath)
|
|
except Exception, e:
|
|
os.close(fd)
|
|
log.exception(e)
|
|
return None
|
|
|
|
os.close(fd)
|
|
return tmppath
|
|
|
|
|
|
def fast_sync_inspect( fd ):
|
|
"""
|
|
Inspect a snapshot, given its file descriptor.
|
|
Get the signatures and payload size
|
|
Return {'status': True,
|
|
'signatures': signatures,
|
|
'payload_size': payload size,
|
|
'sig_append_offset': offset} on success
|
|
Return {'error': ...} on error
|
|
"""
|
|
sb = os.fstat(fd.fileno())
|
|
ptr = sb.st_size
|
|
if ptr < 8:
|
|
log.debug("fd is {} bytes".format(ptr))
|
|
return {'error': 'File is too small to be a snapshot'}
|
|
|
|
signatures = []
|
|
sig_append_offset = 0
|
|
|
|
fd.seek(0, os.SEEK_SET)
|
|
|
|
# read number of signatures
|
|
num_signatures = snapshot_peek_number(fd, ptr)
|
|
if num_signatures is None or num_signatures > 256:
|
|
log.error("Unparseable num_signatures field")
|
|
return {'error': 'Unparseable num_signatures'}
|
|
|
|
# consumed
|
|
ptr -= 8
|
|
|
|
# future signatures get written here
|
|
sig_append_offset = ptr
|
|
|
|
# read signatures
|
|
for i in xrange(0, num_signatures):
|
|
sigb64_len = snapshot_peek_number(fd, ptr)
|
|
if sigb64_len is None or sigb64_len > 100:
|
|
log.error("Unparseable signature length field")
|
|
return {'error': 'Unparseable signature length'}
|
|
|
|
# consumed length
|
|
ptr -= 8
|
|
|
|
sigb64 = snapshot_peek_sigb64(fd, ptr, sigb64_len)
|
|
if sigb64 is None:
|
|
log.error("Unparseable signature")
|
|
return {'error': 'Unparseable signature'}
|
|
|
|
# consumed signature
|
|
ptr -= len(sigb64)
|
|
|
|
signatures.append( sigb64 )
|
|
|
|
return {'status': True, 'signatures': signatures, 'payload_size': ptr, 'sig_append_offset': sig_append_offset}
|
|
|
|
|
|
def fast_sync_import( working_dir, import_url, public_keys=config.FAST_SYNC_PUBLIC_KEYS, num_required=len(config.FAST_SYNC_PUBLIC_KEYS) ):
|
|
"""
|
|
Fast sync import.
|
|
Verify the given fast-sync file from @import_path using @public_key, and then
|
|
uncompress it into @working_dir.
|
|
|
|
Verify that at least `num_required` public keys in `public_keys` signed.
|
|
NOTE: `public_keys` needs to be in the same order as the private keys that signed.
|
|
"""
|
|
|
|
# make sure we have the apppriate tools
|
|
tools = ['tar', 'bzip2', 'mv']
|
|
for tool in tools:
|
|
rc = os.system("which {} > /dev/null".format(tool))
|
|
if rc != 0:
|
|
log.error("'{}' command not found".format(tool))
|
|
return False
|
|
|
|
if working_dir is None:
|
|
working_dir = virtualchain.get_working_dir()
|
|
|
|
if not os.path.exists(working_dir):
|
|
log.error("No such directory {}".format(working_dir))
|
|
return False
|
|
|
|
# go get it
|
|
import_path = fast_sync_fetch(import_url)
|
|
if import_path is None:
|
|
log.error("Failed to fetch {}".format(import_url))
|
|
return False
|
|
|
|
# format: <signed bz2 payload> <sigb64> <sigb64 length (8 bytes hex)> ... <num signatures>
|
|
file_size = 0
|
|
try:
|
|
sb = os.stat(import_path)
|
|
file_size = sb.st_size
|
|
except Exception as e:
|
|
log.exception(e)
|
|
return False
|
|
|
|
num_signatures = 0
|
|
ptr = file_size
|
|
signatures = []
|
|
|
|
with open(import_path, 'r') as f:
|
|
info = fast_sync_inspect( f )
|
|
if 'error' in info:
|
|
log.error("Failed to inspect snapshot {}: {}".format(import_path, info['error']))
|
|
return False
|
|
|
|
signatures = info['signatures']
|
|
ptr = info['payload_size']
|
|
|
|
# get the hash of the file
|
|
hash_hex = blockstack_client.storage.get_file_hash(f, hashlib.sha256, fd_len=ptr)
|
|
|
|
# validate signatures over the hash
|
|
log.debug("Verify {} bytes".format(ptr))
|
|
key_idx = 0
|
|
num_match = 0
|
|
for next_pubkey in public_keys:
|
|
for sigb64 in signatures:
|
|
valid = blockstack_client.keys.verify_digest( hash_hex, keylib.ECPublicKey(next_pubkey).to_hex(), sigb64, hashfunc=hashlib.sha256 )
|
|
if valid:
|
|
num_match += 1
|
|
if num_match >= num_required:
|
|
break
|
|
|
|
log.debug("Public key {} matches {} ({})".format(next_pubkey, sigb64, hash_hex))
|
|
signatures.remove(sigb64)
|
|
|
|
elif os.environ.get("BLOCKSTACK_TEST") == "1":
|
|
log.debug("Public key {} does NOT match {} ({})".format(next_pubkey, sigb64, hash_hex))
|
|
|
|
# enough signatures?
|
|
if num_match < num_required:
|
|
log.error("Not enough signatures match (required {}, found {})".format(num_required, num_match))
|
|
return False
|
|
|
|
# decompress
|
|
import_path = os.path.abspath(import_path)
|
|
cmd = "cd '{}' && tar xf '{}'".format(working_dir, import_path)
|
|
log.debug(cmd)
|
|
rc = os.system(cmd)
|
|
if rc != 0:
|
|
log.error("Failed to decompress. Exit code {}. Command: {}".format(rc, cmd))
|
|
return False
|
|
|
|
# restore from backup
|
|
rc = blockstack_backup_restore(working_dir, None)
|
|
if not rc:
|
|
log.error("Failed to instantiate blockstack name database")
|
|
return False
|
|
|
|
# success!
|
|
log.debug("Restored to {}".format(working_dir))
|
|
return True
|
|
|
|
|
|
def blockstack_backup_restore( working_dir, block_number ):
|
|
"""
|
|
Restore the database from a backup in the backups/ directory.
|
|
If block_number is None, then use the latest backup.
|
|
Return True on success
|
|
Return False on failure
|
|
"""
|
|
|
|
# TODO: this is pretty shady...
|
|
old_working_dir = os.environ.get('VIRTUALCHAIN_WORKING_DIR', None)
|
|
os.environ['VIRTUALCHAIN_WORKING_DIR'] = working_dir
|
|
|
|
if block_number is None:
|
|
all_blocks = BlockstackDB.get_backup_blocks( virtualchain_hooks )
|
|
if len(all_blocks) == 0:
|
|
log.error("No backups available")
|
|
|
|
# TODO: this is pretty shady...
|
|
if old_working_dir:
|
|
os.environ['VIRTUALCHAIN_WORKING_DIR'] = old_working_dir
|
|
|
|
return False
|
|
|
|
block_number = max(all_blocks)
|
|
|
|
found = True
|
|
backup_paths = BlockstackDB.get_backup_paths( block_number, virtualchain_hooks )
|
|
for p in backup_paths:
|
|
if not os.path.exists(p):
|
|
log.error("Missing backup file: '%s'" % p)
|
|
found = False
|
|
|
|
if not found:
|
|
|
|
# TODO: this is pretty shady...
|
|
if old_working_dir:
|
|
os.environ['VIRTUALCHAIN_WORKING_DIR'] = old_working_dir
|
|
|
|
return False
|
|
|
|
rc = BlockstackDB.backup_restore( block_number, virtualchain_hooks )
|
|
if not rc:
|
|
log.error("Failed to restore backup")
|
|
|
|
# TODO: this is pretty shady...
|
|
if old_working_dir:
|
|
os.environ['VIRTUALCHAIN_WORKING_DIR'] = old_working_dir
|
|
|
|
return False
|
|
|
|
log.debug("Restored backup from {}".format(block_number))
|
|
|
|
# TODO: this is pretty shady...
|
|
if old_working_dir:
|
|
os.environ['VIRTUALCHAIN_WORKING_DIR'] = old_working_dir
|
|
|
|
return True
|
|
|