mirror of
https://github.com/alexgo-io/stacks-puppet-node.git
synced 2026-04-08 22:35:15 +08:00
1339 lines
42 KiB
Python
1339 lines
42 KiB
Python
#!/usr/bin/env python
|
|
# -*- coding: utf-8 -*-
|
|
|
|
from __future__ import print_function
|
|
|
|
"""
|
|
Blockstack-client
|
|
~~~~~
|
|
copyright: (c) 2014 by Halfmoon Labs, Inc.
|
|
copyright: (c) 2015 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 <http://www.gnu.org/licenses/>.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import itertools
|
|
import logging
|
|
import traceback
|
|
import uuid
|
|
import urllib2
|
|
import copy
|
|
import time
|
|
import shutil
|
|
import requests
|
|
import keylib
|
|
|
|
from binascii import hexlify
|
|
from ConfigParser import SafeConfigParser
|
|
|
|
import virtualchain
|
|
from .utxo import *
|
|
from .constants import *
|
|
from .logger import get_logger
|
|
|
|
log = get_logger('blockstack-client')
|
|
|
|
|
|
# NOTE: duplicated from blockstack-core and streamlined.
|
|
def op_get_opcode_name(op_string):
|
|
"""
|
|
Get the name of an opcode, given the 'op' byte sequence of the operation.
|
|
"""
|
|
global OPCODE_NAMES
|
|
|
|
# special case...
|
|
if op_string == '{}:'.format(NAME_REGISTRATION):
|
|
return 'NAME_RENEWAL'
|
|
|
|
op = op_string[0]
|
|
if op not in OPCODE_NAMES:
|
|
raise Exception('No such operation "{}"'.format(op))
|
|
|
|
return OPCODE_NAMES[op]
|
|
|
|
|
|
def interactive_prompt(message, parameters, default_opts):
|
|
"""
|
|
Prompt the user for a series of parameters
|
|
Return a dict mapping the parameter name to the
|
|
user-given value.
|
|
"""
|
|
|
|
# pretty-print the message
|
|
lines = message.split('\n')
|
|
max_line_len = max([len(l) for l in lines])
|
|
|
|
print('-' * max_line_len)
|
|
print(message)
|
|
print('-' * max_line_len)
|
|
|
|
ret = {}
|
|
for param in parameters:
|
|
formatted_param = param
|
|
prompt_str = '{}: '.format(formatted_param)
|
|
if param in default_opts:
|
|
prompt_str = '{} (default: "{}"): '.format(formatted_param, default_opts[param])
|
|
|
|
try:
|
|
value = raw_input(prompt_str)
|
|
except KeyboardInterrupt:
|
|
log.debug('Exiting on keyboard interrupt')
|
|
sys.exit(0)
|
|
|
|
if len(value) > 0:
|
|
ret[param] = value
|
|
elif param in default_opts:
|
|
ret[param] = default_opts[param]
|
|
else:
|
|
ret[param] = None
|
|
|
|
return ret
|
|
|
|
|
|
def find_missing(message, all_params, given_opts, default_opts, header=None, prompt_missing=True):
|
|
"""
|
|
Find and interactively prompt the user for missing parameters,
|
|
given the list of all valid parameters and a dict of known options.
|
|
|
|
Return the (updated dict of known options, missing, num_prompted), with the user's input.
|
|
"""
|
|
|
|
# are we missing anything?
|
|
missing_params = list(set(all_params) - set(given_opts))
|
|
|
|
num_prompted = 0
|
|
|
|
if not missing_params:
|
|
return given_opts, missing_params, num_prompted
|
|
|
|
if not prompt_missing:
|
|
# count the number missing, and go with defaults
|
|
missing_values = set(default_opts) - set(given_opts)
|
|
num_prompted = len(missing_values)
|
|
given_opts.update(default_opts)
|
|
|
|
else:
|
|
if header is not None:
|
|
print('-' * len(header))
|
|
print(header)
|
|
|
|
missing_values = interactive_prompt(message, missing_params, default_opts)
|
|
num_prompted = len(missing_values)
|
|
given_opts.update(missing_values)
|
|
|
|
return given_opts, missing_params, num_prompted
|
|
|
|
|
|
def opt_strip(prefix, opts):
|
|
"""
|
|
Given a dict of opts that start with prefix,
|
|
remove the prefix from each of them.
|
|
"""
|
|
|
|
ret = {}
|
|
for opt_name, opt_value in opts.items():
|
|
# remove prefix
|
|
if opt_name.startswith(prefix):
|
|
opt_name = opt_name[len(prefix):]
|
|
|
|
ret[opt_name] = opt_value
|
|
|
|
return ret
|
|
|
|
|
|
def opt_restore(prefix, opts):
|
|
"""
|
|
Given a dict of opts, add the given prefix to each key
|
|
"""
|
|
|
|
return {prefix + name: value for name, value in opts.items()}
|
|
|
|
|
|
def default_bitcoind_opts(config_file=None, prefix=False):
|
|
"""
|
|
Get our default bitcoind options, such as from a config file,
|
|
or from sane defaults
|
|
"""
|
|
|
|
default_bitcoin_opts = virtualchain.get_bitcoind_config(config_file=config_file)
|
|
|
|
# drop dict values that are None
|
|
default_bitcoin_opts = {k: v for k, v in default_bitcoin_opts.items() if v is not None}
|
|
|
|
# strip 'bitcoind_'
|
|
if not prefix:
|
|
default_bitcoin_opts = opt_strip('bitcoind_', default_bitcoin_opts)
|
|
|
|
return default_bitcoin_opts
|
|
|
|
|
|
def client_uuid_path(config_dir=CONFIG_DIR):
|
|
"""
|
|
where is the client UUID stored
|
|
"""
|
|
uuid_path = os.path.join(config_dir, 'client.uuid')
|
|
return uuid_path
|
|
|
|
|
|
def device_id_path(config_dir=CONFIG_DIR):
|
|
"""
|
|
get device ID path
|
|
"""
|
|
id_path = os.path.join(config_dir, 'client.device_id')
|
|
return id_path
|
|
|
|
|
|
def get_or_set_uuid(config_dir=CONFIG_DIR):
|
|
"""
|
|
Get or set the UUID for this installation.
|
|
Return the UUID either way
|
|
Return None on failure
|
|
"""
|
|
uuid_path = client_uuid_path(config_dir=config_dir)
|
|
u = None
|
|
if os.path.exists(uuid_path):
|
|
try:
|
|
with open(uuid_path, 'r') as f:
|
|
u = f.read()
|
|
u = u.strip()
|
|
except Exception as e:
|
|
log.exception(e)
|
|
return None
|
|
else:
|
|
try:
|
|
u = str(uuid.uuid4())
|
|
if not os.path.exists(config_dir):
|
|
os.makedirs(config_dir)
|
|
|
|
with open(uuid_path, 'w') as f:
|
|
f.write(u)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
|
|
except Exception as e:
|
|
log.exception(e)
|
|
return None
|
|
|
|
return u
|
|
|
|
|
|
def get_local_device_id(config_dir=CONFIG_DIR):
|
|
"""
|
|
Get the local device ID
|
|
"""
|
|
id_path = device_id_path(config_dir=config_dir)
|
|
did = None
|
|
if os.path.exists(id_path):
|
|
try:
|
|
with open(id_path, 'r') as f:
|
|
did = f.read()
|
|
|
|
return did
|
|
except Exception as e:
|
|
log.exception(e)
|
|
|
|
return get_or_set_uuid(config_dir=config_dir)
|
|
|
|
|
|
def get_all_device_ids(config_path=CONFIG_PATH):
|
|
"""
|
|
Get the list of all device IDs that use this wallet
|
|
The first device ID is guaranteed to be the local device ID
|
|
"""
|
|
local_device_id = get_local_device_id(config_dir=os.path.dirname(config_path))
|
|
device_ids = [local_device_id]
|
|
|
|
conf = get_config(config_path)
|
|
assert conf
|
|
|
|
if conf.has_key('default_devices'):
|
|
device_ids += filter(lambda x: len(x) > 0, conf['default_devices'].split(','))
|
|
|
|
return device_ids
|
|
|
|
|
|
def configure(config_file=CONFIG_PATH, force=False, interactive=True, set_migrate=False):
|
|
"""
|
|
Configure blockstack-client: find and store configuration parameters to the config file.
|
|
|
|
Optionally prompt for missing data interactively (with interactive=True). Or, raise an exception
|
|
if there are any fields missing.
|
|
|
|
Optionally force a re-prompting for all configuration details (with force=True)
|
|
|
|
Return {
|
|
'blockstack-client': { ... },
|
|
'bitcoind': { ... },
|
|
'blockchain-reader': { ... },
|
|
'blockchain-writer': { ... },
|
|
'uuid': ...
|
|
}
|
|
"""
|
|
|
|
if not os.path.exists(config_file) and interactive:
|
|
# definitely ask for everything
|
|
force = True
|
|
|
|
config_dir = os.path.dirname(config_file)
|
|
|
|
# get blockstack client opts
|
|
blockstack_message = (
|
|
'Your client does not have enough information to connect\n'
|
|
'to a Blockstack server. Please supply the following\n'
|
|
'parameters, or press [ENTER] to select the default value.'
|
|
)
|
|
|
|
all_opts = read_config_file(config_path=config_file, set_migrate=set_migrate)
|
|
blockstack_opts = {}
|
|
blockstack_opts_defaults = all_opts['blockstack-client']
|
|
|
|
migrated = False
|
|
if set_migrate:
|
|
migrated = all_opts['migrated']
|
|
del all_opts['migrated']
|
|
|
|
blockstack_params = blockstack_opts_defaults.keys()
|
|
|
|
if not force:
|
|
# defaults
|
|
blockstack_opts = copy.deepcopy(blockstack_opts_defaults)
|
|
|
|
blockstack_opts, missing_blockstack_opts, num_blockstack_opts_prompted = find_missing(
|
|
blockstack_message,
|
|
blockstack_params,
|
|
blockstack_opts,
|
|
blockstack_opts_defaults,
|
|
prompt_missing=interactive
|
|
)
|
|
|
|
# get bitcoind options
|
|
bitcoind_message = (
|
|
'Blockstack does not have enough information to connect\n'
|
|
'to bitcoind. Please supply the following parameters, or\n'
|
|
'press [ENTER] to select the default value.'
|
|
)
|
|
|
|
bitcoind_opts = {}
|
|
bitcoind_opts_defaults = default_bitcoind_opts(config_file=config_file)
|
|
bitcoind_opts_defaults.update(all_opts.get('bitcoind', {}))
|
|
bitcoind_params = bitcoind_opts_defaults.keys()
|
|
|
|
if not force:
|
|
# get default set of bitcoind opts
|
|
bitcoind_opts = copy.deepcopy(bitcoind_opts_defaults)
|
|
|
|
# get any missing bitcoind fields
|
|
bitcoind_opts, missing_bitcoin_opts, num_bitcoind_prompted = find_missing(
|
|
bitcoind_message,
|
|
bitcoind_params,
|
|
bitcoind_opts,
|
|
bitcoind_opts_defaults,
|
|
prompt_missing=interactive
|
|
)
|
|
|
|
# find the blockchain reader
|
|
blockchain_reader = blockstack_opts.get('blockchain_reader')
|
|
while blockchain_reader not in SUPPORTED_UTXO_PROVIDERS:
|
|
if not(interactive or force):
|
|
raise Exception('No blockchain reader given')
|
|
|
|
# prompt for it?
|
|
blockchain_message = (
|
|
'NOTE: Blockstack currently requires an external API\n'
|
|
'for querying the blockchain. The set of supported\n'
|
|
'service providers are:\n'
|
|
'\t\n'.join(SUPPORTED_UTXO_PROVIDERS) + '\n'
|
|
'Please enter the requisite information here.'
|
|
)
|
|
|
|
blockchain_reader_dict = interactive_prompt(blockchain_message, ['blockchain_reader'], {})
|
|
blockchain_reader = blockchain_reader_dict['blockchain_reader']
|
|
|
|
blockchain_reader_defaults = default_utxo_provider_opts(blockchain_reader, config_file=config_file)
|
|
blockchain_reader_defaults.update(all_opts.get('blockchain_reader', {}))
|
|
blockchain_reader_params = SUPPORTED_UTXO_PARAMS[blockchain_reader]
|
|
|
|
# get current set of reader opts
|
|
blockchain_reader_opts = {} if force else copy.deepcopy(blockchain_reader_defaults)
|
|
|
|
blockchain_reader_opts, missing_reader_opts, num_reader_opts_prompted = find_missing(
|
|
SUPPORTED_UTXO_PROMPT_MESSAGES[blockchain_reader],
|
|
blockchain_reader_params,
|
|
blockchain_reader_opts,
|
|
blockchain_reader_defaults,
|
|
header='Blockchain reader configuration',
|
|
prompt_missing=interactive
|
|
)
|
|
|
|
blockchain_reader_opts['utxo_provider'] = blockchain_reader_defaults['utxo_provider']
|
|
|
|
# find the blockchain writer
|
|
blockchain_writer = blockstack_opts.get('blockchain_writer')
|
|
while blockchain_writer not in SUPPORTED_UTXO_PROVIDERS:
|
|
if not(interactive or force):
|
|
raise Exception('No blockchain reader given')
|
|
|
|
# prompt for it?
|
|
blockchain_message = (
|
|
'NOTE: Blockstack currently requires an external API\n'
|
|
'for sending transactions to the blockchain. The set\n'
|
|
'of supported service providers are:\n'
|
|
'\t\n'.join(SUPPORTED_UTXO_PROVIDERS) + '\n'
|
|
'Please enter the requisite information here.'
|
|
)
|
|
blockchain_writer_dict = interactive_prompt(blockchain_message, ['blockchain_writer'], {})
|
|
blockchain_writer = blockchain_writer_dict['blockchain_writer']
|
|
|
|
blockchain_writer_defaults = default_utxo_provider_opts(blockchain_writer, config_file=config_file)
|
|
blockchain_writer_defaults.update(all_opts.get('blockchain_write', {}))
|
|
blockchain_writer_params = SUPPORTED_UTXO_PARAMS[blockchain_writer]
|
|
|
|
# get current set of writer opts
|
|
blockchain_writer_opts = {} if force else copy.deepcopy(blockchain_writer_defaults)
|
|
|
|
blockchain_writer_opts, missing_writer_opts, num_writer_opts_prompted = find_missing(
|
|
SUPPORTED_UTXO_PROMPT_MESSAGES[blockchain_writer],
|
|
blockchain_writer_params,
|
|
blockchain_writer_opts,
|
|
blockchain_writer_defaults,
|
|
header='Blockchain writer configuration',
|
|
prompt_missing=interactive
|
|
)
|
|
|
|
blockchain_writer_opts['utxo_provider'] = blockchain_writer_defaults['utxo_provider']
|
|
missing_opts = [missing_bitcoin_opts, missing_writer_opts, missing_reader_opts, missing_blockstack_opts]
|
|
if not interactive and any(missing_opts):
|
|
# cannot continue
|
|
raise Exception(
|
|
'Missing configuration fields: {}'.format(
|
|
','.join(list(itertools.chain(*missing_opts)))
|
|
)
|
|
)
|
|
|
|
# ask for contact info, so we can send out notifications for bugfixes and
|
|
# upgrades
|
|
if blockstack_opts.get('email') is None:
|
|
if interactive:
|
|
email_msg = (
|
|
'Would you like to receive notifications\n'
|
|
'from the developers when there are critical\n'
|
|
'updates available to install?\n\n'
|
|
'If so, please enter your email address here.\n'
|
|
'If not, leave this field blank.\n\n'
|
|
'Your email address will be used solely\n'
|
|
'for this purpose.\n'
|
|
)
|
|
email_opts, _, email_prompted = find_missing(
|
|
email_msg, ['email'], {}, {'email': ''}, prompt_missing=interactive
|
|
)
|
|
|
|
# merge with blockstack section
|
|
num_blockstack_opts_prompted += 1
|
|
blockstack_opts['email'] = email_opts['email']
|
|
|
|
else:
|
|
blockstack_opts['email'] = ''
|
|
|
|
# get client UUID for analytics
|
|
u = get_or_set_uuid(config_dir=config_dir)
|
|
if u is None:
|
|
raise Exception('Failed to get/set UUID')
|
|
|
|
ret = {
|
|
'blockstack-client': blockstack_opts,
|
|
'bitcoind': bitcoind_opts,
|
|
'blockchain-reader': blockchain_reader_opts,
|
|
'blockchain-writer': blockchain_writer_opts
|
|
}
|
|
|
|
# if we prompted, then save
|
|
if any([num_bitcoind_prompted, num_reader_opts_prompted, num_writer_opts_prompted, num_blockstack_opts_prompted]):
|
|
print('Saving configuration to {}'.format(config_file), file=sys.stderr)
|
|
|
|
# rename appropriately, so other packages can find them
|
|
write_config_file(ret, config_file)
|
|
|
|
# preserve these extra helper fields
|
|
ret['blockstack-client']['path'] = config_file
|
|
if config_file is not None:
|
|
ret['blockstack-client']['dir'] = os.path.dirname(config_file)
|
|
else:
|
|
ret['blockstack-client']['dir'] = None
|
|
|
|
# set this here, so we don't save it
|
|
ret['uuid'] = u
|
|
|
|
if set_migrate:
|
|
ret['migrated'] = migrated
|
|
|
|
return ret
|
|
|
|
|
|
def clear_runtime_fields(opts):
|
|
"""
|
|
Remove runtime opts from a config dict.
|
|
"""
|
|
for opt in ['path', 'dir', 'migrated', 'uuid']:
|
|
if opts.has_key(opt):
|
|
del opts[opt]
|
|
|
|
return opts
|
|
|
|
|
|
def write_config_file(opts, config_file):
|
|
"""
|
|
Write our config file with the given options dict.
|
|
Each key is a section name, and each value is the list of options.
|
|
|
|
If the file exists, do not remove unaffected sections. Instead,
|
|
merge the sections in opts into the file.
|
|
|
|
Return True on success
|
|
Raise on error
|
|
"""
|
|
|
|
if 'blockstack-client' in opts:
|
|
opts['blockstack-client'] = clear_runtime_fields(opts['blockstack-client'])
|
|
|
|
opts = clear_runtime_fields(opts)
|
|
|
|
parser = SafeConfigParser()
|
|
|
|
if os.path.exists(config_file):
|
|
parser.read(config_file)
|
|
|
|
for sec_name in opts:
|
|
sec_opts = opts[sec_name]
|
|
|
|
if parser.has_section(sec_name):
|
|
parser.remove_section(sec_name)
|
|
|
|
parser.add_section(sec_name)
|
|
for opt_name, opt_value in sec_opts.items():
|
|
if opt_value is None:
|
|
opt_value = ''
|
|
|
|
parser.set(sec_name, opt_name, '{}'.format(opt_value))
|
|
|
|
with open(config_file, 'w') as fout:
|
|
os.fchmod(fout.fileno(), 0600)
|
|
parser.write(fout)
|
|
|
|
return True
|
|
|
|
|
|
def write_config_field(config_path, section_name, field_name, field_value):
|
|
"""
|
|
Set a particular config file field
|
|
Return True on success
|
|
Return False on error
|
|
"""
|
|
if not os.path.exists(config_path):
|
|
return False
|
|
|
|
parser = SafeConfigParser()
|
|
parser.read(config_path)
|
|
|
|
if not parser.has_section(section_name):
|
|
parser.add_section(section_name)
|
|
|
|
parser.set(section_name, field_name, '{}'.format(field_value))
|
|
with open(config_path, 'w') as fout:
|
|
os.fchmod(fout.fileno(), 0600)
|
|
parser.write(fout)
|
|
|
|
return True
|
|
|
|
|
|
def delete_config_field(config_path, section_name, field_name ):
|
|
"""
|
|
Delete a config field
|
|
Return True on success
|
|
Return False on error
|
|
"""
|
|
if not os.path.exists(config_path):
|
|
return False
|
|
|
|
parser = SafeConfigParser()
|
|
parser.read(config_path)
|
|
|
|
parser.remove_option(section_name, field_name)
|
|
with open(config_path, 'w') as fout:
|
|
os.fchmod(fout.fileno(), 0600)
|
|
parser.write(fout)
|
|
|
|
return True
|
|
|
|
|
|
def delete_config_section(config_path, section_name):
|
|
"""
|
|
Delete a config section
|
|
Return True on success
|
|
Return False on error
|
|
"""
|
|
if not os.path.exists(config_path):
|
|
return False
|
|
|
|
parser = SafeConfigParser()
|
|
parser.read(config_path)
|
|
|
|
parser.remove_section(section_name)
|
|
with open(config_path, 'w') as fout:
|
|
os.fchmod(fout.fileno(), 0600)
|
|
parser.write(fout)
|
|
|
|
return True
|
|
|
|
|
|
def set_advanced_mode(status, config_path=CONFIG_PATH):
|
|
"""
|
|
Enable or disable advanced mode
|
|
@status must be a bool
|
|
"""
|
|
return write_config_field(config_path, 'blockstack-client', 'advanced_mode', str(status))
|
|
|
|
|
|
def get_utxo_provider_client(config_path=CONFIG_PATH):
|
|
"""
|
|
Get or instantiate our blockchain UTXO provider's client.
|
|
Return None if we were unable to connect
|
|
"""
|
|
|
|
# acquire configuration (which we should already have)
|
|
opts = configure(interactive=False, config_file=config_path)
|
|
reader_opts = opts['blockchain-reader']
|
|
|
|
try:
|
|
utxo_provider = connect_utxo_provider(reader_opts)
|
|
return utxo_provider
|
|
except Exception as e:
|
|
log.exception(e)
|
|
return
|
|
|
|
return
|
|
|
|
|
|
def get_tx_broadcaster(config_path=CONFIG_PATH):
|
|
"""
|
|
Get or instantiate our blockchain UTXO provider's transaction broadcaster.
|
|
fall back to the utxo provider client, if one is not designated
|
|
"""
|
|
|
|
# acquire configuration (which we should already have)
|
|
opts = configure(interactive=False, config_file=config_path)
|
|
writer_opts = opts['blockchain-writer']
|
|
|
|
try:
|
|
blockchain_broadcaster = connect_utxo_provider(writer_opts)
|
|
return blockchain_broadcaster
|
|
except Exception as e:
|
|
log.exception(e)
|
|
return
|
|
|
|
return
|
|
|
|
|
|
def str_to_bool(s):
|
|
"""
|
|
Convert 'true' to True; 'false' to False
|
|
"""
|
|
if type(s) not in [str, unicode]:
|
|
raise ValueError('"{}" is not a string'.format(s))
|
|
|
|
if s.lower() == 'false':
|
|
return False
|
|
elif s.lower() == 'true':
|
|
return True
|
|
else:
|
|
raise ValueError('Indeterminate boolean "{}"'.format(s))
|
|
|
|
|
|
def read_config_file(config_path=CONFIG_PATH, set_migrate=False):
|
|
"""
|
|
Read or make a new empty config file with sane defaults.
|
|
Automatically convert legacy config field and values into their current equivalents.
|
|
If set_migrate is True, then include 'set_migrate: True/False' in the top-level dict returned
|
|
in order to indicate whether or not any config field migration took place.
|
|
|
|
Return the config dict on success
|
|
Raise on error
|
|
"""
|
|
global CONFIG_PATH, BLOCKSTACKD_SERVER, BLOCKSTACKD_PORT
|
|
|
|
BLOCKSTACK_CLI_SERVER_HOST = os.environ.get('BLOCKSTACK_CLI_SERVER_HOST', None) # overrides config file
|
|
BLOCKSTACK_CLI_SERVER_PORT = os.environ.get('BLOCKSTACK_CLI_SERVER_PORT', None) # overrides config file
|
|
|
|
if BLOCKSTACK_CLI_SERVER_PORT is not None:
|
|
try:
|
|
BLOCKSTACK_CLI_SERVER_PORT = int(BLOCKSTACK_CLI_SERVER_PORT)
|
|
except:
|
|
raise Exception("Invalid server port")
|
|
|
|
# try to create
|
|
if config_path is not None:
|
|
dirname = os.path.dirname(config_path)
|
|
if not os.path.exists(dirname):
|
|
os.makedirs(dirname)
|
|
if not os.path.isdir(dirname):
|
|
raise Exception('Not a directory: {}'.format(config_path))
|
|
|
|
client_uuid = get_or_set_uuid(config_dir=os.path.dirname(config_path))
|
|
if client_uuid is None:
|
|
raise Exception("Failed to get client device ID")
|
|
|
|
config_dir = os.path.dirname(config_path)
|
|
if config_path is None or not os.path.exists(config_path):
|
|
|
|
# make a new config structure and save it
|
|
parser = SafeConfigParser()
|
|
parser.add_section('blockstack-client')
|
|
parser.set('blockstack-client', 'server', str(BLOCKSTACKD_SERVER))
|
|
parser.set('blockstack-client', 'port', str(BLOCKSTACKD_PORT))
|
|
parser.set('blockstack-client', 'metadata', METADATA_DIRNAME)
|
|
parser.set('blockstack-client', 'storage_drivers', BLOCKSTACK_DEFAULT_STORAGE_DRIVERS)
|
|
parser.set('blockstack-client', 'storage_drivers_local', 'disk')
|
|
parser.set('blockstack-client', 'storage_drivers_required_write', BLOCKSTACK_REQUIRED_STORAGE_DRIVERS_WRITE)
|
|
parser.set('blockstack-client', 'advanced_mode', 'false')
|
|
parser.set('blockstack-client', 'api_endpoint_port', str(DEFAULT_API_PORT))
|
|
parser.set('blockstack-client', 'api_endpoint_host', 'localhost')
|
|
parser.set('blockstack-client', 'api_endpoint_bind', 'localhost')
|
|
parser.set('blockstack-client', 'queue_path', str(DEFAULT_QUEUE_PATH))
|
|
parser.set('blockstack-client', 'poll_interval', str(DEFAULT_POLL_INTERVAL))
|
|
parser.set('blockstack-client', 'blockchain_reader', DEFAULT_BLOCKCHAIN_READER)
|
|
parser.set('blockstack-client', 'blockchain_writer', DEFAULT_BLOCKCHAIN_WRITER)
|
|
parser.set('blockstack-client', 'anonymous_statistics', 'True')
|
|
parser.set('blockstack-client', 'client_version', VERSION)
|
|
parser.set('blockstack-client', 'default_devices', '')
|
|
|
|
api_pass = os.urandom(32)
|
|
parser.set('blockstack-client', 'api_password', hexlify(api_pass))
|
|
|
|
if config_path is not None:
|
|
try:
|
|
with open(config_path, 'w') as f:
|
|
parser.write(f)
|
|
f.flush()
|
|
|
|
except:
|
|
traceback.print_exc()
|
|
log.error('Failed to write default configuration file to "{}".'.format(config_path))
|
|
return False
|
|
|
|
parser.add_section('blockchain-reader')
|
|
parser.set('blockchain-reader', 'utxo_provider', DEFAULT_BLOCKCHAIN_READER)
|
|
|
|
parser.add_section('blockchain-writer')
|
|
parser.set('blockchain-writer', 'utxo_provider', DEFAULT_BLOCKCHAIN_WRITER)
|
|
|
|
parser.add_section('bitcoind')
|
|
|
|
bitcoind_config = default_bitcoind_opts()
|
|
for k, v in bitcoind_config.items():
|
|
if v is not None:
|
|
parser.set('bitcoind', k, '{}'.format(v))
|
|
|
|
# save
|
|
if config_path is not None:
|
|
with open(config_path, 'w') as f:
|
|
parser.write(f)
|
|
f.flush()
|
|
|
|
# now read it back
|
|
parser = SafeConfigParser()
|
|
parser.read(config_path)
|
|
|
|
# these are booleans--convert them
|
|
bool_values = {
|
|
'blockstack-client': [
|
|
'advanced_mode',
|
|
'anonymous_statistics',
|
|
]
|
|
}
|
|
|
|
ret = {}
|
|
for sec in parser.sections():
|
|
ret[sec] = {}
|
|
for opt in parser.options(sec):
|
|
if opt in bool_values.get(sec, {}):
|
|
# decode to bool
|
|
ret[sec][opt] = str_to_bool(parser.get(sec, opt))
|
|
else:
|
|
# literal
|
|
ret[sec][opt] = parser.get(sec, opt)
|
|
|
|
# advanced mode is off by default
|
|
if 'advanced_mode' not in ret.get('blockstack-client', {}):
|
|
ret['blockstack-client']['advanced_mode'] = False
|
|
|
|
# convert field names
|
|
renamed_fields_014_1 = {
|
|
'blockstack-client': {
|
|
'rpc_token': 'api_pass', # renamed in 0.14.1
|
|
},
|
|
}
|
|
|
|
dropped_fields_014_1 = {
|
|
'blockstack-client': [
|
|
'blockchain_headers',
|
|
],
|
|
}
|
|
|
|
added_fields_014_1 = {
|
|
'bitcoind': {
|
|
'spv_path': os.path.expanduser('~/.virtualchain-spv-headers.dat'), # from virtualchain
|
|
},
|
|
}
|
|
|
|
changed_fields_014_1 = {
|
|
'blockstack-client': {
|
|
'client_version': VERSION
|
|
}
|
|
}
|
|
|
|
# grow this list with future releases...
|
|
renamed_fields = [renamed_fields_014_1]
|
|
removed_fields = [dropped_fields_014_1]
|
|
added_fields = [added_fields_014_1]
|
|
changed_fields = [changed_fields_014_1]
|
|
|
|
migrated = False
|
|
|
|
assert len(renamed_fields) == len(removed_fields)
|
|
assert len(removed_fields) == len(added_fields)
|
|
assert len(added_fields) == len(changed_fields)
|
|
|
|
for i in xrange(0, len(renamed_fields)):
|
|
# order: rename, add, drop, change
|
|
renamed_field_set = renamed_fields[i]
|
|
dropped_field_set = removed_fields[i]
|
|
added_field_set = added_fields[i]
|
|
changed_field_set = changed_fields[i]
|
|
|
|
for sec in renamed_field_set.keys():
|
|
if ret.has_key(sec):
|
|
for old_field_name in renamed_field_set[sec].keys():
|
|
if ret[sec].has_key( old_field_name ):
|
|
new_field_name = renamed_field_set[sec][old_field_name]
|
|
value = ret[sec][old_field_name]
|
|
|
|
log.debug("Migrate {}.{} to {}.{}".format(sec, old_field_name, sec, new_field_name))
|
|
|
|
del ret[sec][old_field_name]
|
|
ret[sec][new_field_name] = value
|
|
|
|
migrated = True
|
|
|
|
for sec in added_field_set.keys():
|
|
if not ret.has_key(sec):
|
|
ret[sec] = {}
|
|
|
|
for new_field_name in added_field_set[sec].keys():
|
|
if not ret[sec].has_key(new_field_name):
|
|
|
|
log.debug("Add new field {}.{}".format(sec, new_field_name))
|
|
ret[sec][new_field_name] = added_field_set[sec][new_field_name]
|
|
|
|
migrated = True
|
|
|
|
for sec in dropped_field_set.keys():
|
|
if ret.has_key(sec):
|
|
for dropped_field_name in dropped_field_set[sec]:
|
|
if ret[sec].has_key(dropped_field_name):
|
|
|
|
log.debug("Remove old field {}.{}".format(sec, dropped_field_name))
|
|
del ret[sec][dropped_field_name]
|
|
|
|
migrated = True
|
|
|
|
for sec in changed_field_set.keys():
|
|
if not ret.has_key(sec):
|
|
ret[sec] = {}
|
|
|
|
for changed_field_name in changed_field_set[sec]:
|
|
|
|
if ret[sec][changed_field_name] != changed_field_set[sec][changed_field_name]:
|
|
log.debug("Change {}.{} to {}".format(sec, changed_field_name, changed_field_set[sec][changed_field_name]))
|
|
ret[sec][changed_field_name] = changed_field_set[sec][changed_field_name]
|
|
|
|
migrated = True
|
|
|
|
# overrides from the environment
|
|
env_overrides = {
|
|
'blockstack-client': {
|
|
'server': BLOCKSTACK_CLI_SERVER_HOST,
|
|
'port': BLOCKSTACK_CLI_SERVER_PORT,
|
|
},
|
|
}
|
|
|
|
for sec in env_overrides.keys():
|
|
if ret.has_key(sec):
|
|
for field_name in env_overrides[sec].keys():
|
|
new_value = env_overrides[sec][field_name]
|
|
if new_value is not None:
|
|
log.debug("Override {}.{} from {} to {}".format(sec, field_name, ret[sec][field_name], new_value))
|
|
ret[sec][field_name] = new_value
|
|
|
|
|
|
# helpful at runtime
|
|
ret['path'] = config_path
|
|
ret['dir'] = os.path.dirname(config_path)
|
|
|
|
if set_migrate:
|
|
ret['migrated'] = migrated
|
|
|
|
return ret
|
|
|
|
|
|
def get_config(path=CONFIG_PATH, interactive=False):
|
|
"""
|
|
Read our config file (legacy compat).
|
|
Flatten the resulting config:
|
|
* make all bitcoin-specific fields start with 'bitcoind_' (makes this config compatible with virtualchain)
|
|
* keep only the blockstack-client and bitcoin fields
|
|
|
|
Return our flattened configuration (as a dict) on success.
|
|
Return None on error
|
|
"""
|
|
|
|
try:
|
|
opts = configure(config_file=path, interactive=interactive)
|
|
except Exception as e:
|
|
log.exception(e)
|
|
return None
|
|
|
|
# flatten
|
|
blockstack_opts = opts['blockstack-client']
|
|
bitcoin_opts = opts['bitcoind']
|
|
|
|
bitcoin_opts = opt_restore('bitcoind_', bitcoin_opts)
|
|
blockstack_opts.update(bitcoin_opts)
|
|
|
|
# pass along the config path and dir, and statistics info
|
|
blockstack_opts['path'] = path
|
|
blockstack_opts['dir'] = os.path.dirname(path)
|
|
blockstack_opts['uuid'] = opts['uuid']
|
|
blockstack_opts['client_version'] = blockstack_opts.get('client_version', '')
|
|
if 'anonymous_statistics' not in blockstack_opts:
|
|
# not disabled
|
|
blockstack_opts['anonymous_statistics'] = True
|
|
|
|
return blockstack_opts
|
|
|
|
|
|
def setup_config(config_path=CONFIG_PATH, interactive=False):
|
|
"""
|
|
Set up our config file:
|
|
* create it if it doesn't exist
|
|
* migrate field names and values
|
|
* back up the old config file if we changed anything during migration.
|
|
|
|
Return {'status': True, 'config': ..., 'migrated': True/False, 'backup_path': ...} on success
|
|
Return {'error': ...} on failure
|
|
"""
|
|
|
|
conf = configure(config_file=config_path, interactive=interactive, set_migrate=True)
|
|
if conf is None:
|
|
return {'error': 'Failed to load config'}
|
|
|
|
conf_migrated = conf['migrated']
|
|
del conf['migrated']
|
|
|
|
conf_backed_up = False
|
|
backup_path = None
|
|
conf_version = conf['blockstack-client'].get('client_version', '')
|
|
if conf_version != VERSION:
|
|
# back up the config file
|
|
backup_path = backup_config_file(config_path=config_path)
|
|
if not backup_path:
|
|
return {'error': 'Failed to load backup path'}
|
|
|
|
else:
|
|
conf_backed_up = True
|
|
|
|
if conf_migrated:
|
|
log.warning("Migrating config file...")
|
|
if not conf_backed_up:
|
|
# back up the config file
|
|
backup_path = backup_config_file(config_path=config_path)
|
|
if not backup_path:
|
|
return {'error': 'Failed to load backup path'}
|
|
|
|
# save config file
|
|
try:
|
|
write_config_file(conf, config_path)
|
|
except Exception as e:
|
|
log.exception(e)
|
|
return {'error': 'Failed to save new config file'}
|
|
|
|
return {'status': True, 'config': conf, 'migrated': conf_migrated, 'backup_path': backup_path}
|
|
|
|
|
|
def get_version_parts(whole, func):
|
|
return [func(_.strip()) for _ in whole[0:3]]
|
|
|
|
|
|
def semver_match(v1, v2):
|
|
"""
|
|
Verify that two semantic version strings match:
|
|
the major and the minor versions must be equal.
|
|
Patch versions can be different
|
|
"""
|
|
v1_parts = v1.split('.')
|
|
v2_parts = v2.split('.')
|
|
if len(v1_parts) < 3 or len(v2_parts) < 3:
|
|
# one isn't a semantic version
|
|
return False
|
|
|
|
v1_major, v1_minor, v1_patch = get_version_parts(v1_parts, str)
|
|
v2_major, v2_minor, v2_patch = get_version_parts(v2_parts, str)
|
|
|
|
# NOTE: patch versions are not relevant here.
|
|
return [v1_major, v1_minor] == [v2_major, v2_minor]
|
|
|
|
|
|
def semver_newer(v1, v2):
|
|
"""
|
|
Verify (as semantic versions) if v1 < v2
|
|
Patch versions can be different
|
|
"""
|
|
v1_parts = v1.split('.')
|
|
v2_parts = v2.split('.')
|
|
if len(v1_parts) < 3 or len(v2_parts) < 3:
|
|
# one isn't a semantic version
|
|
return False
|
|
|
|
v1_major, v1_minor, v1_patch = get_version_parts(v1_parts, int)
|
|
v2_major, v2_minor, v2_patch = get_version_parts(v2_parts, int)
|
|
|
|
if v1_major > v2_major:
|
|
return False
|
|
|
|
if v1_major == v2_major and v1_minor >= v2_minor:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def backup_config_file(config_path=CONFIG_PATH):
|
|
"""
|
|
Back up the given config file
|
|
Return the backup file
|
|
"""
|
|
if not os.path.exists(config_path):
|
|
return None
|
|
|
|
legacy_path = config_path + ".legacy.{}".format(int(time.time()))
|
|
while os.path.exists(legacy_path):
|
|
time.sleep(1.0)
|
|
legacy_path = config_path + ".legacy.{}".format(int(time.time()))
|
|
|
|
log.warning('Back up old config file from {} to {}'.format(config_path, legacy_path))
|
|
shutil.copy(config_path, legacy_path)
|
|
return legacy_path
|
|
|
|
|
|
def configure_zonefile(name, zonefile, data_pubkey ):
|
|
"""
|
|
Given a name and zonefile, help the user configure the
|
|
zonefile information to store (just URLs for now).
|
|
|
|
@zonefile must be parsed and must be a dict.
|
|
|
|
Return the new zonefile on success
|
|
Return None if the zonefile did not change.
|
|
"""
|
|
|
|
from .zonefile import make_empty_zonefile
|
|
from .user import user_zonefile_data_pubkey, user_zonefile_set_data_pubkey, user_zonefile_remove_data_pubkey, \
|
|
user_zonefile_urls, add_user_zonefile_url, remove_user_zonefile_url, swap_user_zonefile_urls, \
|
|
add_user_zonefile_txt, remove_user_zonefile_txt, user_zonefile_txts
|
|
|
|
from .storage import get_drivers_for_url
|
|
|
|
if zonefile is None:
|
|
print('WARNING: No zonefile could be found.')
|
|
print('WARNING: Creating an empty zonefile.')
|
|
zonefile = make_empty_zonefile(name, data_pubkey)
|
|
|
|
running = True
|
|
do_update = True
|
|
old_zonefile = {}
|
|
old_zonefile.update( zonefile )
|
|
|
|
while running:
|
|
public_key = None
|
|
try:
|
|
public_key = user_zonefile_data_pubkey(zonefile)
|
|
except ValueError:
|
|
# multiple keys
|
|
public_key = None
|
|
|
|
urls = user_zonefile_urls(zonefile)
|
|
if urls is None:
|
|
urls = []
|
|
|
|
txts = user_zonefile_txts(zonefile)
|
|
if txts is None:
|
|
txts = []
|
|
|
|
url_drivers = {}
|
|
|
|
# which drivers?
|
|
for url in urls:
|
|
drivers = get_drivers_for_url(url)
|
|
url_drivers[url] = drivers
|
|
|
|
print('-' * 80)
|
|
|
|
if public_key is not None:
|
|
print('Data public key: {}'.format(public_key))
|
|
else:
|
|
print('Data public key: (not set)')
|
|
|
|
print('')
|
|
print('Profile replicas ({}):'.format(len(urls)))
|
|
if len(urls) > 0:
|
|
for i in xrange(0, len(urls)):
|
|
url = urls[i]
|
|
drivers = get_drivers_for_url(url)
|
|
print('({}) {}\n Handled by drivers: [{}]'.format(i+1, url, ','.join([d.__name__ for d in drivers])))
|
|
|
|
else:
|
|
print('(none)')
|
|
|
|
print('')
|
|
|
|
# don't count the public key...
|
|
print("TXT records ({}):".format(len(txts) - (1 if public_key else 0)))
|
|
if len(txts) > 0:
|
|
for i in xrange(0, len(txts)):
|
|
# skip public key
|
|
if txts[i]['name'] == 'pubkey':
|
|
continue
|
|
|
|
print('{} "{}"'.format(txts[i]['name'], txts[i]['txt']))
|
|
|
|
else:
|
|
print("(none)")
|
|
|
|
print('')
|
|
print('What would you like to do?')
|
|
print('(a) Add profile URL')
|
|
print('(b) Remove profile URL')
|
|
print('(c) Swap URL order')
|
|
print('(d) Add TXT record')
|
|
print('(e) Remove TXT record')
|
|
print('(f) Set or change public key')
|
|
print('(g) Save zonefile')
|
|
print('(h) Do not save zonefile')
|
|
print('')
|
|
|
|
selection = raw_input('Selection: ').lower()
|
|
|
|
if selection == 'h':
|
|
do_update = False
|
|
break
|
|
|
|
elif selection == 'a':
|
|
# add a url
|
|
while True:
|
|
try:
|
|
new_url = raw_input('Enter the new profile URL: ')
|
|
except KeyboardInterrupt:
|
|
print('Keyboard interrupt')
|
|
return None
|
|
|
|
new_url = new_url.strip()
|
|
|
|
# do any drivers accept this URL?
|
|
drivers = get_drivers_for_url( new_url )
|
|
if len(drivers) == 0:
|
|
print('No drivers can handle "{}"'.format(new_url))
|
|
continue
|
|
|
|
else:
|
|
# add to the zonefile
|
|
new_zonefile = add_user_zonefile_url( zonefile, new_url )
|
|
if new_zonefile is None:
|
|
print('Duplicate URL')
|
|
continue
|
|
|
|
else:
|
|
zonefile = new_zonefile
|
|
break
|
|
|
|
|
|
elif selection == 'b':
|
|
# remove a URL
|
|
url_to_remove = None
|
|
while True:
|
|
try:
|
|
url_to_remove = raw_input('Which URL do you want to remove? ({}-{}): '.format(1, len(urls)))
|
|
try:
|
|
url_to_remove = int(url_to_remove)
|
|
assert 1 <= url_to_remove and url_to_remove <= len(urls)
|
|
except:
|
|
print('Bad selection')
|
|
continue
|
|
|
|
except KeyboardInterrupt:
|
|
running = False
|
|
print('Keyboard interrupt')
|
|
return None
|
|
|
|
if url_to_remove is not None:
|
|
# remove this URL
|
|
url = urls[url_to_remove-1]
|
|
|
|
log.debug("Remove '{}'".format(url))
|
|
|
|
new_zonefile = remove_user_zonefile_url( zonefile, url )
|
|
if new_zonefile is None:
|
|
print('BUG: failed to remove url "{}" from zonefile\n{}\n'.format(url, json.dumps(zonefile, indent=4, sort_keys=True)))
|
|
os.abort()
|
|
|
|
else:
|
|
zonefile = new_zonefile
|
|
break
|
|
|
|
else:
|
|
print("Bad selection")
|
|
|
|
elif selection == 'c':
|
|
while True:
|
|
# swap order
|
|
try:
|
|
url_1 = raw_input('Which URL do you want to move? ({}-{}): '.format(1, len(urls)))
|
|
url_2 = raw_input('Where do you want to move it? ({}-{}): '.format(1, len(urls)))
|
|
except KeyboardInterrupt:
|
|
running = False
|
|
print('Keyboard interrupt')
|
|
return None
|
|
|
|
try:
|
|
url_1 = int(url_1)
|
|
url_2 = int(url_2)
|
|
|
|
assert 1 <= url_1 <= len(urls)
|
|
assert 1 <= url_2 <= len(urls)
|
|
assert url_1 != url_2
|
|
|
|
except:
|
|
print("Bad selection")
|
|
continue
|
|
|
|
new_zonefile = swap_user_zonefile_urls( zonefile, url_1-1, url_2-1 )
|
|
if new_zonefile is None:
|
|
print('BUG: failed to remove url "{}" from zonefile\n{}\n'.format(url, json.dumps(zonefile, indent=4, sort_keys=True)))
|
|
os.abort()
|
|
|
|
else:
|
|
zonefile = new_zonefile
|
|
break
|
|
|
|
|
|
elif selection == 'd':
|
|
# add txt record
|
|
while True:
|
|
try:
|
|
txtrec_name = raw_input("New TXT record name: ")
|
|
txtrec_txt = raw_input("New TXT record data: ")
|
|
except KeyboardInterrupt:
|
|
running = False
|
|
print("Keyboard interrupt")
|
|
return None
|
|
|
|
if txtrec_name == 'pubkey':
|
|
print("Change the ECDSA key explicitly")
|
|
break
|
|
|
|
new_zonefile = add_user_zonefile_txt(zonefile, txtrec_name, txtrec_txt)
|
|
if new_zonefile is None:
|
|
print("Duplicate TXT record")
|
|
break
|
|
|
|
else:
|
|
zonefile = new_zonefile
|
|
break
|
|
|
|
|
|
elif selection == 'e':
|
|
# remove txt record
|
|
while True:
|
|
try:
|
|
txtrec_name = raw_input('Name of TXT record to remove: ')
|
|
except KeyboardInterrupt:
|
|
running = False
|
|
print("Keyboard interrupt")
|
|
return None
|
|
|
|
if txtrec_name == 'pubkey':
|
|
print("Change the ECDSA key explicitly")
|
|
break
|
|
|
|
new_zonefile = remove_user_zonefile_txt(zonefile, txtrec_name)
|
|
if new_zonefile is None:
|
|
print("No such TXT record")
|
|
break
|
|
|
|
else:
|
|
zonefile = new_zonefile
|
|
break
|
|
|
|
elif selection == 'f':
|
|
# change public key
|
|
while True:
|
|
try:
|
|
pubkey = raw_input("New ECDSA public key (empty for None): ")
|
|
|
|
if len(pubkey) > 0:
|
|
pubkey = keylib.ECPublicKey(pubkey).to_hex()
|
|
|
|
except KeyboardInterrupt:
|
|
running = False
|
|
print("Keyboard interrupt")
|
|
return None
|
|
|
|
except:
|
|
print("Invalid public key")
|
|
continue
|
|
|
|
new_zonefile = None
|
|
|
|
if len(pubkey) == 0:
|
|
# delete public key
|
|
new_zonefile = user_zonefile_remove_data_pubkey(zonefile)
|
|
|
|
else:
|
|
# set public key
|
|
new_zonefile = user_zonefile_set_data_pubkey(zonefile, pubkey)
|
|
|
|
zonefile = new_zonefile
|
|
break
|
|
|
|
elif selection == 'g':
|
|
# save zonefile
|
|
break
|
|
|
|
elif selection == 'h':
|
|
# do not save zonefile
|
|
return None
|
|
|
|
log.debug("zonefile is now:\n{}".format(json.dumps(zonefile, indent=4, sort_keys=True)))
|
|
|
|
return zonefile
|