#!/usr/bin/env python3 # SPDX-FileCopyrightText: 2021-2022 Citadel and contributors # # SPDX-License-Identifier: GPL-3.0-or-later import json import os import re import shutil import subprocess import sys from binascii import hexlify from os import urandom from time import sleep import yaml from lib.rpcauth import get_data def generate_password(size): """Create size byte hex salt""" return hexlify(urandom(size)).decode() # Print an error if the user isn't running on Linux. if sys.platform != 'linux': print('This script only works on Linux!') exit(1) # Print an error if user is not root if os.getuid() != 0: print('This script must be run as root!') exit(1) # Check if the system is arm64 or amd64 is_arm64 = subprocess.check_output(['uname', '-m']).decode('utf-8').strip() == 'aarch64' is_amd64 = subprocess.check_output(['uname', '-m']).decode('utf-8').strip() == 'x86_64' if not is_arm64 and not is_amd64: print('Citadel only works on arm64 and amd64!') exit(1) dependencies = False # Check the output of "docker compose version", if it matches "Docker Compose version v2.0.0-rc.3", return true # Otherwise, return false def is_compose_version_except(target_version): try: output = subprocess.check_output(['docker', 'compose', 'version']) if output.decode('utf-8').strip() != 'Docker Compose version {}'.format(target_version): return True else: return False except: return True # Download docker-compose from GitHub and put it in $HOME/.docker/cli-plugins/docker-compose def download_docker_compose(): # Skip if os.path.expanduser('~/.docker/cli-plugins/docker-compose') exists subprocess.check_call(["mkdir", "-p", os.path.expanduser('~/.docker/cli-plugins/')]) if is_arm64: compose_arch = 'aarch64' elif is_amd64: compose_arch = 'x86_64' # We validate that no other case than the two above can happen before if is_compose_version_except(dependencies['compose']): print("Docker compose not found or not required version, updating.") compose_url = 'https://github.com/docker/compose/releases/download/{}/docker-compose-linux-{}'.format(dependencies['compose'], compose_arch) compose_file = os.path.expanduser('~/.docker/cli-plugins/docker-compose') subprocess.check_call(['wget', compose_url, '-O', compose_file]) os.chmod(compose_file, 0o755) if not shutil.which("wget"): print('Wget is not installed!') exit(1) if not shutil.which("docker"): print('Docker is not installed!') exit(1) # Switch to node root directory. CITADEL_ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) os.chdir(CITADEL_ROOT) with open("./db/dependencies.yml", "r") as file: dependencies = yaml.safe_load(file) # Configure for appropriate network depending # upon the user-supplied value of $NETWORK # If the network is not specified, then use the mainnet BITCOIN_NETWORK=os.environ.get('NETWORK') or 'mainnet' DEVICE_IP=os.environ.get("DEVICE_IP") # Check if network neither mainnet nor testnet nor regtest if BITCOIN_NETWORK not in ['mainnet', 'testnet', 'signet', 'regtest']: print('Error: Network must be either mainnet, testnet, signet or regtest!') exit(1) with open(os.path.join(CITADEL_ROOT, "info.json"), 'r') as file: CITADEL_VERSION=json.load(file)['version'] status_dir = os.path.join(CITADEL_ROOT, 'statuses') print("\n======================================") if os.path.isfile(status_dir+'/configured'): print("=========== RECONFIGURING ============") reconfiguring=os.path.isfile('./.env') else: print("============ CONFIGURING =============") reconfiguring=False print("============== CITADEL ==============") print("======================================\n") print("Installing additional services") data = subprocess.run("\"{}\" setup".format(os.path.join(CITADEL_ROOT, "services", "manage.py")), shell=True) # Parse a dotenv file # Values can either be KEY=VALUE or KEY="VALUE" or KEY='VALUE' # Returns all env vars as a dict def parse_dotenv(file_path): envVars: dict = {} with open(file_path, 'r') as file: for line in file: line = line.strip() if line.startswith('#') or len(line) == 0: continue if '=' in line: key, value = line.split('=', 1) value = value.strip('"').strip("'") envVars[key] = value else: print("Error: Invalid line in {}: {}".format(file_path, line)) print("Line should be in the format KEY=VALUE or KEY=\"VALUE\" or KEY='VALUE'") exit(1) return envVars ########################################################## ############ Generate configuration variables ############ ########################################################## CADDY_PORT=os.environ.get('CADDY_PORT') or "80" CADDY_HTTPS_PORT=os.environ.get('CADDY_PORT') or "443" UPDATE_CHANNEL="stable" if reconfiguring: dotenv=parse_dotenv('./.env') BITCOIN_NETWORK=os.environ.get('OVERWRITE_NETWORK') or dotenv['BITCOIN_NETWORK'] # Check if network neither mainnet nor testnet nor regtest if BITCOIN_NETWORK not in ['mainnet', 'testnet', 'signet', 'regtest']: print('Error: Network must be either mainnet, testnet, signet or regtest!') exit(1) print("Using {} network\n".format(BITCOIN_NETWORK)) BITCOIN_RPC_PORT=dotenv['BITCOIN_RPC_PORT'] BITCOIN_P2P_PORT=dotenv['BITCOIN_P2P_PORT'] BITCOIN_RPC_USER=dotenv['BITCOIN_RPC_USER'] BITCOIN_RPC_PASS=dotenv['BITCOIN_RPC_PASS'] BITCOIN_RPC_AUTH=dotenv['BITCOIN_RPC_AUTH'] if 'NGINX_PORT' in dotenv: CADDY_PORT=dotenv['NGINX_PORT'] if 'CADDY_PORT' in dotenv: CADDY_PORT=dotenv['CADDY_PORT'] CADDY_HTTPS_PORT="443" if 'CADDY_HTTPS_PORT' in dotenv: CADDY_HTTPS_PORT=dotenv['CADDY_HTTPS_PORT'] if CADDY_HTTPS_PORT == "80" and CADDY_PORT == "80": CADDY_HTTPS_PORT="443" if 'UPDATE_CHANNEL' in dotenv and dotenv['UPDATE_CHANNEL'] != "main" and dotenv['UPDATE_CHANNEL'] != "migration": UPDATE_CHANNEL=dotenv['UPDATE_CHANNEL'] if 'I2P_PASSWORD' in dotenv: I2P_PASSWORD=dotenv['I2P_PASSWORD'] else: I2P_PASSWORD=generate_password(64) else: # Generate RPC credentials print("Generating auth credentials\n") BITCOIN_RPC_USER="citadel" BITCOIN_RPC_DETAILS=get_data(BITCOIN_RPC_USER) BITCOIN_RPC_AUTH=BITCOIN_RPC_DETAILS['auth'] BITCOIN_RPC_PASS=BITCOIN_RPC_DETAILS['password'] I2P_PASSWORD=generate_password(64) EXTERNAL_IP = "" if os.path.isfile('./tor/data/bitcoin-p2p/hostname'): EXTERNAL_IP="externalip=" + open('./tor/data/bitcoin-p2p/hostname').read() if BITCOIN_NETWORK == "mainnet": BITCOIN_RPC_PORT=8332 BITCOIN_P2P_PORT=8333 elif BITCOIN_NETWORK == "testnet": BITCOIN_RPC_PORT=18332 BITCOIN_P2P_PORT=18333 elif BITCOIN_NETWORK == "signet": BITCOIN_RPC_PORT=38332 BITCOIN_P2P_PORT=38333 BITCOIN_NODE="bitcoind" elif BITCOIN_NETWORK == "regtest": BITCOIN_RPC_PORT=18334 BITCOIN_P2P_PORT=18335 BITCOIN_NODE="bitcoind" else: exit(1) NETWORK_SECTION="" if BITCOIN_NETWORK != "mainnet": NETWORK_SECTION = "[{}]".format(BITCOIN_NETWORK) if BITCOIN_NETWORK == "testnet": NETWORK_SECTION = "[test]" # IP addresses for services NETWORK_IP="10.21.21.0" GATEWAY_IP="10.21.21.1" DASHBOARD_IP="10.21.21.3" MANAGER_IP="10.21.21.4" #MIDDLEWARE_IP="10.21.21.5" #NEUTRINO_SWITCHER_IP="10.21.21.6" BITCOIN_IP="10.21.21.7" #LND_IP="10.21.21.8" TOR_PROXY_IP="10.21.21.9" APPS_TOR_IP="10.21.21.10" APPS_2_TOR_IP="10.21.21.11" APPS_3_TOR_IP="10.21.21.12" I2P_IP="10.21.21.13" # IP6 addresses for services NETWORK_IP6="fd00::21:0:0:0" GATEWAY_IP6="fd00::21:0:0:1" DASHBOARD_IP6="fd00::21:0:0:3" MANAGER_IP6="fd00::21:0:0:4" #MIDDLEWARE_IP6="fd00::21:0:0:5" #NEUTRINO_SWITCHER_IP6="fd00::21:0:0:6" BITCOIN_IP6="fd00::21:0:0:7" #LND_IP6="fd00::21:0:0:8" TOR_PROXY_IP6="fd00::21:0:0:9" APPS_TOR_IP6="fd00::21:0:0:10" APPS_2_TOR_IP6="fd00::21:0:0:11" APPS_3_TOR_IP6="fd00::21:0:0:12" I2P_IP6="fd00::21:0:0:13" # Ports BITCOIN_RPC_PORT="8332" BITCOIN_P2P_PORT="8333" BITCOIN_ZMQ_RAWBLOCK_PORT="28332" BITCOIN_ZMQ_RAWTX_PORT="28333" BITCOIN_ZMQ_HASHBLOCK_PORT="28334" BITCOIN_ZMQ_SEQUENCE_PORT="28335" TOR_PROXY_PORT="9050" I2P_SAM_PORT="7656" TOR_CONTROL_PORT="29051" DEVICE_HOSTNAME="" try: DEVICE_HOSTNAME=subprocess.check_output("hostname").decode("utf-8").strip() except: # The content of /etc/hostname is the device's hostname DEVICE_HOSTNAME=open("/etc/hostname").read().strip() DOCKER_EXECUTABLE=subprocess.check_output(["which", "docker"]).decode("utf-8").strip() # Get the real path by following symlinks DOCKER_BINARY=subprocess.check_output(["readlink", "-f", DOCKER_EXECUTABLE]).decode("utf-8").strip() # Checks if a variable with the name exists, if not, check if an env var with the name existts # if neither exists, then exit with an error def get_var(var_name, other_locals, file_name): if var_name in locals(): return str(locals()[var_name]) elif var_name in other_locals: return str(other_locals[var_name]) elif var_name in globals(): return str(globals()[var_name]) else: print("Error: {} is not defined! (In file {})".format(var_name, file_name)) exit(1) # Converts a string to uppercase, also replaces all - with _ def convert_to_upper(string): return string.upper().replace('-', '_') # Put variables in the config file. A config file accesses an env var $EXAMPLE_VARIABLE by containing # in the config file. Check for such occurences and replace them with the actual variable def replace_vars(file_path): with open(file_path, 'r') as file: file_contents = file.read() return re.sub(r'<(.*?)>', lambda m: get_var(convert_to_upper(m.group(1)), locals(), file_path), file_contents) def build_template(template_path, output_path): data = replace_vars(template_path) # If output path is a list, then it is a list of output paths if isinstance(output_path, list): for output_path_item in output_path: # Delete the output path, no matter if it's a file or a directory if os.path.isdir(output_path_item): shutil.rmtree(output_path_item) with open(output_path_item, 'w') as file: file.write(data) else: # Delete the output path, no matter if it's a file or a directory if os.path.isdir(output_path): shutil.rmtree(output_path) with open(output_path, 'w') as file: file.write(data) print("Generating configuration files...") build_template("./templates/torrc-core-sample", "./tor/torrc-core") build_template("./templates/bitcoin-sample.conf", "./bitcoin/bitcoin.conf") build_template("./templates/i2p-sample.conf", "./i2p/i2pd.conf") build_template("./templates/i2p-tunnels-sample.conf", "./i2p/tunnels.conf") MIDDLEWARE_IP="NOT_YET_SET" MIDDLEWARE_IP6="NOT_YET_SET" build_template("./templates/.env-sample", "./.env") print("Ensuring Docker Compose is up to date...") download_docker_compose() print("Updating core services...") print() with open("docker-compose.yml", 'r') as stream: compose = yaml.safe_load(stream) for service in ["manager", "dashboard"]: compose["services"][service]["image"] = dependencies[service] for service in ["tor", "app-tor", "app-2-tor", "app-3-tor"]: compose["services"][service]["image"] = dependencies["tor"] with open("docker-compose.yml", "w") as stream: yaml.dump(compose, stream, sort_keys=False) print("Configuring permissions...\n") try: os.system('chown -R 1000:1000 {}'.format(CITADEL_ROOT)) except: pass if not reconfiguring: print("Downloading apps...\n") os.system('./scripts/app update') else: print("Generating app configuration...\n") os.system('./scripts/app generate') # Run ./scripts/app get-implementation lightning to get the implementation # If it fails, install the LND app and set the implementation to LND if reconfiguring: try: implementation = subprocess.check_output("./scripts/app get-implementation lightning", shell=True).decode("utf-8").strip() except: print("Installing LND...\n") os.system('./scripts/app install lnd') implementation = "lnd" # Get APP__MIDDLEWARE_IP from the .env file if reconfiguring: dotenv=parse_dotenv('./.env') MIDDLEWARE_IP = dotenv["APP_{}_MIDDLEWARE_IP".format(implementation.upper().replace("-", "_"))] MIDDLEWARE_IP6 = dotenv["APP_{}_MIDDLEWARE_IP6".format(implementation.upper().replace("-", "_"))] else: MIDDLEWARE_IP = "0.0.0.0" MIDDLEWARE_IP6 = "::" build_template("./templates/.env-sample", "./.env") print("Updating app configuration...\n") os.system('./scripts/app generate') # Touch status_dir/configured with open(status_dir+'/configured', 'w') as file: file.write('') print("Configuring permissions...\n") try: os.system('chown -R 1000:1000 {}'.format(CITADEL_ROOT)) except: pass print("Configuration successful\n") print("You can now start Citadel by running:") print(" sudo ./scripts/start")