diff --git a/documentation/docs/pages/operators/tools.mdx b/documentation/docs/pages/operators/tools.mdx index 77ffef91dcd..984369429d4 100644 --- a/documentation/docs/pages/operators/tools.mdx +++ b/documentation/docs/pages/operators/tools.mdx @@ -71,14 +71,14 @@ There are two ways how to run the program. fully interactive or with provided ar - An example can look like this: ```sh # substitute with your real values: -./nym-node-cli.py install - --hostname node-install.devrel.nymte.ch - --moniker MainnetGW-DE - --description "This node is installed with nym-node-cli v1.2.0" - --wireguard-enabled true - --location DE - --mode exit-gateway - --email kawa_hesinkar@example.ku +./nym-node-cli.py install \ + --hostname test-node.devrel.nymte.ch \ + --moniker MainnetGW-KU \ + --description "This node is installed with nym-node-cli v1.3.0" \ + --wireguard-enabled true \ + --location KU \ + --mode exit-gateway \ + --email kawa_hesinkar@ciya.ku ``` ###### 4. Read and follow the prompts diff --git a/scripts/nym-node-setup/nym-node-cli.py b/scripts/nym-node-setup/nym-node-cli.py index a8354b67683..11373dc45f7 100755 --- a/scripts/nym-node-setup/nym-node-cli.py +++ b/scripts/nym-node-setup/nym-node-cli.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 -__version__ = "1.2.0" +__version__ = "1.3.0" __default_branch__ = "develop" import os @@ -11,10 +11,10 @@ import tempfile import shlex import time +import traceback from datetime import datetime from pathlib import Path -from typing import Iterable, Optional, Mapping -from typing import Optional, Tuple +from typing import Iterable, Optional, Mapping, Tuple class NodeSetupCLI: """All CLI main functions""" @@ -23,23 +23,62 @@ def __init__(self, args): self.branch = args.dev self.welcome_message = self.print_welcome_message() self.mode = self._get_or_prompt_mode(args) + + # Resolve WireGuard up front (CLI > env > env.sh > prompt) so we can + # derive the full capability set before fetching anything. + self.wg_enabled = self.check_wg_enabled(args) + + # --- Derive capabilities from mode + WireGuard --- + # + # Ground truth of how Nym node roles compose: + # * every exit-gateway also serves as an entry-gateway + # * every WireGuard node routes exit traffic, so it is effectively an + # exit-gateway (and therefore also an entry-gateway) + # * a non-WireGuard entry-gateway serves entry traffic only + # + # From that: + # needs_quic -> any gateway (entry or exit) runs a QUIC bridge + # needs_exit_setup -> exit-gateway OR any WireGuard node + # (nginx/WSS, NTM routing, exit-policy iptables) + # needs_ufw -> only where NTM does NOT manage the firewall: + # mixnodes, and entry-only nodes without WireGuard + is_mix = self.mode == "mixnode" + is_entry = self.mode == "entry-gateway" + is_exit = self.mode == "exit-gateway" + + self.needs_quic = is_entry or is_exit + self.needs_exit_setup = is_exit or self.wg_enabled + self.needs_ufw = is_mix or (is_entry and not self.wg_enabled) + + # Inform the operator when an entry-gateway is promoted to an effective + # exit-gateway purely because WireGuard was enabled. + if is_entry and self.wg_enabled: + print( + "\n[INFO] WireGuard is enabled on an entry-gateway.\n" + " WireGuard nodes route exit traffic, so this node will be\n" + " set up as a full exit-gateway (nginx/WSS, routing, exit\n" + " policy and QUIC) and listed as both entry and exit in the app.\n" + ) + + # --- Base scripts, always needed --- self.prereqs_install_sh = self.fetch_script("nym-node-prereqs-install.sh") self.node_install_sh = self.fetch_script("nym-node-install.sh") self.service_config_sh = self.fetch_script("setup-systemd-service-file.sh") self.start_node_systemd_service_sh = self.fetch_script("start-node-systemd-service.sh") - self.is_gwx = self.mode == "exit-gateway" - if self.is_gwx: + + # --- Conditional scripts --- + self.landing_page_html = None + self.nginx_proxy_wss_sh = None + self.tunnel_manager_sh = None + self.quic_bridge_deployment_sh = None + + if self.needs_exit_setup: self.landing_page_html = self.fetch_script("landing-page.html") self.nginx_proxy_wss_sh = self.fetch_script("setup-nginx-proxy-wss.sh") self.tunnel_manager_sh = self.fetch_script("network_tunnel_manager.sh") + + if self.needs_quic: self.quic_bridge_deployment_sh = self.fetch_script("quic_bridge_deployment.sh") - else: - self.landing_page_html = None - self.nginx_proxy_wss_sh = None - self.tunnel_manager_sh = None - self.wg_ip_tables_manager_sh = None - self.wg_ip_tables_test_sh = None - self.quic_bridge_deployment_sh = None def print_welcome_message(self): @@ -202,22 +241,41 @@ def _get_or_prompt_mode(self, args): return mode def fetch_script(self, script_name): - """Fetches needed scripts according to a defined mode""" + """Fetch a required script over HTTPS. + + Uses Python's urllib rather than shelling out to wget/curl: on a fresh + machine those tools are installed *by* the prereqs script, which runs + after this constructor, so depending on them here caused intermittent + "script not downloaded" failures (notably NTM). urllib is always present + with the interpreter, and we retry to ride out transient network blips. + """ + import urllib.request + import urllib.error + # print header only the first time if not getattr(self, "_fetched_once", False): print("\n* * * Fetching required scripts * * *") self._fetched_once = True + url = self._return_script_url(script_name) print(f"Fetching file from: {url}") - result = subprocess.run(["wget", "-qO-", url], capture_output=True, text=True) - if result.returncode != 0 or not result.stdout.strip(): - print(f"wget failed to download the file.") - print("stderr:", result.stderr) - raise RuntimeError(f"Failed to fetch {url}") - # Optional sanity check: - first_line = result.stdout.splitlines()[0] if result.stdout else "" - print(f"Downloaded {len(result.stdout)} bytes.") - return result.stdout + + last_err = None + for attempt in range(1, 4): + try: + req = urllib.request.Request(url, headers={"User-Agent": "nym-node-cli"}) + with urllib.request.urlopen(req, timeout=30) as resp: + data = resp.read().decode("utf-8") + if not data.strip(): + raise RuntimeError("empty response body") + print(f"Downloaded {len(data)} bytes.") + return data + except (urllib.error.URLError, urllib.error.HTTPError, RuntimeError, TimeoutError) as e: + last_err = e + print(f"[WARN] fetch attempt {attempt}/3 failed for {script_name}: {e}") + time.sleep(2 * attempt) + + raise RuntimeError(f"Failed to fetch {url} after 3 attempts: {last_err}") def _return_script_url(self, script_init_name): """Dictionary pointing to scripts url returning value according to a passed key""" @@ -302,10 +360,6 @@ def _write_temp_script(self, script_text: str) -> Path: os.chmod(path, 0o700) return path - def _check_gwx_mode(self): - """Helper: Several fns run only for GWx - this fn checks this condition""" - return self.mode == "exit-gateway" - def check_wg_enabled(self, args=None): """Determine if WireGuard is enabled; precedence: CLI > env > env.sh > prompt. Persist normalized value.""" @@ -314,6 +368,11 @@ def check_wg_enabled(self, args=None): def norm(v): return "true" if str(v).strip().lower() == "true" else "false" + # WireGuard is a gateway concept only; mixnodes never route WG traffic. + if getattr(self, "mode", None) == "mixnode": + os.environ["WIREGUARD"] = "false" + return False + val = None # CLI argument @@ -378,6 +437,45 @@ def run_bash_command(self, command, args=None, *, env=None, cwd=None, check=True return subprocess.run(cmd, env=env, cwd=cwd, check=check) + def setup_ufw(self): + """Configure ufw for nodes NOT managed by the network tunnel manager. + + Applies only to mixnodes and entry-only (non-WireGuard) gateways. Exit + gateways and WireGuard nodes are excluded because NTM owns their firewall + via complete_networking_configuration; layering ufw on top would clash. + """ + print("\n* * * Setting up firewall using ufw * * *") + + ssh_port = os.environ.get("HOST_SSH_PORT", "22") + + # Base rules common to every ufw-managed node. + rules = [ + f"{ssh_port}/tcp", # SSH (operator-controlled) + "80/tcp", # HTTP + "443/tcp", # HTTPS + "1789/tcp", # Nym mixnet + "1790/tcp", # Nym mixnet + "8080/tcp", # nym-node HTTP API + "9000/tcp", # clients port + ] + + # Entry gateways (non-WireGuard) additionally expose the WSS port. + if self.mode == "entry-gateway": + rules.append("9001/tcp") # WSS + + script_lines = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "export DEBIAN_FRONTEND=noninteractive", + "echo 'y' | ufw enable || ufw --force enable", + ] + for rule in rules: + script_lines.append(f"ufw allow {rule}") + script_lines.append("ufw reload") + script_lines.append("ufw status verbose") + + self.run_script("\n".join(script_lines) + "\n") + def run_tunnel_manager_setup(self): """A standalone fn to pass full cmd list needed for correct setup and test network tunneling, using an external script""" print( @@ -624,14 +722,31 @@ def run_node_installation(self,args): self.run_script(self.prereqs_install_sh) self.run_script(self.node_install_sh) self.run_script(self.service_config_sh) - self._check_gwx_mode() and self.run_script(self.nginx_proxy_wss_sh) + + # nginx reverse proxy + WSS: only nodes doing exit setup (exit-gateway + # or any WireGuard node) serve the landing page and WSS endpoint. + if self.needs_exit_setup: + self.run_script(self.nginx_proxy_wss_sh) + + # Firewall: ufw is only used where NTM does NOT manage iptables, i.e. + # mixnodes and entry-only nodes without WireGuard. On exit / WireGuard + # nodes, NTM's complete_networking_configuration owns the firewall and + # running ufw on top would conflict with its rules. + if self.needs_ufw: + self.setup_ufw() + self.run_nym_node_as_service() self.run_bonding_prompt() - if self._check_gwx_mode(): + + # Exit / WireGuard nodes: NTM routing, then (for WireGuard) exit-policy + # iptables. QUIC runs on any gateway. + if self.needs_exit_setup: self.run_tunnel_manager_setup() - if self.check_wg_enabled(args): + if self.wg_enabled: self.setup_test_wg_ip_tables() - self.quic_bridge_deploy() + + if self.needs_quic: + self.quic_bridge_deploy() @@ -791,4 +906,4 @@ def _cap_controller_memory(self, bytes_limit: int = 2 * 1024**3): safeguards._protect_from_oom(-900) # de-prioritize controller as OOM victim safeguards._cap_controller_memory(2 * 1024**3) # optional: cap controller to 2 GiB app = ArgParser() - app.parser_main() + app.parser_main() \ No newline at end of file diff --git a/scripts/nym-node-setup/nym-node-prereqs-install.sh b/scripts/nym-node-setup/nym-node-prereqs-install.sh index e462ecb5c92..5f9ffec2395 100644 --- a/scripts/nym-node-setup/nym-node-prereqs-install.sh +++ b/scripts/nym-node-setup/nym-node-prereqs-install.sh @@ -5,28 +5,50 @@ if [[ "$(id -u)" -ne 0 ]]; then exit 1 fi -# update, upgrade and install dependencies -echo -e "\n* * * Installing needed prerequisities * * *" - -apt update -y && apt --fix-broken install -apt upgrade -apt install apt ca-certificates jq curl wget ufw jq tmux pkg-config build-essential libssl-dev git ntp ntpdate neovim tree tmux tig nginx -y -apt install ufw --fix-missing - -# enable & setup firewall -echo -e "\n* * * Setting up firewall using ufw * * * " -echo "Please enable the firewall in the next prompt for node proper routing." -echo -ufw enable -ufw allow 22/tcp # SSH - you're in control of these ports -ufw allow 80/tcp # HTTP -ufw allow 443/tcp # HTTPS -ufw allow 1789/tcp # Nym specific -ufw allow 1790/tcp # Nym specific -ufw allow 8080/tcp # Nym specific - nym-node-api -ufw allow 9000/tcp # Nym Specific - clients port -ufw allow 9001/tcp # Nym specific - wss port -ufw allow 51822/udp # WireGuard -ufw allow in on nymwg to any port 51830 proto tcp # bandwidth queries/topup - inside the tunnel -ufw reload && \ -ufw status +# Non-interactive so nothing blocks on a prompt during automated setup +export DEBIAN_FRONTEND=noninteractive +set -euo pipefail + +echo -e "\n* * * Installing needed prerequisites * * *" + +# --- Recover from any previously interrupted dpkg/apt state --- +# A killed install or a reboot mid-install leaves dpkg half-configured and every +# subsequent apt call fails with: "dpkg was interrupted, you must manually run +# 'dpkg --configure -a'". Run the recovery unconditionally; it is a no-op when clean. +echo "Ensuring package system is in a consistent state..." +dpkg --configure -a || true +apt-get --fix-broken install -y || true + +# --- Update and upgrade --- +apt-get update -y +apt-get upgrade -y + +# --- Core dependencies (hard requirements) --- +# If any of these fail the node cannot be set up, so we let a failure surface. +apt-get install -y \ + ca-certificates jq curl wget ufw tmux pkg-config build-essential \ + libssl-dev git nginx + +# --- Optional/convenience packages (best-effort) --- +# Package names differ across Debian 12, Ubuntu 22/24/26 (e.g. ntp -> ntpsec, +# ntpdate deprecated). Install each independently so a missing one on a given +# release does not abort the whole run. +for pkg in tree tig neovim; do + apt-get install -y "$pkg" || echo "[WARN] optional package '$pkg' not installed (not available on this release)" +done + +# --- Time sync (critical for WireGuard handshake validity) --- +# Try modern then legacy providers; whichever exists on this release wins. +if apt-get install -y ntpsec 2>/dev/null; then + echo "[OK] time sync via ntpsec" +elif apt-get install -y ntp 2>/dev/null; then + echo "[OK] time sync via ntp" +elif apt-get install -y systemd-timesyncd 2>/dev/null; then + systemctl enable --now systemd-timesyncd 2>/dev/null || true + echo "[OK] time sync via systemd-timesyncd" +else + echo "[WARN] no NTP package could be installed; ensure clock is synced manually" +fi + +echo -e "\n* * * Prerequisites installed * * *" +echo "Firewall (ufw) configuration is handled by the CLI according to node mode." \ No newline at end of file diff --git a/scripts/nym-node-setup/setup-nginx-proxy-wss.sh b/scripts/nym-node-setup/setup-nginx-proxy-wss.sh index cf1378f5b6c..727dca3b90b 100644 --- a/scripts/nym-node-setup/setup-nginx-proxy-wss.sh +++ b/scripts/nym-node-setup/setup-nginx-proxy-wss.sh @@ -1,12 +1,22 @@ #!/usr/bin/env bash set -euo pipefail +# nginx reverse-proxy + WSS setup for a Nym exit gateway. +# +# This mirrors the Ansible role at ansible/nym-node/roles/nginx exactly: +# - HTTP vhost (port 80): serves ACME challenge + 301 redirect to HTTPS +# - own SSL options snippet (does NOT rely on certbot-generated files) +# - certbot certonly --nginx (obtain only; never lets certbot rewrite vhosts) +# - HTTPS vhost (443): reverse proxy to nym-node API on 127.0.0.1:8080 +# - WSS vhost (9001): proxy to 127.0.0.1:9000 with CORS + upgrade headers +# SSL/WSS vhosts are only enabled once a certificate actually exists. + if [[ "$(id -u)" -ne 0 ]]; then echo "This script must be run as root." exit 1 fi -# load env +# --- load env (matches the CLI: ENV_FILE, else ./env.sh) --- if [[ -n "${ENV_FILE:-}" && -f "${ENV_FILE}" ]]; then set -a; . "${ENV_FILE}"; set +a elif [[ -f "./env.sh" ]]; then @@ -21,17 +31,41 @@ export DEBIAN_FRONTEND=noninteractive WEBROOT="/var/www/${HOSTNAME}" SITES_AVAIL="/etc/nginx/sites-available" SITES_EN="/etc/nginx/sites-enabled" +SNIPPETS="/etc/nginx/snippets" HTTP_CONF="${SITES_AVAIL}/${HOSTNAME}" -WSS_CONF="${SITES_AVAIL}/wss-config-nym" +SSL_CONF="${SITES_AVAIL}/${HOSTNAME}-ssl" +WSS_CONF="${SITES_AVAIL}/nym-wss-config" # matches Ansible role filename +SSL_SNIPPET="${SNIPPETS}/nym-ssl-options.conf" echo -echo "* * * Starting nginx configuration for landing page, reverse proxy and WSS * * *" +echo "* * * Starting nginx configuration (landing page, reverse proxy, WSS) * * *" + +# --- ensure certbot present (role installs nginx + certbot + plugin) --- +apt-get update -y >/dev/null 2>&1 || true +apt-get install -y certbot python3-certbot-nginx >/dev/null 2>&1 || true ############################################################################### -# step 1: ensure landing page exists (local fetch -> github -> template) +# step 1: SSL options snippet (own defaults, not certbot's) ############################################################################### +mkdir -p "${SNIPPETS}" +cat > "${SSL_SNIPPET}" <<'EOF' +ssl_session_cache shared:NYMSSL:10m; +ssl_session_timeout 1d; +ssl_session_tickets off; +ssl_protocols TLSv1.2 TLSv1.3; +ssl_prefer_server_ciphers off; + +# Reasonable modern cipher set (works across Ubuntu nginx builds) +ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305"; + +# OCSP stapling is nice but can break if resolver isn't set; keep minimal here. +EOF + +############################################################################### +# step 2: landing page (local fetch -> github -> minimal fallback) +############################################################################### mkdir -p "${WEBROOT}" SCRIPT_DIR="$(dirname "${ENV_FILE:-./env.sh}")" @@ -56,35 +90,25 @@ else EOF fi - echo "Landing page at ${WEBROOT}/index.html" ############################################################################### -# step 2: remove default site and old configs, restart nginx +# step 3: clean existing config for this host + default site ############################################################################### - echo "Cleaning existing nginx configuration" - -# remove default nginx site [[ -L "${SITES_EN}/default" ]] && unlink "${SITES_EN}/default" || true - -# optional: remove default available config if present -rm -f /etc/nginx/sites-available/default || true - -# remove old vhosts for this domain -rm -f "${SITES_EN}/${HOSTNAME}" || true -rm -f "${SITES_EN}/${HOSTNAME}-ssl" || true -rm -f "${SITES_EN}/wss-config-nym" || true - -rm -f "${HTTP_CONF}" || true -rm -f "${WSS_CONF}" || true - -systemctl restart nginx || systemctl start nginx +rm -f "${SITES_AVAIL}/default" || true +rm -f "${SITES_EN}/${HOSTNAME}" "${SITES_EN}/${HOSTNAME}-ssl" "${SITES_EN}/nym-wss-config" || true +# also drop legacy filename from older script versions +rm -f "${SITES_EN}/wss-config-nym" "${SITES_AVAIL}/wss-config-nym" || true ############################################################################### -# step 3: create basic HTTP config like manual flow (80 -> 8080) +# step 4: HTTP vhost (ACME challenge + redirect to HTTPS) - always enabled ############################################################################### +CERT_EXISTS=false +[[ -s "/etc/letsencrypt/live/${HOSTNAME}/fullchain.pem" ]] && CERT_EXISTS=true + cat > "${HTTP_CONF}" </dev/null 2>&1 || true +systemctl restart nginx || systemctl start nginx ############################################################################### -# step 4: install certbot and obtain certificate (letsencrypt) +# step 5: obtain certificate (certonly - never lets certbot edit vhosts) ############################################################################### - -apt-get update -y >/dev/null 2>&1 || true -apt-get install -y certbot python3-certbot-nginx >/dev/null 2>&1 || true - echo "Requesting Let's Encrypt certificate for ${HOSTNAME}" - -certbot --nginx --non-interactive --agree-tos --redirect --reuse-key \ +certbot certonly --nginx \ + --non-interactive --agree-tos --keep-until-expiring \ -m "${EMAIL}" -d "${HOSTNAME}" || true ############################################################################### -# step 5: create WSS 9001 config using certbot-generated certs +# step 6: HTTPS + WSS vhosts - only if the cert now exists ############################################################################### - if [[ -s "/etc/letsencrypt/live/${HOSTNAME}/fullchain.pem" ]]; then - echo "Certificate detected, creating WSS config" + echo "Certificate detected, enabling HTTPS and WSS vhosts" + + # HTTPS vhost (443) -> nym-node API 8080 + cat > "${SSL_CONF}" < clients port 9000 cat > "${WSS_CONF}" <