Compare commits

...

5 Commits

Author SHA1 Message Date
durch a880b59f03 Force IPv4 2025-09-04 12:47:45 +02:00
import this 4062734a31 [DOCs/operators]: NIP-2 tokenomics update & fix csv2md bug (#6008) 2025-09-04 11:29:44 +02:00
import this ccd8ff26a3 Feature: Delegation program stake checker and adjuster (#5980)
* initialise stake adjustment program

* add readme file with a simple guide

* syntax

* syntax

* FINISHED: faster and returning more data

* change dwl link to develop branch
2025-09-03 16:06:06 +00:00
import this 43d043a9cd 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
2025-09-02 20:34:24 +00:00
Drazen Urch 3d6cf730c2 NS-API: Cast to BIGINT to make i64 work (#6003) 2025-09-02 18:35:25 +01:00
30 changed files with 2132 additions and 76 deletions
+5
View File
@@ -151,6 +151,7 @@ use reqwest::{RequestBuilder, Response};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use std::net::IpAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use thiserror::Error;
@@ -158,6 +159,7 @@ use tracing::{debug, instrument, warn};
#[cfg(not(target_arch = "wasm32"))]
use std::net::SocketAddr;
use std::str::FromStr;
use std::sync::Arc;
#[cfg(feature = "tunneling")]
@@ -412,11 +414,14 @@ impl ClientBuilder {
//
// I am going to leave these here anyways so that removing a decompression method
// from the features list will throw an error if it is not also removed here.
warn!("FORCING IPv4 CONNECTIONS");
reqwest::ClientBuilder::new()
.gzip(true)
.deflate(true)
.brotli(true)
.zstd(true)
.local_address(IpAddr::from_str("0.0.0.0:0").unwrap())
};
ClientBuilder {
@@ -5,7 +5,7 @@
},
"mixmining_reserve": {
"denom": "unym",
"amount": "184339094131786"
"amount": "182883243257647"
},
"vesting_tokens": {
"denom": "unym",
@@ -13,6 +13,6 @@
},
"circulating_supply": {
"denom": "unym",
"amount": "815660905868214"
"amount": "817116756742353"
}
}
@@ -1 +1 @@
815_660_905
817_116_756
@@ -1 +1 @@
249_796_152
60_000_000
@@ -1 +1 @@
249_796_152
60_000_000
@@ -1,7 +1,7 @@
| **Item** | **Description** | **Amount in NYM** |
|:-------------------|:------------------------------------------------------|--------------------:|
| Total Supply | Maximum amount of NYM token in existence | 1_000_000_000 |
| Mixmining Reserve | Tokens releasing for operators rewards | 184_339_094 |
| Mixmining Reserve | Tokens releasing for operators rewards | 182_883_243 |
| Vesting Tokens | Tokens locked outside of cicrulation for future claim | 0 |
| Circulating Supply | Amount of unlocked tokens | 815_660_905 |
| Stake Saturation | Optimal size of node self-bond + delegation | 1_040_817 |
| Circulating Supply | Amount of unlocked tokens | 817_116_756 |
| Stake Saturation | Optimal size of node self-bond + delegation | 250_000 |
@@ -1,18 +1,18 @@
{
"interval": {
"reward_pool": "184339094131786.145886263466367605",
"staking_supply": "249796152422140.492822331813424919",
"staking_supply_scale_factor": "0.30625",
"epoch_reward_budget": "5120530392.54961516350731851",
"stake_saturation_point": "1040817301758.918720093049222603",
"reward_pool": "182883243257647.891553460395608456",
"staking_supply": "60000000000000",
"staking_supply_scale_factor": "0.07342892",
"epoch_reward_budget": "5080090090.490219209818344322",
"stake_saturation_point": "250000000000",
"sybil_resistance": "0.3",
"active_set_work_factor": "10",
"interval_pool_emission": "0.02"
},
"rewarded_set": {
"entry_gateways": 50,
"exit_gateways": 70,
"mixnodes": 120,
"entry_gateways": 80,
"exit_gateways": 100,
"mixnodes": 60,
"standby": 0
}
}
@@ -1 +1 @@
Friday, August 22nd 2025, 10:15:08 UTC
Thursday, September 4th 2025, 09:12:25 UTC
@@ -58,8 +58,8 @@ Options:
Specifies whether the wireguard service is enabled on this node [env: NYMNODE_WG_ENABLED=] [possible values: true, false]
--wireguard-bind-address <WIREGUARD_BIND_ADDRESS>
Socket address this node will use for binding its wireguard interface. default: `[::]:51822` [env: NYMNODE_WG_BIND_ADDRESS=]
--wireguard-tunnel-announced-port <WIREGUARD_TUNNEL_ANNOUNCED_PORT>
Tunnel port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=]
--wireguard-announced-port <WIREGUARD_ANNOUNCED_PORT>
Port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=]
--wireguard-private-network-prefix <WIREGUARD_PRIVATE_NETWORK_PREFIX>
The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is 32 and for IPv6 is 128 [env: NYMNODE_WG_PRIVATE_NETWORK_PREFIX=]
--verloc-bind-address <VERLOC_BIND_ADDRESS>
@@ -4,7 +4,7 @@
| [AmeriNoc](https://www.amerinoc.com) | USA | Yes | nan | nan | 07/2025 |
| [BitLaunch](https://bitlaunch.io) | Canada, USA, UK | No | Yes | Expensive. Digial Ocean through BitLanch has IPv6 | 05/2024 |
| [Cherry Servers](https://www.cherryservers.com) | Lithuania, Netherlands, USA, Singapore | No | Yes | Issued IP doesnt match the location offered by the provider. | 05/2024 |
| [Colocall](https://www.colocall.net/) | Ukraine | Yes | nan | 07/2025 | nan |
| [Colocall](https://www.colocall.net/) | Ukraine | Yes | nan | nan | 07/2025 |
| [DataPacket](https://www.datapacket.com/pricing) | NL, GR, SK, BE, RO, HU, DK, IE, DE, UA, PT, GB, ES, FR, IT, NO, CZ, BG, SE, AT, PL, HR, CH, USA, CO, AR, PE, MX, CL, TR, ZA, NG, IL, HK, AU, SG, JP | Yes | nan | nan | 07/2025 |
| [Dataclub](https://www.dataclub.eu/) | Latvia, Sweden, Netherlands | Yes | nan | nan | 07/2027 |
| [Flokinet](https://flokinet.is) | Netherlands, Iceland, Romania,France | Yes, needs a ticket and custom setup | yes, including XMR | Very slow customer support | 05/2024 |
@@ -13,6 +13,7 @@
| [HostSailor](https://hostsailor.com) | USA | Yes, based on ticket | Yes | The IPv6 setup needs custom research and is not documented | 05/2024 |
| [Hostiko](https://hostiko.com.ua) | Ukraine, Germany | Yes, on by default | Yes | Ukrainian provider. They allow Exit nodes on Germany boxes but limit the bandwidth, you also have to restrict certain ports like 25 and 587. Make sure you open a ticket. | 07/2024 |
| [Hostinger](https://hostinger.com) | France, Lithuania, India, USA, Brazil | Yes, out of the box | Yes | Not fast enough, Crypto payments must be done per each server monthly or annually. | 07/2025 |
| [Hostraha](https://hostraha.com) | Kenya and other African countries | No, but advertised otherwise | Yes, USDT TRC20 | Don't recommend. Unresponsive technical and billing support, never provided IPv6 even though advertised and paid for. When VPS cancelled, company still tried to bill the credit card on file multiple times. | 08/2025 |
| [Hostroyale](https://hostroyale.com/hosting/dedicated-server/) | Various countries with different pricing | nan | Yes | nan | 07/2025 |
| [Hostslick](https://hostslick.com) | Netherlands, Germany | Yes, on by default | Yes | Good amount of bandwidth for the price. Make sure you open the ticket if you want to run Exit node | 07/2024 |
| [Incognet](https://incognet.io) | Netherlands and USA | Yes, on by default | Yes | They allow Tor exit nodes but you must adhere to their rules https://incognet.io/tor-exits | 07/2024 |
+2 -2
View File
@@ -32,7 +32,7 @@
[Zenlayer](https://www.zenlayer.com/bare-metal/), [advertised over 50 locations](50+ https://www.zenlayer.com/global-network),,,,07/2025
[PrivateLayer](https://privatelayer.com),Swiss,Yes,Yes,Slow customer response,07/2025
[AmeriNoc](https://www.amerinoc.com),USA,Yes,,,07/2025
[Colocall](https://www.colocall.net/),Ukraine,Yes,,07/2025,
[Colocall](https://www.colocall.net/),Ukraine,Yes,,,07/2025
[Incognet](https://incognet.io/kansas-city-dedicated-servers),"USA, Netherlands",Yes,,,07/2025
[FranTech](https://my.frantech.ca),USA,Yes,,,07/2025
[Psychz](https://www.psychz.net),"US, UK, Brazil, Japan, Russia, South Africa and many more",Yes,,,07/2025
@@ -41,4 +41,4 @@
[Dataclub](https://www.dataclub.eu/),"Latvia, Sweden, Netherlands",Yes,,,07/2027
[Privex](https://www.privex.io/tor-exit-policy/),"USA, Germany, Sweden",Yes,Yes,,07/2025
[Svea](https://svea.net/vps),Sweden,Yes,,,07/2025
[Hostraha](https://hostraha.com),"Kenya and other African countries", "No, but advertised otherwise", "Yes, USDT TRC20", "Don't recommend. Unresponsive technical and billing support, never provided IPv6 even though advertised and paid for. When VPS cancelled, company still tried to bill the credit card on file multiple times.", 08/2025
[Hostraha](https://hostraha.com),Kenya and other African countries,"No, but advertised otherwise","Yes, USDT TRC20","Don't recommend. Unresponsive technical and billing support, never provided IPv6 even though advertised and paid for. When VPS cancelled, company still tried to bill the credit card on file multiple times.",08/2025
1 **ISP** **Locations** **Public IPv6** **Crypto Payments** **Comments** **Last Updated**
32 [Zenlayer](https://www.zenlayer.com/bare-metal/) [advertised over 50 locations](50+ https://www.zenlayer.com/global-network) 07/2025
33 [PrivateLayer](https://privatelayer.com) Swiss Yes Yes Slow customer response 07/2025
34 [AmeriNoc](https://www.amerinoc.com) USA Yes 07/2025
35 [Colocall](https://www.colocall.net/) Ukraine Yes 07/2025 07/2025
36 [Incognet](https://incognet.io/kansas-city-dedicated-servers) USA, Netherlands Yes 07/2025
37 [FranTech](https://my.frantech.ca) USA Yes 07/2025
38 [Psychz](https://www.psychz.net) US, UK, Brazil, Japan, Russia, South Africa and many more Yes 07/2025
41 [Dataclub](https://www.dataclub.eu/) Latvia, Sweden, Netherlands Yes 07/2027
42 [Privex](https://www.privex.io/tor-exit-policy/) USA, Germany, Sweden Yes Yes 07/2025
43 [Svea](https://svea.net/vps) Sweden Yes 07/2025
44 [Hostraha](https://hostraha.com) Kenya and other African countries No, but advertised otherwise Yes, USDT TRC20 Don't recommend. Unresponsive technical and billing support, never provided IPv6 even though advertised and paid for. When VPS cancelled, company still tried to bill the credit card on file multiple times. 08/2025
@@ -1,44 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n date_utc as \"date_utc!\",\n SUM(total_stake) as \"total_stake!: i64\",\n SUM(packets_received) as \"total_packets_received!: i64\",\n SUM(packets_sent) as \"total_packets_sent!: i64\",\n SUM(packets_dropped) as \"total_packets_dropped!: i64\"\n FROM (\n SELECT\n date_utc,\n n.total_stake,\n n.packets_received,\n n.packets_sent,\n n.packets_dropped\n FROM nym_node_daily_mixing_stats n\n UNION ALL\n SELECT\n m.date_utc,\n m.total_stake,\n m.packets_received,\n m.packets_sent,\n m.packets_dropped\n FROM mixnode_daily_stats m\n LEFT JOIN nym_node_daily_mixing_stats ON m.mix_id = nym_node_daily_mixing_stats.node_id\n WHERE nym_node_daily_mixing_stats.node_id IS NULL\n )\n GROUP BY date_utc\n ORDER BY date_utc ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "date_utc!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "total_stake!: i64",
"type_info": "Numeric"
},
{
"ordinal": 2,
"name": "total_packets_received!: i64",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "total_packets_sent!: i64",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "total_packets_dropped!: i64",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null,
null,
null,
null
]
},
"hash": "124d45b9604439584650f401607c46bdbd162c7c689f74fe9ddfdfd48f5ddc07"
}
@@ -0,0 +1,44 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n date_utc as \"date_utc!\",\n SUM(total_stake)::BIGINT as \"total_stake!: i64\",\n SUM(packets_received)::BIGINT as \"total_packets_received!: i64\",\n SUM(packets_sent)::BIGINT as \"total_packets_sent!: i64\",\n SUM(packets_dropped)::BIGINT as \"total_packets_dropped!: i64\"\n FROM (\n SELECT\n date_utc,\n n.total_stake,\n n.packets_received,\n n.packets_sent,\n n.packets_dropped\n FROM nym_node_daily_mixing_stats n\n UNION ALL\n SELECT\n m.date_utc,\n m.total_stake,\n m.packets_received,\n m.packets_sent,\n m.packets_dropped\n FROM mixnode_daily_stats m\n LEFT JOIN nym_node_daily_mixing_stats ON m.mix_id = nym_node_daily_mixing_stats.node_id\n WHERE nym_node_daily_mixing_stats.node_id IS NULL\n )\n GROUP BY date_utc\n ORDER BY date_utc ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "date_utc!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "total_stake!: i64",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "total_packets_received!: i64",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "total_packets_sent!: i64",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "total_packets_dropped!: i64",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null,
null,
null,
null
]
},
"hash": "a19da2073bc691fe5cd2119a9527c16d6a8806ba50dcb759ac705b1a19288ca9"
}
@@ -3,7 +3,7 @@
[package]
name = "nym-node-status-api"
version = "3.3.1"
version = "3.3.2"
authors.workspace = true
repository.workspace = true
homepage.workspace = true
@@ -108,10 +108,10 @@ pub(crate) async fn get_daily_stats(pool: &DbPool) -> anyhow::Result<Vec<DailySt
r#"
SELECT
date_utc as "date_utc!",
SUM(total_stake) as "total_stake!: i64",
SUM(packets_received) as "total_packets_received!: i64",
SUM(packets_sent) as "total_packets_sent!: i64",
SUM(packets_dropped) as "total_packets_dropped!: i64"
SUM(total_stake)::BIGINT as "total_stake!: i64",
SUM(packets_received)::BIGINT as "total_packets_received!: i64",
SUM(packets_sent)::BIGINT as "total_packets_sent!: i64",
SUM(packets_dropped)::BIGINT as "total_packets_dropped!: i64"
FROM (
SELECT
date_utc,
+60
View File
@@ -0,0 +1,60 @@
## Stake Adjustment Program (`stake_adjustment.py`)
### Overview
This simple argument based program is designed primarily for Delegation program management.
The main goal is to generate a csv of which first two columns without headers can be passed to nym-cli as input for a quick delegation adjustment, using this command:
```sh
./nym-cli mixnet delegators delegate-multi --mnemonic "<MNEMONIC>" --input <PATH>/<FILE>.csv
```
The default values are in sync with DP rules:
`--wallet_address`: Nym Team DP wallet address
`--saturation`: 250k NYM
`--stake_cap`: 90% as per DP rules
`--adjustment_step`: 25k NYM as per DP rules
`--max_wallet_delegation`: 125k NYM as per DP rules
`--denom`: NYM not uNYM to make it smoother and aligned with delegate-multi command of nym-cli
Additionaly the program scrapes [api.nym.spectredao.net/api/v1/nodes](https://api.nym.spectredao.net/api/v1/nodes) and [validator.nymtech.net/api/v1/nym-nodes/described](https://validator.nymtech.net/api/v1/nym-nodes/described) endpoints and returns a sheet with 20 values per eacvh node passed in the csv input.
The outcome is a table with these values:
NODE ID, SUGGESTED WALLET DELEGATION, CURRENT WALLET DELEGATION, SUGGESTED TOTAL STAKE, CURRENT TOTAL STAKE, SUGGESTED SATURATION, CURRENT SATURATION, UPTIME, VERSION, T&C, BINARY, ROLE, WIREGUARD, IP ADDRESS, HOSTNAME, WSS PORT, MONIKER, IDENTITY KEY, BONDING WALLET, EXPLORER URL.
### Install and Run
1. Download from this branch and make executable
```sh
wget https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/delegation-program/stake_adjustment.py && chmod u+x stake_adjustment.py
```
2. Make a simple column csv with `node_ids` (for DP, use all the DP nodes `node_id` column, and save it to the same dir, example can be `input.csv`:
```csv
# example
1398
1365
2464
1423
1269
1870
1824
1707
```
3. Run the program with the `input.csv` positional arg (for DP use the default values without any optional args):
```sh
./stake_adjustment.py input.csv
```
4. The output is a `csv` with bunch of useful data
5. For DP extract the first two columns with `NODE_ID` and `SUGGESTED_WALLET_DELEGATION` without the headers and save them as a `dp_update.csv`
6. Run this `dp_update.csv` as an input with `nym-cli` to adjust the DP delegations:
```sh
./nym-cli mixnet delegators delegate-multi --mnemonic "<MNEMONIC>" --input dp_update.csv
```
### Help
To preview all commands and arguments, run it with `--help` flag:
```sh
./stake_adjustment.py --help
```
+569
View File
@@ -0,0 +1,569 @@
#!/usr/bin/env python3
import argparse
import csv
import sys
from pathlib import Path
import requests
import pandas as pd
import re
API_SPECTRE_ROOT = "https://api.nym.spectredao.net/api/v1"
API_VALIDATOR = "https://validator.nymtech.net/api/v1"
API_BASE = f"{API_SPECTRE_ROOT}/nodes"
NYM_FACTOR = 1_000_000
"""
This simple argument based program is designed primarily for Delegation program management.
The main goal is to generate a csv of which first two columns without headers can also be reused for nym-cli as input:
./nym-cli mixnet delegators delegate-multi --mnemonic "<MNEMONIC>" --input <PATH>/<FILE>.csv
The default values therefore are:
--wallet_address: Nym Team DP wallet address
--saturation: 250k NYM
--stake_cap: 90% as per DP rules
--adjustment_Step: 25k NYM as per DP rules
--max_wallet_delegation: 125k NYM as per DP rules
--denom: NYM not uNYM to make it smoother and aligned with delegate-multi command of nym-cli
Additionaly the program scrapes described endpoint and returns a sheet with 20 values per node. Those are:
NODE ID, SUGGESTED WALLET DELEGATION, CURRENT WALLET DELEGATION, SUGGESTED TOTAL STAKE, CURRENT TOTAL STAKE,
SUGGESTED SATURATION, CURRENT SATURATION, UPTIME, VERSION, T&C, BINARY, ROLE, WIREGUARD, IP ADDRESS, HOSTNAME,
WSS PORT, MONIKER, IDENTITY KEY, BONDING WALLET, EXPLORER URL.
"""
def parse_args():
p = argparse.ArgumentParser(
prog="stake_adjustment.py",
description="Suggest wallet delegation adjustments per node to hit a target saturation cap",
)
p.add_argument("input", help="Path to CSV with a single column of NODE_ID values")
p.add_argument("--saturation", type=int, default=250_000,
help="Stake saturation in NYM (or uNYM if --denom uNYM). Default: 250000")
p.add_argument("--wallet_address", default="n1rnxpdpx3kldygsklfft0gech7fhfcux4zst5lw",
help="Delegation wallet address to track and adjust. Default: %(default)s")
p.add_argument("--stake_cap", type=int, default=90,
help="Target percentage of max saturation (e.g., 90 for 90%%). Default: 90")
p.add_argument("--adjustment_step", type=int, default=25_000,
help="Amount to undelegate per step (NYM or uNYM) until target delegation percentage is met. Default: 25000")
p.add_argument("--max_wallet_delegation", type=int, default=125_000,
help="Maximum delegation allowed by the wallet (NYM or uNYM). Default: 125000")
p.add_argument("--denom", type=str, default="NYM", choices=["NYM", "uNYM", "nym", "unym"],
help="Input/output denomination. Default: NYM")
return p.parse_args()
def to_unym(value: int, denom: str) -> int:
d = denom.lower()
if d == "nym":
return int(value) * NYM_FACTOR
if d == "unym":
return int(value)
raise ValueError("denom must be NYM or uNYM")
def from_unym(value_unym: int, denom: str) -> int:
d = denom.lower()
if d == "nym":
return int(value_unym // NYM_FACTOR)
if d == "unym":
return int(value_unym)
raise ValueError("denom must be NYM or uNYM")
def read_node_ids(csv_path: str) -> list[int]:
path = Path(csv_path)
if not path.exists():
raise RuntimeError(f"Input file not found: {csv_path}")
node_ids: list[int] = []
with path.open(newline="") as f:
reader = csv.reader(f)
for row in reader:
if not row:
continue
if len(row) != 1:
raise RuntimeError("Input CSV must have exactly one column of NODE_ID values.")
try:
node_ids.append(int(row[0]))
except ValueError:
raise RuntimeError(f"Invalid NODE_ID (not an integer): {row[0]!r}")
if not node_ids:
raise RuntimeError("Input CSV contains no NODE_IDs.")
return node_ids
# pagination helpers: limit/offset -> then page/page_size -> single shot fallback
def _fetch_all_limit_offset(url: str, limit: int = 1000, timeout: int = 60) -> list:
items = []
offset = 0
seen_guard = None
loops = 0
while True:
loops += 1
r = requests.get(url, params={"limit": limit, "offset": offset}, timeout=timeout)
if r.status_code >= 400:
return None
try:
data = r.json()
except Exception:
return None
if isinstance(data, dict) and "data" in data and isinstance(data["data"], list):
batch = data["data"]
elif isinstance(data, list):
batch = data
else:
return None
if not batch:
break
items.extend(batch)
if len(batch) < limit:
break
# guard against APIs that ignore offset
first_sig = str(batch[0])
if first_sig == seen_guard:
break
seen_guard = first_sig
offset += len(batch)
if loops > 1000 or offset > 1_000_000:
break
return items
def _fetch_all_page_pagesize(url: str, page_size: int = 1000, timeout: int = 60) -> list:
items = []
page = 1
seen_guard = None
loops = 0
while True:
loops += 1
r = requests.get(url, params={"page": page, "page_size": page_size}, timeout=timeout)
if r.status_code >= 400:
return None
try:
data = r.json()
except Exception:
return None
if isinstance(data, dict) and "data" in data and isinstance(data["data"], list):
batch = data["data"]
elif isinstance(data, list):
batch = data
else:
return None
if not batch:
break
items.extend(batch)
if len(batch) < page_size:
break
first_sig = str(batch[0])
if first_sig == seen_guard:
break
seen_guard = first_sig
page += 1
if loops > 1000 or page > 10000:
break
return items
def _fetch_all_any(url: str, timeout: int = 60) -> list:
# try limit/offset
got = _fetch_all_limit_offset(url, limit=1000, timeout=timeout)
if isinstance(got, list) and got:
return got
# try page/page_size
got = _fetch_all_page_pagesize(url, page_size=1000, timeout=timeout)
if isinstance(got, list) and got:
return got
# fallback: single shot
r = requests.get(url, timeout=timeout)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "data" in data and isinstance(data["data"], list):
return data["data"]
if isinstance(data, list):
return data
raise RuntimeError(f"Unexpected response format from {url}")
# fetching functions using robust pagination
def fetch_wallet_delegations(wallet: str) -> list[dict]:
url = f"{API_SPECTRE_ROOT}/delegations/{wallet}"
return _fetch_all_any(url, timeout=45)
def fetch_nodes_spectre() -> list[dict]:
url = f"{API_SPECTRE_ROOT}/nodes"
return _fetch_all_any(url, timeout=60)
def fetch_nodes_validator() -> list[dict]:
url = f"{API_VALIDATOR}/nym-nodes/described"
return _fetch_all_any(url, timeout=60)
def fetch_node_delegations_sum_unym(node_id: int) -> int:
url = f"{API_SPECTRE_ROOT}/nodes/{node_id}/delegations"
try:
data = _fetch_all_any(url, timeout=45)
except Exception:
# last resort
r = requests.get(url, timeout=45)
r.raise_for_status()
data = r.json()
if not isinstance(data, list):
return 0
total = 0
for item in data:
try:
total += int(item.get("amount", {}).get("amount"))
except Exception:
pass
return total
def suggest_wallet_delegation(
node_id: int,
wallet: str,
saturation_unym: int,
cap_pct: int,
step_unym: int,
max_wallet_unym: int,
out_denom: str,
nodes_map: dict[int, dict],
val_map: dict[int, dict],
wallet_map: dict[int, int],
) -> dict:
# CURRENT TOTAL STAKE (in uNYM)
current_total_unym = None
meta_src = None
if node_id in nodes_map and isinstance(nodes_map[node_id], dict):
# spectre nodes
current_total_unym = int(nodes_map[node_id].get("total_stake") or 0)
meta_src = nodes_map[node_id]
elif node_id in val_map and isinstance(val_map[node_id], dict):
# validator described
current_total_unym = int(val_map[node_id].get("total_stake") or 0)
meta_src = val_map[node_id]
# if still unknown, sum delegations as a fallback
if current_total_unym is None or current_total_unym == 0:
current_total_unym = fetch_node_delegations_sum_unym(node_id)
# CURRENT WALLET DELEGATION (in uNYM) from wallet_map
wallet_unym = int(wallet_map.get(node_id, 0))
# target cap in uNYM
target_unym = (saturation_unym * cap_pct) // 100
# start from min(current_wallet, max_wallet) and back off by step until under target
suggested_wallet_unym = min(wallet_unym, max_wallet_unym)
suggested_total_unym = current_total_unym - wallet_unym + suggested_wallet_unym
if suggested_total_unym > target_unym and step_unym > 0:
while suggested_total_unym > target_unym and suggested_wallet_unym > 0:
dec = min(step_unym, suggested_wallet_unym)
suggested_wallet_unym -= dec
suggested_total_unym -= dec
# convert to denom for output
suggested_wallet = from_unym(suggested_wallet_unym, out_denom)
current_wallet = from_unym(wallet_unym, out_denom)
suggested_total = from_unym(suggested_total_unym, out_denom)
current_total = from_unym(current_total_unym, out_denom)
saturation_val = from_unym(saturation_unym, out_denom)
# saturation as integer percentages
suggested_sat = int((suggested_total * 100) // (saturation_val or 1))
current_sat = int((current_total * 100) // (saturation_val or 1))
# extra fields
uptime = None
version = None
accepted_tnc= None
binary_name = _sanitize_text(binary_name)
role = _sanitize_text(role)
ip_address = _sanitize_text(ip_address)
hostname = _sanitize_text(hostname)
moniker = _sanitize_text(moniker)
identity_key = _sanitize_text(identity_key)
bonding_addr = _sanitize_text(bonding_addr)
explorer_url = _sanitize_text(explorer_url)
version = _sanitize_text(version)
m = meta_src or {}
def suggest_wallet_delegation(
node_id: int,
wallet: str,
saturation_unym: int,
cap_pct: int,
step_unym: int,
max_wallet_unym: int,
out_denom: str,
# extra maps for node
nodes_map: dict[int, dict],
val_map: dict[int, dict],
wallet_map: dict[int, int],
) -> dict:
# CURRENT TOTAL STAKE (in uNYM)
current_total_unym = None
meta_src = None
if node_id in nodes_map and isinstance(nodes_map[node_id], dict):
# spectre nodes
current_total_unym = int(nodes_map[node_id].get("total_stake") or 0)
meta_src = nodes_map[node_id]
elif node_id in val_map and isinstance(val_map[node_id], dict):
# validator described
current_total_unym = int(val_map[node_id].get("total_stake") or 0)
meta_src = val_map[node_id]
# if still unknown, sum delegations as a fallback
if current_total_unym is None or current_total_unym == 0:
current_total_unym = fetch_node_delegations_sum_unym(node_id)
# CURRENT WALLET DELEGATION (in uNYM) from wallet_map
wallet_unym = int(wallet_map.get(node_id, 0))
# target cap in uNYM
target_unym = (saturation_unym * cap_pct) // 100
# start from min(current_wallet, max_wallet) and back off by step until under target
suggested_wallet_unym = min(wallet_unym, max_wallet_unym)
suggested_total_unym = current_total_unym - wallet_unym + suggested_wallet_unym
if suggested_total_unym > target_unym and step_unym > 0:
while suggested_total_unym > target_unym and suggested_wallet_unym > 0:
dec = min(step_unym, suggested_wallet_unym)
suggested_wallet_unym -= dec
suggested_total_unym -= dec
# convert to denom for output
suggested_wallet = from_unym(suggested_wallet_unym, out_denom)
current_wallet = from_unym(wallet_unym, out_denom)
suggested_total = from_unym(suggested_total_unym, out_denom)
current_total = from_unym(current_total_unym, out_denom)
saturation_val = from_unym(saturation_unym, out_denom)
# saturation as integer percentages
suggested_sat = int((suggested_total * 100) // (saturation_val or 1))
current_sat = int((current_total * 100) // (saturation_val or 1))
# extra fields
uptime = None
version = None
accepted_tnc = None
binary_name = None
role = None
wg_enabled = None
ip_address = None
hostname = None
wss_port = None
moniker = None
identity_key = None
bonding_addr = None
explorer_url = None
m = meta_src or {}
# spectre and validator conventions translation
identity_key = m.get("identity_key")
bonding_addr = m.get("bonding_address")
uptime = m.get("uptime")
accepted_tnc = m.get("accepted_tnc")
desc = m.get("description") or {}
build = desc.get("build_information") or {}
binary_name = build.get("binary_name")
version = build.get("build_version")
declared = desc.get("declared_role") or {}
if declared:
if declared.get("exit_ipr") or declared.get("exit_nr"):
role = "exit-gateway"
elif declared.get("entry"):
role = "entry-gateway"
elif declared.get("mixnode"):
role = "mixnode"
host_info = desc.get("host_information") or {}
ip_list = host_info.get("ip_address")
if isinstance(ip_list, list) and ip_list:
ip_address = ip_list[0]
elif isinstance(ip_list, str):
ip_address = ip_list
hostname = host_info.get("hostname")
# wss/ws ports under mixnet_websockets
webs = desc.get("mixnet_websockets") or {}
wss_port = webs.get("wss_port")
# wireguard info
wg = m.get("wireguard") or desc.get("wireguard")
if isinstance(wg, dict) and wg.get("port") and wg.get("public_key"):
wg_enabled = True
else:
wg_enabled = False
# self desc to get moniker
self_desc = m.get("self_description") or {}
moniker = self_desc.get("moniker")
explorer_url = f"https://explorer.nym.spectredao.net/nodes/{identity_key}" if identity_key else ""
# sanitize all string-ish fields to kill tabs/newlines and weird spacing
binary_name = _sanitize_text(binary_name)
role = _sanitize_text(role)
ip_address = _sanitize_text(ip_address)
hostname = _sanitize_text(hostname)
moniker = _sanitize_text(moniker)
identity_key = _sanitize_text(identity_key)
bonding_addr = _sanitize_text(bonding_addr)
explorer_url = _sanitize_text(explorer_url)
version = _sanitize_text(version)
return {
"NODE ID": node_id,
"SUGGESTED WALLET DELEGATION": suggested_wallet,
"CURRENT WALLET DELEGATION": current_wallet,
"SUGGESTED TOTAL STAKE": suggested_total,
"CURRENT TOTAL STAKE": current_total,
"SUGGESTED SATURATION": suggested_sat,
"CURRENT SATURATION": current_sat,
"UPTIME": uptime,
"VERSION": version,
"T&C": bool(accepted_tnc) if accepted_tnc is not None else None,
"BINARY": binary_name,
"ROLE": role,
"WIREGUARD": wg_enabled,
"IP ADDRESS": ip_address,
"HOSTNAME": hostname,
"WSS PORT": wss_port,
"MONIKER": moniker,
"IDENTITY KEY": identity_key,
"BONDING WALLET": bonding_addr,
"EXPLORER URL": explorer_url,
}
def _sanitize_text(val):
"""Collapse whitespace, remove control chars, strip pipes that break CSVs."""
if val is None:
return None
s = str(val)
s = s.replace("\r", " ").replace("\n", " ").replace("\t", " ")
s = re.sub(r"\s+", " ", s)
s = s.replace("|", " ")
s = "".join(ch for ch in s if ch.isprintable())
return s.strip()
def main():
args = parse_args()
denom = args.denom
# convert user inputs to uNYM
saturation_unym = to_unym(args.saturation, denom)
step_unym = to_unym(args.adjustment_step, denom)
max_wallet_unym = to_unym(args.max_wallet_delegation, denom)
node_ids = read_node_ids(args.input)
# detect duplicates (just report, do not modify order)
dups = pd.Series(node_ids).duplicated(keep=False)
if dups.any():
dup_ids = sorted(set([nid for nid, d in zip(node_ids, dups.tolist()) if d]))
print(f"warning: These node IDs are duplicated: {dup_ids}")
else:
print("There are no duplicated node IDs.")
# one call for wallet delegations (then map node_id -> wallet amount)
print("* * * Fetching wallet delegations * * *")
wallet_delegs = fetch_wallet_delegations(args.wallet_address)
wallet_map: dict[int, int] = {}
for d in wallet_delegs:
try:
nid = int(d.get("node_id"))
amt = int((d.get("amount") or {}).get("amount"))
except Exception:
continue
wallet_map[nid] = wallet_map.get(nid, 0) + amt
# pull nodes from Spectre (paginated)
print("* * * Fetching nodes (Spectre) with pagination * * *")
spectre_nodes = fetch_nodes_spectre()
nodes_map: dict[int, dict] = {}
for n in spectre_nodes:
try:
nid = int(n.get("node_id"))
except Exception:
continue
nodes_map[nid] = n
# pull nodes from Validator (paginated)
print("* * * Fetching nodes (Validator) with pagination * * *")
validator_nodes = fetch_nodes_validator()
val_map: dict[int, dict] = {}
for n in validator_nodes:
try:
nid = int(n.get("node_id"))
except Exception:
continue
# do not overwrite spectre if present; keep as fallback
if nid not in nodes_map:
val_map[nid] = n
# build rows
rows = []
for nid in node_ids:
try:
row = suggest_wallet_delegation(
node_id=nid,
wallet=args.wallet_address,
saturation_unym=saturation_unym,
cap_pct=args.stake_cap,
step_unym=step_unym,
max_wallet_unym=max_wallet_unym,
out_denom=denom,
nodes_map=nodes_map,
val_map=val_map,
wallet_map=wallet_map,
)
except Exception as e:
row = {
"NODE ID": nid,
"SUGGESTED WALLET DELEGATION": 0,
"CURRENT WALLET DELEGATION": 0,
"SUGGESTED TOTAL STAKE": 0,
"CURRENT TOTAL STAKE": 0,
"SUGGESTED SATURATION": 0,
"CURRENT SATURATION": 0,
"UPTIME": None,
"VERSION": None,
"T&C": None,
"BINARY": None,
"ROLE": None,
"WIREGUARD": None,
"IP ADDRESS": None,
"HOSTNAME": None,
"WSS PORT": None,
"MONIKER": None,
"IDENTITY KEY": None,
"BONDING WALLET": None,
"EXPLORER URL": "",
}
print(f"warning: node {nid}: {e}", file=sys.stderr)
rows.append(row)
df = pd.DataFrame(rows)
print("\nResult preview:")
print(df.to_string(index=False))
ans = input("\nSave to ./delegations_adjusted.csv ? [y/N]: ").strip().lower()
if ans == "y":
out_path = Path("./delegations_adjusted.csv")
df.to_csv(out_path, index=False)
print(f"Saved: {out_path.resolve()}")
else:
print("Not saved.")
if __name__ == "__main__":
main()
+24
View File
@@ -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>
+637
View File
@@ -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 didnt 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()
+227
View File
@@ -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
+75
View File
@@ -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 (well 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