diff --git a/nucypher/characters/base.py b/nucypher/characters/base.py index 2345b99b2..516e831e8 100644 --- a/nucypher/characters/base.py +++ b/nucypher/characters/base.py @@ -104,6 +104,8 @@ class Character(Learner): # # Operating Mode + # + if hasattr(self, '_interface_class'): # TODO: have argument about meaning of 'lawful' # and whether maybe only Lawful characters have an interface self.interface = self._interface_class(character=self) diff --git a/nucypher/characters/lawful.py b/nucypher/characters/lawful.py index a7711cf09..6efe10be8 100644 --- a/nucypher/characters/lawful.py +++ b/nucypher/characters/lawful.py @@ -17,7 +17,7 @@ along with nucypher. If not, see . import json -from collections import OrderedDict +from collections import OrderedDict, defaultdict import contextlib import maya @@ -39,7 +39,6 @@ from constant_sorrow.constants import ( READY, INVALIDATED ) -from collections import OrderedDict, defaultdict from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurve from cryptography.hazmat.primitives.serialization import Encoding @@ -59,7 +58,6 @@ from typing import Dict, Iterable, List, Tuple, Union, Optional, Sequence, Set from umbral import pre from umbral.keys import UmbralPublicKey from umbral.kfrags import KFrag -from umbral.pre import UmbralCorrectnessError from umbral.signing import Signature import nucypher @@ -101,6 +99,7 @@ from nucypher.network.protocols import InterfaceInfo, parse_node_uri from nucypher.network.server import ProxyRESTServer, TLSHostingPower, make_rest_app from nucypher.network.trackers import AvailabilityTracker from nucypher.utilities.logging import Logger +from nucypher.utilities.networking import validate_worker_ip class Alice(Character, BlockchainPolicyAuthor): @@ -1258,9 +1257,13 @@ class Ursula(Teacher, Character, Worker): if result > 0: self.log.debug(f"Pruned {result} treasure maps.") + def __preflight(self) -> None: + """Called immediately before running services""" + validate_worker_ip(worker_ip=self.rest_interface.host) + def run(self, emitter: StdoutEmitter = None, - discovery: bool = True, + discovery: bool = True, # TODO: see below availability: bool = True, worker: bool = True, pruning: bool = True, @@ -1272,6 +1275,8 @@ class Ursula(Teacher, Character, Worker): """Schedule and start select ursula services, then optionally start the reactor.""" + self.__preflight() + # # Async loops ordered by schedule priority # diff --git a/nucypher/cli/actions/configure.py b/nucypher/cli/actions/configure.py index 196fff46a..3eb6a77a3 100644 --- a/nucypher/cli/actions/configure.py +++ b/nucypher/cli/actions/configure.py @@ -37,10 +37,11 @@ from nucypher.cli.literature import ( COLLECT_URSULA_IPV4_ADDRESS, CONFIRM_URSULA_IPV4_ADDRESS ) -from nucypher.cli.types import IPV4_ADDRESS +from nucypher.cli.types import IPV4_ADDRESS, WORKER_IP from nucypher.config.characters import StakeHolderConfiguration from nucypher.config.constants import NUCYPHER_ENVVAR_WORKER_IP_ADDRESS from nucypher.config.node import CharacterConfiguration +from nucypher.utilities.networking import InvalidWorkerIP, validate_worker_ip from nucypher.utilities.networking import determine_external_ip_address, UnknownIPAddress @@ -119,12 +120,12 @@ def handle_invalid_configuration_file(emitter: StdoutEmitter, raise # crash :-( -def collect_external_ip_address(emitter: StdoutEmitter, network: str, force: bool = False) -> str: +def collect_worker_ip_address(emitter: StdoutEmitter, network: str, force: bool = False) -> str: # From environment variable # TODO: remove this environment variable? ip = os.environ.get(NUCYPHER_ENVVAR_WORKER_IP_ADDRESS) if ip: - message = f'Using IP address from {NUCYPHER_ENVVAR_WORKER_IP_ADDRESS} environment variable' + message = f'Using IP address ({ip}) from {NUCYPHER_ENVVAR_WORKER_IP_ADDRESS} environment variable' emitter.message(message, verbosity=2) return ip @@ -141,8 +142,9 @@ def collect_external_ip_address(emitter: StdoutEmitter, network: str, force: boo # Confirmation if not force: if not click.confirm(CONFIRM_URSULA_IPV4_ADDRESS.format(rest_host=ip)): - ip = click.prompt(COLLECT_URSULA_IPV4_ADDRESS, type=IPV4_ADDRESS) + ip = click.prompt(COLLECT_URSULA_IPV4_ADDRESS, type=WORKER_IP) + validate_worker_ip(worker_ip=ip) return ip @@ -157,7 +159,17 @@ def perform_ip_checkup(emitter: StdoutEmitter, ursula: Ursula, force: bool = Fal message = 'Cannot automatically determine external IP address' emitter.message(message) return # TODO: crash, or not to crash... that is the question - ip_mismatch = external_ip != ursula.rest_interface.host + rest_host = ursula.rest_interface.host + try: + validate_worker_ip(worker_ip=rest_host) + except InvalidWorkerIP: + message = f'{rest_host} is not a valid or permitted worker IP address. Set the correct external IP then try again\n' \ + f'automatic configuration -> nucypher ursula config ip-address\n' \ + f'manual configuration -> nucypher ursula config --rest-host ' + emitter.message(message) + return + + ip_mismatch = external_ip != rest_host if ip_mismatch and not force: error = f'\nX External IP address ({external_ip}) does not match configuration ({ursula.rest_interface.host}).\n' hint = f"Run 'nucypher ursula config ip-address' to reconfigure the IP address then try " \ diff --git a/nucypher/cli/commands/ursula.py b/nucypher/cli/commands/ursula.py index 1c7ce6fa1..2f36f7513 100644 --- a/nucypher/cli/commands/ursula.py +++ b/nucypher/cli/commands/ursula.py @@ -18,12 +18,14 @@ import click +from nucypher.blockchain.eth.networks import NetworksInventory from nucypher.blockchain.eth.signers.software import ClefSigner from nucypher.cli.actions.auth import get_client_password, get_nucypher_password from nucypher.cli.actions.configure import ( destroy_configuration, handle_missing_configuration_file, - get_or_update_configuration, collect_external_ip_address + get_or_update_configuration, + collect_worker_ip_address ) from nucypher.cli.actions.configure import forget as forget_nodes, perform_ip_checkup from nucypher.cli.actions.select import ( @@ -59,7 +61,7 @@ from nucypher.cli.options import ( option_max_gas_price ) from nucypher.cli.painting.help import paint_new_installation_help -from nucypher.cli.types import EIP55_CHECKSUM_ADDRESS, NETWORK_PORT +from nucypher.cli.types import EIP55_CHECKSUM_ADDRESS, NETWORK_PORT, WORKER_IP from nucypher.cli.utils import make_cli_character, setup_emitter from nucypher.config.characters import UrsulaConfiguration from nucypher.config.constants import ( @@ -176,7 +178,7 @@ class UrsulaConfigOptions: # Resolve rest host if not self.rest_host: - self.rest_host = collect_external_ip_address(emitter, network=self.domain, force=force) + self.rest_host = collect_worker_ip_address(emitter, network=self.domain, force=force) return UrsulaConfiguration.generate(password=get_nucypher_password(confirm=True), config_root=config_root, @@ -223,10 +225,10 @@ group_config_options = group_options( max_gas_price=option_max_gas_price, worker_address=click.option('--worker-address', help="Run the worker-ursula with a specified address", type=EIP55_CHECKSUM_ADDRESS), federated_only=option_federated_only, - rest_host=click.option('--rest-host', help="The host IP address to run Ursula network services on", type=click.STRING), + rest_host=click.option('--rest-host', help="The host IP address to run Ursula network services on", type=WORKER_IP), rest_port=click.option('--rest-port', help="The host port to run Ursula network services on", type=NETWORK_PORT), db_filepath=option_db_filepath, - network=option_network(), + network=option_network(default=NetworksInventory.DEFAULT), registry_filepath=option_registry_filepath, poa=option_poa, light=option_light, @@ -422,7 +424,7 @@ def config(general_config, config_options, config_file, force, action): checksum_address=config_options.worker_address, config_class=UrsulaConfiguration) if action == 'ip-address': - rest_host = collect_external_ip_address(emitter=emitter, network=config_options.domain, force=force) + rest_host = collect_worker_ip_address(emitter=emitter, network=config_options.domain, force=force) config_options.rest_host = rest_host updates = config_options.get_updates() get_or_update_configuration(emitter=emitter, diff --git a/nucypher/cli/literature.py b/nucypher/cli/literature.py index 2ccdd3222..7d47e9691 100644 --- a/nucypher/cli/literature.py +++ b/nucypher/cli/literature.py @@ -377,7 +377,7 @@ DECRYPTING_CHARACTER_KEYRING = 'Authenticating {name}' CONFIRM_URSULA_IPV4_ADDRESS = "Detected IPv4 address ({rest_host}) - Is this the public-facing address of Ursula?" -COLLECT_URSULA_IPV4_ADDRESS = "Enter Ursula's public-facing IPv4 address:" +COLLECT_URSULA_IPV4_ADDRESS = "Enter Ursula's public-facing IPv4 address" # diff --git a/nucypher/cli/types.py b/nucypher/cli/types.py index 763d85451..08dcd2aff 100644 --- a/nucypher/cli/types.py +++ b/nucypher/cli/types.py @@ -26,6 +26,7 @@ from nucypher.blockchain.economics import StandardTokenEconomics from nucypher.blockchain.eth.interfaces import BlockchainInterface from nucypher.blockchain.eth.networks import NetworksInventory from nucypher.blockchain.eth.token import NU +from nucypher.utilities.networking import validate_worker_ip, InvalidWorkerIP class ChecksumAddress(click.ParamType): @@ -46,12 +47,24 @@ class IPv4Address(click.ParamType): def convert(self, value, param, ctx): try: _address = ip_address(value) - except ValueError as e: + except ValueError: self.fail("Invalid IP Address") else: return value +class WorkerIPAddress(IPv4Address): + name = 'worker_ip' + + def convert(self, value, param, ctx): + _ip = super().convert(value, param, ctx) + try: + validate_worker_ip(worker_ip=_ip) + except InvalidWorkerIP as e: + self.fail(str(e)) + return value + + class DecimalType(click.ParamType): name = 'decimal' @@ -136,6 +149,7 @@ EXISTING_READABLE_FILE = click.Path(exists=True, dir_okay=False, file_okay=True, # Network NETWORK_PORT = click.IntRange(min=0, max=65535, clamp=False) IPV4_ADDRESS = IPv4Address() +WORKER_IP = WorkerIPAddress() GAS_STRATEGY_CHOICES = click.Choice(list(BlockchainInterface.GAS_STRATEGIES.keys())) UMBRAL_PUBLIC_KEY_HEX = UmbralPublicKeyHex() diff --git a/nucypher/config/characters.py b/nucypher/config/characters.py index e0cc682eb..2f7707061 100644 --- a/nucypher/config/characters.py +++ b/nucypher/config/characters.py @@ -39,7 +39,6 @@ class UrsulaConfiguration(CharacterConfiguration): CHARACTER_CLASS = Ursula NAME = CHARACTER_CLASS.__name__.lower() - DEFAULT_REST_HOST = '127.0.0.1' DEFAULT_REST_PORT = 9151 DEFAULT_DEVELOPMENT_REST_PORT = 10151 __DEFAULT_TLS_CURVE = ec.SECP384R1 @@ -48,10 +47,10 @@ class UrsulaConfiguration(CharacterConfiguration): LOCAL_SIGNERS_ALLOWED = True def __init__(self, + rest_host: str, worker_address: str = None, dev_mode: bool = False, db_filepath: str = None, - rest_host: str = None, rest_port: int = None, tls_curve: EllipticCurve = None, certificate: Certificate = None, @@ -64,7 +63,7 @@ class UrsulaConfiguration(CharacterConfiguration): else: rest_port = self.DEFAULT_REST_PORT self.rest_port = rest_port - self.rest_host = rest_host or self.DEFAULT_REST_HOST + self.rest_host = rest_host self.tls_curve = tls_curve or self.__DEFAULT_TLS_CURVE self.certificate = certificate self.db_filepath = db_filepath or UNINITIALIZED_CONFIGURATION diff --git a/nucypher/network/nodes.py b/nucypher/network/nodes.py index 020aa6907..bc6713df2 100644 --- a/nucypher/network/nodes.py +++ b/nucypher/network/nodes.py @@ -245,8 +245,7 @@ class Learner: from nucypher.characters.lawful import Ursula self.node_class = node_class or Ursula - self.node_class.set_cert_storage_function( - node_storage.store_node_certificate) # TODO: Fix this temporary workaround for on-disk cert storage. #1481 + self.node_class.set_cert_storage_function(node_storage.store_node_certificate) # TODO: Fix this temporary workaround for on-disk cert storage. #1481 known_nodes = known_nodes or tuple() self.unresponsive_startup_nodes = list() # TODO: Buckets - Attempt to use these again later #567 diff --git a/nucypher/utilities/networking.py b/nucypher/utilities/networking.py index 3a7311b80..973528ae8 100644 --- a/nucypher/utilities/networking.py +++ b/nucypher/utilities/networking.py @@ -16,16 +16,15 @@ """ -from ipaddress import ip_address - import random import requests +from ipaddress import ip_address from requests.exceptions import RequestException, HTTPError from typing import Union -from nucypher.blockchain.eth.registry import BaseContractRegistry, InMemoryContractRegistry +from nucypher.config.storages import LocalFileBasedNodeStorage from nucypher.acumen.perception import FleetSensor -from nucypher.characters.lawful import Ursula +from nucypher.blockchain.eth.registry import BaseContractRegistry, InMemoryContractRegistry from nucypher.network.middleware import RestMiddleware, NucypherMiddlewareClient from nucypher.utilities.logging import Logger @@ -34,6 +33,10 @@ class UnknownIPAddress(RuntimeError): pass +class InvalidWorkerIP(RuntimeError): + """Raised when an Ursula is using an invalid IP address for it's server.""" + + RequestErrors = ( # https://requests.readthedocs.io/en/latest/user/quickstart/#errors-and-exceptions ConnectionError, @@ -42,9 +45,22 @@ RequestErrors = ( HTTPError ) +RESERVED_IP_ADDRESSES = ( + '0.0.0.0', + '127.0.0.1', + '1.2.3.4' +) + IP_DETECTION_LOGGER = Logger('external-ip-detection') + +def validate_worker_ip(worker_ip: str) -> None: + if worker_ip in RESERVED_IP_ADDRESSES: + raise InvalidWorkerIP(f'{worker_ip} is not a valid or permitted worker IP address. ' + f'Verify the rest_host is set to the external IPV4 address') + + def __request(url: str, certificate=None) -> Union[str, None]: """ Utility function to send a GET request to a URL returning it's @@ -65,6 +81,8 @@ def get_external_ip_from_default_teacher(network: str, log: Logger = IP_DETECTION_LOGGER, registry: BaseContractRegistry = None ) -> Union[str, None]: + from nucypher.characters.lawful import Ursula + if federated_only and registry: raise ValueError('Federated mode must not be true if registry is provided.') base_error = 'Cannot determine IP using default teacher' @@ -76,13 +94,19 @@ def get_external_ip_from_default_teacher(network: str, except KeyError: log.debug(f'{base_error}: Unknown network "{network}".') return - if not registry: - # Registry is needed to perform on-chain staking verification. - registry = InMemoryContractRegistry.from_latest_publication(network=network) + + #### + # TODO: Clean this mess #1481 + node_storage = LocalFileBasedNodeStorage(federated_only=federated_only) + Ursula.set_cert_storage_function(node_storage.store_node_certificate) + Ursula.set_federated_mode(federated_only) + ##### + teacher = Ursula.from_teacher_uri(teacher_uri=top_teacher_url, - registry=registry, federated_only=federated_only, min_stake=0) # TODO: Handle customized min stake here. + + # TODO: Pass registry here to verify stake (not essential here since it's a hardcoded node) client = NucypherMiddlewareClient() try: response = client.get(node_or_sprout=teacher, path=f"ping", timeout=2) # TLS certificate logic within @@ -97,6 +121,8 @@ def get_external_ip_from_default_teacher(network: str, raise UnknownIPAddress(error) log.info(f'Fetched external IP address from default teacher ({top_teacher_url} reported {ip}).') return ip + else: + log.debug(f'Failed to get external IP from teacher node ({response.status_code})') def get_external_ip_from_known_nodes(known_nodes: FleetSensor,