Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a880b59f03 | |||
| 4062734a31 | |||
| ccd8ff26a3 | |||
| 43d043a9cd | |||
| 3d6cf730c2 |
@@ -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
@@ -1 +1 @@
|
||||
815_660_905
|
||||
817_116_756
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
5_120
|
||||
5_080
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.74%
|
||||
0.77%
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
38.479
|
||||
37.305
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1_040_817
|
||||
250_000
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
30.63%
|
||||
7.34%
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
249_796_152
|
||||
60_000_000
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
249_796_152
|
||||
60_000_000
|
||||
|
||||
+3
-3
@@ -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 doesn’t 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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
-44
@@ -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"
|
||||
}
|
||||
+44
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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