Testnet seeds: NET=testnet profile for the seed scripts, seeds-testnet.tsv (3 Hetzner VMs), dns.sh (deSEC), the public RPC allowlist and nginx site, build-job.mjs forwards --node-tests

seed1.testnet nbg1 195.201.35.33, seed2.testnet ash 5.161.232.205, seed3.testnet sin 5.223.52.210 (5 October 2026). Ports 26810/26811/28810/26890
from seed.env; provision-seed.sh BUILD_WHERE=cross takes the PC build job's Linux igneumd from infra/cross/out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 15:46:22 +00:00
parent 442a1ccd0f
commit b0652f2d8a
16 changed files with 307 additions and 25 deletions

View file

@ -25,11 +25,22 @@ SSH_SOURCE="${SSH_SOURCE:-any}"
HETZNER_TOKEN_FILE="${HETZNER_TOKEN_FILE:-$HOME/.config/igneum/hetzner-token}" # one line, the API token; never printed
NETWORK_ARGS="${NETWORK_ARGS:---devnet}" # the live devnet; a suffixed test network: "--devnet --devnet-suffix=20"
# NET picks the network profile (5 October 2026): devnet = the shared devnet on the 266xx ports, seeds.tsv, firewall
# igneum-seed; testnet = igneum-testnet-1 (--testnet --netsuffix=1) on the 268xx ports (docs/testnet/README.md section 1),
# seeds-testnet.tsv, firewall igneum-testnet-seed, DNS names seedN.testnet.igneum.network (dns.sh).
NET="${NET:-devnet}"
case "$NET" in
devnet)
NETWORK_ARGS="${NETWORK_ARGS:---devnet}" # the live devnet; a suffixed test network: "--devnet --devnet-suffix=20"
P2P_PORT=26611; RPC_PORT=26610; RPC_JSON_PORT=28610; EVM_RPC_PORT=26790
FIREWALL_NAME="${FIREWALL_NAME:-igneum-seed}"; SEEDS_FILE_BASE=seeds; DNS_ZONE_SUB="" ;;
testnet)
NETWORK_ARGS="${NETWORK_ARGS:---testnet --netsuffix=1}"
P2P_PORT=26811; RPC_PORT=26810; RPC_JSON_PORT=28810; EVM_RPC_PORT=26890
FIREWALL_NAME="${FIREWALL_NAME:-igneum-testnet-seed}"; SEEDS_FILE_BASE=seeds-testnet; DNS_ZONE_SUB="testnet" ;;
*) echo "NET must be devnet or testnet" >&2; exit 1 ;;
esac
SEED_PEERS="${SEED_PEERS:-}" # other seeds to --addpeer, comma separated ip:port (filled from seeds.txt by provision)
P2P_PORT=26611
RPC_PORT=26610
RPC_JSON_PORT=28610
# The node source (shared with the 20-node network): vendor/igneum-node working tree plus igneum-pow
NODE_SRC="${NODE_SRC:-$REPO/vendor/igneum-node}"

View file

@ -38,14 +38,14 @@ else
if ! hcloud ssh-key describe "$SSH_KEY_NAME" >/dev/null 2>&1; then
hcloud ssh-key create --name "$SSH_KEY_NAME" --public-key-from-file "$SSH_KEY_FILE.pub" >/dev/null; log "uploaded ssh key $SSH_KEY_NAME"
fi
if ! hcloud firewall describe igneum-seed >/dev/null 2>&1; then
hcloud firewall create --name igneum-seed --label igneum=seed >/dev/null
hcloud firewall add-rule igneum-seed --direction in --protocol tcp --port 22 --source-ips "$ssh_cidr" --description ssh >/dev/null
hcloud firewall add-rule igneum-seed --direction in --protocol tcp --port "$P2P_PORT" --source-ips 0.0.0.0/0 --source-ips ::/0 --description igneum-p2p >/dev/null
hcloud firewall add-rule igneum-seed --direction in --protocol icmp --source-ips 0.0.0.0/0 --source-ips ::/0 --description ping >/dev/null
log "created firewall igneum-seed (in: 22 from $ssh_cidr, $P2P_PORT from anywhere, icmp)"
if ! hcloud firewall describe "$FIREWALL_NAME" >/dev/null 2>&1; then
hcloud firewall create --name "$FIREWALL_NAME" --label igneum=seed --label net="$NET" >/dev/null
hcloud firewall add-rule "$FIREWALL_NAME" --direction in --protocol tcp --port 22 --source-ips "$ssh_cidr" --description ssh >/dev/null
hcloud firewall add-rule "$FIREWALL_NAME" --direction in --protocol tcp --port "$P2P_PORT" --source-ips 0.0.0.0/0 --source-ips ::/0 --description igneum-p2p >/dev/null
hcloud firewall add-rule "$FIREWALL_NAME" --direction in --protocol icmp --source-ips 0.0.0.0/0 --source-ips ::/0 --description ping >/dev/null
log "created firewall $FIREWALL_NAME (in: 22 from $ssh_cidr, $P2P_PORT from anywhere, icmp)"
fi
log "plan: $SEED_NAME, $SEED_TYPE, $IMAGE, $SEED_LOCATION, persistent primary IPv4, firewall igneum-seed"
log "plan: $SEED_NAME, $SEED_TYPE, $IMAGE, $SEED_LOCATION, persistent primary IPv4, firewall $FIREWALL_NAME"
hcloud server-type describe "$SEED_TYPE" -o json | python3 -c '
import json, sys
j = json.load(sys.stdin); loc = sys.argv[1]
@ -57,7 +57,7 @@ for p in j["prices"]:
[ "${YES:-0}" = 1 ] || { printf 'create it now (billing starts) [type yes]: '; read -r a; [ "$a" = yes ] || die "not confirmed"; }
if hcloud server describe "$SEED_NAME" >/dev/null 2>&1; then log "$SEED_NAME exists, reusing it"; else
hcloud server create --name "$SEED_NAME" --type "$SEED_TYPE" --image "$IMAGE" --location "$SEED_LOCATION" \
--ssh-key "$SSH_KEY_NAME" --firewall igneum-seed --label igneum=seed --label role=seed >/dev/null
--ssh-key "$SSH_KEY_NAME" --firewall "$FIREWALL_NAME" --label igneum=seed --label role=seed --label net="$NET" >/dev/null
log "created $SEED_NAME"
fi
ip=$(hcloud server ip "$SEED_NAME")

37
infra/seed-nodes/dns.sh Executable file
View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# DNS for the seeds (5 October 2026): one A record per seed in seeds-testnet.tsv (seedN.testnet.igneum.network) and
# rpc.testnet.igneum.network at the first seed (the public JSON-RPC behind nginx, node/install-rpc.sh), through the
# deSEC API (igneum.network's nameservers are ns1.desec.io and ns2.desec.org; token at ~/.config/igneum/desec-token,
# never printed). Idempotent: an existing record set is replaced. NET=testnet ./dns.sh [--check]
. "$(dirname "$0")/lib.sh"
[ "$NET" = testnet ] || die "dns.sh is for NET=testnet (the devnet seed has no DNS name)"
DESEC_TOKEN_FILE="${DESEC_TOKEN_FILE:-$HOME/.config/igneum/desec-token}"
[ -s "$DESEC_TOKEN_FILE" ] || die "no token at $DESEC_TOKEN_FILE"
ZONE="igneum.network"
auth=(-H "Authorization: Token $(tr -d '[:space:]' < "$DESEC_TOKEN_FILE")" -H "Content-Type: application/json")
put() { # put <subname> <ip>; deSEC answers 429 to more than about one write a second, so each call retries after a pause
local sub="$1" ip="$2" code try
for try in 1 2 3 4 5 6; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "${auth[@]}" -X PUT "https://desec.io/api/v1/domains/$ZONE/rrsets/$sub/A/" \
-d "{\"subname\":\"$sub\",\"type\":\"A\",\"ttl\":3600,\"records\":[\"$ip\"]}")
if [ "$code" = 404 ]; then
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "${auth[@]}" -X POST "https://desec.io/api/v1/domains/$ZONE/rrsets/" \
-d "{\"subname\":\"$sub\",\"type\":\"A\",\"ttl\":3600,\"records\":[\"$ip\"]}")
fi
case "$code" in 200|201) log "$sub.$ZONE A $ip ($code)"; sleep 2; return 0 ;; 429) sleep $((try * 3)) ;; *) die "$sub.$ZONE: http $code" ;; esac
done
die "$sub.$ZONE: rate limited six times"
}
if [ "${1:-}" = --check ]; then
while IFS=$'\t' read -r name _ _ ip _; do [ -n "$name" ] || continue; printf '%s.%s -> %s (want %s)\n' "$name" "$ZONE" "$(dig +short "$name.$ZONE" @ns1.desec.io | tr '\n' ' ')" "$ip"; done < "$SEEDS_TSV"
printf 'rpc.%s.%s -> %s\n' "$DNS_ZONE_SUB" "$ZONE" "$(dig +short "rpc.$DNS_ZONE_SUB.$ZONE" @ns1.desec.io | tr '\n' ' ')"
exit 0
fi
first=""
while IFS=$'\t' read -r name _ _ ip _; do
[ -n "$name" ] || continue
put "$name" "$ip"
[ -n "$first" ] || first="$ip"
done < "$SEEDS_TSV"
[ -n "$first" ] && put "rpc.$DNS_ZONE_SUB" "$first"
log "done; propagation: dig +short seed1.$DNS_ZONE_SUB.$ZONE"

View file

@ -10,7 +10,7 @@ check_one() {
local port=FAIL unit=? info= dag= peers=? known=? disk=? mem=? synced=? ver=? blocks=? headers=? sink=?
if nc -z -w 5 "$ip" "$P2P_PORT" >/dev/null 2>&1; then port=open; fi
local raw
raw=$(sssh "$ip" "if systemctl is-active igneumd-v4 >/dev/null 2>&1; then echo active-v4; else systemctl is-active igneumd 2>/dev/null; fi; echo '|'; python3 /opt/igneum/bin/wrpc.py call getInfo 2>/dev/null; echo '|'; python3 /opt/igneum/bin/wrpc.py call getBlockDagInfo 2>/dev/null; echo '|'; python3 /opt/igneum/bin/wrpc.py call getConnectedPeerInfo 2>/dev/null | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get(\"peerInfo\",[])))' 2>/dev/null; echo '|'; python3 /opt/igneum/bin/wrpc.py call getPeerAddresses 2>/dev/null | python3 -c 'import json,sys; j=json.load(sys.stdin); print(len(j.get(\"knownAddresses\",[])), len(j.get(\"bannedAddresses\",[])))' 2>/dev/null; echo '|'; df -h / | awk 'NR==2 { print \$5 }'; echo '|'; free -m | awk 'NR==2 { print \$3 \"/\" \$2 \"MB\" }'" 2>/dev/null) || raw=""
raw=$(sssh "$ip" "export IGNEUM_RPC=ws://127.0.0.1:$RPC_JSON_PORT; if systemctl is-active igneumd-v4 >/dev/null 2>&1; then echo active-v4; else systemctl is-active igneumd 2>/dev/null; fi; echo '|'; python3 /opt/igneum/bin/wrpc.py call getInfo 2>/dev/null; echo '|'; python3 /opt/igneum/bin/wrpc.py call getBlockDagInfo 2>/dev/null; echo '|'; python3 /opt/igneum/bin/wrpc.py call getConnectedPeerInfo 2>/dev/null | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get(\"peerInfo\",[])))' 2>/dev/null; echo '|'; python3 /opt/igneum/bin/wrpc.py call getPeerAddresses 2>/dev/null | python3 -c 'import json,sys; j=json.load(sys.stdin); print(len(j.get(\"knownAddresses\",[])), len(j.get(\"bannedAddresses\",[])))' 2>/dev/null; echo '|'; df -h / | awk 'NR==2 { print \$5 }'; echo '|'; free -m | awk 'NR==2 { print \$3 \"/\" \$2 \"MB\" }'" 2>/dev/null) || raw=""
unit=$(printf '%s' "$raw" | awk -F'|' 'NR==1 { gsub(/\n/, "", $1); print $1 }' | tr -d '\n')
info=$(printf '%s' "$raw" | awk 'BEGIN{RS="|"} NR==2' | tr -d '\n')
dag=$(printf '%s' "$raw" | awk 'BEGIN{RS="|"} NR==3' | tr -d '\n')

View file

@ -0,0 +1,22 @@
#!/usr/bin/env bash
# The public RPC on one seed (the one rpc.<net>.igneum.network points at): NET=testnet ./install-rpc-from-mac.sh seed1.testnet [email]
# Adds the igneum-<net>-rpc firewall (80, 443 from anywhere) to that server, uploads rpc/ and node/install-rpc.sh, runs it.
. "$(dirname "$0")/lib.sh"
name="${1:-}"; email="${2:-}"; [ -n "$name" ] || die "which seed?"
ip=$(seed_ip "$name"); [ -n "$ip" ] || die "$name not in $SEEDS_TSV"
host="rpc${DNS_ZONE_SUB:+.$DNS_ZONE_SUB}.igneum.network"
hetzner_auth
fw="igneum-$NET-rpc"
if ! hcloud firewall describe "$fw" >/dev/null 2>&1; then
hcloud firewall create --name "$fw" --label igneum=rpc --label net="$NET" >/dev/null
hcloud firewall add-rule "$fw" --direction in --protocol tcp --port 80 --source-ips 0.0.0.0/0 --source-ips ::/0 --description http-acme >/dev/null
hcloud firewall add-rule "$fw" --direction in --protocol tcp --port 443 --source-ips 0.0.0.0/0 --source-ips ::/0 --description https-rpc >/dev/null
log "created firewall $fw (80, 443)"
fi
hcloud firewall apply-to-resource "$fw" --type server --server "$name" >/dev/null 2>&1 || true
log "firewall $fw on $name"
sscp "$HERE/rpc/rpc-filter.py" "$SSH_USER@$ip:/opt/igneum/bin/"
sscp "$HERE/rpc/igneum-rpc-filter.service" "$HERE/rpc/nginx-rpc.conf" "$HERE/node/install-rpc.sh" "$SSH_USER@$ip:/root/"
sssh "$ip" "bash /root/install-rpc.sh '$host' '$email'"
log "public RPC: https://$host (chain id check):"
curl -s -m 15 -X POST -H 'Content-Type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' "https://$host"; echo

View file

@ -5,8 +5,8 @@ REPO="$(cd "$HERE/../.." && pwd)"
export REPO
# shellcheck source=config.sh
. "$HERE/config.sh"
SEEDS_TXT="$HERE/seeds.txt"
SEEDS_TSV="$HERE/seeds.tsv"
SEEDS_TXT="$HERE/$SEEDS_FILE_BASE.txt"
SEEDS_TSV="$HERE/$SEEDS_FILE_BASE.tsv"
BUILD_DIR="$HERE/build"
log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*"; }

View file

@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Runs ON the seed as root: install-rpc.sh <rpc host, e.g. rpc.testnet.igneum.network> <contact email>
# Expects /opt/igneum/bin/rpc-filter.py, /root/igneum-rpc-filter.service, /root/nginx-rpc.conf and /etc/igneum/seed.env
# (EVM_RPC_PORT). Installs the filter unit, the nginx site, then certbot --nginx for the TLS certificate (port 80 and
# 443 must be open: the Mac side adds the igneum-testnet-rpc firewall first). Idempotent.
set -euo pipefail
host="$1"; email="${2:-}"
export DEBIAN_FRONTEND=noninteractive
command -v nginx >/dev/null 2>&1 || { apt-get update -qq; apt-get install -y -qq nginx certbot python3-certbot-nginx >/dev/null; }
chmod +x /opt/igneum/bin/rpc-filter.py
python3 /opt/igneum/bin/rpc-filter.py --test
cp /root/igneum-rpc-filter.service /etc/systemd/system/igneum-rpc-filter.service
systemctl daemon-reload
systemctl enable igneum-rpc-filter >/dev/null 2>&1
systemctl restart igneum-rpc-filter
sed "s/RPC_HOST/$host/" /root/nginx-rpc.conf > /etc/nginx/sites-available/igneum-rpc
ln -sf /etc/nginx/sites-available/igneum-rpc /etc/nginx/sites-enabled/igneum-rpc
rm -f /etc/nginx/sites-enabled/default
nginx -t
systemctl enable nginx >/dev/null 2>&1; systemctl restart nginx
if [ ! -d "/etc/letsencrypt/live/$host" ]; then
certbot --nginx -n --agree-tos --no-eff-email ${email:+-m "$email"} ${email:---register-unsafely-without-email} -d "$host" --redirect
fi
systemctl is-active igneum-rpc-filter nginx
curl -s -m 10 -X POST -H 'Content-Type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' http://127.0.0.1:8545; echo
curl -s -m 10 -X POST -H 'Content-Type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"admin_peers","params":[]}' http://127.0.0.1:8545; echo

View file

@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Runs ON the seed VM as root: install-seed.sh <name> <external-ip> "<network args>" "<peer list>"
# Runs ON the seed VM as root: install-seed.sh <name> <external-ip> "<network args>" "<peer list>" [p2p-port rpc-port json-port evm-port]
# Expects /opt/igneum/bin/{igneumd,igneum-miner,wrpc.py,run-seed.sh} and /root/igneumd.service.
set -euo pipefail
name="$1"; extip="$2"; netargs="$3"; peers="${4:-}"
name="$1"; extip="$2"; netargs="$3"; peers="${4:-}"; p2p="${5:-26611}"; rpc="${6:-26610}"; rpcjson="${7:-28610}"; evm="${8:-26790}"
export DEBIAN_FRONTEND=noninteractive
command -v chronyd >/dev/null 2>&1 || { apt-get update -qq; apt-get install -y -qq chrony python3 >/dev/null; }
systemctl enable --now chrony >/dev/null 2>&1 || true
@ -15,6 +15,11 @@ SEED_NAME=$name
EXTERNAL_IP=$extip
NETWORK_ARGS=$netargs
SEED_PEERS=$peers
P2P_PORT=$p2p
RPC_PORT=$rpc
RPC_JSON_PORT=$rpcjson
EVM_RPC_PORT=$evm
IGNEUM_RPC=ws://127.0.0.1:$rpcjson
EXTRA_ARGS=
EOF
cp /root/igneumd.service /etc/systemd/system/igneumd.service

View file

@ -7,8 +7,12 @@
set -euo pipefail
. /etc/igneum/seed.env
# shellcheck disable=SC2206
args=($NETWORK_ARGS --appdir=/var/lib/igneum --rpclisten=127.0.0.1:26610 --rpclisten-json=127.0.0.1:28610
--listen=0.0.0.0:26611 --externalip="$EXTERNAL_IP" --nodnsseed --disable-upnp --nologfiles --yes
# Ports come from seed.env (5 October 2026: the testnet seeds run on 26810/26811/28810/26890); an env file without
# them is a devnet seed on the 266xx ports. The EVM JSON-RPC binds to loopback too; node/install-rpc.sh puts nginx
# in front of it on the seed that serves rpc.<net>.igneum.network.
args=($NETWORK_ARGS --appdir="${APPDIR:-/var/lib/igneum}" --rpclisten=127.0.0.1:"${RPC_PORT:-26610}" --rpclisten-json=127.0.0.1:"${RPC_JSON_PORT:-28610}"
--evm-rpclisten=127.0.0.1:"${EVM_RPC_PORT:-26790}"
--listen=0.0.0.0:"${P2P_PORT:-26611}" --externalip="$EXTERNAL_IP" --nodnsseed --disable-upnp --nologfiles --yes
--maxinpeers=128 --outpeers=8 --loglevel=info)
IFS=',' read -r -a peers <<< "${SEED_PEERS:-}"
for p in "${peers[@]}"; do [ -n "$p" ] && args+=(--addpeer="$p"); done

View file

@ -14,11 +14,16 @@ CD="$HERE/../cloud-devnet"
for try in $(seq 1 20); do sssh "$ip" true >/dev/null 2>&1 && break; log "waiting for ssh ($try)"; sleep 10; done
sssh "$ip" true || die "ssh to $ip failed"
if [ "$BUILD_WHERE" = bin ]; then
[ -x "$CD/build/bin/igneumd" ] || die "no $CD/build/bin/igneumd (run ../cloud-devnet/provision.sh build)"
log "uploading prebuilt binaries"
if [ "$BUILD_WHERE" = bin ] || [ "$BUILD_WHERE" = cross ]; then
# bin: ../cloud-devnet/build/bin; cross: infra/cross/out, where infra/cross/build-linux.sh and the PC build job
# (tools/build-job.mjs, Linux target) leave igneumd and igneum-miner; BIN_DIR overrides either
bindir="$CD/build/bin"; [ "$BUILD_WHERE" = cross ] && bindir="$REPO/infra/cross/out"; bindir="${BIN_DIR:-$bindir}"
[ -x "$bindir/igneumd" ] || die "no $bindir/igneumd (run ../cloud-devnet/provision.sh build, infra/cross/build-linux.sh or a Linux build job)"
file "$bindir/igneumd" | grep -q "x86-64" || die "$bindir/igneumd is not an x86-64 binary"
log "uploading prebuilt binaries from $bindir ($(sha256sum "$bindir/igneumd" 2>/dev/null || shasum -a 256 "$bindir/igneumd" | cut -c1-16))"
sssh "$ip" 'mkdir -p /opt/igneum/bin'
sscp "$CD/build/bin/igneumd" "$CD/build/bin/igneum-miner" "$SSH_USER@$ip:/opt/igneum/bin/"
sscp "$bindir/igneumd" "$bindir/igneum-miner" "$SSH_USER@$ip:/opt/igneum/bin/"
sssh "$ip" 'chmod +x /opt/igneum/bin/igneumd /opt/igneum/bin/igneum-miner; /opt/igneum/bin/igneumd --version | head -1' || die "the uploaded igneumd does not run on $name"
else
NODE_SRC="$NODE_SRC" POW_SRC="$POW_SRC" SRC_MODE="$SRC_MODE" "$CD/make-source.sh"
cp "$CD/build/src.stamp" "$BUILD_DIR/src.stamp"
@ -34,6 +39,6 @@ peers=$(awk -F'\t' -v n="$name" -v p="$P2P_PORT" '$1 != n { print $4 ":" p }' "$
[ -n "$SEED_PEERS" ] && peers="${peers:+$peers,}$SEED_PEERS"
sscp "$CD/node/wrpc.py" "$HERE/node/run-seed.sh" "$SSH_USER@$ip:/opt/igneum/bin/"
sscp "$HERE/node/install-seed.sh" "$HERE/node/igneumd.service" "$SSH_USER@$ip:/root/"
sssh "$ip" "bash /root/install-seed.sh '$name' '$ip' '$NETWORK_ARGS' '$peers'"
sssh "$ip" "bash /root/install-seed.sh '$name' '$ip' '$NETWORK_ARGS' '$peers' $P2P_PORT $RPC_PORT $RPC_JSON_PORT $EVM_RPC_PORT"
log "started. Health in 30 s:"; sleep 30
"$HERE/health.sh" "$name" || true

View file

@ -0,0 +1,17 @@
[Unit]
Description=Igneum public RPC allowlist (between nginx and the node's EVM JSON-RPC)
After=network-online.target igneumd.service
[Service]
Type=simple
User=igneum
Group=igneum
EnvironmentFile=/etc/igneum/seed.env
Environment=RPC_LISTEN=127.0.0.1:8545
ExecStart=/bin/sh -c 'exec env RPC_UPSTREAM=http://127.0.0.1:${EVM_RPC_PORT} /usr/bin/python3 /opt/igneum/bin/rpc-filter.py'
Restart=always
RestartSec=5
MemoryMax=512M
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,29 @@
# The public JSON-RPC: TLS (certbot), rate limited, proxied to the allowlist filter on 127.0.0.1:8545
# (rpc-filter.py), which forwards to the node's EVM JSON-RPC on loopback. Installed by node/install-rpc.sh as
# /etc/nginx/sites-available/igneum-rpc; certbot adds the TLS block.
limit_req_zone $binary_remote_addr zone=rpc:10m rate=20r/s;
limit_conn_zone $binary_remote_addr zone=rpcconn:10m;
server {
listen 80;
listen [::]:80;
server_name RPC_HOST;
client_max_body_size 256k;
client_body_timeout 10s;
location / {
limit_req zone=rpc burst=40 nodelay;
limit_conn rpcconn 20;
limit_req_status 429;
limit_conn_status 429;
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
proxy_pass http://127.0.0.1:8545;
proxy_http_version 1.1;
proxy_read_timeout 35s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

View file

@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""The public JSON-RPC allowlist (5 October 2026). Sits between nginx (TLS, rate limit) and the node's Ethereum
JSON-RPC on loopback. Read-mostly: eth_* and igneum_* read methods and eth_sendRawTransaction pass; everything else
is answered with JSON-RPC error -32601 and never reaches the node. Batches are checked element by element. Bodies over
BODY_LIMIT bytes are refused (413). No state, no logging of bodies.
Environment: RPC_UPSTREAM (default http://127.0.0.1:26890), RPC_LISTEN (default 127.0.0.1:8545).
Self-test: rpc-filter.py --test
"""
import json, os, sys, urllib.request, urllib.error
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
UPSTREAM = os.environ.get("RPC_UPSTREAM", "http://127.0.0.1:26890")
LISTEN = os.environ.get("RPC_LISTEN", "127.0.0.1:8545")
BODY_LIMIT = 256 * 1024
ALLOWED = {
# eth: reads
"eth_chainId", "eth_blockNumber", "eth_getBalance", "eth_getCode", "eth_getStorageAt", "eth_getTransactionCount",
"eth_getBlockByNumber", "eth_getBlockByHash", "eth_getBlockReceipts", "eth_getBlockTransactionCountByNumber",
"eth_getTransactionByHash", "eth_getTransactionByBlockNumberAndIndex", "eth_getTransactionReceipt", "eth_getLogs",
"eth_call", "eth_estimateGas", "eth_gasPrice", "eth_maxPriorityFeePerGas", "eth_feeHistory", "eth_syncing",
"eth_protocolVersion", "eth_mining", "eth_accounts",
# the one write
"eth_sendRawTransaction",
# igneum: reads (igneum_submitProofRecord and igneum_exportSegments stay out: a public endpoint never takes a record or exports)
"igneum_estimateGas", "igneum_getBudgets", "igneum_getProofRecords", "igneum_getProvingStatus", "igneum_getSegment",
"igneum_getShardPlan", "igneum_getAssignedShards", "igneum_getTransactionStatus",
# identity
"net_version", "net_listening", "net_peerCount", "web3_clientVersion",
}
def err(id_, code, msg):
return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": msg}}
def check(req):
"""None when the request may pass, else the error reply."""
if not isinstance(req, dict):
return err(None, -32600, "invalid request")
m = req.get("method")
if not isinstance(m, str) or m not in ALLOWED:
return err(req.get("id"), -32601, "method not available on the public RPC")
return None
def filter_body(raw):
"""(forward: bool, reply bytes or None, upstream body bytes or None)"""
try:
body = json.loads(raw)
except Exception:
return False, json.dumps(err(None, -32700, "parse error")).encode(), None
if isinstance(body, list):
if not body or len(body) > 50:
return False, json.dumps(err(None, -32600, "batch of 1 to 50 requests")).encode(), None
bad = [check(r) for r in body]
if all(b is None for b in bad):
return True, None, raw
# a batch with a refused element is answered in full here, nothing reaches the node
return False, json.dumps([b if b is not None else err(r.get("id"), -32601, "refused with the batch") for r, b in zip(body, bad)]).encode(), None
e = check(body)
if e is not None:
return False, json.dumps(e).encode(), None
return True, None, raw
class H(BaseHTTPRequestHandler):
server_version = "igneum-rpc-filter/1"
protocol_version = "HTTP/1.1"
def log_message(self, *a): # nginx logs the request line; nothing here
pass
def _send(self, code, body, ctype="application/json"):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self._send(200, b'{"service":"igneum public rpc","methods":"eth_* reads, igneum_* reads, eth_sendRawTransaction"}')
def do_OPTIONS(self):
self._send(204, b"")
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
if n > BODY_LIMIT:
return self._send(413, json.dumps(err(None, -32600, "body over 256 KiB")).encode())
raw = self.rfile.read(n)
forward, reply, up = filter_body(raw)
if not forward:
return self._send(200, reply)
try:
r = urllib.request.urlopen(urllib.request.Request(UPSTREAM, data=up, headers={"Content-Type": "application/json"}), timeout=30)
self._send(r.status, r.read())
except urllib.error.HTTPError as e:
self._send(e.code, e.read())
except Exception:
self._send(502, json.dumps(err(None, -32000, "node unavailable")).encode())
def selftest():
ok = lambda b: filter_body(json.dumps(b).encode())[0]
assert ok({"jsonrpc": "2.0", "id": 1, "method": "eth_chainId", "params": []})
assert ok({"jsonrpc": "2.0", "id": 1, "method": "eth_sendRawTransaction", "params": ["0x00"]})
assert ok({"jsonrpc": "2.0", "id": 1, "method": "igneum_getBudgets", "params": []})
assert not ok({"jsonrpc": "2.0", "id": 1, "method": "igneum_submitProofRecord", "params": []})
assert not ok({"jsonrpc": "2.0", "id": 1, "method": "igneum_exportSegments", "params": []})
assert not ok({"jsonrpc": "2.0", "id": 1, "method": "admin_peers"})
assert not ok({"jsonrpc": "2.0", "id": 1, "method": "debug_traceTransaction"})
assert not ok({"jsonrpc": "2.0", "id": 1})
assert not ok([])
assert ok([{"jsonrpc": "2.0", "id": 1, "method": "eth_chainId"}, {"jsonrpc": "2.0", "id": 2, "method": "eth_blockNumber"}])
f, reply, _ = filter_body(json.dumps([{"jsonrpc": "2.0", "id": 1, "method": "eth_chainId"}, {"jsonrpc": "2.0", "id": 2, "method": "admin_x"}]).encode())
assert not f and len(json.loads(reply)) == 2 and json.loads(reply)[1]["error"]["code"] == -32601
f, reply, _ = filter_body(b"not json")
assert not f and json.loads(reply)["error"]["code"] == -32700
print("rpc-filter: self-test ok (%d methods allowed)" % len(ALLOWED))
if __name__ == "__main__":
if "--test" in sys.argv:
selftest(); sys.exit(0)
host, port = LISTEN.rsplit(":", 1)
ThreadingHTTPServer((host, int(port)), H).serve_forever()

View file

@ -0,0 +1,3 @@
seed1.testnet hetzner nbg1 195.201.35.33 cx23 2026-10-05T15:38:38Z
seed2.testnet hetzner ash 5.161.232.205 cpx21 2026-10-05T15:39:24Z
seed3.testnet hetzner sin 5.223.52.210 cpx22 2026-10-05T15:40:16Z
1 seed1.testnet hetzner nbg1 195.201.35.33 cx23 2026-10-05T15:38:38Z
2 seed2.testnet hetzner ash 5.161.232.205 cpx21 2026-10-05T15:39:24Z
3 seed3.testnet hetzner sin 5.223.52.210 cpx22 2026-10-05T15:40:16Z

View file

@ -0,0 +1,3 @@
195.201.35.33:26811
5.161.232.205:26811
5.223.52.210:26811

View file

@ -5,6 +5,7 @@
// and puts them where the packaging scripts look.
// node tools/build-job.mjs run [--node vendor/igneum-node-v4] [--target ae432dc7] [--budget-minutes 40]
// [--stage-minutes '{"linux":20}'] [--targets linux,windows] [--no-tests]
// [--node-tests "kaspa-consensus-core"] [--app-tests "igneum-app"] [--no-app]
// [--title "..."] [--no-place] [--out dir] pack + publish + watch + fetch
// node tools/build-job.mjs publish [the same flags] pack + publish, prints the id
// node tools/build-job.mjs watch <job id> STAGE and RESULT lines as they land
@ -31,7 +32,7 @@ const RELAY_BASE = (cfg('relay-url') || 'https://relay.igneum.network').replace(
const argv = process.argv.slice(2);
const flags = {}; const pos = [];
const BOOL = new Set(['no-tests', 'no-place', 'no-deploy', 'help']);
const BOOL = new Set(['no-tests', 'no-place', 'no-deploy', 'no-app', 'help']);
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (!BOOL.has(k) && next !== undefined && !next.startsWith('--')) { flags[k] = next; i++; } else flags[k] = true; }
@ -198,6 +199,9 @@ function placeFor(o) {
function publish() {
const pack = ['packaging/windows/push-build-inputs.sh'];
if (flags.node) pack.push('--node', flags.node);
if (flags['node-tests']) pack.push('--node-tests', String(flags['node-tests']));
if (flags['app-tests']) pack.push('--app-tests', String(flags['app-tests']));
if (flags['no-app']) pack.push('--no-app');
if (flags['no-deploy']) pack.push('--no-deploy');
console.log(`$ ${pack.join(' ')}`);
const r = spawnSync('bash', pack, { cwd: ROOT, stdio: 'inherit' });