citadel-core/scripts/configure
2023-09-27 22:07:45 +02:00

378 lines
12 KiB
Python
Executable File

#!/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 <example-variable>
# 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_<IMPLEMENTATION>_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")