#!/usr/bin/env python # -*- coding: utf-8 -*- """ Blockstack-client ~~~~~ copyright: (c) 2014-2015 by Halfmoon Labs, Inc. copyright: (c) 2016 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 json import blockstack_profiles import blockstack_zones import base64 import socket from keylib import ECPrivateKey from .proxy import * import storage import user as user_db from .config import get_config from .logger import get_logger from .constants import USER_ZONEFILE_TTL, CONFIG_PATH, BLOCKSTACK_TEST, BLOCKSTACK_DEBUG log = get_logger() def url_to_uri_record(url, datum_name=None): """ Convert a URL into a DNS URI record """ try: scheme, _ = url.split('://') except ValueError: msg = 'BUG: invalid storage driver implementation: no scheme given in "{}"' raise Exception(msg.format(url)) scheme = scheme.lower() proto = None # tcp or udp? try: port = socket.getservbyname(scheme, 'tcp') proto = 'tcp' except socket.error: try: port = socket.getservbyname(scheme, 'udp') proto = 'udp' except socket.error: # this is weird--maybe it's embedded in the scheme? try: assert len(scheme.split('+')) == 2 scheme, proto = scheme.split('+') except (AssertionError, ValueError): msg = 'WARN: Scheme "{}" has no known transport protocol' log.debug(msg.format(scheme)) name = None if proto is not None: name = '_{}._{}'.format(scheme, proto) else: name = '_{}'.format(scheme) if datum_name is not None: name = '{}.{}'.format(name, str(datum_name)) ret = { 'name': name, 'priority': 10, 'weight': 1, 'target': url, } return ret def make_empty_zonefile(username, data_pubkey, urls=None): """ Create an empty user record from a name record. """ # make a URI record for every mutable storage provider urls = storage.make_mutable_data_urls(username) if urls is None else urls assert urls, 'No profile URLs' user = { 'txt': [], 'uri': [], '$origin': username, '$ttl': USER_ZONEFILE_TTL, } if data_pubkey is not None: user.setdefault('txt', []) pubkey = str(data_pubkey) name_txt = {'name': 'pubkey', 'txt': 'pubkey:data:{}'.format(pubkey)} user['txt'].append(name_txt) for url in urls: urirec = url_to_uri_record(url) user['uri'].append(urirec) return user def decode_name_zonefile(name, zonefile_txt, allow_legacy=False): """ Decode a serialized zonefile into a JSON dict. If allow_legacy is True, then support legacy zone file formats (including Onename profiles) Otherwise, the data must actually be a Blockstack zone file. * If the zonefile does not have $ORIGIN, or if $ORIGIN does not match the name, then this fails. Return None on error """ user_zonefile = None try: # by default, it's a zonefile-formatted text file user_zonefile_defaultdict = blockstack_zones.parse_zone_file(zonefile_txt) assert user_db.is_user_zonefile(user_zonefile_defaultdict), 'Not a user zonefile' # force dict user_zonefile = dict(user_zonefile_defaultdict) except (IndexError, ValueError, blockstack_zones.InvalidLineException): if not allow_legacy: return {'error': 'Legacy zone file'} # might be legacy profile log.debug('WARN: failed to parse user zonefile; trying to import as legacy') try: user_zonefile = json.loads(zonefile_txt) if not isinstance(user_zonefile, dict): log.debug('Not a legacy user zonefile') return None except Exception as e: if BLOCKSTACK_DEBUG is not None: log.exception(e) log.error('Failed to parse non-standard zonefile') return None except Exception as e: log.exception(e) log.error('Failed to parse zonefile') return None if user_zonefile is None: return None if not allow_legacy: # additional checks if not user_zonefile.has_key('$origin'): log.debug("Zonefile has no $ORIGIN") return None if user_zonefile['$origin'] != name: log.debug("Name/zonefile mismatch: $ORIGIN = {}, name = {}".format(user_zonefile['$origin'], name)) return None return user_zonefile def load_name_zonefile(name, expected_zonefile_hash, storage_drivers=None, raw_zonefile=False, allow_legacy=False, proxy=None ): """ Fetch and load a user zonefile from the storage implementation with the given hex string hash, The user zonefile hash should have been loaded from the blockchain, and thereby be the authentic hash. If raw_zonefile is True, then return the raw zonefile data. Don't parse it. If however, raw_zonefile is False, the zonefile will be parsed. If name is given, the $ORIGIN will be checked. Return the user zonefile (as a dict) on success Return None on error """ proxy = get_default_proxy() if proxy is None else proxy conf = proxy.conf assert 'server' in conf, json.dumps(conf, indent=4, sort_keys=True) assert 'port' in conf, json.dumps(conf, indent=4, sort_keys=True) atlas_host = conf['server'] atlas_port = conf['port'] hostport = '{}:{}'.format( atlas_host, atlas_port ) zonefile_txt = None expected_zonefile_hash = str(expected_zonefile_hash) # try atlas node first res = get_zonefiles( hostport, [expected_zonefile_hash], proxy=proxy ) if 'error' in res or expected_zonefile_hash not in res['zonefiles'].keys(): # fall back to storage drivers if atlas node didn't have it zonefile_txt = storage.get_immutable_data( expected_zonefile_hash, hash_func=storage.get_zonefile_data_hash, fqu=name, zonefile=True, drivers=storage_drivers ) if zonefile_txt is None: log.error('Failed to load user zonefile "{}"'.format(expected_zonefile_hash)) return None else: # extract log.debug('Fetched {} from Atlas peer {}'.format(expected_zonefile_hash, hostport)) zonefile_txt = res['zonefiles'][expected_zonefile_hash] if raw_zonefile: msg = 'Driver did not return a serialized zonefile' try: assert isinstance(zonefile_txt, (str, unicode)), msg except AssertionError as ae: if BLOCKSTACK_TEST is not None: log.exception(ae) log.error(msg) return None return zonefile_txt parsed_zonefile = decode_name_zonefile(name, zonefile_txt, allow_legacy=allow_legacy) return parsed_zonefile def load_data_pubkey_for_new_zonefile(wallet_keys={}, config_path=CONFIG_PATH): """ Find the right public key to use for data when creating a new zonefile. If the wallet has a data keypair defined, use that. Otherwise, fall back to the owner public key """ data_pubkey = None data_privkey = wallet_keys.get('data_privkey', None) if data_privkey is not None: # force compressed data_pubkey = ECPrivateKey(data_privkey, compressesd=True).public_key().to_hex() return data_pubkey data_pubkey = wallet_keys.get('data_pubkey', None) return data_pubkey def get_name_zonefile(name, storage_drivers=None, proxy=None, name_record=None, include_name_record=False, raw_zonefile=False, include_raw_zonefile=False, allow_legacy=False): """ Given a name, go fetch its zonefile. Verifies that the hash on the blockchain matches the zonefile. Returns {'status': True, 'zonefile': zonefile dict} on success. Returns a dict with "error" defined and a message on failure to load. Return None if there is no zonefile (i.e. the hash is null) if 'include_name_record' is true, then zonefile will contain an extra key called 'name_record' that includes the blockchain name record. If 'raw_zonefile' is true, no attempt to parse the zonefile will be made. The raw zonefile will be returned in 'zonefile'. allow_legacy is ignored. If 'allow_legacy' is true, then support returning older supported versions of the zone file (including old Onename profiles). Otherwise, this method fails. """ proxy = get_default_proxy() if proxy is None else proxy # find name record first if name_record is None: name_record = get_name_blockchain_record(name, proxy=proxy) if name_record is None or not name_record: # failed to look up or zero-length name return {'error': 'No such name'} # sanity check if 'value_hash' not in name_record: return {'error': 'Name has no user record hash defined'} value_hash = name_record['value_hash'] # is there a user record loaded? if value_hash in [None, 'null', '']: log.error("Failed to load zone file: no value hash") return {'error': 'No zone file hash for name'} user_zonefile_hash = value_hash raw_zonefile_data = None user_zonefile_data = None if raw_zonefile or include_raw_zonefile: raw_zonefile_data = load_name_zonefile( name, user_zonefile_hash, storage_drivers=storage_drivers, raw_zonefile=True, proxy=proxy, allow_legacy=allow_legacy ) if raw_zonefile_data is None: return {'error': 'Failed to load raw name zonefile'} if raw_zonefile: user_zonefile_data = raw_zonefile_data else: # further decode user_zonefile_data = decode_name_zonefile(name, raw_zonefile_data, allow_legacy=allow_legacy) if user_zonefile_data is None: return {'error': 'Failed to decode name zonefile'} else: user_zonefile_data = load_name_zonefile( name, user_zonefile_hash, storage_drivers=storage_drivers, proxy=proxy, allow_legacy=allow_legacy ) if user_zonefile_data is None: return {'error': 'Failed to load or decode name zonefile'} ret = { 'zonefile': user_zonefile_data } if include_name_record: ret['name_record'] = name_record if include_raw_zonefile: ret['raw_zonefile'] = raw_zonefile_data return ret def store_name_zonefile_data(name, user_zonefile_txt, txid, storage_drivers=None): """ Store a serialized zonefile to immutable storage providers, synchronously. This is only necessary if we've added/changed/removed immutable data. Return (True, hash(user zonefile)) on success Return (False, None) on failure. """ storage_drivers = [] if storage_drivers is None else storage_drivers data_hash = storage.get_zonefile_data_hash(user_zonefile_txt) result = storage.put_immutable_data( user_zonefile_txt, txid, data_hash=data_hash, required=storage_drivers ) rc = bool(result) return rc, data_hash def store_name_zonefile(name, user_zonefile, txid, storage_drivers=None): """ Store JSON user zonefile data to the immutable storage providers, synchronously. This is only necessary if we've added/changed/removed immutable data. Return (True, hash(user zonefile)) on success Return (False, None) on failure """ storage_drivers = [] if storage_drivers is None else storage_drivers assert not blockstack_profiles.is_profile_in_legacy_format(user_zonefile), 'User zonefile is a legacy profile' assert user_db.is_user_zonefile(user_zonefile), 'Not a user zonefile (maybe a custom legacy profile?)' # serialize and send off user_zonefile_txt = blockstack_zones.make_zone_file(user_zonefile, origin=name, ttl=USER_ZONEFILE_TTL) return store_name_zonefile_data(name, user_zonefile_txt, txid, storage_drivers=storage_drivers) def remove_name_zonefile(user, txid): """ Delete JSON user zonefile data from immutable storage providers, synchronously. Return (True, hash(user)) on success Return (False, hash(user)) on error """ # serialize user_json = json.dumps(user, sort_keys=True) data_hash = storage.get_data_hash(user_json) result = storage.delete_immutable_data(data_hash, txid) rc = bool(result) return rc, data_hash def zonefile_data_publish(fqu, zonefile_txt, server_list, wallet_keys=None): """ Replicate a zonefile to as many blockstack servers as possible. @server_list is a list of (host, port) tuple Return {'status': True, 'servers': ...} on success, if we succeeded to replicate at least once. 'servers' will be a list of (host, port) tuples Return {'error': ...} if we failed on all accounts. """ successful_servers = [] for server_host, server_port in server_list: try: log.debug('Replicate zonefile to {}:{}'.format(server_host, server_port)) hostport = '{}:{}'.format(server_host, server_port) res = put_zonefiles(hostport, [base64.b64encode(zonefile_txt)]) if 'error' in res or len(res['saved']) == 0 or res['saved'][0] != 1: log.debug("server returned {}".format(res)) if not res.has_key('error'): res['error'] = 'server did not save' msg = 'Failed to publish zonefile to {}:{}: {}' log.error(msg.format(server_host, server_port, res['error'])) continue log.debug('Replicated zonefile to {}:{}'.format(server_host, server_port)) successful_servers.append((server_host, server_port)) except Exception as e: log.exception(e) log.error('Failed to publish zonefile to {}:{}'.format(server_host, server_port)) continue if successful_servers: return {'status': True, 'servers': successful_servers} return {'error': 'Failed to publish zonefile to all backend providers'} def zonefile_data_replicate(fqu, zonefile_data, tx_hash, server_list, config_path=CONFIG_PATH, storage_drivers=None): """ Replicate zonefile data both to a list of blockstack servers, as well as to the user's storage drivers. Return {'status': True, 'servers': successful server list} on success Return {'error': ...} """ conf = get_config(config_path) # find required storage drivers required_storage_drivers = None if storage_drivers is not None: required_storage_drivers = storage_drivers else: required_storage_drivers = conf.get('storage_drivers_required_write', None) if required_storage_drivers is not None: required_storage_drivers = required_storage_drivers.split(',') else: required_storage_drivers = conf.get('storage_drivers', '').split(',') assert required_storage_drivers, 'No zonefile storage drivers specified' # replicate to our own storage providers rc = store_name_zonefile_data( fqu, zonefile_data, tx_hash, storage_drivers=required_storage_drivers ) if not rc: log.info('Failed to replicate zonefile for {} to {}'.format(fqu)) return {'error': 'Failed to store user zonefile'} # replicate to blockstack servers res = zonefile_data_publish(fqu, zonefile_data, server_list) if 'error' in res: return res return {'status': True, 'servers': res['servers']}