diff --git a/.env.example b/.env.example index e6dec81..0889fb4 100644 --- a/.env.example +++ b/.env.example @@ -7,23 +7,31 @@ # Host data directory (JWT + execution + consensus). Default matches official docs. # DATA_DIR=/blockchain -# HTTP JSON-RPC (wallets). Default 8545 +# HTTP JSON-RPC (wallets). Bound to 0.0.0.0 (LAN). Default 8545 # HTTP_PORT=8545 -# WebSocket RPC. Default 8546 +# WebSocket RPC. Bound to 0.0.0.0 (LAN). Default 8546 # WS_PORT=8546 -# Beacon REST/JSON HTTP API. Default 3500 +# Beacon REST/JSON HTTP API. Default host 127.0.0.1 (this machine only), port 3500. +# Set BEACON_HTTP_HOST=0.0.0.0 if you need the beacon API on the LAN. +# BEACON_HTTP_HOST=127.0.0.1 # BEACON_HTTP_PORT=3500 -# Beacon gRPC API. Default 4000 +# Beacon gRPC API. Default host 127.0.0.1, port 4000. Not needed for MetaMask. +# BEACON_GRPC_HOST=127.0.0.1 # BEACON_GRPC_PORT=4000 +# Go-Pulse memory cache in MB (geth --cache). Default 1024. +# Lower this on 16 GB hosts if the machine swaps; raise it on 32 GB+ SSDs. +# GETH_CACHE=1024 + # Optional: pin or float client images. -# Defaults in docker-compose.yml are the last known-good version tags. +# Defaults in docker-compose.yml are digest-pinned version tags. # GETH_IMAGE=registry.gitlab.com/pulsechaincom/go-pulse:v3.3.0 # BEACON_IMAGE=registry.gitlab.com/pulsechaincom/prysm-pulse/beacon-chain:v2.3.0 # To float on upstream latest instead: ./update.sh --latest +# (that command writes GETH_IMAGE / BEACON_IMAGE into this file so it sticks) # Suggested validator fee recipient (when you later run a validator). # Not used by this RPC stack. Leave blank if you only need a private RPC. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9ddfdeb..50cb821 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + - name: shellcheck + run: shellcheck -x common.sh install.sh start.sh stop.sh restart.sh logs.sh update.sh status.sh tests/test_common.sh tests/test_beacon_flags.sh tests/test_image_pins.sh - name: Helper and compose contract tests + env: + CI: "true" run: | bash tests/test_common.sh bash tests/test_beacon_flags.sh + bash tests/test_image_pins.sh diff --git a/README.md b/README.md index d3edaaa..e2929de 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,13 @@ This project packages the official PulseChain clients in Docker Compose with a s | [Go-Pulse](https://gitlab.com/pulsechaincom/go-pulse) | Execution layer (JSON-RPC / WebSocket) | `registry.gitlab.com/pulsechaincom/go-pulse:v3.3.0` | | [Prysm-Pulse](https://gitlab.com/pulsechaincom/prysm-pulse) | Consensus layer (beacon chain) | `registry.gitlab.com/pulsechaincom/prysm-pulse/beacon-chain:v2.3.0` | -**Defaults:** mainnet · checkpoint sync · data under `/blockchain` · wallet RPC on the LAN (`0.0.0.0`) · Engine API localhost-only · pinned client tags (`./update.sh --latest` to float) +**Defaults:** mainnet · checkpoint sync · data under `/blockchain` · wallet RPC on the LAN (`0.0.0.0`) · Engine API and beacon HTTP/gRPC localhost-only · digest-pinned client tags (`./update.sh --latest` writes `:latest` into `.env`) --- ## Quick start -**Requirements:** Linux (Ubuntu 22.04 / 24.04 or Debian recommended), `sudo`, outbound internet, and a large SSD mounted where `/blockchain` will live. +**Requirements:** Linux (Ubuntu 22.04 / 24.04 or Debian recommended; amd64 or arm64), `sudo`, outbound internet, and a large SSD mounted where `/blockchain` will live. ```bash git clone https://github.com/DavidFeder/pulsechain-rpc-node.git @@ -60,8 +60,8 @@ If you need ready-to-run node hardware, you can find pre-built options at [valid |------|---------| | **8545** | HTTP JSON-RPC (primary wallet endpoint) | | **8546** | WebSocket RPC | -| **3500** | Beacon HTTP API | -| **4000** | Beacon gRPC (not needed for MetaMask) | +| **3500** | Beacon HTTP API (**localhost** by default) | +| **4000** | Beacon gRPC (**localhost** by default; not needed for MetaMask) | **Intended use** @@ -73,7 +73,9 @@ If you need ready-to-run node hardware, you can find pre-built options at [valid - Public internet exposure - Untrusted or shared networks without additional controls -**Do not** port-forward **8545**, **8546**, **3500**, or **4000** to the public internet. This project is a **private RPC**, not a public endpoint. The Engine API on **8551** is bound to localhost. +**Do not** port-forward **8545**, **8546**, **3500**, or **4000** to the public internet. This project is a **private RPC**, not a public endpoint. The Engine API on **8551** and the beacon HTTP/gRPC APIs are bound to localhost. + +Checkpoint sync trusts [checkpoint.pulsechain.com](https://checkpoint.pulsechain.com) (same URL as the official PulseChain docs). That is a trusted third party for the initial beacon state. Prysm also recommends pairing it with a weak-subjectivity checkpoint if you want extra assurance against a long-range attack. CORS / vhosts default to `*` so LAN web wallets can reach the node. A page you visit can also call that RPC if it can reach the LAN IP — keep this on a trusted network. @@ -87,7 +89,7 @@ LAN binding is intentional so phones and other machines on the same network can ### Prerequisites -- Linux host (Ubuntu 22.04 / 24.04 or Debian recommended) +- Linux host (Ubuntu 22.04 / 24.04 or Debian recommended; **amd64 or arm64**) - `sudo` privileges - Sufficient free space for `/blockchain` - Outbound connectivity to pull images and sync with the network @@ -115,11 +117,7 @@ chmod +x *.sh ### Discover your LAN IP -Printed by the installer. You can also run: - -```bash -hostname -I | awk '{print $1}' -``` +Printed by the installer and by `./status.sh` (source address of the default route). Prefer that over `hostname -I`, which often prints Docker's `172.17.0.1` (`docker0`) first and will not work from a phone. --- @@ -167,9 +165,9 @@ Run the following from the project directory: | Follow beacon logs | `./logs.sh beacon` | | Stop | `./stop.sh` | | Start | `./start.sh` | -| Recreate from compose (applies flag/.env changes) | `./restart.sh` | +| Recreate from compose (applies flag/.env changes and bounces containers) | `./restart.sh` | | Update pinned images and recreate | `./update.sh` | -| Float on upstream `:latest` images | `./update.sh --latest` | +| Float on upstream `:latest` images (persists to `.env`) | `./update.sh --latest` | Equivalent Docker Compose commands: @@ -186,16 +184,15 @@ Chain data is stored under **`/blockchain`** and is retained when containers are ## Localhost-only mode -By default, wallet RPC binds to `0.0.0.0` (all interfaces). The Engine API (`--authrpc.addr=127.0.0.1`, port 8551) is already host-only. To accept wallet/beacon API connections **only on the host**: +By default, **wallet** RPC binds to `0.0.0.0` (all interfaces). The Engine API (`--authrpc.addr=127.0.0.1`, port 8551) and beacon HTTP/gRPC (`BEACON_HTTP_HOST` / `BEACON_GRPC_HOST`, default `127.0.0.1`) are already host-only. + +To accept wallet RPC connections **only on the host**: 1. Edit `docker-compose.yml`. 2. Under the **geth** service, change: - `--http.addr=0.0.0.0` → `--http.addr=127.0.0.1` - `--ws.addr=0.0.0.0` → `--ws.addr=127.0.0.1` -3. Under **beacon**, change: - - `--grpc-gateway-host=0.0.0.0` → `--grpc-gateway-host=127.0.0.1` - - `--rpc-host=0.0.0.0` → `--rpc-host=127.0.0.1` -4. Apply the change (`./restart.sh` recreates containers from compose; it does not keep stale flags): +3. Apply the change (`./restart.sh` recreates containers from compose; it does not keep stale flags): ```bash ./restart.sh @@ -203,6 +200,17 @@ By default, wallet RPC binds to `0.0.0.0` (all interfaces). The Engine API (`--a Use `http://127.0.0.1:8545` in wallets on **that machine only**. +### LAN beacon API (optional) + +Beacon REST/gRPC are not required for MetaMask. Prysm-Pulse still uses `--grpc-gateway-host` / `--rpc-host` (not `--http-host`). To expose them on the LAN, set in `.env`: + +```bash +BEACON_HTTP_HOST=0.0.0.0 +BEACON_GRPC_HOST=0.0.0.0 +``` + +Then run `./restart.sh`. Keep those ports firewalled to your LAN; never port-forward them. + --- ## Network ports @@ -211,8 +219,8 @@ Use `http://127.0.0.1:8545` in wallets on **that machine only**. |------|----------|---------|--------------| | 8545 | TCP | HTTP JSON-RPC (wallets) | `0.0.0.0` (LAN) | | 8546 | TCP | WebSocket RPC | `0.0.0.0` (LAN) | -| 3500 | TCP | Beacon REST API | `0.0.0.0` (LAN) | -| 4000 | TCP | Beacon gRPC | `0.0.0.0` (LAN) | +| 3500 | TCP | Beacon REST API | `127.0.0.1` (localhost; override with `BEACON_HTTP_HOST`) | +| 4000 | TCP | Beacon gRPC | `127.0.0.1` (localhost; override with `BEACON_GRPC_HOST`) | | 8551 | TCP | Engine API (JWT; geth ↔ beacon) | `127.0.0.1` (localhost only) | | 30303 | TCP/UDP | Execution P2P | Host | | 13000 | TCP | Beacon P2P | Host | @@ -293,6 +301,8 @@ sudo ufw status numbered Replace `192.168.0.0/16` with your actual LAN range. Never open the RPC ports to `0.0.0.0/0` or the public internet. These examples are IPv4; if the host has global IPv6, add matching `from ` rules or disable IPv6 on the node. +On a VPS or cloud VM, `10.0.0.0/8` (and often `172.16.0.0/12`) is the **VPC**, not a home Wi-Fi. Tighten `from` to your office/VPN prefix instead of the whole RFC1918 range. + --- ## Configuration summary @@ -304,8 +314,12 @@ Replace `192.168.0.0/16` with your actual LAN range. Never open the RPC ports to | Execution datadir | `$DATA_DIR/execution` | | Consensus datadir | `$DATA_DIR/consensus` | | JWT secret | `$DATA_DIR/jwt.hex` (mode `600`) | -| Execution image | `go-pulse:v3.3.0` (override with `GETH_IMAGE` or `./update.sh --latest`) | -| Beacon image | `beacon-chain:v2.3.0` (override with `BEACON_IMAGE` or `./update.sh --latest`) | +| Execution image | `go-pulse:v3.3.0` digest-pinned (override with `GETH_IMAGE` or `./update.sh --latest`) | +| Beacon image | `beacon-chain:v2.3.0` digest-pinned (override with `BEACON_IMAGE` or `./update.sh --latest`) | +| Beacon HTTP / gRPC | `127.0.0.1` (`BEACON_HTTP_HOST` / `BEACON_GRPC_HOST`) | +| Geth IPC | disabled (`--ipcdisable`; admin APIs are not on the host filesystem) | +| Geth cache | `1024` MB (`GETH_CACHE`) | +| File descriptors | `nofile` 65535 (container ulimit) | | Checkpoint sync | `https://checkpoint.pulsechain.com` (trusted third party; same as official docs) | | Restart policy | `unless-stopped` | | Stop grace period | `5m` | @@ -323,10 +337,11 @@ Optional variables (`DATA_DIR`, ports, image pins) are documented in `.env.examp | Docker permission denied | Log out and back in after install (docker group membership), or prefix commands with `sudo` | | `address already in use` / crash loop | Another node is using ports 8545, 8546, 3500, 4000, or 8551. Stop the other process or change ports in `docker-compose.yml` | | Beacon cannot find execution client | Confirm both containers are running and that `/blockchain/jwt.hex` exists and is shared by both | -| JWT / `401 Unauthorized` to execution | Ensure only one execution client is on port 8551 and both services use the same `/blockchain/jwt.hex` | -| Wallet cannot connect | Verify LAN IP, same network, host firewall rules; test `curl` against `127.0.0.1:8545` on the node. `./restart.sh` after compose edits (it recreates containers). | +| JWT / `401 Unauthorized` to execution | Ensure only one execution client is on port 8551 and both services use the same `/blockchain/jwt.hex`. The file must be 64 hex characters with **no newline**. Keep the host clock in sync (NTP / `timedatectl`); JWT `iat` skew also returns 401. | +| Wallet cannot connect | Verify LAN IP from `./status.sh` (not `hostname -I` / docker0), same network, host firewall rules; test `curl` against `127.0.0.1:8545` on the node. `./restart.sh` after compose edits (it recreates containers). | +| `./restart.sh` did nothing (old versions) | Current `restart.sh` uses `docker compose up -d --force-recreate`. Upgrade the scripts if an older copy only ran `up -d`. | | Disk space pressure | Full nodes grow over time — monitor free space and use a large SSD | -| Slow sync | Prefer NVMe storage, adequate RAM, and open P2P ports where practical | +| Slow sync | Prefer NVMe storage, adequate RAM, and open P2P ports where practical. On 16 GB hosts, try `GETH_CACHE=512` in `.env` if the machine swaps. | **Health checks** (run on the node host): @@ -334,7 +349,7 @@ Optional variables (`DATA_DIR`, ports, image pins) are documented in `.env.examp ./status.sh ``` -`status.sh` reports container state, `eth_syncing` / block / peers, beacon sync, and disk free on the data directory. +`status.sh` reports container state, `eth_syncing` / block / peers, beacon sync, disk free, and the wallet RPC URL. If `eth_syncing` returns `false`, the execution client reports that it is synced. Wait for the beacon section to report synced as well before relying on the endpoint. @@ -356,8 +371,10 @@ pulsechain-rpc-node/ ├── restart.sh # Recreate from compose (applies edits) ├── logs.sh ├── update.sh # Pull pinned images; --latest to float -└── tests/ - └── test_beacon_flags.sh +├── tests/ +│ ├── test_common.sh +│ ├── test_beacon_flags.sh +│ └── test_image_pins.sh ``` --- diff --git a/common.sh b/common.sh index 99215b9..871c885 100755 --- a/common.sh +++ b/common.sh @@ -4,59 +4,187 @@ _COMMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GETH_IMAGE_PINNED="registry.gitlab.com/pulsechaincom/go-pulse:v3.3.0@sha256:d2f59592244decca2d1f53b5c8a1d2f7b26cf25d0722118567cc8978b44e526f" +BEACON_IMAGE_PINNED="registry.gitlab.com/pulsechaincom/prysm-pulse/beacon-chain:v2.3.0@sha256:31b44010a9e1ed35125541c4347bae8d343ea98e792651d48d595e610f4d1d78" +GETH_IMAGE_LATEST="registry.gitlab.com/pulsechaincom/go-pulse:latest" +BEACON_IMAGE_LATEST="registry.gitlab.com/pulsechaincom/prysm-pulse/beacon-chain:latest" +export GETH_IMAGE_LATEST BEACON_IMAGE_LATEST +GETH_CONTAINER="pulse-geth" +BEACON_CONTAINER="pulse-beacon" + +# Env keys interpolated by docker-compose.yml / passed through sudo. +COMPOSE_ENV_KEYS="DATA_DIR,HTTP_PORT,WS_PORT,BEACON_HTTP_PORT,BEACON_GRPC_PORT,BEACON_HTTP_HOST,BEACON_GRPC_HOST,GETH_IMAGE,BEACON_IMAGE,GETH_CACHE" + +_trim() { + local s="$1" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "${s}" +} + # Load KEY=VALUE pairs from .env without executing it. Existing environment # variables win so `DATA_DIR=/other ./install.sh` still works. +# Accepts optional "export ", optional spaces around "=", and unquoted inline comments. load_dotenv() { local file="${1:-}" - local line key val + local line key val rest quoted [[ -n "${file}" && -f "${file}" ]] || return 0 while IFS= read -r line || [[ -n "${line}" ]]; do line="${line%$'\r'}" [[ "${line}" =~ ^[[:space:]]*$ ]] && continue [[ "${line}" =~ ^[[:space:]]*# ]] && continue - if [[ "${line}" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then + rest="${line}" + if [[ "${rest}" =~ ^[[:space:]]*export[[:space:]]+ ]]; then + rest="${rest#*export}" + rest="${rest#"${rest%%[![:space:]]*}"}" + fi + if [[ "${rest}" =~ ^([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then key="${BASH_REMATCH[1]}" val="${BASH_REMATCH[2]}" - if [[ "${val}" =~ ^\"(.*)\"$ ]]; then + quoted=0 + if [[ "${val}" =~ ^\"(.*)\"[[:space:]]*(#.*)?$ ]]; then val="${BASH_REMATCH[1]}" - elif [[ "${val}" =~ ^\'(.*)\'$ ]]; then + quoted=1 + elif [[ "${val}" =~ ^\'(.*)\'[[:space:]]*(#.*)?$ ]]; then val="${BASH_REMATCH[1]}" + quoted=1 + fi + if [[ "${quoted}" -eq 0 && "${val}" == *" #"* ]]; then + val="${val%% #*}" fi + val="$(_trim "${val}")" if [[ -z "${!key+x}" ]]; then printf -v "${key}" '%s' "${val}" - export "${key}" + export "${key?}" fi + elif [[ "${line}" == *"="* ]]; then + echo "load_dotenv: skipped line (expected KEY=VALUE): ${line}" >&2 fi done < "${file}" } +# Set or uncomment KEY=VALUE in a dotenv file. Preserves other lines. +upsert_dotenv() { + local file="$1" + local key="$2" + local value="$3" + local tmp line replaced=0 + + if [[ ! -f "${file}" ]]; then + printf '%s=%s\n' "${key}" "${value}" > "${file}" + return 0 + fi + + tmp="$(mktemp)" + while IFS= read -r line || [[ -n "${line}" ]]; do + if [[ "${line}" =~ ^[[:space:]]*#?[[:space:]]*${key}= ]]; then + if [[ "${replaced}" -eq 0 ]]; then + printf '%s=%s\n' "${key}" "${value}" + replaced=1 + fi + else + printf '%s\n' "${line}" + fi + done < "${file}" > "${tmp}" + if [[ "${replaced}" -eq 0 ]]; then + printf '%s=%s\n' "${key}" "${value}" >> "${tmp}" + fi + cat "${tmp}" > "${file}" + rm -f "${tmp}" +} + load_dotenv "${_COMMON_DIR}/.env" # Defaults match docker-compose.yml ${VAR:-default} interpolation. -: "${DATA_DIR:=/blockchain}" -: "${HTTP_PORT:=8545}" -: "${WS_PORT:=8546}" -: "${BEACON_HTTP_PORT:=3500}" -: "${BEACON_GRPC_PORT:=4000}" -: "${GETH_IMAGE:=registry.gitlab.com/pulsechaincom/go-pulse:v3.3.0}" -: "${BEACON_IMAGE:=registry.gitlab.com/pulsechaincom/prysm-pulse/beacon-chain:v2.3.0}" -export DATA_DIR HTTP_PORT WS_PORT BEACON_HTTP_PORT BEACON_GRPC_PORT GETH_IMAGE BEACON_IMAGE +# Empty values (HTTP_PORT=) are treated as unset so scripts and Compose agree. +[[ -n "${DATA_DIR:-}" ]] || DATA_DIR=/blockchain +[[ -n "${HTTP_PORT:-}" ]] || HTTP_PORT=8545 +[[ -n "${WS_PORT:-}" ]] || WS_PORT=8546 +[[ -n "${BEACON_HTTP_PORT:-}" ]] || BEACON_HTTP_PORT=3500 +[[ -n "${BEACON_GRPC_PORT:-}" ]] || BEACON_GRPC_PORT=4000 +[[ -n "${BEACON_HTTP_HOST:-}" ]] || BEACON_HTTP_HOST=127.0.0.1 +[[ -n "${BEACON_GRPC_HOST:-}" ]] || BEACON_GRPC_HOST=127.0.0.1 +[[ -n "${GETH_CACHE:-}" ]] || GETH_CACHE=1024 +[[ -n "${GETH_IMAGE:-}" ]] || GETH_IMAGE="${GETH_IMAGE_PINNED}" +[[ -n "${BEACON_IMAGE:-}" ]] || BEACON_IMAGE="${BEACON_IMAGE_PINNED}" +export DATA_DIR HTTP_PORT WS_PORT BEACON_HTTP_PORT BEACON_GRPC_PORT +export BEACON_HTTP_HOST BEACON_GRPC_HOST GETH_CACHE GETH_IMAGE BEACON_IMAGE -GETH_IMAGE_LATEST="registry.gitlab.com/pulsechaincom/go-pulse:latest" -BEACON_IMAGE_LATEST="registry.gitlab.com/pulsechaincom/prysm-pulse/beacon-chain:latest" +# Cache how we talk to Docker so we do not run `docker info` on every call. +_DOCKER_MODE="" -# Run docker compose with sudo only when the current user cannot talk to the daemon. -# Does NOT hide real compose errors (unlike "try docker; on any failure try sudo"). -run_compose() { +_detect_docker_mode() { if docker info >/dev/null 2>&1; then - docker compose "$@" - elif command -v sudo >/dev/null 2>&1; then - sudo --preserve-env=DATA_DIR,HTTP_PORT,WS_PORT,BEACON_HTTP_PORT,BEACON_GRPC_PORT,GETH_IMAGE,BEACON_IMAGE \ - docker compose "$@" - else - echo "Cannot access Docker. Install Docker, or add your user to the docker group, or re-run with sudo." >&2 + _DOCKER_MODE=direct + return 0 + fi + if command -v sudo >/dev/null 2>&1 && sudo docker info >/dev/null 2>&1; then + _DOCKER_MODE=sudo + return 0 + fi + _DOCKER_MODE=none + return 1 +} + +wait_for_docker() { + local i + _DOCKER_MODE="" + for ((i = 1; i <= 30; i++)); do + if _detect_docker_mode; then + return 0 + fi + sleep 1 + done + return 1 +} + +# Run docker with sudo only when the current user cannot talk to the daemon. +run_docker() { + if [[ -z "${_DOCKER_MODE}" ]]; then + _detect_docker_mode || true + fi + case "${_DOCKER_MODE}" in + direct) docker "$@" ;; + sudo) + sudo --preserve-env="${COMPOSE_ENV_KEYS}" docker "$@" + ;; + *) + echo "Cannot access Docker. Install Docker, or add your user to the docker group, or re-run with sudo." >&2 + return 1 + ;; + esac +} + +run_compose() { + run_docker compose "$@" +} + +container_running() { + local name="$1" + local state="" + state="$(run_docker inspect -f '{{.State.Running}}' "${name}" 2>/dev/null || true)" + [[ "${state}" == "true" ]] +} + +our_stack_running() { + container_running "${GETH_CONTAINER}" || container_running "${BEACON_CONTAINER}" +} + +# True when TCP or UDP sport is bound on the host. +port_in_use() { + local port="$1" + [[ "${port}" =~ ^[0-9]+$ ]] || return 1 + if command -v ss >/dev/null 2>&1; then + [[ -n "$(ss -H -ltn "sport = :${port}" 2>/dev/null)" ]] && return 0 + [[ -n "$(ss -H -lun "sport = :${port}" 2>/dev/null)" ]] && return 0 + return 1 + fi + if command -v lsof >/dev/null 2>&1; then + lsof -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1 && return 0 + lsof -iUDP:"${port}" >/dev/null 2>&1 && return 0 return 1 fi + return 1 } # Prefer the source address of the default route over `hostname -I` (which can @@ -70,6 +198,25 @@ detect_lan_ip() { printf '%s\n' "${ip:-YOUR_LAN_IP}" } +# Loopback, RFC1918, link-local, and CGNAT (100.64.0.0/10) are not "public". +is_nonpublic_ipv4() { + local addr="$1" + case "${addr}" in + 127.*|10.*|192.168.*|169.254.*) return 0 ;; + 172.1[6-9].*|172.2[0-9].*|172.3[0-1].*) return 0 ;; + 100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) return 0 ;; + *) return 1 ;; + esac +} + +is_public_ipv4() { + [[ -n "${1:-}" ]] || return 1 + if is_nonpublic_ipv4 "$1"; then + return 1 + fi + return 0 +} + # True when the host itself has a publicly routable IPv4 address (typical VPS). # IPv6 is ignored: wallet listeners bind 0.0.0.0, so dual-stack home fiber # must not trigger the installer warning. CGNAT (100.64.0.0/10) is not public. @@ -77,18 +224,36 @@ host_has_public_ip() { local addr while read -r addr; do [[ -z "${addr}" ]] && continue - case "${addr}" in - 127.*|10.*|192.168.*|169.254.*) ;; - 172.1[6-9].*|172.2[0-9].*|172.3[0-1].*) ;; - # Carrier-grade NAT: 100.64.0.0–100.127.255.255 - 100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) ;; - *) return 0 ;; - esac + if is_public_ipv4 "${addr}"; then + return 0 + fi done < <(ip -o -4 addr show up 2>/dev/null | awk '{print $4}' | cut -d/ -f1) - return 1 } +effective_install_user() { + if [[ "${EUID}" -eq 0 && -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then + printf '%s\n' "${SUDO_USER}" + return 0 + fi + id -un +} + +effective_install_group() { + local user="$1" + id -gn "${user}" +} + +# Canonical JWT payload: 64 hex chars, no whitespace. +jwt_payload() { + tr -d '[:space:]' < "$1" +} + +jwt_payload_is_valid() { + local payload="$1" + [[ "${#payload}" -eq 64 && "${payload}" =~ ^[0-9a-fA-F]{64}$ ]] +} + confirm_yes() { local prompt="$1" local reply="" diff --git a/docker-compose.yml b/docker-compose.yml index 65fd65a..bc28335 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,8 @@ # # ⚠ Wallet RPC is bound to 0.0.0.0 (LAN-visible) by default. # Use only on a trusted home network. Never port-forward 8545/8546/3500/4000. -# Engine API (8551) is localhost-only. +# Engine API (8551) and beacon HTTP/gRPC are localhost-only. +# Image tags are pinned by digest (see GETH_IMAGE / BEACON_IMAGE). x-logging: &default-logging driver: json-file @@ -16,24 +17,32 @@ x-logging: &default-logging max-size: "50m" max-file: "5" +x-ulimits: &default-ulimits + nofile: + soft: 65535 + hard: 65535 + services: # --------------------------------------------------------------------------- # Execution client — Go-Pulse (geth) - # Pinned to v3.3.0 (same digest as :latest on 2026-08-24). + # Pinned to v3.3.0 by digest (same as :latest on 2026-08-28). # Override with GETH_IMAGE=registry.gitlab.com/pulsechaincom/go-pulse:latest # --------------------------------------------------------------------------- geth: - image: ${GETH_IMAGE:-registry.gitlab.com/pulsechaincom/go-pulse:v3.3.0} + image: ${GETH_IMAGE:-registry.gitlab.com/pulsechaincom/go-pulse:v3.3.0@sha256:d2f59592244decca2d1f53b5c8a1d2f7b26cf25d0722118567cc8978b44e526f} container_name: pulse-geth network_mode: host restart: unless-stopped stop_grace_period: 5m logging: *default-logging + ulimits: *default-ulimits volumes: - ${DATA_DIR:-/blockchain}:/blockchain command: - --pulsechain - --datadir=/blockchain/execution + - --ipcdisable + - --cache=${GETH_CACHE:-1024} - --authrpc.jwtsecret=/blockchain/jwt.hex - --authrpc.addr=127.0.0.1 - --authrpc.port=8551 @@ -55,16 +64,17 @@ services: # --------------------------------------------------------------------------- # Consensus client — Prysm-Pulse Beacon Chain - # Pinned to v2.3.0 (same digest as :latest on 2026-08-24). + # Pinned to v2.3.0 by digest (same as :latest on 2026-08-28). # Override with BEACON_IMAGE=.../beacon-chain:latest # --------------------------------------------------------------------------- beacon: - image: ${BEACON_IMAGE:-registry.gitlab.com/pulsechaincom/prysm-pulse/beacon-chain:v2.3.0} + image: ${BEACON_IMAGE:-registry.gitlab.com/pulsechaincom/prysm-pulse/beacon-chain:v2.3.0@sha256:31b44010a9e1ed35125541c4347bae8d343ea98e792651d48d595e610f4d1d78} container_name: pulse-beacon network_mode: host restart: unless-stopped stop_grace_period: 5m logging: *default-logging + ulimits: *default-ulimits depends_on: - geth volumes: @@ -77,10 +87,11 @@ services: - --checkpoint-sync-url=https://checkpoint.pulsechain.com - --genesis-beacon-api-url=https://checkpoint.pulsechain.com # REST/JSON HTTP API — prysm-pulse still uses grpc-gateway-* (not --http-host/--http-port) - - --grpc-gateway-host=0.0.0.0 + # Localhost by default (not needed for MetaMask). Set BEACON_HTTP_HOST=0.0.0.0 for LAN. + - --grpc-gateway-host=${BEACON_HTTP_HOST:-127.0.0.1} - --grpc-gateway-port=${BEACON_HTTP_PORT:-3500} - # gRPC API (LAN-visible; not needed for MetaMask) - - --rpc-host=0.0.0.0 + # gRPC API (localhost; not needed for MetaMask) + - --rpc-host=${BEACON_GRPC_HOST:-127.0.0.1} - --rpc-port=${BEACON_GRPC_PORT:-4000} - --p2p-tcp-port=13000 - --p2p-udp-port=12000 diff --git a/install.sh b/install.sh index 315c194..9200e60 100755 --- a/install.sh +++ b/install.sh @@ -32,6 +32,9 @@ echo "" # --------------------------------------------------------------------------- # 1. Root / sudo check # --------------------------------------------------------------------------- +INSTALL_USER="$(effective_install_user)" +INSTALL_GROUP="$(effective_install_group "${INSTALL_USER}")" + if [[ "${EUID}" -eq 0 ]]; then warn "You are running as root." warn "This works, but running as a normal user with sudo is safer and recommended." @@ -91,6 +94,8 @@ if [[ "${need_docker_install}" == true ]]; then case "${OS_ID}" in ubuntu|debian|linuxmint|pop) $SUDO apt-get update -y + # Distro docker.io / containerd packages conflict with Docker CE. + $SUDO apt-get remove -y docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc || true $SUDO apt-get install -y ca-certificates curl gnupg openssl $SUDO install -m 0755 -d /etc/apt/keyrings if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then @@ -114,7 +119,12 @@ if [[ "${need_docker_install}" == true ]]; then $SUDO apt-get update -y $SUDO apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin $SUDO systemctl enable --now docker - ok "Docker installed." + info "Waiting for the Docker daemon..." + if wait_for_docker; then + ok "Docker installed." + else + die "Docker was installed but the daemon is not responding. Try: sudo systemctl status docker" + fi ;; *) die "Automatic Docker install is only supported on Ubuntu/Debian. Install Docker manually: https://docs.docker.com/engine/install/ then re-run this script." @@ -129,7 +139,8 @@ if ! command -v openssl >/dev/null 2>&1; then info "Installing openssl..." case "${OS_ID}" in ubuntu|debian|linuxmint|pop) - $SUDO apt-get update -y && $SUDO apt-get install -y openssl || die "Please install openssl and re-run." + $SUDO apt-get update -y + $SUDO apt-get install -y openssl || die "Please install openssl and re-run." ;; *) die "openssl is required. Please install it and re-run." @@ -137,12 +148,12 @@ if ! command -v openssl >/dev/null 2>&1; then esac fi -# Allow current user to run docker without sudo (best-effort; needs re-login) -if [[ "${EUID}" -ne 0 ]]; then - if ! id -nG "${USER}" | tr ' ' '\n' | grep -qx docker; then - info "Adding ${USER} to the docker group (log out/in may be required)..." +# Allow the invoking user to run docker without sudo (best-effort; needs re-login) +if [[ "${INSTALL_USER}" != "root" ]]; then + if ! id -nG "${INSTALL_USER}" | tr ' ' '\n' | grep -qx docker; then + info "Adding ${INSTALL_USER} to the docker group (log out/in may be required)..." warn "Members of the docker group can effectively become root via the Docker daemon." - $SUDO usermod -aG docker "${USER}" || warn "Could not add user to docker group." + $SUDO usermod -aG docker "${INSTALL_USER}" || warn "Could not add user to docker group." fi fi @@ -155,12 +166,14 @@ $SUDO chmod 755 "${DATA_DIR}" "${DATA_DIR}/execution" "${DATA_DIR}/consensus" # Only chown when safe: empty tree or already owned by this user. # Avoid recursive chown of multi-TB chain data on every re-run. -if [[ "${EUID}" -ne 0 ]]; then - if [[ -z "$(ls -A "${DATA_DIR}/execution" 2>/dev/null || true)" ]] \ - && [[ -z "$(ls -A "${DATA_DIR}/consensus" 2>/dev/null || true)" ]]; then - $SUDO chown -R "${USER}:${USER}" "${DATA_DIR}" 2>/dev/null || true +# Use sudo ls so a permission error cannot look like "empty". +if [[ "${INSTALL_USER}" != "root" ]]; then + exec_listing="$($SUDO ls -A "${DATA_DIR}/execution" 2>/dev/null || true)" + cons_listing="$($SUDO ls -A "${DATA_DIR}/consensus" 2>/dev/null || true)" + if [[ -z "${exec_listing}" && -z "${cons_listing}" ]]; then + $SUDO chown -R "${INSTALL_USER}:${INSTALL_GROUP}" "${DATA_DIR}" 2>/dev/null || true else - $SUDO chown "${USER}:${USER}" "${DATA_DIR}" 2>/dev/null || true + $SUDO chown "${INSTALL_USER}:${INSTALL_GROUP}" "${DATA_DIR}" 2>/dev/null || true fi fi ok "${DATA_DIR} is ready (execution + consensus subdirs)." @@ -184,17 +197,55 @@ fi # 5. JWT secret (required for Engine API between geth and beacon) # --------------------------------------------------------------------------- JWT_PATH="${DATA_DIR}/jwt.hex" -if [[ -f "${JWT_PATH}" ]]; then - ok "JWT secret already exists at ${JWT_PATH}" -else + +read_jwt_payload() { + local path="$1" + if [[ -r "${path}" ]]; then + tr -d '[:space:]' < "${path}" + else + $SUDO cat "${path}" 2>/dev/null | tr -d '[:space:]' + fi +} + +write_jwt_payload() { + local path="$1" + local payload="$2" + printf '%s' "${payload}" | $SUDO tee "${path}" >/dev/null +} + +if $SUDO test -f "${JWT_PATH}"; then + jwt_existing="$(read_jwt_payload "${JWT_PATH}")" + if jwt_payload_is_valid "${jwt_existing}"; then + jwt_raw_len="$($SUDO wc -c < "${JWT_PATH}" | tr -d ' ')" + if [[ "${jwt_raw_len}" -ne 64 ]]; then + info "Normalizing JWT secret at ${JWT_PATH} (stripping whitespace/newlines)..." + write_jwt_payload "${JWT_PATH}" "${jwt_existing}" + fi + ok "JWT secret already exists at ${JWT_PATH}" + else + warn "JWT secret at ${JWT_PATH} is invalid (expected 64 hex characters, no whitespace)." + jwt_bak="${JWT_PATH}.bak.$(date +%s)" + $SUDO mv "${JWT_PATH}" "${jwt_bak}" + warn "Moved it aside to ${jwt_bak}" + fi +fi + +if ! $SUDO test -f "${JWT_PATH}"; then info "Generating JWT secret at ${JWT_PATH} ..." # No trailing newline (required by clients / official docs) - openssl rand -hex 32 | tr -d '\n' | $SUDO tee "${JWT_PATH}" >/dev/null - if [[ "${EUID}" -ne 0 ]]; then - $SUDO chown "${USER}:${USER}" "${JWT_PATH}" 2>/dev/null || true + jwt_new="$(openssl rand -hex 32 | tr -d '[:space:]')" + if ! jwt_payload_is_valid "${jwt_new}"; then + die "openssl failed to produce a 64-character hex JWT." fi - # Sanity: 64 hex chars, no newline - if [[ ! -s "${JWT_PATH}" ]] || [[ "$(wc -c < "${JWT_PATH}" | tr -d ' ')" -ne 64 ]]; then + write_jwt_payload "${JWT_PATH}" "${jwt_new}" + if [[ "${INSTALL_USER}" != "root" ]]; then + $SUDO chown "${INSTALL_USER}:${INSTALL_GROUP}" "${JWT_PATH}" 2>/dev/null || true + fi + if [[ ! -s "${JWT_PATH}" ]] && ! $SUDO test -s "${JWT_PATH}"; then + die "JWT secret at ${JWT_PATH} was not written." + fi + jwt_written="$(read_jwt_payload "${JWT_PATH}")" + if ! jwt_payload_is_valid "${jwt_written}"; then die "JWT secret at ${JWT_PATH} looks invalid (expected 64 hex characters)." fi ok "JWT secret created." @@ -219,37 +270,31 @@ fi # --------------------------------------------------------------------------- # 7. Port conflict pre-check (host networking shares the host's ports) # --------------------------------------------------------------------------- -check_port_in_use() { - local port="$1" - if command -v ss >/dev/null 2>&1; then - ss -lntu 2>/dev/null | awk '{print $5}' | grep -Eq "[:.]${port}$" - elif command -v lsof >/dev/null 2>&1; then - lsof -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1 \ - || lsof -iUDP:"${port}" >/dev/null 2>&1 - else - return 1 - fi -} - PORTS_TO_CHECK=("${HTTP_PORT}" "${WS_PORT}" "${BEACON_HTTP_PORT}" "${BEACON_GRPC_PORT}" 8551 30303 13000 12000) -PORT_CONFLICTS=() -for port in "${PORTS_TO_CHECK[@]}"; do - if check_port_in_use "${port}"; then - PORT_CONFLICTS+=("${port}") - fi -done -if [[ "${#PORT_CONFLICTS[@]}" -gt 0 ]]; then - warn "These ports are already in use on this machine: ${PORT_CONFLICTS[*]}" - warn "A full node needs them free (or you must change ports in .env / docker-compose.yml)." - warn "Common cause: another Geth/Prysm/PulseChain node already running." - echo "" - if confirm_yes "Continue anyway? [y/N] "; then - warn "Continuing despite port conflicts..." - else - if [[ ! -t 0 ]]; then - die "Aborted due to port conflicts (non-interactive). Free the ports and re-run ./install.sh" +if our_stack_running; then + ok "Existing ${GETH_CONTAINER}/${BEACON_CONTAINER} detected — re-run will refresh this stack (not a foreign port conflict)." +else + PORT_CONFLICTS=() + for port in "${PORTS_TO_CHECK[@]}"; do + if port_in_use "${port}"; then + PORT_CONFLICTS+=("${port}") + fi + done + if [[ "${#PORT_CONFLICTS[@]}" -gt 0 ]]; then + warn "These ports are already in use on this machine: ${PORT_CONFLICTS[*]}" + warn "A full node needs them free (or you must change ports in .env / docker-compose.yml)." + warn "Common cause: another Geth/Prysm/PulseChain node already running." + echo "" + if [[ "${PULSE_ALLOW_PORT_CONFLICTS:-}" == "1" ]]; then + warn "Continuing because PULSE_ALLOW_PORT_CONFLICTS=1" + elif confirm_yes "Continue anyway? [y/N] "; then + warn "Continuing despite port conflicts..." + else + if [[ ! -t 0 ]]; then + die "Aborted due to port conflicts (non-interactive). Free the ports, or re-run with PULSE_ALLOW_PORT_CONFLICTS=1" + fi + die "Aborted due to port conflicts. Free the ports and re-run ./install.sh" fi - die "Aborted due to port conflicts. Free the ports and re-run ./install.sh" fi fi @@ -265,7 +310,8 @@ if host_has_public_ip; then echo "" warn "This machine appears to have a public IP address on a local interface." warn "RPC binds to 0.0.0.0 — without a firewall this is a public unauthenticated endpoint." - warn "Do not use this stack on a VPS/cloud VM unless you restrict ${HTTP_PORT}/${WS_PORT}/${BEACON_HTTP_PORT}/${BEACON_GRPC_PORT}." + warn "Do not use this stack on a VPS/cloud VM unless you restrict ${HTTP_PORT}/${WS_PORT}." + warn "On a cloud VPC, UFW rules that allow 10.0.0.0/8 expose RPC to the whole VPC, not just your home LAN." if ufw_is_active; then ok "UFW is active. Confirm RPC rules are LAN-only before relying on this node." else @@ -292,21 +338,42 @@ fi if command -v ufw >/dev/null 2>&1; then info "UFW is installed — adding recommended rules (RPC restricted to common private ranges)..." + ufw_ok=0 + ufw_fail=0 + ufw_try() { + local out="" + if out="$($SUDO ufw allow "$@" 2>&1)"; then + ufw_ok=$((ufw_ok + 1)) + else + ufw_fail=$((ufw_fail + 1)) + warn "UFW command failed: $*" + [[ -n "${out}" ]] && warn " ${out}" + fi + } + # Allow P2P for better connectivity - $SUDO ufw allow 30303/tcp comment 'PulseChain Geth P2P' >/dev/null 2>&1 || true - $SUDO ufw allow 30303/udp comment 'PulseChain Geth P2P' >/dev/null 2>&1 || true - $SUDO ufw allow 13000/tcp comment 'PulseChain Beacon P2P TCP' >/dev/null 2>&1 || true - $SUDO ufw allow 12000/udp comment 'PulseChain Beacon P2P UDP' >/dev/null 2>&1 || true + ufw_try 30303/tcp comment 'PulseChain Geth P2P' + ufw_try 30303/udp comment 'PulseChain Geth P2P' + ufw_try 13000/tcp comment 'PulseChain Beacon P2P TCP' + ufw_try 12000/udp comment 'PulseChain Beacon P2P UDP' - # Restrict RPC / beacon APIs to common private LAN ranges (safe default) + # Restrict wallet RPC to common private LAN ranges (safe default). + # Beacon HTTP/gRPC default to localhost; rules still help if you later bind them to the LAN. for range in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16; do - $SUDO ufw allow from "${range}" to any port "${HTTP_PORT}" proto tcp comment 'Pulse RPC HTTP - LAN' >/dev/null 2>&1 || true - $SUDO ufw allow from "${range}" to any port "${WS_PORT}" proto tcp comment 'Pulse RPC WS - LAN' >/dev/null 2>&1 || true - $SUDO ufw allow from "${range}" to any port "${BEACON_HTTP_PORT}" proto tcp comment 'Pulse Beacon API - LAN' >/dev/null 2>&1 || true - $SUDO ufw allow from "${range}" to any port "${BEACON_GRPC_PORT}" proto tcp comment 'Pulse Beacon gRPC - LAN' >/dev/null 2>&1 || true + ufw_try from "${range}" to any port "${HTTP_PORT}" proto tcp comment 'Pulse RPC HTTP - LAN' + ufw_try from "${range}" to any port "${WS_PORT}" proto tcp comment 'Pulse RPC WS - LAN' + ufw_try from "${range}" to any port "${BEACON_HTTP_PORT}" proto tcp comment 'Pulse Beacon API - LAN' + ufw_try from "${range}" to any port "${BEACON_GRPC_PORT}" proto tcp comment 'Pulse Beacon gRPC - LAN' done - ok "UFW rules added (P2P open, RPC limited to private networks)." + if [[ "${ufw_fail}" -gt 0 ]]; then + warn "UFW accepted ${ufw_ok} rule(s) and failed ${ufw_fail}. Check: sudo ufw status numbered" + elif $SUDO ufw status 2>/dev/null | grep -q 'Pulse RPC HTTP'; then + ok "UFW rules present (P2P open, RPC limited to private networks)." + else + warn "UFW commands succeeded (${ufw_ok}) but could not verify 'Pulse RPC HTTP' in ufw status." + warn "If UFW is not enabled yet, the rules are stored and apply after: sudo ufw enable" + fi echo "" warn "IMPORTANT about UFW:" warn " The rules have been added, but UFW may still be inactive." @@ -316,6 +383,7 @@ if command -v ufw >/dev/null 2>&1; then warn " Then check: sudo ufw status numbered" warn " If your home network uses a different subnet, edit the rules accordingly." warn " IPv4 rules do not cover IPv6 — if the host has global IPv6, add matching rules or disable it." + warn " 10.0.0.0/8 on a cloud VPC is the VPC, not a home LAN — tighten that range on VPS hosts." else info "UFW not found — assuming no software firewall (or it is managed elsewhere). Skipping firewall rules." fi @@ -327,11 +395,24 @@ if [[ ! -f docker-compose.yml ]]; then die "docker-compose.yml not found in ${SCRIPT_DIR}" fi +if ! wait_for_docker; then + die "Cannot reach the Docker daemon. Is it running? sudo systemctl status docker" +fi + info "Pulling official PulseChain Docker images (this may take a few minutes)..." run_compose pull || die "Failed to pull images. Check your internet connection and try again." info "Starting node containers..." -run_compose up -d || die "Failed to start containers. Run: ./logs.sh" +run_compose up -d --remove-orphans || die "Failed to start containers. Run: ./logs.sh" + +info "Checking that containers stayed running..." +sleep 3 +if ! container_running "${GETH_CONTAINER}" || ! container_running "${BEACON_CONTAINER}"; then + err "One or both containers are not running." + run_compose ps || true + die "Install did not finish cleanly. Check logs with: ./logs.sh" +fi +ok "Containers ${GETH_CONTAINER} and ${BEACON_CONTAINER} are running." # --------------------------------------------------------------------------- # 11. Success message @@ -343,14 +424,15 @@ echo -e "${GREEN}${BOLD}========================================${NC}" echo -e "${GREEN}${BOLD} Node is starting!${NC}" echo -e "${GREEN}${BOLD}========================================${NC}" echo "" -echo -e " Containers: ${BOLD}pulse-geth${NC} + ${BOLD}pulse-beacon${NC}" +echo -e " Containers: ${BOLD}${GETH_CONTAINER}${NC} + ${BOLD}${BEACON_CONTAINER}${NC}" echo -e " Data dir: ${BOLD}${DATA_DIR}${NC} (execution + consensus)" echo -e " Network: ${BOLD}PulseChain Mainnet${NC} (chain id 369)" echo -e " Images: ${BOLD}${GETH_IMAGE}${NC}" echo -e " ${BOLD}${BEACON_IMAGE}${NC}" echo "" echo -e "${YELLOW}${BOLD}SECURITY REMINDER${NC}" -echo -e " RPC ports ${BOLD}${HTTP_PORT}${NC}, ${BOLD}${WS_PORT}${NC}, beacon ${BOLD}${BEACON_HTTP_PORT}${NC}, and gRPC ${BOLD}${BEACON_GRPC_PORT}${NC} are open on your LAN." +echo -e " Wallet RPC ports ${BOLD}${HTTP_PORT}${NC} and ${BOLD}${WS_PORT}${NC} are open on your LAN." +echo -e " Beacon HTTP (${BEACON_HTTP_PORT}) and gRPC (${BEACON_GRPC_PORT}) bind ${BEACON_HTTP_HOST} / ${BEACON_GRPC_HOST}." echo -e " Engine API (8551) is localhost-only. Use only on a trusted home network." echo -e " ${BOLD}Do not${NC} port-forward RPC/API ports to the internet." echo "" @@ -362,11 +444,11 @@ echo -e " Symbol: PLS" echo -e " Explorer: https://scan.pulsechain.com" echo "" echo -e "${BOLD}Useful commands (from this directory):${NC}" -echo -e " ./status.sh # sync, peers, disk" +echo -e " ./status.sh # sync, peers, disk, wallet URL" echo -e " ./logs.sh # follow logs" echo -e " ./stop.sh # stop node" echo -e " ./start.sh # start node" -echo -e " ./restart.sh # apply compose changes / restart" +echo -e " ./restart.sh # recreate containers from compose" echo -e " ./update.sh # pull pinned images & recreate" echo "" echo -e " Or: docker compose logs -f" diff --git a/restart.sh b/restart.sh index 5f14b8f..b1e04d1 100755 --- a/restart.sh +++ b/restart.sh @@ -1,15 +1,16 @@ #!/usr/bin/env bash # restart.sh — recreate the PulseChain node stack from docker-compose.yml -# Uses `up -d` so flag, image, and env changes are actually applied. -# (`docker compose restart` would keep the old container config.) +# Uses `up -d --force-recreate` so flag, image, and env changes apply and +# running containers actually bounce (`compose restart` keeps stale flags; +# plain `up -d` is a no-op when the config hash is unchanged). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" # shellcheck source=common.sh source "${SCRIPT_DIR}/common.sh" -echo "Recreating node from docker-compose.yml (applies flag/image/.env changes)..." -run_compose up -d -echo "Node is up (pulse-geth + pulse-beacon)." +echo "Recreating node from docker-compose.yml (graceful stop up to ~5 minutes)..." +run_compose up -d --force-recreate --remove-orphans +echo "Node is up (${GETH_CONTAINER} + ${BEACON_CONTAINER})." echo "Follow logs with: ./logs.sh" echo "Check sync with: ./status.sh" diff --git a/start.sh b/start.sh index 7c389b6..3574f5e 100755 --- a/start.sh +++ b/start.sh @@ -6,6 +6,6 @@ cd "$SCRIPT_DIR" # shellcheck source=common.sh source "${SCRIPT_DIR}/common.sh" -run_compose up -d -echo "Node started (pulse-geth + pulse-beacon)." +run_compose up -d --remove-orphans +echo "Node started (${GETH_CONTAINER} + ${BEACON_CONTAINER})." echo "Follow logs with: ./logs.sh" diff --git a/status.sh b/status.sh index 94245a9..468096a 100755 --- a/status.sh +++ b/status.sh @@ -62,7 +62,7 @@ PY else if curl -s --max-time 5 -X POST "http://127.0.0.1:${HTTP_PORT}" \ -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":1}' | grep -q '"result":false'; then + -d '{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":1}' | grep -Eq '"result"[[:space:]]*:[[:space:]]*false'; then echo "eth_syncing: false (execution client reports synced)" elif curl -s --max-time 3 -X POST "http://127.0.0.1:${HTTP_PORT}" \ -H 'Content-Type: application/json' \ @@ -121,6 +121,14 @@ else fi fi +echo "" +echo "=== Wallet RPC ===" +LAN_IP="$(detect_lan_ip)" +echo " LAN: http://${LAN_IP}:${HTTP_PORT}" +echo " This host: http://127.0.0.1:${HTTP_PORT}" +echo " Chain ID: 369 (PulseChain)" +echo " (hostname -I can print docker0 — prefer the LAN line above or this script)" + echo "" echo "=== Disk (${DATA_DIR}) ===" if [[ -d "${DATA_DIR}" ]]; then diff --git a/tests/test_beacon_flags.sh b/tests/test_beacon_flags.sh index c3475ed..f4c2436 100755 --- a/tests/test_beacon_flags.sh +++ b/tests/test_beacon_flags.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Assert compose + README contracts: prysm grpc-gateway flags, localhost Engine API, -# pinned images, and interpolated defaults. +# localhost beacon APIs, ipcdisable, pinned digests, and interpolated defaults. # Drives the real shipped docker-compose.yml via `docker compose config` when Docker exists. set -euo pipefail @@ -30,10 +30,11 @@ else pass "no --http-port command flag in docker-compose.yml" fi -if grep -E '^\s+-\s+--grpc-gateway-host=0\.0\.0\.0' "$COMPOSE" >/dev/null; then - pass "grpc-gateway-host=0.0.0.0 present in source" +if grep -E '^\s+-\s+--grpc-gateway-host=\$\{BEACON_HTTP_HOST:-127\.0\.0\.1\}' "$COMPOSE" >/dev/null \ + || grep -E '^\s+-\s+--grpc-gateway-host=127\.0\.0\.1' "$COMPOSE" >/dev/null; then + pass "grpc-gateway-host defaults to 127.0.0.1" else - fail "missing --grpc-gateway-host=0.0.0.0 in docker-compose.yml" + fail "missing --grpc-gateway-host default 127.0.0.1 in docker-compose.yml" fi if grep -E '^\s+-\s+--grpc-gateway-port=\$\{BEACON_HTTP_PORT:-3500\}' "$COMPOSE" >/dev/null \ @@ -43,10 +44,11 @@ else fail "missing --grpc-gateway-port default 3500 in docker-compose.yml" fi -if grep -E '^\s+-\s+--rpc-host=0\.0\.0\.0' "$COMPOSE" >/dev/null; then - pass "rpc-host=0.0.0.0 still present" +if grep -E '^\s+-\s+--rpc-host=\$\{BEACON_GRPC_HOST:-127\.0\.0\.1\}' "$COMPOSE" >/dev/null \ + || grep -E '^\s+-\s+--rpc-host=127\.0\.0\.1' "$COMPOSE" >/dev/null; then + pass "rpc-host defaults to 127.0.0.1" else - fail "missing --rpc-host=0.0.0.0 (must remain for current LAN default)" + fail "missing --rpc-host default 127.0.0.1 (beacon API is localhost unless overridden)" fi if grep -E '^\s+-\s+--authrpc\.addr=127\.0\.0\.1' "$COMPOSE" >/dev/null; then @@ -61,6 +63,18 @@ else pass "Engine API is not bound to 0.0.0.0" fi +if grep -E '^\s+-\s+--ipcdisable' "$COMPOSE" >/dev/null; then + pass "geth IPC disabled" +else + fail "docker-compose.yml should pass --ipcdisable (IPC exposes admin APIs on the host datadir)" +fi + +if grep -E '^\s+-\s+--cache=\$\{GETH_CACHE:-1024\}' "$COMPOSE" >/dev/null; then + pass "geth --cache is tunable via GETH_CACHE" +else + fail "missing --cache=\${GETH_CACHE:-1024} in docker-compose.yml" +fi + if grep -E '^\s+-\s+--subscribe-all-subnets' "$COMPOSE" >/dev/null; then fail "subscribe-all-subnets should not be set for a private RPC node" else @@ -79,12 +93,24 @@ else pass "beacon image default is not :latest" fi +if grep -E '^\s+image:' "$COMPOSE" | grep -q 'sha256:'; then + pass "compose image defaults include a digest pin" +else + fail "compose image defaults should pin sha256 digests" +fi + if grep -q 'max-size' "$COMPOSE"; then pass "container log rotation configured" else fail "docker-compose.yml missing log rotation (max-size)" fi +if grep -q 'nofile' "$COMPOSE"; then + pass "container nofile ulimit configured" +else + fail "docker-compose.yml missing nofile ulimits (geth needs more than the Docker default)" +fi + # --- 2. Parsed compose config (real docker compose entry point) --- run_compose_config() { local cfg="" @@ -98,6 +124,7 @@ run_compose_config() { fi cfg="$( env -u DATA_DIR -u HTTP_PORT -u WS_PORT -u BEACON_HTTP_PORT -u BEACON_GRPC_PORT \ + -u BEACON_HTTP_HOST -u BEACON_GRPC_HOST -u GETH_CACHE \ -u GETH_IMAGE -u BEACON_IMAGE \ docker compose --env-file /dev/null -f "$COMPOSE" config 2>&1 )" || { @@ -118,21 +145,31 @@ extract_service_command() { ' } +assert_contains() { + local haystack="$1" + local needle="$2" + local okmsg="$3" + local failmsg="$4" + if printf '%s\n' "${haystack}" | grep -q -- "${needle}"; then + pass "${okmsg}" + else + fail "${failmsg}" + fi +} + if CFG="$(run_compose_config)"; then BEACON_CMD="$(printf '%s\n' "$CFG" | extract_service_command beacon)" GETH_CMD="$(printf '%s\n' "$CFG" | extract_service_command geth)" - echo "$BEACON_CMD" | grep -q -- '--grpc-gateway-host=0.0.0.0' \ - && pass "compose config: --grpc-gateway-host=0.0.0.0" \ - || fail "compose config missing --grpc-gateway-host=0.0.0.0" - - echo "$BEACON_CMD" | grep -q -- '--grpc-gateway-port=3500' \ - && pass "compose config: --grpc-gateway-port=3500" \ - || fail "compose config missing --grpc-gateway-port=3500" - - echo "$BEACON_CMD" | grep -q -- '--rpc-host=0.0.0.0' \ - && pass "compose config: --rpc-host=0.0.0.0" \ - || fail "compose config missing --rpc-host=0.0.0.0" + assert_contains "$BEACON_CMD" '--grpc-gateway-host=127.0.0.1' \ + "compose config: --grpc-gateway-host=127.0.0.1" \ + "compose config missing --grpc-gateway-host=127.0.0.1" + assert_contains "$BEACON_CMD" '--grpc-gateway-port=3500' \ + "compose config: --grpc-gateway-port=3500" \ + "compose config missing --grpc-gateway-port=3500" + assert_contains "$BEACON_CMD" '--rpc-host=127.0.0.1' \ + "compose config: --rpc-host=127.0.0.1" \ + "compose config missing --rpc-host=127.0.0.1" if echo "$BEACON_CMD" | grep -qE -- '--http-host|--http-port'; then fail "compose config beacon command still contains --http-host or --http-port" @@ -146,43 +183,57 @@ if CFG="$(run_compose_config)"; then pass "compose config has no --subscribe-all-subnets" fi - echo "$GETH_CMD" | grep -q -- '--authrpc.addr=127.0.0.1' \ - && pass "compose config: --authrpc.addr=127.0.0.1" \ - || fail "compose config missing --authrpc.addr=127.0.0.1" - - echo "$GETH_CMD" | grep -q -- '--http.port=8545' \ - && pass "compose config: --http.port=8545" \ - || fail "compose config missing default --http.port=8545" - - echo "$CFG" | grep -q 'go-pulse:v3.3.0' \ - && pass "compose config pins go-pulse:v3.3.0" \ - || fail "compose config did not pin go-pulse:v3.3.0" - - echo "$CFG" | grep -q 'beacon-chain:v2.3.0' \ - && pass "compose config pins beacon-chain:v2.3.0" \ - || fail "compose config did not pin beacon-chain:v2.3.0" + assert_contains "$GETH_CMD" '--authrpc.addr=127.0.0.1' \ + "compose config: --authrpc.addr=127.0.0.1" \ + "compose config missing --authrpc.addr=127.0.0.1" + assert_contains "$GETH_CMD" '--ipcdisable' \ + "compose config: --ipcdisable" \ + "compose config missing --ipcdisable" + assert_contains "$GETH_CMD" '--http.port=8545' \ + "compose config: --http.port=8545" \ + "compose config missing default --http.port=8545" + assert_contains "$CFG" 'go-pulse:v3.3.0' \ + "compose config pins go-pulse:v3.3.0" \ + "compose config did not pin go-pulse:v3.3.0" + assert_contains "$CFG" 'beacon-chain:v2.3.0' \ + "compose config pins beacon-chain:v2.3.0" \ + "compose config did not pin beacon-chain:v2.3.0" + assert_contains "$CFG" 'sha256:d2f59592244decca2d1f53b5c8a1d2f7b26cf25d0722118567cc8978b44e526f' \ + "compose config pins go-pulse digest" \ + "compose config missing go-pulse digest pin" + assert_contains "$CFG" 'sha256:31b44010a9e1ed35125541c4347bae8d343ea98e792651d48d595e610f4d1d78' \ + "compose config pins beacon digest" \ + "compose config missing beacon digest pin" # .env / environment interpolation still works OVERRIDE="$(DATA_DIR=/mnt/pulse-data HTTP_PORT=18545 BEACON_HTTP_PORT=13500 \ + BEACON_HTTP_HOST=0.0.0.0 GETH_CACHE=512 \ docker compose --env-file /dev/null -f "$COMPOSE" config 2>&1)" || { fail "docker compose config with overrides failed: $OVERRIDE" OVERRIDE="" } if [[ -n "$OVERRIDE" ]]; then - echo "$OVERRIDE" | grep -q '/mnt/pulse-data' \ - && echo "$OVERRIDE" | grep -q 'target: /blockchain' \ - && pass "DATA_DIR override interpolates into volume" \ - || fail "DATA_DIR override did not appear in compose config" - echo "$OVERRIDE" | grep -q -- '--http.port=18545' \ - && pass "HTTP_PORT override interpolates" \ - || fail "HTTP_PORT override did not interpolate" - echo "$OVERRIDE" | grep -q -- '--grpc-gateway-port=13500' \ - && pass "BEACON_HTTP_PORT override interpolates" \ - || fail "BEACON_HTTP_PORT override did not interpolate" + if echo "$OVERRIDE" | grep -q '/mnt/pulse-data' && echo "$OVERRIDE" | grep -q 'target: /blockchain'; then + pass "DATA_DIR override interpolates into volume" + else + fail "DATA_DIR override did not appear in compose config" + fi + assert_contains "$OVERRIDE" '--http.port=18545' \ + "HTTP_PORT override interpolates" \ + "HTTP_PORT override did not interpolate" + assert_contains "$OVERRIDE" '--grpc-gateway-port=13500' \ + "BEACON_HTTP_PORT override interpolates" \ + "BEACON_HTTP_PORT override did not interpolate" + assert_contains "$OVERRIDE" '--grpc-gateway-host=0.0.0.0' \ + "BEACON_HTTP_HOST override interpolates" \ + "BEACON_HTTP_HOST override did not interpolate" + assert_contains "$OVERRIDE" '--cache=512' \ + "GETH_CACHE override interpolates" \ + "GETH_CACHE override did not interpolate" fi fi -# --- 3. README must document grpc-gateway (not modern http-host) for localhost mode --- +# --- 3. README must document grpc-gateway (not modern http-host) --- README="$ROOT/README.md" if [[ -f "$README" ]]; then if grep -q -- '--grpc-gateway-host' "$README"; then @@ -190,20 +241,27 @@ if [[ -f "$README" ]]; then else fail "README missing --grpc-gateway-host localhost instructions" fi - # Must not tell users to edit beacon --http-host - if grep -E 'beacon|--http-host' "$README" | grep -q -- '--http-host'; then - # Only fail if --http-host appears in a beacon context instruction - if grep -A5 -B5 -- '--http-host' "$README" | grep -qi beacon; then - fail "README still references --http-host for beacon" - fi + if grep -q 'BEACON_HTTP_HOST' "$README"; then + pass "README documents BEACON_HTTP_HOST" else - pass "README has no beacon --http-host references" + fail "README should document BEACON_HTTP_HOST for LAN beacon API" + fi + # Must not tell users to configure beacon with the modern --http-host flag. + if grep -q -- '--http-host=' "$README"; then + fail "README still instructs setting --http-host= (prysm-pulse uses --grpc-gateway-host)" + else + pass "README does not instruct setting --http-host=" fi if grep -q -- '--authrpc.addr=127.0.0.1' "$README"; then pass "README documents localhost Engine API" else fail "README should mention --authrpc.addr=127.0.0.1" fi + if grep -q './status.sh' "$README" && ! grep -Fq 'hostname -I | awk' "$README"; then + pass "README does not recommend hostname -I as the LAN IP method" + else + fail "README should not tell users to use hostname -I (docker0 footgun)" + fi fi if [[ "$FAILED" -ne 0 ]]; then diff --git a/tests/test_common.sh b/tests/test_common.sh index 8dd9162..cfc4248 100755 --- a/tests/test_common.sh +++ b/tests/test_common.sh @@ -4,6 +4,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" # shellcheck source=../common.sh +# shellcheck disable=SC1091 source "${ROOT}/common.sh" FAILED=0 @@ -14,6 +15,7 @@ tmp="$(mktemp)" trap 'rm -f "$tmp"' EXIT # load_dotenv does not execute shell +# shellcheck disable=SC2016 printf 'DATA_DIR=/mnt/from-env\n# comment\nEVIL=$(echo pwned)\n' > "$tmp" unset DATA_DIR load_dotenv "$tmp" @@ -22,12 +24,42 @@ if [[ "${DATA_DIR}" == "/mnt/from-env" ]]; then else fail "load_dotenv DATA_DIR got '${DATA_DIR:-}'" fi +# shellcheck disable=SC2016 if [[ "${EVIL:-}" == '$(echo pwned)' ]]; then pass "load_dotenv stores command substitutions as literals" else fail "load_dotenv mishandled EVIL='${EVIL:-}'" fi +# Spaces around equals + inline comment +unset SPACY_PORT +printf 'SPACY_PORT = 18545 # wallets\n' > "$tmp" +load_dotenv "$tmp" +if [[ "${SPACY_PORT}" == "18545" ]]; then + pass "load_dotenv accepts spaces around = and strips unquoted comments" +else + fail "load_dotenv SPACY_PORT got '${SPACY_PORT:-}'" +fi + +# export prefix +unset EXPORTED_DIR +printf 'export EXPORTED_DIR=/opt/pulse\n' > "$tmp" +load_dotenv "$tmp" +if [[ "${EXPORTED_DIR}" == "/opt/pulse" ]]; then + pass "load_dotenv accepts export KEY=VALUE" +else + fail "load_dotenv EXPORTED_DIR got '${EXPORTED_DIR:-}'" +fi + +# Skipped malformed line is reported +unset SKIP_ME +skip_err="$(printf 'this is not = valid\n' > "$tmp"; load_dotenv "$tmp" 2>&1 >/dev/null || true)" +if [[ "${skip_err}" == *"skipped line"* ]]; then + pass "load_dotenv warns on non KEY=VALUE lines" +else + fail "load_dotenv should warn on malformed assignment, got '${skip_err}'" +fi + # Existing environment wins export DATA_DIR=/already-set printf 'DATA_DIR=/should-not-win\n' > "$tmp" @@ -38,11 +70,18 @@ else fail "load_dotenv overrode DATA_DIR to '${DATA_DIR}'" fi -# Defaults for ports -if [[ "${HTTP_PORT}" == "8545" || -n "${HTTP_PORT}" ]]; then - pass "HTTP_PORT is set (${HTTP_PORT})" +# Defaults for ports (empty counts as unset) +if [[ "${HTTP_PORT}" == "8545" ]]; then + pass "HTTP_PORT defaults to 8545" else - fail "HTTP_PORT missing" + fail "HTTP_PORT is '${HTTP_PORT:-}'" +fi + +empty_port="$(HTTP_PORT='' BEACON_HTTP_HOST='' bash -c "source '${ROOT}/common.sh'; printf '%s %s' \"\${HTTP_PORT}\" \"\${BEACON_HTTP_HOST}\"")" +if [[ "${empty_port}" == "8545 127.0.0.1" ]]; then + pass "empty HTTP_PORT / BEACON_HTTP_HOST fall back to defaults" +else + fail "empty-value defaults got '${empty_port}'" fi # confirm_yes is non-interactive safe @@ -52,6 +91,59 @@ else pass "confirm_yes declines when stdin is not a TTY" fi +# Public vs RFC1918 / CGNAT +if is_public_ipv4 "8.8.8.8" && is_public_ipv4 "1.2.3.4"; then + pass "is_public_ipv4 accepts public addresses" +else + fail "is_public_ipv4 rejected a public address" +fi +if is_public_ipv4 "10.0.0.1" || is_public_ipv4 "192.168.1.1" || is_public_ipv4 "172.16.5.5" \ + || is_public_ipv4 "127.0.0.1" || is_public_ipv4 "169.254.1.1" || is_public_ipv4 "100.64.0.1" \ + || is_public_ipv4 "100.127.255.255"; then + fail "is_public_ipv4 treated a private/CGNAT/loopback address as public" +else + pass "is_public_ipv4 rejects RFC1918, loopback, link-local, and CGNAT" +fi +if is_public_ipv4 "100.63.0.1"; then + pass "100.63.0.1 is not CGNAT (treated as public)" +else + fail "100.63.0.1 should not be classified as CGNAT" +fi + +# JWT helper +if jwt_payload_is_valid ""; then + fail "jwt_payload_is_valid accepted empty" +else + pass "jwt_payload_is_valid rejects empty" +fi +if jwt_payload_is_valid "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; then + pass "jwt_payload_is_valid accepts 64 hex chars" +else + fail "jwt_payload_is_valid rejected a valid payload" +fi +printf '%s\n' "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" > "$tmp" +if [[ "$(jwt_payload "$tmp")" == "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ]]; then + pass "jwt_payload strips trailing newline" +else + fail "jwt_payload did not strip newline" +fi +if jwt_payload_is_valid "not-hex" || jwt_payload_is_valid "$(printf '0%.0s' {1..63})"; then + fail "jwt_payload_is_valid accepted an invalid payload" +else + pass "jwt_payload_is_valid rejects short or non-hex payloads" +fi + +# upsert_dotenv uncomments and replaces +printf '# GETH_IMAGE=registry.gitlab.com/pulsechaincom/go-pulse:v3.3.0\nother=keep\n' > "$tmp" +upsert_dotenv "$tmp" GETH_IMAGE "${GETH_IMAGE_LATEST}" +if grep -qx "GETH_IMAGE=${GETH_IMAGE_LATEST}" "$tmp" \ + && grep -qx 'other=keep' "$tmp" \ + && ! grep -q '^# GETH_IMAGE=' "$tmp"; then + pass "upsert_dotenv uncomments and sets GETH_IMAGE" +else + fail "upsert_dotenv result was: $(tr '\n' '|' < "$tmp")" +fi + # Syntax of helper scripts for script in install.sh start.sh stop.sh restart.sh logs.sh update.sh status.sh common.sh; do if bash -n "${ROOT}/${script}"; then @@ -61,31 +153,73 @@ for script in install.sh start.sh stop.sh restart.sh logs.sh update.sh status.sh fi done -# restart.sh must recreate from compose, not `compose restart` +# restart.sh must recreate from compose, not `compose restart`, and must force-recreate if grep -E 'run_compose[[:space:]]+restart' "${ROOT}/restart.sh" >/dev/null; then fail "restart.sh still uses 'compose restart' (drops compose/flag edits)" else pass "restart.sh does not use 'compose restart'" fi -if grep -qE 'up -d' "${ROOT}/restart.sh"; then - pass "restart.sh uses up -d" +if grep -qE 'up -d --force-recreate' "${ROOT}/restart.sh"; then + pass "restart.sh uses up -d --force-recreate" else - fail "restart.sh should call up -d so compose edits apply" + fail "restart.sh should call up -d --force-recreate so running nodes actually bounce" fi -# update.sh opt-in latest -if grep -q -- '--latest' "${ROOT}/update.sh"; then - pass "update.sh documents --latest" +# update.sh opt-in latest persists to .env +if grep -q -- '--latest' "${ROOT}/update.sh" && grep -q 'upsert_dotenv' "${ROOT}/update.sh"; then + pass "update.sh --latest writes .env via upsert_dotenv" +else + fail "update.sh should persist --latest into .env" +fi +if grep -qE 'up -d --force-recreate' "${ROOT}/update.sh"; then + pass "update.sh recreates containers after pull" else - fail "update.sh missing --latest opt-in" + fail "update.sh should force-recreate after pull" fi -# JWT hardened in installer +# Installer hardening if grep -q 'chmod 600' "${ROOT}/install.sh"; then pass "install.sh sets JWT mode 600" else fail "install.sh should chmod 600 the JWT" fi +if grep -q 'jwt_payload_is_valid' "${ROOT}/install.sh"; then + pass "install.sh validates existing JWT secrets" +else + fail "install.sh should validate existing jwt.hex" +fi +if grep -q 'our_stack_running' "${ROOT}/install.sh"; then + pass "install.sh ignores ports held by this stack" +else + fail "install.sh should skip port conflicts when pulse-geth/pulse-beacon are running" +fi +if grep -q 'PULSE_ALLOW_PORT_CONFLICTS' "${ROOT}/install.sh"; then + pass "install.sh has PULSE_ALLOW_PORT_CONFLICTS override" +else + fail "install.sh should allow non-interactive port-conflict override" +fi +if grep -q 'docker.io' "${ROOT}/install.sh" && grep -q 'wait_for_docker' "${ROOT}/install.sh"; then + pass "install.sh removes distro docker packages and waits for the daemon" +else + fail "install.sh should remove docker.io conflicts and wait_for_docker" +fi +if grep -q 'container_running' "${ROOT}/install.sh"; then + pass "install.sh checks containers after up" +else + fail "install.sh should verify pulse-geth and pulse-beacon are running" +fi +if grep -q 'effective_install_user' "${ROOT}/install.sh"; then + pass "install.sh uses effective_install_user (not raw \$USER)" +else + fail "install.sh should not rely on possibly-empty USER" +fi + +# status.sh prints wallet URL via detect_lan_ip +if grep -q 'detect_lan_ip' "${ROOT}/status.sh"; then + pass "status.sh prints wallet RPC via detect_lan_ip" +else + fail "status.sh should print the LAN RPC URL" +fi if [[ "$FAILED" -ne 0 ]]; then echo "One or more common.sh / script checks failed." >&2 diff --git a/tests/test_image_pins.sh b/tests/test_image_pins.sh new file mode 100755 index 0000000..8834fc3 --- /dev/null +++ b/tests/test_image_pins.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Confirm compose/common.sh digest pins match each other and the registry tag. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=../common.sh +# shellcheck disable=SC1091 +source "${ROOT}/common.sh" + +FAILED=0 +fail() { echo "FAIL: $*" >&2; FAILED=1; } +pass() { echo "PASS: $*"; } + +compose_geth="$(grep -E 'image: \$\{GETH_IMAGE:-' "${ROOT}/docker-compose.yml" | head -n1)" +compose_beacon="$(grep -E 'image: \$\{BEACON_IMAGE:-' "${ROOT}/docker-compose.yml" | head -n1)" + +if [[ "${compose_geth}" == *"${GETH_IMAGE_PINNED}"* ]]; then + pass "common.sh GETH_IMAGE_PINNED matches docker-compose.yml default" +else + fail "GETH_IMAGE_PINNED drift: common='${GETH_IMAGE_PINNED}' compose='${compose_geth}'" +fi + +if [[ "${compose_beacon}" == *"${BEACON_IMAGE_PINNED}"* ]]; then + pass "common.sh BEACON_IMAGE_PINNED matches docker-compose.yml default" +else + fail "BEACON_IMAGE_PINNED drift: common='${BEACON_IMAGE_PINNED}' compose='${compose_beacon}'" +fi + +registry_digest() { + local repo="$1" + local tag="$2" + python3 - "$repo" "$tag" <<'PY' +import json, sys, urllib.parse, urllib.request + +repo, tag = sys.argv[1], sys.argv[2] +scope = f"repository:{repo}:pull" +auth = "https://gitlab.com/jwt/auth?service=container_registry&scope=" + urllib.parse.quote(scope) +with urllib.request.urlopen(auth, timeout=20) as r: + token = json.load(r)["token"] +req = urllib.request.Request( + f"https://registry.gitlab.com/v2/{repo}/manifests/{tag}", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.v2+json", + }, +) +with urllib.request.urlopen(req, timeout=20) as r: + digest = r.headers.get("Docker-Content-Digest") +if not digest: + raise SystemExit("missing Docker-Content-Digest") +print(digest) +PY +} + +GETH_PIN_DIGEST="${GETH_IMAGE_PINNED##*@}" +BEACON_PIN_DIGEST="${BEACON_IMAGE_PINNED##*@}" + +if geth_tag_digest="$(registry_digest pulsechaincom/go-pulse v3.3.0)"; then + if [[ "${geth_tag_digest}" == "${GETH_PIN_DIGEST}" ]]; then + pass "registry go-pulse:v3.3.0 digest matches pin (${GETH_PIN_DIGEST})" + else + fail "go-pulse:v3.3.0 registry digest ${geth_tag_digest} != pin ${GETH_PIN_DIGEST} (tag was retagged or pin is stale)" + fi +else + if [[ -n "${CI:-}${GITHUB_ACTIONS:-}" ]]; then + fail "could not fetch go-pulse:v3.3.0 digest from GitLab registry" + else + echo "SKIP: could not fetch go-pulse:v3.3.0 digest" + fi +fi + +if beacon_tag_digest="$(registry_digest pulsechaincom/prysm-pulse/beacon-chain v2.3.0)"; then + if [[ "${beacon_tag_digest}" == "${BEACON_PIN_DIGEST}" ]]; then + pass "registry beacon-chain:v2.3.0 digest matches pin (${BEACON_PIN_DIGEST})" + else + fail "beacon-chain:v2.3.0 registry digest ${beacon_tag_digest} != pin ${BEACON_PIN_DIGEST}" + fi +else + if [[ -n "${CI:-}${GITHUB_ACTIONS:-}" ]]; then + fail "could not fetch beacon-chain:v2.3.0 digest from GitLab registry" + else + echo "SKIP: could not fetch beacon-chain:v2.3.0 digest" + fi +fi + +# Informational: :latest moving off the pin is expected eventually, not a hard fail. +if geth_latest="$(registry_digest pulsechaincom/go-pulse latest 2>/dev/null || true)"; then + if [[ -n "${geth_latest}" && "${geth_latest}" != "${GETH_PIN_DIGEST}" ]]; then + echo "NOTE: go-pulse:latest is ${geth_latest} (pin is ${GETH_PIN_DIGEST}) — consider bumping GETH_IMAGE_PINNED" + elif [[ -n "${geth_latest}" ]]; then + pass "go-pulse:latest still matches the pinned digest" + fi +fi +if beacon_latest="$(registry_digest pulsechaincom/prysm-pulse/beacon-chain latest 2>/dev/null || true)"; then + if [[ -n "${beacon_latest}" && "${beacon_latest}" != "${BEACON_PIN_DIGEST}" ]]; then + echo "NOTE: beacon-chain:latest is ${beacon_latest} (pin is ${BEACON_PIN_DIGEST}) — consider bumping BEACON_IMAGE_PINNED" + elif [[ -n "${beacon_latest}" ]]; then + pass "beacon-chain:latest still matches the pinned digest" + fi +fi + +if [[ "$FAILED" -ne 0 ]]; then + echo "One or more image pin checks failed." >&2 + exit 1 +fi +echo "All image pin checks passed." +exit 0 diff --git a/update.sh b/update.sh index aaf16c8..033a3a4 100755 --- a/update.sh +++ b/update.sh @@ -2,6 +2,7 @@ # update.sh — pull images and recreate containers # Default: pull the pinned tags in docker-compose.yml / .env # ./update.sh --latest float on upstream :latest (can break flags) +# writes GETH_IMAGE / BEACON_IMAGE into .env so it sticks set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" @@ -11,6 +12,17 @@ source "${SCRIPT_DIR}/common.sh" if [[ "${1:-}" == "--latest" ]]; then export GETH_IMAGE="${GETH_IMAGE_LATEST}" export BEACON_IMAGE="${BEACON_IMAGE_LATEST}" + if [[ ! -f .env ]]; then + if [[ -f .env.example ]]; then + cp .env.example .env + else + touch .env + fi + fi + upsert_dotenv .env GETH_IMAGE "${GETH_IMAGE_LATEST}" + upsert_dotenv .env BEACON_IMAGE "${BEACON_IMAGE_LATEST}" + echo "Wrote GETH_IMAGE and BEACON_IMAGE=:latest into .env (survives restart/start)." + echo "To return to digest-pinned images, remove those two lines from .env and run ./update.sh." echo "Pulling floating :latest PulseChain images..." echo "Note: :latest can change client flags and occasionally require a compose update." else @@ -24,7 +36,7 @@ run_compose pull echo "Recreating containers with pulled images..." echo "Client upgrades are usually compatible, but a rare release can stall sync." -run_compose up -d +run_compose up -d --force-recreate --remove-orphans echo "Update complete." echo "Follow logs with: ./logs.sh"