#!/usr/bin/env bash # install-nullwire-node.sh — one-command NullWire RELAY node installer (v0.4.0). # # Run on a FRESH Linux VPS as root. Installs the relay + CLI binaries, generates # a node identity, writes a hardened systemd unit + a registry-heartbeat sidecar, # and prints the exact on-chain registration command + the details an admission # authority needs to admit you. # # Relays are the on-ramp: stateless onion forwarders (no user mailboxes, no keys # of anyone else). More independent relays = a bigger anonymity set. That is the # single most valuable thing a volunteer can contribute. # # curl -fsSL https://raw.githubusercontent.com/yunomiwell/nullwire-node/main/install-nullwire-node.sh | sudo bash # # Override anything via env, e.g.: # NW_LAYER=layer2 NW_PUBLIC_IP=203.0.113.7 sudo -E bash install-nullwire-node.sh # NW_SPONSOR_URL=https://gw.example:9101 sudo -E bash install-nullwire-node.sh # zero-SOL heartbeat # # v0.4.0 changes vs v0.3.0: # - binaries include the Sphinx anti-replay fix (bind replay to packet bytes). # - the relay unit reads the seed from a FILE (--secret-file), never argv, so the # node's transport seed is not visible in `ps` for the life of the daemon. # - the heartbeat supports a SPONSORING gateway (sponsor_url): the gateway pays the # fee and the operator key only signs, so an operator never needs SOL. set -euo pipefail KIT_VERSION="0.4.0" # ── config (override via env) ──────────────────────────────────────────────── NW_BASE_URL="${NW_BASE_URL:-https://github.com/yunomiwell/nullwire-node/releases/latest/download}" NW_LAYER="${NW_LAYER:-layer2}" # layer1 | layer2 | layer3 — ask the coordinator which to run NW_PORT="${NW_PORT:-9201}" NW_DIR="${NW_DIR:-/etc/nullwire}" NW_BIN=/usr/local/bin/nullwire-relay NW_CLI=/usr/local/bin/nullwire-cli NW_UNIT=/etc/systemd/system/nullwire-relay.service NW_HB_UNIT=/etc/systemd/system/nullwire-heartbeat.service # Devnet control-plane defaults (public). The heartbeat sends the on-chain # `Heartbeat` instruction (liveness + attestation only) to keep the node inside its # 60s registry TTL — it does NOT re-register. Match register-node-solana's defaults. NW_RPC_URL="${NW_RPC_URL:-https://api.devnet.solana.com}" NW_PROGRAM_ID="${NW_PROGRAM_ID:-4vSXEeoVi2Qm27x1pDCNXSrFirD5488knPfY91kjs8S8}" NW_REGISTRY="${NW_REGISTRY:-A1JpCZfxgYNePWzkZVoToRMktFKdWy2SEohxFF5ZKqTH}" # OPTIONAL sponsoring gateway(s). If set, the heartbeat beats THROUGH the gateway: # the gateway pays the fee, the operator key only signs → the operator needs no SOL. # Comma-separate several for failover. Empty = self-funded beats (operator pays). NW_SPONSOR_URL="${NW_SPONSOR_URL:-}" [ "$(id -u)" = "0" ] || { echo "run as root (sudo)"; exit 1; } for t in curl uname; do command -v "$t" >/dev/null || { echo "missing tool: $t"; exit 1; }; done # ── arch ───────────────────────────────────────────────────────────────────── case "$(uname -m)" in aarch64|arm64) ARCH=aarch64-unknown-linux-musl ;; x86_64|amd64) ARCH=x86_64-unknown-linux-musl ;; *) echo "unsupported arch $(uname -m) (need aarch64 or x86_64)"; exit 1 ;; esac # ── public endpoint + node id ──────────────────────────────────────────────── PUBLIC_IP="${NW_PUBLIC_IP:-$(curl -fsS https://api.ipify.org 2>/dev/null || true)}" [ -n "$PUBLIC_IP" ] || { echo "could not auto-detect public IP — set NW_PUBLIC_IP="; exit 1; } ENDPOINT="http://${PUBLIC_IP}:${NW_PORT}" NODE_ID="${NW_NODE_ID:-nullwire-relay-$(echo "$PUBLIC_IP" | tr '.' '-')}" NW_ROLE="RelayLayer${NW_LAYER#layer}" echo "==> NullWire relay installer v$KIT_VERSION arch=$ARCH node_id=$NODE_ID endpoint=$ENDPOINT layer=$NW_LAYER" # ── download binaries (fail CLOSED on checksum mismatch) ───────────────────── # Every release ships SHA256SUMS covering BOTH binaries. We verify relay AND cli # and refuse to install anything unverified: a missing SHA256SUMS, a missing entry # for this arch, or a hash mismatch all abort. No unverified binary ever runs. mkdir -p "$NW_DIR"; chmod 700 "$NW_DIR" dl() { curl -fsSL "$NW_BASE_URL/$1" -o "$2"; } echo "==> downloading binaries from $NW_BASE_URL" dl "nullwire-relay-$ARCH" "$NW_BIN.new" dl "nullwire-cli-$ARCH" "$NW_CLI.new" dl "SHA256SUMS" "$NW_DIR/SHA256SUMS" \ || { echo "!! SHA256SUMS not published — refusing to install unverified binaries. Aborting."; rm -f "$NW_BIN.new" "$NW_CLI.new"; exit 1; } echo "==> verifying checksums (relay + cli)" SUMS_CHECK="$(awk -v a="$ARCH" -v r="$NW_BIN.new" -v c="$NW_CLI.new" ' $2 == "nullwire-relay-"a {print $1" "r} $2 == "nullwire-cli-"a {print $1" "c}' "$NW_DIR/SHA256SUMS")" [ "$(printf '%s\n' "$SUMS_CHECK" | grep -c .)" -eq 2 ] \ || { echo "!! SHA256SUMS is missing the relay or cli entry for $ARCH — aborting"; rm -f "$NW_BIN.new" "$NW_CLI.new"; exit 1; } printf '%s\n' "$SUMS_CHECK" | sha256sum -c - \ || { echo "!! checksum FAILED — aborting"; rm -f "$NW_BIN.new" "$NW_CLI.new"; exit 1; } chmod +x "$NW_BIN.new" "$NW_CLI.new"; mv "$NW_BIN.new" "$NW_BIN"; mv "$NW_CLI.new" "$NW_CLI" # ── node identity: one SEED configures both registration + the running node ── # Written 0600; the relay reads it with --secret-file so the seed never enters argv # (a value in argv is readable by any local user via `ps` for the daemon's whole life). if [ ! -f "$NW_DIR/node.seed" ]; then SEED="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" printf '%s' "$SEED" > "$NW_DIR/node.seed"; chmod 600 "$NW_DIR/node.seed" echo "==> generated a fresh node seed (0600)" fi # Derive the transport pubkey FROM THE FILE — never `derive-pubkey --seed `, # which would place the seed in argv. PUBKEY="$("$NW_CLI" node-pubkey --seed-file "$NW_DIR/node.seed" 2>/dev/null || echo '?')" # ── systemd unit (seed via --secret-file; NOT in argv or env) ──────────────── printf 'NW_LAYER=%s\nNW_BIND=0.0.0.0:%s\n' "$NW_LAYER" "$NW_PORT" > "$NW_DIR/node.env" chmod 600 "$NW_DIR/node.env" cat > "$NW_UNIT" < "$NW_DIR/heartbeat.json" < "$NW_HB_UNIT" </dev/null 2>&1 || true systemctl enable nullwire-heartbeat.service >/dev/null 2>&1 || true # ── next steps (printed, NOT auto-run — they need your Solana/operator key) ──── SPONSOR_NOTE="self-funded (operator key must hold SOL)" [ -n "$NW_SPONSOR_URL" ] && SPONSOR_NOTE="SPONSORED by $NW_SPONSOR_URL (operator key needs NO SOL — it only signs)" cat < \\ --operator-keypair \\ --node-id $NODE_ID --role $NW_ROLE --endpoint $ENDPOINT \\ --secret-seed "\$(cat $NW_DIR/node.seed)" - SELF-FUNDED (needs a funded key): nullwire-cli register-node-solana \\ --payer-keypair \\ --node-id $NODE_ID --role $NW_ROLE \\ --endpoint $ENDPOINT --secret-seed "\$(cat $NW_DIR/node.seed)" 2b) KEEP IT ALIVE (without this your node drops out after ~60s): - edit $NW_DIR/heartbeat.json → set "payer_keypair" to the OPERATOR keypair you registered with (for the SPONSORED path this key needs NO SOL — only to sign; set NW_SPONSOR_URL at install to pre-fill "sponsor_url"). - systemctl start nullwire-heartbeat && journalctl -u nullwire-heartbeat -f (expect a landed beat every ~30s; a keypair path that does not exist fails LOUD) 3) ASK THE COORDINATOR TO ADMIT YOU — send: node_id=$NODE_ID endpoint=$ENDPOINT operator_pubkey= (an authority runs: nullwire-cli approve-node-solana --node-id $NODE_ID ...) 4) Start it: systemctl start nullwire-relay && journalctl -u nullwire-relay -f ──────────────────────────────────────────────────────────────────────────── NEXT