diff --git a/scripts/nym-node-setup/landing-page.html b/scripts/nym-node-setup/landing-page.html new file mode 100644 index 00000000000..4e094508709 --- /dev/null +++ b/scripts/nym-node-setup/landing-page.html @@ -0,0 +1,24 @@ + + + + + + Nym Node + + + +

Nym Node

+

This is a devrel testing placeholder page for Nym Node landing page.

+ + diff --git a/scripts/nym-node-setup/nym-node-cli.py b/scripts/nym-node-setup/nym-node-cli.py new file mode 100755 index 00000000000..8c97104e61f --- /dev/null +++ b/scripts/nym-node-setup/nym-node-cli.py @@ -0,0 +1,637 @@ +#!/usr/bin/python3 + +__version__ = "1.0.0" +__default_branch__ = "develop" + +import os +import re +import sys +import subprocess +import argparse +import tempfile +import shlex +import time +from datetime import datetime +from pathlib import Path +from typing import Iterable, Optional, Mapping +from typing import Optional, Tuple + +class NodeSetupCLI: + """All CLI main functions""" + + def __init__(self, args): + self.branch = args.dev + self.welcome_message = self.print_welcome_message() + self.mode = self.prompt_mode() + self.prereqs_install_sh = self.fetch_script("nym-node-prereqs-install.sh") + self.env_vars_install_sh = self.fetch_script("setup-env-vars.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.landing_page_html = self._check_gwx_mode() and self.fetch_script("landing-page.html") + self.nginx_proxy_wss_sh = self._check_gwx_mode() and self.fetch_script("nginx_proxy_wss_sh") + self.tunnel_manager_sh = self._check_gwx_mode() and self.fetch_script("network_tunnel_manager.sh") + self.wg_ip_tables_manager_sh = self._check_gwx_mode() and self.fetch_script("wireguard-exit-policy-manager.sh") + self.wg_ip_tables_test_sh = self._check_gwx_mode() and self.fetch_script("exit-policy-tests.sh") + + def print_welcome_message(self): + """Welcome user, warns for needed pre-reqs and asks for confimation""" + self.print_character("=", 41) + print(\ + "* * * * * * NYM - NODE - CLI * * * * * *\n" \ + "An interactive tool to download, install\n" \ + "* * * * * setup & run nym-node * * * * *" + ) + self.print_character("=", 41) + msg = \ + "Before you begin, make sure that:\n"\ + "1. You run this setup on Debian based Linux (ie Ubuntu)\n"\ + "2. You run this installation program from a root shell\n"\ + "3. You meet minimal requirements: https://nym.com/docs/operators/nodes\n"\ + "4. You accept Operators Terms & Conditions: https://nym.com/operators-validators-terms\n"\ + "5. You have Nym wallet with at least 101 NYM: https://nym.com/docs/operators/nodes/preliminary-steps/wallet-preparation\n"\ + "6. In case of Gateway behind reverse proxy, you have A and AAAA DNS record pointing to this IP and propagated\n"\ + "\nTo confirm and continue, write 'ACCEPT' and press enter:" + print(msg) + confirmation = input("\n") + if confirmation.upper() == "ACCEPT": + pass + else: + print("Without confirming the points above, we cannot continue.") + exit(1) + + def prompt_mode(self): + """Ask user to insert node functionality and save it in python and bash envs""" + mode = input( + "\nEnter the mode you want to run nym-node in: " + "\n1. mixnode " + "\n2. entry-gateway " + "\n3. exit-gateway (works as entry-gateway as well) " + "\nPress 1, 2 or 3 and enter:\n" + ).strip() + + if mode in ("1", "mixnode"): + mode = "mixnode" + elif mode in ("2", "entry-gateway"): + mode = "entry-gateway" + elif mode in ("3", "exit-gateway"): + mode = "exit-gateway" + else: + print("Only numbers 1, 2 or 3 are accepted.") + raise SystemExit(1) + + # save mode for this Python instance + self.mode = mode + os.environ["MODE"] = mode + + # persist to env.sh so other scripts can source it + env_file = Path("env.sh") + with env_file.open("a") as f: + f.write(f'export MODE="{mode}"\n') + + # source env.sh so future bash subprocesses see it immediately + subprocess.run("source ./env.sh", shell=True, executable="/bin/bash") + + print(f"Mode set to '{mode}' — stored in env.sh and sourced for immediate use.") + return mode + + + def fetch_script(self, script_name): + """Fetches needed scripts according to a defined mode""" + # 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 + + def _return_script_url(self, script_init_name): + """Dictionary pointing to scripts url returning value according to a passed key""" + github_raw_nymtech_nym_scripts_url = f"https://raw.githubusercontent.com/nymtech/nym/refs/heads/{self.branch}/scripts/" + scripts_urls = { + "nym-node-prereqs-install.sh": f"{github_raw_nymtech_nym_scripts_url}nym-node-setup/nym-node-prereqs-install.sh", + "setup-env-vars.sh": f"{github_raw_nymtech_nym_scripts_url}nym-node-setup/setup-env-vars.sh", + "nym-node-install.sh": f"{github_raw_nymtech_nym_scripts_url}nym-node-setup/nym-node-install.sh", + "setup-systemd-service-file.sh": f"{github_raw_nymtech_nym_scripts_url}nym-node-setup/setup-systemd-service-file.sh", + "start-node-systemd-service.sh": f"{github_raw_nymtech_nym_scripts_url}nym-node-setup/start-node-systemd-service.sh", + "nginx_proxy_wss_sh": f"{github_raw_nymtech_nym_scripts_url}nym-node-setup/setup-nginx-proxy-wss.sh", + "landing-page.html": f"{github_raw_nymtech_nym_scripts_url}nym-node-setup/landing-page.html", + "network_tunnel_manager.sh": f"https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/network_tunnel_manager.sh", + "wireguard-exit-policy-manager.sh": f"https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/wireguard-exit-policy/wireguard-exit-policy-manager.sh", + "exit-policy-tests.sh": f"https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/wireguard-exit-policy/exit-policy-tests.sh", + } + return scripts_urls[script_init_name] + + def run_script( + self, + script_text: str, + args: Optional[Iterable[str]] = None, + env: Optional[Mapping[str, str]] = None, + cwd: Optional[str] = None, + sudo: bool = False, # ignored for root; kept for signature compat + detached: bool = False, + ) -> int: + """ + Save script to a temp file and run it + - Automatically injects ENV_FILE= unless already provided + - Adds SYSTEMD_PAGER="" and SYSTEMD_COLORS="0" by default + Returns exit code (0 if detached fire-and-forget) + """ + import os, subprocess + + path = self._write_temp_script(script_text) + try: + # build env with sensible defaults + run_env = dict(os.environ) + if env: + run_env.update(env) + + # ensure ENV_FILE is absolute and present for all scripts + if "ENV_FILE" not in run_env: + # if env.sh is elsewhere, change this to your known base dir + env_file = os.path.abspath(os.path.join(os.getcwd(), "env.sh")) + run_env["ENV_FILE"] = env_file + + # make systemctl non-interactive everywhere + run_env.setdefault("SYSTEMD_PAGER", "") + run_env.setdefault("SYSTEMD_COLORS", "0") + + cmd = [str(path)] + (list(args) if args else []) + + if detached: + subprocess.Popen( + cmd, + env=run_env, + cwd=cwd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + close_fds=True, + ) + return 0 + else: + cp = subprocess.run(cmd, env=run_env, cwd=cwd) + return cp.returncode + finally: + try: + path.unlink(missing_ok=True) + except Exception: + pass + + def _write_temp_script(self, script_text: str) -> Path: + """Helper: write script text to a temp file, ensure bash shebang, chmod +x, return its path""" + if not script_text.lstrip().startswith("#!"): + script_text = "#!/usr/bin/env bash\n" + script_text + with tempfile.NamedTemporaryFile("w", delete=False, suffix=".sh") as f: + f.write(script_text) + path = Path(f.name) + os.chmod(path, 0o700) + return path + + def _check_gwx_mode(self): + """Helper: Several fns run only for GWx - this fn checks this condition""" + if self.mode == "exit-gateway": + return True + else: + return False + + def check_wg_enabled(self): + """Checks if Wireguard is enabled and if not, prompts user if they want to enable it, stores it to env.sh""" + + + env_file = os.path.abspath(os.path.join(os.getcwd(), "env.sh")) + + def norm(v): # -> "true" or "false" + return "true" if str(v).strip().lower() in ("1", "true", "yes", "y") else "false" + + # precedence: process env → env.sh → prompt + val = os.environ.get("WIREGUARD") + + if val is None and os.path.isfile(env_file): + try: + with open(env_file, "r", encoding="utf-8") as f: + m = re.search(r'^\s*export\s+WIREGUARD\s*=\s*"?([^"\n]+)"?', f.read(), re.M) + if m: + val = m.group(1) + except Exception: + pass + + if val is None: + ans = input( + "\nWireGuard is not configured.\n" + "Nodes routing WireGuard can be listed as both entry and exit in the app.\n" + "Enable WireGuard support? (y/n): " + ).strip().lower() + val = "true" if ans in ("y", "yes") else "false" + + val = norm(val) + os.environ["WIREGUARD"] = val + + # persist to env.sh (replace or append) + try: + text = "" + if os.path.isfile(env_file): + with open(env_file, "r", encoding="utf-8") as f: + text = f.read() + if re.search(r'^\s*export\s+WIREGUARD\s*=.*$', text, re.M): + text = re.sub(r'^\s*export\s+WIREGUARD\s*=.*$', f'export WIREGUARD="{val}"', text, flags=re.M) + else: + if text and not text.endswith("\n"): + text += "\n" + text += f'export WIREGUARD="{val}"\n' + with open(env_file, "w", encoding="utf-8") as f: + f.write(text) + print(f'WIREGUARD={val} saved to {env_file}') + except Exception as e: + print(f"Warning: could not write {env_file}: {e}") + + return (val == "true") + + def run_bash_command(self, command, args=None, *, env=None, cwd=None, check=True): + """ + Run a command with optional args (no script stdin) + `command` can be a string (e.g., "ls") or a list (e.g., ["ls", "-la"]). + """ + # normalize command into a list + if isinstance(command, str): + cmd = shlex.split(command) + else: + cmd = list(command) + + if args: + cmd += list(args) + + print("Running:", " ".join(shlex.quote(c) for c in cmd)) + return subprocess.run(cmd, env=env, cwd=cwd, check=check) + + + 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( + "\n* * * Setting up network configuration for mixnet IP router and Wireguard tunneling * * *" + "\nMore info: https://nym.com/docs/operators/nodes/nym-node/configuration#1-download-network_tunnel_managersh-make-executable-and-run" + "\nThis may take a while; follow the steps below and don't kill the process..." + ) + + # each entry is the exact argv to pass to the script + steps = [ + ["check_nymtun_iptables"], + ["remove_duplicate_rules", "nymtun0"], + ["remove_duplicate_rules", "nymwg"], + ["check_nymtun_iptables"], + ["adjust_ip_forwarding"], + ["apply_iptables_rules"], + ["check_nymtun_iptables"], + ["apply_iptables_rules_wg"], + ["configure_dns_and_icmp_wg"], + ["adjust_ip_forwarding"], + ["check_ipv6_ipv4_forwarding"], + ["joke_through_the_mixnet"], + ["joke_through_wg_tunnel"], + ] + + for argv in steps: + print("Running: network_tunnel_manager.sh", *argv) + rc = self.run_script(self.tunnel_manager_sh, args=argv) + if rc != 0: + print(f"Step {' '.join(argv)} failed with exit code {rc}. Stopping.") + return rc + + print("Network tunnel manager setup completed successfully.") + return 0 + + def setup_test_wg_ip_tables(self): + """Configuration and test of Wireguard exit policy according to mixnet exit policy using external scripts""" + print( + "Setting up Wireguard IP tables to match Nym exit policy for mixnet, stored at: https://nymtech.net/.wellknown/network-requester/exit-policy.txt" + "\nThis may take a while, follow the steps below and don't kill the process..." + ) + self.run_script(self.wg_ip_tables_manager_sh, args=["install"]) + self.run_script(self.wg_ip_tables_manager_sh, args=["status"]) + self.run_script(self.wg_ip_tables_test_sh) + + + def run_nym_node_as_service(self): + """Starts /etc/systemd/system/nym-node.service based on prompt using external script""" + service = "nym-node.service" + service_path = "/etc/systemd/system/nym-node.service" + print(f"\n* * * We are going to start {service} from systemd config located at: {service_path} * * *") + + # if the service file is missing, run setup non-interactively + if not os.path.isfile(service_path): + print(f"Service file not found at {service_path}. Running setup...") + setup_env = { + **os.environ, + "SYSTEMD_PAGER": "", + "SYSTEMD_COLORS": "0", + "NONINTERACTIVE": "1", + "MODE": os.environ.get("MODE", "mixnode"), + } + self.run_script(self.service_config_sh, env=setup_env) + if not os.path.isfile(service_path): + print("Service file still not found after setup. Aborting.") + return + + run_env = {**os.environ, "SYSTEMD_PAGER": "", "SYSTEMD_COLORS": "0", "WAIT_TIMEOUT": "600"} + is_active = subprocess.run(["systemctl", "is-active", "--quiet", service], env=run_env).returncode == 0 + + if is_active: + while True: + ans = input(f"{service} is already running. Restart it now? (y/n):\n").strip().lower() + if ans == "y": + self.run_script(self.start_node_systemd_service_sh, args=["restart-poll"], env=run_env) + return + elif ans == "n": + print("Continuing without restart.") + return + else: + print("Invalid input. Please press 'y' or 'n' and press enter.") + else: + while True: + ans = input(f"{service} is not running. Start it now? (y/n):\n").strip().lower() + if ans == "y": + self.run_script(self.start_node_systemd_service_sh, args=["start-poll"], env=run_env) + return + elif ans == "n": + print("Okay, not starting it.") + return + else: + print("Invalid input. Please press 'y' or 'n' and press enter.") + + + + def run_bonding_prompt(self): + """Interactive function navigating user to bond node""" + print("\n") + print("* * * Bonding Nym Node * * *") + print("Time to register your node to Nym Network by bonding it using Nym wallet ...") + node_path = os.path.expandvars(os.path.expanduser("$HOME/nym-binaries/nym-node")) + if not (os.path.isfile(node_path) and os.access(node_path, os.X_OK)): + print(f"Nym node not found at {node_path}, we cannot run a bonding prompt!") + exit(1) + else: + while True: + subprocess.run([os.path.expanduser(node_path), "bonding-information"]) + self.run_bash_command(command="curl", args=["-4", "https://ifconfig.me"]) + print("\n") + self.print_character("=", 56) + print("* * * FOLLOW THESE STEPS TO BOND YOUR NODE * * *") + print("If you already bonded your node before, just press enter") + self.print_character("=", 56) + print( + "1. Open your wallet and go to Bonding menu\n" + "2. Paste Identity key and your IP address (printed above)\n" + "3. Setup your operators cost and profit margin\n" + "4. Copy the long contract message from your wallet" + ) + msg = "5. Paste the contract message from clipboard here and press enter:\n" + contract_msg = input(msg).strip() + if contract_msg == "": + print("Skipping bonding process as your node is already bonded\n") + return + else: + subprocess.run([ + os.path.expanduser(node_path), + "sign", + "--contract-msg", + contract_msg + ]) + print( + "6. Copy the last part of the string back to your Nym wallet\n" + "7. Confirm the transaction" + ) + confirmation = input( + "\n* * * Is your node bonded?\n" + "1. YES\n" + "2. NO, try again\n" + "3. Skip bonding for now\n" + "Press 1, 2, or 3 and enter:\n" + ).strip() + + if confirmation == "1": + # NEW: fetch identity + composed message and print it + _, message = self._explorer_message_from_identity(node_path) + self.print_character("*", 42) + print(message) + self.print_character("*", 42) + return + elif confirmation == "3": + print( + "Your node is not bonded, we are skipping this step.\n" + "Note that without bonding network tunnel manager will not work fully!\n" + "You can always bond manually using:\n" + "`$HOME/nym-binaries/nym-node sign --contract-msg `" + ) + return + elif confirmation == "2": + continue + else: + print( + "Your input was wrong, we are skipping this step. You can always bond manually using:\n" + "`$HOME/nym-binaries/nym-node sign --contract-msg `" + ) + return + + def _explorer_message_from_identity(self, node_path: str) -> Tuple[Optional[str], str]: + """ + Runs `$HOME/nym-binaries/nym-node bonding-information` to + extract the id_key and returns explorer URL with a message + else return the message without the URL + """ + try: + cp = subprocess.run( + [os.path.expanduser(node_path), "bonding-information"], + capture_output=True, text=True, check=False, timeout=30 + ) + output = cp.stdout or "" + except Exception as e: + output = "" + # still return the generic message + key = None + msg = ( + "* * * C O N G R A T U L A T I O N ! * * *\n" + "Your Nym node is registered to Nym network\n" + "Wait until the end of epoch for the change\n" + "to propagate (max 60 min)\n" + "(Could not obtain Identity Key automatically.)" + ) + return key, msg + + # parse the id_key + m = re.search(r"^Identity Key:\s*([A-Za-z0-9]+)\s*$", output, flags=re.MULTILINE) + key = m.group(1) if m else None + + base_msg = ( + "* * * C O N G R A T U L A T I O N ! * * *\n" + "Your Nym node is registered to Nym network\n" + "Wait until the end of epoch for the change\n" + "to propagate (max 60 min)\n" + ) + + if key: + url = f"https://explorer.nym.spectredao.net/nodes/{key}" + msg = base_msg + f"Then you can see your node at:\n{url}" + else: + msg = base_msg + "(Could not obtain Identity Key automatically.)" + + return key, msg + + def print_character(self, ch: str, count: int): + """Print `ch` repeated `count` times (no unbounded growth)""" + if not ch: + return + # Use exactly one codepoint char; trim if longer + ch = ch[:1] + # Clamp count to a sensible max to avoid huge outputs + try: + n = int(count) + except Exception: + n = 0 + n = max(0, min(n, 161)) + print(ch * n) + + def _env_with_envfile(self) -> dict: + """Helper for env persistence sanity""" + env = dict(os.environ) + env["SYSTEMD_PAGER"] = "" + env["SYSTEMD_COLORS"] = "0" + env["ENV_FILE"] = os.path.abspath(os.path.join(os.getcwd(), "env.sh")) + return env + + def run_node_installation(self,args): + """Main function called by argparser command install running full node install flow""" + self.run_script(self.prereqs_install_sh) + self.run_script(self.env_vars_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) + self.run_nym_node_as_service() + self.run_bonding_prompt() + if self._check_gwx_mode(): + self.run_tunnel_manager_setup() + if self.check_wg_enabled(): + self.setup_test_wg_ip_tables() + self.setup_test_wg_ip_tables() + + + +class ArgParser: + """CLI argument interface managing the NodeSetupCLI functions based on user input""" + + def parser_main(self): + # shared options to work before and after subcommands + parent = argparse.ArgumentParser(add_help=False) + parent.add_argument( + "-V", "--version", + action="version", + version=f"nym-node-cli {__version__}" + ) + parent.add_argument("-d", "--dev", metavar="BRANCH", + help="Define github branch", + type=str, + default=argparse.SUPPRESS) + parent.add_argument("-v", "--verbose", action="store_true", + help="Show full error tracebacks") + + parser = argparse.ArgumentParser( + prog="nym-node-cli", + description="An interactive tool to download, install, setup and run nym-node", + epilog="Privacy infrastructure operated by people around the world", + parents=[parent], + ) + + subparsers = parser.add_subparsers(dest="command", help="subcommands") + subparsers.required = True + + p_install = subparsers.add_parser( + "install", parents=[parent], + help="Starts nym-node installation setup CLI", + aliases=["i", "I"], add_help=True + ) + + args = parser.parse_args() + + # assign default manually only if user didn’t supply --dev + if not hasattr(args, "dev"): + args.dev = __default_branch__ + + try: + # build CLI with parsed args to catch errors soon + cli = NodeSetupCLI(args) + + commands = { + "install": cli.run_node_installation, + "i": cli.run_node_installation, + "I": cli.run_node_installation, + } + + func = commands.get(args.command) + if func is None: + parser.print_help() + parser.error(f"Unknown command: {args.command}") + + # execute subcommand within error test + func(args) + + except SystemExit: + raise + except RuntimeError as e: + print(f"{e}\nMake sure that the your BRANCH ('{args.dev}') provided in --dev option contains this program.") + sys.exit(1) + except Exception as e: + if getattr(args, "verbose", False): + traceback.print_exc() + else: + print(f"error: {e}", file=sys.stderr) + sys.exit(1) + + +class SystemSafeGuards: + """A few safe guards to deal with memory usage by this program""" + + def _protect_from_oom(self, score: int = -900): + try: + with open("/proc/self/oom_score_adj", "w") as f: + f.write(str(score)) + except Exception: + pass + + def _trim_memory(self): + """Liberate freeable Python objects and return arenas to the OS if possible""" + try: + import gc, ctypes + gc.collect() + try: + libc = ctypes.CDLL("libc.so.6") + # 0 = “trim as much as possible” + libc.malloc_trim(0) + except Exception: + pass + except Exception: + pass + + def _cap_controller_memory(self, bytes_limit: int = 2 * 1024**3): + # limit this Python process to e.g. 2 GiB virtual memory + try: + import resource + resource.setrlimit(resource.RLIMIT_AS, (bytes_limit, bytes_limit)) + except Exception: + pass + + +if __name__ == '__main__': + safeguards = SystemSafeGuards() + 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() diff --git a/scripts/nym-node-setup/nym-node-install.sh b/scripts/nym-node-setup/nym-node-install.sh new file mode 100644 index 00000000000..13d97ede08c --- /dev/null +++ b/scripts/nym-node-setup/nym-node-install.sh @@ -0,0 +1,227 @@ +#!/bin/bash +set -euo pipefail + +echo -e "\n* * * Ensuring ~/nym-binaries exists * * *" +mkdir -p "$HOME/nym-binaries" + +# Load env.sh via absolute path if provided, else try ./env.sh +if [[ -n "${ENV_FILE:-}" && -f "${ENV_FILE}" ]]; then + set -a + # shellcheck disable=SC1090 + . "${ENV_FILE}" + set +a +elif [[ -f "./env.sh" ]]; then + set -a + # shellcheck disable=SC1091 + . ./env.sh + set +a +fi + +# check for existing node config and optionally reset +NODE_CONFIG_DIR="$HOME/.nym/nym-nodes/default-nym-node" + +check_existing_config() { + # proceed only if dir exists AND has any entries inside + if [[ -d "$NODE_CONFIG_DIR" ]] && find "$NODE_CONFIG_DIR" -mindepth 1 -maxdepth 1 | read -r _; then + echo + echo "Nym node configuration already exist at $NODE_CONFIG_DIR" + echo + echo "Initialising nym-node again will NOT overwrite your existing private keys, only adjust your preferences (like mode, wireguard optionality etc)." + echo + echo "If you want to remove your current node configuration and all data files including nodes keys type 'RESET' and press enter." + echo + read -r -p "To keep your existing node and just change its preferences press enter: " resp + + if [[ "${resp}" =~ ^([Rr][Ee][Ss][Ee][Tt])$ ]]; then + echo + read -r -p "We are going to remove the existing node with configuration $NODE_CONFIG_DIR and replace it with a fresh one, do you want to back up the old one first? (y/n) " backup_ans + if [[ "${backup_ans}" =~ ^[Yy]$ ]]; then + ts="$(date +%Y%m%d-%H%M%S)" + backup_dir="$HOME/.nym/backup/$(basename "$NODE_CONFIG_DIR")-$ts" + echo "Backing up to: $backup_dir" + mkdir -p "$(dirname "$backup_dir")" + cp -a "$NODE_CONFIG_DIR" "$backup_dir" + fi + echo "Removing $NODE_CONFIG_DIR ..." + rm -rf "$NODE_CONFIG_DIR" + echo "Old node removed. Proceeding with fresh initialization..." + else + echo "Keeping existing node configuration. Proceeding to re-configure." + export ASK_WG="1" + fi + fi +} + +# run the check before any initialization +check_existing_config + +echo -e "\n* * * Resolving latest release tag URL * * *" +LATEST_TAG_URL="$(curl -sI -L -o /dev/null -w '%{url_effective}' https://github.com/nymtech/nym/releases/latest)" +# expected example: https://github.com/nymtech/nym/releases/tag/nym-binaries-v2025.13-emmental + +if [[ -z "${LATEST_TAG_URL}" || "${LATEST_TAG_URL}" != *"/releases/tag/"* ]]; then + echo "ERROR: Could not resolve latest tag URL from GitHub." >&2 + exit 1 +fi + +DOWNLOAD_URL="${LATEST_TAG_URL/tag/download}/nym-node" +NYM_NODE="$HOME/nym-binaries/nym-node" + +# if binary already exists, ask to overwrite; if yes, remove first +if [[ -e "${NYM_NODE}" ]]; then + echo + echo -e "\n* * * A nym-node binary already exists at: ${NYM_NODE}" + read -r -p "Overwrite with the latest release? (y/n): " ow_ans + if [[ "${ow_ans}" =~ ^[Yy]$ ]]; then + echo "Removing existing binary to avoid 'text file busy'..." + rm -f "${NYM_NODE}" + else + echo "Keeping existing binary." + fi +fi + +echo -e "\n* * * Downloading nym-node from:" +echo " ${DOWNLOAD_URL}" +# only download if file is missing (or we just removed it) +if [[ ! -e "${NYM_NODE}" ]]; then + curl -fL "${DOWNLOAD_URL}" -o "${NYM_NODE}" +fi + +echo -e "\n * * * Making binary executable * * *" +chmod +x "${NYM_NODE}" + +echo "---------------------------------------------------" +echo "Nym node binary downloaded:" +"${NYM_NODE}" --version || true +echo "---------------------------------------------------" + +# check that MODE is set (after sourcing env.sh) +if [[ -z "${MODE:-}" ]]; then + echo "ERROR: Environment variable MODE is not set." + echo "Please export MODE as one of: mixnode, entry-gateway, exit-gateway" + exit 1 +fi + +# determine public IP (fallback if ifconfig.me fails) +echo -e "\n* * * Discovering public IP (IPv4) * * *" +if ! PUBLIC_IP="$(curl -fsS -4 https://ifconfig.me)"; then + PUBLIC_IP="$(curl -fsS https://api.ipify.org || echo '')" +fi +if [[ -z "${PUBLIC_IP}" ]]; then + echo "WARNING: Could not determine public IP automatically." +fi + +# respect existing WIREGUARD; for gateways: prompt if unset OR if we kept config and ASK_WG=1 +WIREGUARD="${WIREGUARD:-}" +if [[ ( "$MODE" == "entry-gateway" || "$MODE" == "exit-gateway" ) && ( -n "${ASK_WG:-}" || -z "$WIREGUARD" ) ]]; then + echo + echo "Gateways can also route WireGuard in NymVPN." + echo "Enabling it means your node may be listed as both entry and exit in the app." + # show current default in the prompt if present + def_hint="" + [[ -n "${WIREGUARD}" ]] && def_hint=" [current: ${WIREGUARD}]" + read -r -p "Enable WireGuard support? (y/n)${def_hint}: " answer || true + case "${answer:-}" in + [Yy]* ) WIREGUARD="true" ;; + [Nn]* ) WIREGUARD="false" ;; + * ) : ;; # keep existing value if user just pressed enter + esac +fi +# final default only if still empty +WIREGUARD="${WIREGUARD:-false}" + +# persist WIREGUARD to the same env file Python CLI uses +ENV_PATH="${ENV_FILE:-./env.sh}" +if [[ -n "$ENV_PATH" ]]; then + mkdir -p "$(dirname "$ENV_PATH")" + if [[ -f "$ENV_PATH" ]]; then + # replace existing export or append + if grep -qE '^[[:space:]]*export[[:space:]]+WIREGUARD=' "$ENV_PATH"; then + sed -i -E 's|^[[:space:]]*export[[:space:]]+WIREGUARD=.*$|export WIREGUARD="'"$WIREGUARD"'"|' "$ENV_PATH" + else + printf '\nexport WIREGUARD="%s"\n' "$WIREGUARD" >> "$ENV_PATH" + fi + else + printf 'export WIREGUARD="%s"\n' "$WIREGUARD" > "$ENV_PATH" + fi + echo "WIREGUARD=${WIREGUARD} persisted to $ENV_PATH" +fi + +# helpers: ensure optional env vars exist (avoid -u issues) +HOSTNAME="${HOSTNAME:-}" +LOCATION="${LOCATION:-}" +EMAIL="${EMAIL:-}" +MONIKER="${MONIKER:-}" +DESCRIPTION="${DESCRIPTION:-}" + +# initialize node config +case "${MODE}" in + mixnode) + echo -e "\n* * * Initialising nym-node in mode: mixnode * * *" + "${NYM_NODE}" run \ + --mode mixnode \ + ${PUBLIC_IP:+--public-ips "$PUBLIC_IP"} \ + ${HOSTNAME:+--hostname "$HOSTNAME"} \ + ${LOCATION:+--location "$LOCATION"} \ + -w \ + --init-only + ;; + entry-gateway) + echo -e "\n* * * Initialising nym-node in mode: entry-gateway * * *" + "${NYM_NODE}" run \ + --mode entry-gateway \ + ${PUBLIC_IP:+--public-ips "$PUBLIC_IP"} \ + ${HOSTNAME:+--hostname "$HOSTNAME"} \ + ${LOCATION:+--location "$LOCATION"} \ + --wireguard-enabled "${WIREGUARD}" \ + ${HOSTNAME:+--landing-page-assets-path "/var/www/${HOSTNAME}"} \ + -w \ + --init-only + ;; + exit-gateway) + echo -e "\n* * * Initialising nym-node in mode: exit-gateway * * *" + if [[ -z "${HOSTNAME:-}" || -z "${LOCATION:-}" ]]; then + echo "ERROR: HOSTNAME and LOCATION must be exported for exit-gateway." + exit 1 + fi + "${NYM_NODE}" run \ + --mode exit-gateway \ + ${PUBLIC_IP:+--public-ips "$PUBLIC_IP"} \ + --hostname "$HOSTNAME" \ + --location "$LOCATION" \ + --wireguard-enabled "${WIREGUARD:-false}" \ + --announce-wss-port 9001 \ + --landing-page-assets-path "/var/www/${HOSTNAME}" \ + -w \ + --init-only + ;; + *) + echo "ERROR: Unsupported MODE: '${MODE}'" + echo "Valid values: mixnode, entry-gateway, exit-gateway" + exit 1 + ;; +esac + +echo +echo "* * * nym-node initialised. Config path should be:" +echo " $HOME/.nym/nym-nodes/default-nym-node/" + +# setup description.toml (if init created the dir) +DESC_DIR="$HOME/.nym/nym-nodes/default-nym-node/data" +DESC_FILE="$DESC_DIR/description.toml" + +if [[ -d "$DESC_DIR" ]]; then + echo -e "\n* * * Writing node description: $DESC_FILE * * *" + mkdir -p "$DESC_DIR" + cat > "$DESC_FILE" < env.sh + +echo -e "\nVariables saved to ./env.sh" + +if [[ $__SOURCED -eq 1 ]]; then + # shellcheck disable=SC1091 + . ./env.sh + echo "Loaded into current shell (because you sourced this script)." +else + echo "To load them into your current shell, run: source ./env.sh" +fi diff --git a/scripts/nym-node-setup/setup-nginx-proxy-wss.sh b/scripts/nym-node-setup/setup-nginx-proxy-wss.sh new file mode 100644 index 00000000000..7dd613fd2ad --- /dev/null +++ b/scripts/nym-node-setup/setup-nginx-proxy-wss.sh @@ -0,0 +1,283 @@ +#!/usr/bin/env bash +set -euo pipefail + +# load env (prefer absolute ENV_FILE injected by Python CLI; fallback to ./env.sh) +if [[ -n "${ENV_FILE:-}" && -f "${ENV_FILE}" ]]; then + set -a; . "${ENV_FILE}"; set +a +elif [[ -f "./env.sh" ]]; then + set -a; . ./env.sh; set +a +fi + +: "${HOSTNAME:?HOSTNAME not set in env.sh}" +: "${EMAIL:?EMAIL not set in env.sh}" + +export SYSTEMD_PAGER="" +export SYSTEMD_COLORS="0" +DEBIAN_FRONTEND=noninteractive + +# sanity check +if [[ "${HOSTNAME}" == "localhost" || "${HOSTNAME}" == "127.0.0.1" ]]; then + echo "ERROR: HOSTNAME cannot be 'localhost'. Use a public FQDN." >&2 + exit 1 +fi + +echo -e "\n* * * Starting nginx configuration for landing page, reverse proxy and WSS * * *" + +# set paths & ports vars +WEBROOT="/var/www/${HOSTNAME}" +LE_ACME_DIR="/var/www/letsencrypt" +SITES_AVAIL="/etc/nginx/sites-available" +SITES_EN="/etc/nginx/sites-enabled" +BASE_HTTP="${SITES_AVAIL}/${HOSTNAME}" # :80 vhost +BASE_HTTPS="${SITES_AVAIL}/${HOSTNAME}-ssl" # :443 vhost (we’ll write it ourselves) +WSS_AVAIL="${SITES_AVAIL}/wss-config-nym" +BACKUP_DIR="/etc/nginx/sites-backups" + +NYM_PORT_HTTP="${NYM_PORT_HTTP:-8080}" +NYM_PORT_WSS="${NYM_PORT_WSS:-9000}" +WSS_LISTEN_PORT="${WSS_LISTEN_PORT:-9001}" + +mkdir -p "${WEBROOT}" "${LE_ACME_DIR}" "${BACKUP_DIR}" "${SITES_AVAIL}" "${SITES_EN}" + +# helpers +neat_backup() { + local file="$1"; [[ -f "$file" ]] || return 0 + local sha_now; sha_now="$(sha256sum "$file" | awk '{print $1}')" || return 0 + local tag; tag="$(basename "$file")" + local latest="${BACKUP_DIR}/${tag}.latest" + if [[ -f "$latest" ]]; then + local sha_prev; sha_prev="$(awk '{print $1}' "$latest")" + [[ "$sha_now" == "$sha_prev" ]] && return 0 + fi + cp -a "$file" "${BACKUP_DIR}/${tag}.bak.$(date +%s)" + echo "$sha_now ${tag}" > "$latest" + ls -1t "${BACKUP_DIR}/${tag}.bak."* 2>/dev/null | tail -n +6 | xargs -r rm -f +} + +ensure_enabled() { + local src="$1"; local name; name="$(basename "$src")" + ln -sf "$src" "${SITES_EN}/${name}" +} + +cert_ok() { + [[ -s "/etc/letsencrypt/live/${HOSTNAME}/fullchain.pem" && -s "/etc/letsencrypt/live/${HOSTNAME}/privkey.pem" ]] +} + +fetch_landing() { + local url="https://raw.githubusercontent.com/nymtech/nym/refs/heads/feature/node-setup-cli/scripts/nym-node-setup/landing-page.html" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "${WEBROOT}/index.html" || true + else + wget -qO "${WEBROOT}/index.html" "$url" || true + fi + if [[ ! -s "${WEBROOT}/index.html" ]]; then + cat > "${WEBROOT}/index.html" <<'HTML' +Nym Node + +

Nym node landing

+

This is a placeholder page served by nginx.

+ +HTML + fi +} + +reload_nginx() { nginx -t && systemctl reload nginx; } + +# landing page (idempotent) +fetch_landing +echo "Landing page at ${WEBROOT}/index.html" + +# disable default and stale SSL configs +[[ -L "${SITES_EN}/default" ]] && unlink "${SITES_EN}/default" || true +for f in "${SITES_EN}"/*; do + [[ -L "$f" ]] || continue + if grep -q "/etc/letsencrypt/live/localhost" "$f"; then + echo "Disabling vhost referencing localhost cert: $f"; unlink "$f" + fi +done +for f in "${SITES_EN}"/*; do + [[ -L "$f" ]] || continue + if grep -qE 'listen\s+.*443' "$f"; then + cert=$(awk '/ssl_certificate[ \t]+/ {print $2}' "$f" | tr -d ';' | head -n1) + key=$(awk '/ssl_certificate_key[ \t]+/ {print $2}' "$f" | tr -d ';' | head -n1) + if [[ -n "${cert:-}" && ! -s "$cert" ]] || [[ -n "${key:-}" && ! -s "$key" ]]; then + echo "Disabling SSL vhost with missing cert/key: $f"; unlink "$f" + fi + fi +done + +# HTTP :80 vhost (ACME-safe, proxy to :8080) +neat_backup "${BASE_HTTP}" +cat > "${BASE_HTTP}" </dev/null; then + echo "WARNING: Can't reach Let's Encrypt directory. We'll still keep HTTP up." >&2 +fi +THIS_IP="$(curl -fsS -4 https://ifconfig.me || true)" +DNS_IP="$(getent ahostsv4 "${HOSTNAME}" 2>/dev/null | awk '{print $1; exit}')" +echo "Public IPv4: ${THIS_IP:-unknown} DNS A(${HOSTNAME}): ${DNS_IP:-unresolved}" +if [[ -n "${THIS_IP:-}" && -n "${DNS_IP:-}" && "${THIS_IP}" != "${DNS_IP}" ]]; then + echo "WARNING: DNS for ${HOSTNAME} does not match this server's public IPv4." +fi +timedatectl show -p NTPSynchronized --value 2>/dev/null | grep -qi yes || timedatectl set-ntp true || true + +# install certbot if missing +if ! command -v certbot >/dev/null 2>&1; then + if command -v snap >/dev/null 2>&1; then + snap install core || true; snap refresh core || true + snap install --classic certbot; ln -sf /snap/bin/certbot /usr/bin/certbot + else + apt-get update -y >/dev/null 2>&1 || true + apt-get install -y certbot >/dev/null 2>&1 || true + fi +fi + +# issue/renew via WEBROOT (no nginx auto-edit), non-fatal if it fails +STAGING_FLAG=""; [[ "${CERTBOT_STAGING:-0}" == "1" ]] && STAGING_FLAG="--staging" && echo "Using Let's Encrypt STAGING." +if ! cert_ok; then + certbot certonly --non-interactive --agree-tos -m "${EMAIL}" -d "${HOSTNAME}" \ + --webroot -w "${LE_ACME_DIR}" ${STAGING_FLAG} || true +fi + +# create own 443 vhost (only if certs exist) +if cert_ok; then + neat_backup "${BASE_HTTPS}" + cat > "${BASE_HTTPS}" <HTTPS (keeps ACME path in HTTP too via separate small server) + neat_backup "${BASE_HTTP}" + cat > "${BASE_HTTP}" < "${WSS_AVAIL}" < "$SERVICE_PATH" </dev/null || echo unknown)" + sub="$(systemctl show -p SubState --value "$SERVICE" 2>/dev/null || echo unknown)" + result="$(systemctl show -p Result --value "$SERVICE" 2>/dev/null || echo unknown)" + local cur="${active}/${sub}/${result}" + if [ "$cur" != "$last" ]; then + echo "state: ActiveState=${active} SubState=${sub} Result=${result}" + last="$cur" + fi + [ "$active" = "active" ] && return 0 + if [ "$active" = "failed" ] || [ "$result" = "failed" ] || [ "$result" = "exit-code" ] || [ "$result" = "timeout" ]; then + return 1 + fi + sleep 1 + done + echo "timeout: ${WAIT_TIMEOUT}s exceeded while waiting for ${SERVICE}" + return 1 +} + +restart_poll() { + reload_and_reset + echo "Restarting $SERVICE (non-blocking) and polling up to ${WAIT_TIMEOUT}s..." + systemctl --no-ask-password restart --no-block "$SERVICE" + wait_until_active_or_fail +} + +start_poll() { + reload_and_reset + echo "Starting $SERVICE (non-blocking) and polling up to ${WAIT_TIMEOUT}s..." + systemctl --no-ask-password start --no-block "$SERVICE" + wait_until_active_or_fail +} + +case "${1:-}" in + restart-poll) restart_poll ;; + start-poll) start_poll ;; + *) echo "Usage: $0 {start-poll|restart-poll}"; exit 2 ;; +esac