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