Feature: Nym node autorun CLI (#5916)
* initial commit - add prereqs install script * add env vars prompt * automate latest binary url env var * add install node script * add modes to nym-node install script * start main cli framework * adding branch var for easier deployment and testing * add systemd config * add proxy and wss setup script * add landing page stub and fix nginx script * add nginx setup * fix typo * add checks for existing dir and wg prompt * add nginx commands * add service file check * add service file check * convention alignment * add checks to nginx setup * cleanup old code * add bonding prompt and nym node run fns * fix syntax * fix syntax * fix syntax * fix syntax * fix syntax * fix syntax * fix syntax * fix syntax * add service script to init * fix syntax * fix syntax * add chmod * fix script logic * syntax fix * syntax fix * silent mode trial * fix evn prompt script * make scripts interactive * indent fix * correct node-install script * initial mixnode setup working - gws need more love * fix bonding function * syntax fix * improve run noide as service script * improve service script * improve run service fn * fix logic * beautify * beautify * create run node as service script * syntax fix * attempt to resolve memory running out issue * attempt to resolve memory running out issue * attempt to resolve memory running out issue * attempt to resolve memory running out issue * attempt to resolve memory running out issue * attempt to resolve memory running out issue * attempt to resolve memory running out issue * attempt to resolve memory running out issue * setting wireguard * solved memory issues * rename landing page template * modify wireguard enabled fn * layout change * syntax fix * modify node setup script * sync up envs * return missing function * fix urls * fix network manager script execution * fix wss and nginx * fix layout * tweak WG contion * syntax fix * add init placeholder * syntax fix * redefine wireguard check logic * check if node exists * add argparse and dev option * styling * add panic * add error message * improve logic * improve logic * add arg * add dev arg for all levels * add confirmation loop * styling * fix bonding question * syntax edit * syntax edit * syntax edit * refactor for already bonded nodes * add default branch on top and define metavar * fix node install script * clean and prepare for review * indentation fix * fix nginx setup * fix nginx setup * style cleanup * fix try error logic * tune --dev option to run before command correctly * fix y/n convention across the modules * add explorer URL to the message * minor layout fixes
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nym Node</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
padding: 2em;
|
||||
background-color: #111;
|
||||
color: #0ff;
|
||||
}
|
||||
h1 {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Nym Node</h1>
|
||||
<p>This is a devrel testing placeholder page for Nym Node landing page.</p>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+637
@@ -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=<abs path to ./env.sh> 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 <CONTRACT_MESSAGE>`"
|
||||
)
|
||||
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 <CONTRACT_MESSAGE>`"
|
||||
)
|
||||
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()
|
||||
@@ -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" <<EOF
|
||||
moniker = "${MONIKER}"
|
||||
website = "${HOSTNAME}"
|
||||
security_contact = "${EMAIL}"
|
||||
details = "${DESCRIPTION}"
|
||||
EOF
|
||||
echo "* * * Node description saved * * *"
|
||||
echo "You can edit it later at: $DESC_FILE (restart node to apply)."
|
||||
else
|
||||
echo "NOTE: Description directory not found yet ($DESC_DIR)."
|
||||
echo " It will exist after a full init; you can create the file later."
|
||||
fi
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
# update, upgrade & 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 'Nginx Full' && \
|
||||
ufw reload && \
|
||||
ufw status
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup_env.sh
|
||||
|
||||
# set -euo pipefail
|
||||
|
||||
echo -e "\n* * * Setting up environmental variables to ./env.sh * * *"
|
||||
|
||||
# detect if we're being sourced
|
||||
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
|
||||
__SOURCED=1
|
||||
else
|
||||
__SOURCED=0
|
||||
fi
|
||||
|
||||
while true; do
|
||||
# prompt user
|
||||
read -rp "Enter hostname (if you don't use a DNS, press enter): " HOSTNAME
|
||||
read -rp "Enter node location (country code or name): " LOCATION
|
||||
read -rp "Enter your email: " EMAIL
|
||||
read -rp "Enter node public moniker (visible in the explorer and NymVPN app): " MONIKER
|
||||
read -rp "Enter node public description: " DESCRIPTION
|
||||
|
||||
# show summary table
|
||||
echo -e "\nPlease confirm the values you entered:"
|
||||
echo "---------------------------------------"
|
||||
printf "%-20s %s\n" "HOSTNAME:" "$HOSTNAME"
|
||||
printf "%-20s %s\n" "LOCATION:" "$LOCATION"
|
||||
printf "%-20s %s\n" "EMAIL:" "$EMAIL"
|
||||
printf "%-20s %s\n" "MONIKER:" "$MONIKER"
|
||||
printf "%-20s %s\n" "DESCRIPTION:" "$DESCRIPTION"
|
||||
echo "---------------------------------------"
|
||||
|
||||
read -rp "Are these correct? (y/n): " CONFIRM
|
||||
|
||||
case "$CONFIRM" in
|
||||
[Yy]* ) break ;; # confirmed, exit loop
|
||||
[Nn]* ) echo -e "\nLet's try again...\n" ;; # loop restarts
|
||||
* ) echo "Please answer y or n." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# try to get the latest binary URL (non-fatal if missing)
|
||||
LATEST_BINARY=$(
|
||||
curl -fsSL https://github.com/nymtech/nym/releases/latest \
|
||||
| grep -Eo 'href="/nymtech/nym/releases/download/[^"]+/nym-node"' \
|
||||
| head -n1 \
|
||||
| cut -d'"' -f2
|
||||
)
|
||||
if [[ -z "${LATEST_BINARY:-}" ]]; then
|
||||
echo "WARNING: Could not determine latest nym-node binary URL right now. The installer will resolve it later."
|
||||
fi
|
||||
|
||||
PUBLIC_IP=$(curl -fsS -4 https://ifconfig.me || true)
|
||||
PUBLIC_IP=${PUBLIC_IP:-""}
|
||||
|
||||
# write env.sh
|
||||
{
|
||||
[[ -n "${LATEST_BINARY:-}" ]] && echo "export LATEST_BINARY=\"https://github.com${LATEST_BINARY}\""
|
||||
echo "export HOSTNAME=\"${HOSTNAME}\""
|
||||
echo "export LOCATION=\"${LOCATION}\""
|
||||
echo "export EMAIL=\"${EMAIL}\""
|
||||
echo "export MONIKER=\"${MONIKER}\""
|
||||
echo "export DESCRIPTION=\"${DESCRIPTION}\""
|
||||
echo "export PUBLIC_IP=\"${PUBLIC_IP}\""
|
||||
} > 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
|
||||
@@ -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'
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>Nym Node</title></head>
|
||||
<body style="font-family:sans-serif;margin:2rem">
|
||||
<h1>Nym node landing</h1>
|
||||
<p>This is a placeholder page served by nginx.</p>
|
||||
</body></html>
|
||||
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}" <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${HOSTNAME};
|
||||
|
||||
# ACME challenge path (HTTP only)
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root ${LE_ACME_DIR};
|
||||
default_type "text/plain";
|
||||
}
|
||||
|
||||
root ${WEBROOT};
|
||||
index index.html;
|
||||
|
||||
location = /favicon.ico { return 204; access_log off; log_not_found off; }
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ @app;
|
||||
}
|
||||
|
||||
location @app {
|
||||
proxy_pass http://127.0.0.1:${NYM_PORT_HTTP};
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
ensure_enabled "${BASE_HTTP}"
|
||||
reload_nginx
|
||||
systemctl status nginx --no-pager | sed -n '1,6p' || true
|
||||
|
||||
# ACME preflight (informative)
|
||||
echo -e "\n* * * ACME preflight checks * * *"
|
||||
if ! curl -fsSL https://acme-v02.api.letsencrypt.org/directory >/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}" <<EOF
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name ${HOSTNAME};
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/${HOSTNAME}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/${HOSTNAME}/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
root ${WEBROOT};
|
||||
index index.html;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
location = /favicon.ico { return 204; access_log off; log_not_found off; }
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ @app;
|
||||
}
|
||||
|
||||
location @app {
|
||||
proxy_pass http://127.0.0.1:${NYM_PORT_HTTP};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
ensure_enabled "${BASE_HTTPS}"
|
||||
|
||||
# optional: redirect HTTP->HTTPS (keeps ACME path in HTTP too via separate small server)
|
||||
neat_backup "${BASE_HTTP}"
|
||||
cat > "${BASE_HTTP}" <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${HOSTNAME};
|
||||
|
||||
# Keep ACME reachable over HTTP:
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root ${LE_ACME_DIR};
|
||||
default_type "text/plain";
|
||||
}
|
||||
|
||||
# Redirect the rest to HTTPS
|
||||
location / {
|
||||
return 301 https://\$host\$request_uri;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
ensure_enabled "${BASE_HTTP}"
|
||||
reload_nginx
|
||||
else
|
||||
echo "NOTE: Cert not present yet; HTTPS (443) will not listen. Only HTTP (80) is active."
|
||||
fi
|
||||
|
||||
# WSS TLS :9001 (only if certs exist)
|
||||
if cert_ok; then
|
||||
neat_backup "${WSS_AVAIL}"
|
||||
cat > "${WSS_AVAIL}" <<EOF
|
||||
server {
|
||||
listen ${WSS_LISTEN_PORT} ssl http2;
|
||||
listen [::]:${WSS_LISTEN_PORT} ssl http2;
|
||||
server_name ${HOSTNAME};
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/${HOSTNAME}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/${HOSTNAME}/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
location = /favicon.ico { return 204; access_log off; log_not_found off; }
|
||||
|
||||
location / {
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, HEAD' always;
|
||||
add_header 'Access-Control-Allow-Headers' '*' always;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header X-Forwarded-For \$remote_addr;
|
||||
|
||||
proxy_pass http://127.0.0.1:${NYM_PORT_WSS};
|
||||
proxy_intercept_errors on;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
ensure_enabled "${WSS_AVAIL}"
|
||||
reload_nginx
|
||||
fi
|
||||
|
||||
echo -e "\nDone."
|
||||
if cert_ok; then
|
||||
echo "HTTP : http://${HOSTNAME}/ (redirects to HTTPS)"
|
||||
echo "TLS : https://${HOSTNAME}/ (served by nginx)"
|
||||
echo "WSS : wss://${HOSTNAME}:${WSS_LISTEN_PORT}/ (served by nginx)"
|
||||
else
|
||||
echo "Only HTTP is active (no cert yet). Re-run after DNS/ACME is ready to enable HTTPS + WSS."
|
||||
fi
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/bin/bash
|
||||
# service_config_sh
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE_PATH="/etc/systemd/system/nym-node.service"
|
||||
|
||||
normalize_mode() {
|
||||
local input="${1,,}"
|
||||
case "$input" in
|
||||
1|"mixnode") echo "mixnode" ;;
|
||||
2|"entry-gateway"|"entrygateway"|"entry") echo "entry-gateway" ;;
|
||||
3|"exit-gateway"|"exitgateway"|"exit") echo "exit-gateway" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
ensure_mode() {
|
||||
local m="${MODE:-}"
|
||||
if [[ -z "$m" ]]; then
|
||||
read -rp "Select mode: " m
|
||||
fi
|
||||
m="$(normalize_mode "$m")"
|
||||
while [[ -z "$m" ]]; do
|
||||
echo "Invalid mode. Allowed: mixnode, entry-gateway, exit-gateway (or 1/2/3)."
|
||||
read -rp "Select mode: " m
|
||||
m="$(normalize_mode "$m")"
|
||||
done
|
||||
MODE="$m"
|
||||
}
|
||||
|
||||
create_service_file() {
|
||||
cat > "$SERVICE_PATH" <<EOF
|
||||
[Unit]
|
||||
Description=Nym Node
|
||||
StartLimitInterval=350
|
||||
StartLimitBurst=10
|
||||
|
||||
[Service]
|
||||
User=root
|
||||
LimitNOFILE=65536
|
||||
ExecStart=/root/nym-binaries/nym-node run --mode ${MODE} --accept-operator-terms-and-conditions
|
||||
KillSignal=SIGINT
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
# If you want memory caps, uncomment and tune:
|
||||
# MemoryHigh=800M
|
||||
# MemoryMax=1G
|
||||
# MemorySwapMax=1G
|
||||
# OOMScoreAdjust=500
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo "Service file saved in $SERVICE_PATH, printing it below for control:"
|
||||
cat "$SERVICE_PATH"
|
||||
echo "* * * Reloading systemd and enabling service..."
|
||||
systemctl daemon-reload
|
||||
systemctl enable nym-node.service
|
||||
}
|
||||
|
||||
echo -e "\n* * * Setting up systemd service config file for node automation * * *"
|
||||
|
||||
# if already exists, just reload + enable and exit
|
||||
if [[ -f "$SERVICE_PATH" ]]; then
|
||||
echo "Service file already exists at: $SERVICE_PATH"
|
||||
echo "* * * Reloading systemd and enabling service..."
|
||||
systemctl daemon-reload
|
||||
systemctl enable nym-node.service
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# non-interactive creation (used by Python)
|
||||
if [[ "${NONINTERACTIVE:-}" = "1" ]]; then
|
||||
MODE="$(normalize_mode "${MODE:-}")"
|
||||
if [[ -z "$MODE" ]]; then
|
||||
echo "NONINTERACTIVE=1 requires MODE to be set to 1/2/3 or mixnode|entry-gateway|exit-gateway."
|
||||
exit 2
|
||||
fi
|
||||
create_service_file
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# interactive path (manual runs)
|
||||
ensure_mode
|
||||
read -rp "Service file not found. Create it now? (y/n): " create_ans
|
||||
if [[ "${create_ans:-}" =~ ^[Yy]$ ]]; then
|
||||
create_service_file
|
||||
else
|
||||
echo "Not creating the service file."
|
||||
fi
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
|
||||
# start_node_systemd_service_sh (control script)
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE="nym-node.service"
|
||||
export SYSTEMD_PAGER=""
|
||||
export SYSTEMD_COLORS="0"
|
||||
SYSTEMCTL="systemctl --no-ask-password --quiet"
|
||||
WAIT_TIMEOUT="${WAIT_TIMEOUT:-600}" # seconds
|
||||
|
||||
reload_and_reset() { $SYSTEMCTL daemon-reload || true; $SYSTEMCTL reset-failed "$SERVICE" || true; }
|
||||
|
||||
wait_until_active_or_fail() {
|
||||
local deadline=$(( $(date +%s) + WAIT_TIMEOUT ))
|
||||
local last=""
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
local active sub result
|
||||
active="$(systemctl show -p ActiveState --value "$SERVICE" 2>/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
|
||||
Reference in New Issue
Block a user