diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..08de6ae --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*.sh] +shell_variant = bash +indent_style = space +indent_size = 4 +binary_next_line = true +switch_case_indent = true diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..12687e5 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,63 @@ +name: Checks + +on: + pull_request: + push: + +permissions: + contents: read + +jobs: + shell: + name: Shell checks + runs-on: ubuntu-24.04 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install ShellCheck + run: | + sudo apt-get update + sudo apt-get install --yes shellcheck + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.24.x + cache: false + + - name: Install shfmt + run: | + go install mvdan.cc/sh/v3/cmd/shfmt@v3.12.0 + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + - name: Run shfmt + run: shfmt -d install tools + + - name: Run ShellCheck + working-directory: install + run: | + shellcheck -x \ + install-validator.sh \ + install-observer.sh \ + ../tools/generate-openresty-jwt.sh + + markdown: + name: Markdown checks + runs-on: ubuntu-24.04 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Run Prettier + run: npx --yes prettier@3.6.2 --check '**/*.md' + + - name: Run Markdownlint + run: npx --yes markdownlint-cli2@0.21.0 './**/*.md' '#./.git/**' diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..9e80f1e --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,9 @@ +{ + "config": { + "MD013": { + "line_length": 80, + "code_blocks": false, + "tables": false + } + } +} diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..a68a6f4 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "printWidth": 80, + "proseWrap": "always" +} diff --git a/README.md b/README.md index c95d27e..fdc3ef5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Seismic Node Operations -Operational tooling and configuration for installing and running Seismic validator nodes. +Operational tooling and configuration for installing and running Seismic +validator and observer nodes. ## Validator installation @@ -10,10 +11,55 @@ The interactive validator installer is located at: install/install-validator.sh ``` -For requirements, installation steps, persistent storage, MDBX notes, validator registration, service startup, OpenResty, and troubleshooting, see the **[Validator Installer and First-Start Guide](install/README.md)**. +For requirements, installation steps, persistent storage, MDBX notes, validator +registration, service startup, OpenResty, and troubleshooting, see the +**[Validator Installer and First-Start Guide](install/README.md)**. + +## Observer installation + +The observer installer is located at: + +```text +install/install-observer.sh +``` + +See the **[Observer Installer and First-Start Guide](install/OBSERVER.md)**. + +Generate short-lived bearer tokens for OpenResty-protected endpoints with: + +```text +tools/generate-openresty-jwt.sh +``` The Summit internal-testnet genesis file is provided at: ```text internal_testnet_genesis.toml ``` + +## Repository checks + +GitHub Actions checks shell scripts and Markdown through +[`.github/workflows/checks.yml`](.github/workflows/checks.yml). + +Run the same checks locally from the repository root. ShellCheck must be +installed; Go and Node.js with `npx` are also required. + +```bash +go run mvdan.cc/sh/v3/cmd/shfmt@v3.12.0 -d install tools + +( + cd install + shellcheck -x \ + install-validator.sh \ + install-observer.sh \ + ../tools/generate-openresty-jwt.sh +) + +npx --yes prettier@3.6.2 --check '**/*.md' +npx --yes markdownlint-cli2@0.21.0 './**/*.md' '#./.git/**' +``` + +Shell formatting is defined in [`.editorconfig`](.editorconfig). Markdown +formatting and lint rules are defined in [`.prettierrc.json`](.prettierrc.json) +and [`.markdownlint-cli2.jsonc`](.markdownlint-cli2.jsonc). diff --git a/install/OBSERVER.md b/install/OBSERVER.md new file mode 100644 index 0000000..ea88828 --- /dev/null +++ b/install/OBSERVER.md @@ -0,0 +1,710 @@ +# Seismic Observer Installer and First-Start Guide + +This directory contains the interactive installer for a Seismic observer node: + +```text +install/install-observer.sh +``` + +The installer prepares the observer software, persistent directories, keys, +Supervisor programs, and optional OpenResty endpoint. It deliberately does not +start the observer services. Follow the first-start procedure in this document +after installation. + +For a validator node, use `install/install-validator.sh` and follow the separate +**[Validator Installer and First-Start Guide](README.md)**. + +## Safety model + +The installer is designed to avoid replacing persistent observer state: + +- Observer services run as an existing non-root service user. +- Persistent directories must be absolute, non-root, and non-overlapping. +- Existing Reth P2P keys are validated and preserved. +- Existing observer consensus and parent node keys are preserved only when the + assignment marker matches the configured parent public key and observer index. +- Keys without an assignment marker, or a marker for another assignment, cause + the installer to stop rather than reuse or replace them. +- Existing observer Custodian root keys are preserved for verification against + the parent Custodian. +- Service binaries are installed as root-owned, non-writable executables. +- Supervisor programs use `autostart=false` and `autorestart=false`. +- The installer does not start, enable, reread, update, reload, or restart + observer services. + +Review the complete interactive configuration summary before accepting it. + +## Requirements + +- Ubuntu with `apt-get`. Other Linux distributions are not supported. +- Root access through `sudo`. +- An existing non-root Linux user that will run the observer services. +- Python 3 for public Summit socket-address validation. +- The Summit genesis TOML for the selected network; it must remain readable by + the service user. +- The parent validator's Summit node public key and the selected observer index. +- A literal public IPv4 or bracketed IPv6 Summit P2P socket address for the + observer. Summit's default P2P port is `18551`. +- The parent validator's private Summit `node_key.pem`, which must be placed at + the configured observer Summit key path after installation. +- Sufficient persistent storage for Reth, Summit, observer keys, and any enabled + optional components. +- Network access to package and source repositories when installing packages or + building from source. +- When Custodian is enabled, the reachable council endpoint of the Custodian + running on the parent validator, normally `PARENT_IP:7876`. +- If configuring OpenResty, DNS for the selected domain must point to the node. + +The installer validates local input and key consistency. It deliberately does +not check whether the parent key is a genesis validator or a current validator. +Network authorization is outside the installation process. + +The installation log is written to: + +```text +/var/log/seismic-observer-install.log +``` + +### Network and firewall requirements + +The installer does not configure cloud firewall, security-group, or host +firewall rules. Configure the required access before starting the observer. + +| Port | Protocol | Purpose | Required exposure | +| -------------------------- | ----------- | ----------------------------------- | ----------------------------------------------------------------------------------------- | +| `30303` | TCP and UDP | seismic-reth P2P and discovery | Public | +| `18551` or configured port | TCP and UDP | Summit observer P2P | Public | +| `80` | TCP | HTTP redirect and ACME challenge | Public when OpenResty is enabled | +| `443` | TCP | OpenResty HTTPS endpoint | Public when OpenResty is enabled | +| `7876` or configured port | TCP | Observer Custodian council listener | Externally reachable when Custodian is enabled; restrict sources to intended participants | + +When observer Custodian is enabled, the observer also needs outbound TCP access +to the configured parent validator Custodian council endpoint. The parent +firewall must allow the observer's source IP. + +The parent-Custodian protocol transports root-key and plaintext epoch-key +material. Use a private network or protect the path with a TLS tunnel. Do not +send it over an untrusted plaintext network. + +The following application ports must remain loopback-only: + +```text +3000 3030 3031 8545 8546 8552 8999 9001 9090 42069 +``` + +When summit-checkpointer is enabled, its direct listener remains on +`127.0.0.1:42069`. When OpenResty is enabled, remote clients use the +rate-limited and JWT-protected `/checkpointer` HTTPS route instead of direct +access to that port. + +### Supplied internal-testnet genesis + +The repository includes the Summit genesis file for the internal testnet: + +```text +internal_testnet_genesis.toml +``` + +When the installer asks for the Summit genesis TOML path, enter its absolute +path. + +Keep this file in place after installation. The generated Supervisor program +references the selected path directly, and the configured service user must be +able to read it. + +## Run the installer + +Run the installer from the repository root: + +```bash +sudo ./install/install-observer.sh +``` + +The installer asks you to configure: + +- The non-root service user. +- Persistent Reth, Summit, and observer-key directories. +- An optional public HTTPS endpoint through OpenResty. +- The Summit genesis file and an optional Reth bootnode RPC. +- The parent validator Summit node public key. +- An observer derivation index from `0` through `255`. +- The observer's public Summit P2P `IP:port`; the default Summit port is + `18551`. +- An optional path to a Summit bootstrappers TOML file. +- Summit and seismic-reth binary installation methods. +- summit-checkpointer. +- Centralized Custodian, including the parent validator Custodian council + endpoint and Custodian binary installation method. + +Neither the parent nor nodes listed in an optional bootstrappers file are +required to appear in the genesis `[[validators]]` list. + +### Binary installation methods + +Summit, seismic-reth, summit-checkpointer, and Centralized Custodian support: + +1. Install a supplied prebuilt executable. +2. Build from source during installation. +3. Defer installation and provide the executable later. + +The current source-build defaults are: + +```text +Summit: m/metrics +seismic-reth: feat/purpose-key-rotation-reth +Checkpointer: main +Custodian: d/centralized-custodian +``` + +A prebuilt or already-present deferred summit-checkpointer must support +`--bind-address`; the generated Supervisor program uses it to keep the +checkpointer RPC and snapshot server on `127.0.0.1:42069`. + +A prebuilt or already-present deferred observer Custodian is checked for these +required options: + +```text +--summit-key-dir +--observer +--parent-custodian +``` + +Deferred binaries must be installed at their configured target paths before the +corresponding services are started. + +### Optional Summit bootstrappers file + +A bootstrappers file can contain one or more known Summit peers: + +```toml +[[bootstrappers]] +node_public_key = "32-byte Summit node public key" +address = "203.0.113.10:18551" + +[[bootstrappers]] +node_public_key = "another 32-byte Summit node public key" +address = "198.51.100.20:18551" +``` + +The selected source path must be absolute and point to a non-empty, +non-symbolic-link file readable by the service user. The installer copies it to +the configured Summit key directory as `bootstrappers.toml`; Supervisor +references that stable copy. + +This file configures Summit consensus peers. It is separate from the optional +Reth bootnode RPC used to discover an execution-layer enode. + +## Persistent layout + +The default persistent paths are: + +| State | Default path | +| --------------------------------- | -------------------------- | +| Reth data | `/persistence/reth` | +| Summit observer data | `/persistence/summit` | +| Observer and Reth keys | `/persistence/keys` | +| Checkpointer output, when enabled | `/persistence/checkpoints` | +| Custodian data, when enabled | `/persistence/custodian` | + +Important files derived from those paths include: + +```text +/persistence/keys/reth/p2p-key +/persistence/keys/summit/observer-assignment +/persistence/keys/summit/consensus_key.pem +/persistence/keys/summit/node_key.pem +/persistence/keys/summit/bootstrappers.toml +/persistence/custodian/root.key +``` + +Every persistent directory is configurable. Changing a directory on a later +installer run does not migrate existing state; it selects a separate store. + +### Observer assignment and keys + +The observer assignment is represented as: + +```text +: +``` + +and stored in: + +```text +/summit/observer-assignment +``` + +The installer generates the observer's independent `consensus_key.pem`. It does +not generate or copy the parent validator's private `node_key.pem`. + +Before starting Custodian or `summit-observer`, place the parent validator's +private node key at the configured Summit key path. With the default paths, the +required destination is: + +```text +/persistence/keys/summit/node_key.pem +``` + +The file must be owned by the configured service user and have mode `0600`. The +Summit key directory must have mode `0700`. The private key must correspond to +the parent public key entered during installation. + +Summit derives the observer's secondary P2P identity from the parent node key +and observer index. The observer's separate consensus key does not make it a +voting or proposing validator. + +### Reth MDBX database + +seismic-reth stores its MDBX execution database under: + +```text +/db +``` + +Treat this as persistent execution-layer state. Do not delete it during an +installer rerun, and do not manually copy or modify the live database while Reth +is running. + +When summit-checkpointer is enabled, it uses `mdbx_copy` to produce a consistent +Reth database copy. `mdbx_copy` must be built from the same vendored libmdbx +revision as the seismic-reth binary. If Reth is built from source, the installer +builds `mdbx_copy` from that checkout. For a prebuilt or deferred Reth binary, +provide the matching seismic-reth source checkout when prompted. Do not start +the checkpointer until a compatible executable is installed at: + +```text +/usr/local/bin/mdbx_copy +``` + +## Generated services and configuration + +The observer installer writes the Supervisor configuration to: + +```text +/etc/supervisor/conf.d/seismic-observer.conf +``` + +It always defines: + +- `reth` +- `summit-observer` + +It also defines `checkpointer` and `custodian` when those components are +enabled. It does not define `summit-deposit-rpc`; observers do not create +validator deposit signatures. + +The installer also does not create a checkpoint-start Supervisor program. +Starting a validator or observer from a checkpoint is handled by the dedicated +checkpoint-start CLI. + +Supervisor logs are written under: + +```text +/var/log/seismic-observer/ +``` + +When summit-checkpointer is enabled, the installer prompts for an absolute +configuration-file path and defaults to: + +```text +/etc/seismic/summit-checkpointer.toml +``` + +The generated Supervisor command uses the selected path. Its parent directory +hierarchy must be root-owned and must not be group- or world-writable. + +When OpenResty is enabled, the installer prompts for an absolute JWT-secret file +path and defaults to: + +```text +/etc/seismic/openresty-jwt-secret +``` + +It also writes: + +```text +/usr/local/openresty/nginx/conf/nginx.conf +/usr/local/openresty/nginx/lua/rate_limit.lua +/usr/local/openresty/nginx/lua/jwt_auth.lua +/etc/logrotate.d/openresty +``` + +The generated JWT middleware reads the selected secret path. The secret is +installed as `root:nogroup` with mode `0640` and is not printed by the +installer. Its parent hierarchy must be root-owned, non-writable by group or +others, and traversable by the OpenResty worker user. Reruns reuse a secret +already at the selected path. When changing from the default to a custom path +that does not yet exist, the installer copies the existing default secret +without removing the old file; remove it only after validating the new OpenResty +configuration. + +The installer records the selected path in: + +```text +/etc/seismic/openresty-jwt-secret.path +``` + +This root-owned metadata file contains only the path, not the secret. Installer +reruns use it as the JWT-secret prompt default. Generate a one-hour bearer token +from the repository root with: + +```bash +TOKEN=$(sudo ./tools/generate-openresty-jwt.sh) +``` + +The script reads the metadata file, falls back to the standard secret path for +older installations, and prints only the token. Use `--secret-path` to override +the lookup or `--ttl-seconds` to select a lifetime up to 86400 seconds. + +Reth HTTP, WebSocket, Ops, and metrics endpoints and Summit RPC, admin RPC, and +metrics endpoints bind to loopback whether or not OpenResty is enabled. + +## Observer Custodian + +The parent validator Custodian council endpoint and the observer's local council +listener are different settings: + +- The parent endpoint is a remote `host:port` that the observer contacts to + fetch or verify the root key and synchronize epoch-key deliveries. +- The local listener is the observer Custodian's own bind address and defaults + to `0.0.0.0:7876`. + +The generated observer Custodian command includes: + +```text +--observer +--parent-custodian +--summit-key-dir +``` + +If the configured Custodian `root.key` is absent, the observer Custodian fetches +it from the parent on first startup. If it already exists, the installer +preserves it and the Custodian verifies it against the parent. Installer reruns +do not replace an existing observer root key. + +The parent validator Custodian must be running with its own `--summit-key-dir` +argument so it can authenticate and serve observer requests. + +## First observer startup + +Do not start Custodian or Summit until the parent validator's private +`node_key.pem` exists at the configured observer Summit key path. + +### 1. Load the Supervisor configuration + +```bash +sudo systemctl enable --now supervisor +sudo supervisorctl reread +sudo supervisorctl update +sudo supervisorctl status +``` + +All observer programs should still be stopped because they use: + +```ini +autostart=false +autorestart=false +``` + +### 2. Start Custodian when enabled + +Custodian must start before Reth because Reth connects to its Unix socket during +startup: + +```bash +sudo supervisorctl start custodian +sudo supervisorctl status custodian +``` + +With default paths, verify its state and socket: + +```bash +sudo stat /persistence/custodian/root.key +sudo test -S /tmp/custodian.sock +``` + +If it does not start, inspect: + +```bash +sudo tail -n 100 /var/log/seismic-observer/custodian.err +sudo tail -n 100 /var/log/seismic-observer/custodian.log +``` + +Skip this step when Custodian was not enabled. + +### 3. Start Reth + +```bash +sudo supervisorctl start reth +sudo supervisorctl status reth +``` + +If it does not start, inspect: + +```bash +sudo tail -n 100 /var/log/seismic-observer/reth.err +sudo tail -n 100 /var/log/seismic-observer/reth.log +``` + +Reth must be running before Summit because Summit connects through the Engine +API IPC socket. + +### 4. Start Summit observer + +```bash +sudo supervisorctl start summit-observer +sudo supervisorctl status summit-observer +``` + +If it does not start, inspect: + +```bash +sudo tail -n 100 /var/log/seismic-observer/summit-observer.err +sudo tail -n 100 /var/log/seismic-observer/summit-observer.log +``` + +The dependency order is: + +```text +custodian -> reth -> summit-observer +``` + +When Custodian is disabled, start only Reth and Summit observer in that order. + +### 5. Start summit-checkpointer when enabled + +```bash +sudo supervisorctl start checkpointer +sudo supervisorctl status checkpointer +``` + +If it does not start, inspect: + +```bash +sudo tail -n 100 /var/log/seismic-observer/checkpointer.err +sudo tail -n 100 /var/log/seismic-observer/checkpointer.log +``` + +summit-checkpointer produces checkpoint artifacts. Starting a validator or +observer from a checkpoint remains the responsibility of the dedicated +checkpoint-start CLI. + +Because `autostart=false` and `autorestart=false`, all enabled programs must be +started manually again after a server or Supervisor restart. + +## OpenResty public endpoint + +When enabled, OpenResty terminates HTTPS, obtains certificates through +`lua-resty-auto-ssl`, applies per-client rate limiting, and proxies local Reth, +Summit, and summit-checkpointer endpoints. + +Reth HTTP and WebSocket RPC, Reth Ops RPC, Summit RPC, metrics listeners, and +the summit-checkpointer RPC remain bound to loopback whether or not OpenResty is +enabled. When OpenResty is disabled, these endpoints are available only from the +node itself or through an operator-managed tunnel. + +The configured routes are: + +| Public path | Local upstream | Notes | +| --------------- | ----------------------- | ------------------------------------------------ | +| `/` | `127.0.0.1:3000` | Grafana | +| `/staking` | `/var/www/html/staking` | Static staking UI | +| `/rpc` | `127.0.0.1:8545` | Reth HTTP JSON-RPC | +| `/ws` | `127.0.0.1:8546` | Reth WebSocket JSON-RPC | +| `/summit` | `127.0.0.1:3030` | Summit RPC | +| `/ops` | `127.0.0.1:8552` | Signature-authenticated privileged Reth RPC | +| `/checkpointer` | `127.0.0.1:42069` | Rate-limited and JWT-protected RPC and snapshots | +| `/prom-summit` | `127.0.0.1:9090` | Rate-limited and JWT-protected | +| `/prom-reth` | `127.0.0.1:9001` | Rate-limited and JWT-protected | + +The `/checkpointer` route uses the same secret at the selected JWT-secret path +as the protected metrics routes. The default path is +`/etc/seismic/openresty-jwt-secret`. Clients must send +`Authorization: Bearer `. The Summit admin RPC on loopback port `3031` +remains deliberately unproxied. + +### Start or reload OpenResty + +```bash +sudo openresty -t +sudo systemctl enable openresty + +if sudo systemctl is-active --quiet openresty; then + sudo systemctl reload openresty +else + sudo systemctl start openresty +fi + +sudo systemctl status openresty --no-pager +``` + +## Verify the observer + +Check service state: + +```bash +sudo supervisorctl status +``` + +Inspect listening sockets: + +```bash +sudo ss -lntup +``` + +Confirm that: + +- Reth P2P is reachable on configured TCP and UDP port `30303`. +- Summit P2P is reachable on its configured public address, normally port + `18551`. +- Reth ports `8545`, `8546`, `8552`, and `9001` are loopback-only. +- Summit ports `3030`, `3031`, and `9090` are loopback-only. +- summit-checkpointer port `42069` is loopback-only when enabled. +- Custodian listens on the configured council address when enabled. +- OpenResty listens on ports `80` and `443` only when configured and explicitly + started. + +Follow the main logs: + +```bash +sudo tail -f \ + /var/log/seismic-observer/reth.log \ + /var/log/seismic-observer/summit-observer.log +``` + +There is no staking transaction or deposit-signature workflow for an observer. + +## Safe installer reruns + +The installer can be rerun to update binaries or rendered configuration, but it +does not persist previous answers. Re-enter the same service user, persistent +directories, genesis path, observer assignment, and optional component settings +unless you are intentionally changing them. + +On a normal rerun, the installer: + +- Preserves the Reth P2P key. +- Preserves Summit keys when the assignment marker matches. +- Refuses to reuse keys for another parent or observer index. +- Preserves an existing observer Custodian root key. +- Replaces the installer-managed bootstrappers file from the accepted source, or + removes it when no source is configured. +- Replaces generated OpenResty and Supervisor configuration. +- Leaves services stopped and does not reload Supervisor or OpenResty. + +Changing a persistent path does not migrate existing data. It creates or uses a +separate store. + +For source installations, new checkouts fetch all remote branches. On a rerun, +the installer validates the origin and clean working tree, configures `origin` +to fetch all branches, fetches and prunes remote references, checks out or +creates the configured local branch, and merges `origin/` with +`--ff-only` before rebuilding. Dirty, diverged, or force-pushed checkouts are +rejected rather than reset. The installer does not update its own +`seismic-node-ops` checkout. + +Before applying a later `supervisorctl update`, inspect running services: + +```bash +sudo supervisorctl status +``` + +Supervisor may restart a running program whose definition changed when the +updated configuration is applied. Plan that operation separately for a live +observer. + +## Troubleshooting + +### Parent node key is missing or does not match + +Confirm that `node_key.pem` exists at the configured Summit key path, is owned +by the service user, and has mode `0600`. Run: + +```bash +sudo -u SERVICE_USER /usr/local/bin/summit keys show \ + --key-store-path /path/to/configured/summit/keys +``` + +The displayed node public key must match the parent public key accepted by the +installer. + +### Observer assignment marker mismatch + +The marker must contain exactly: + +```text +: +``` + +Do not edit it to repurpose existing keys or state. Use separate persistent key +and Summit data paths for a different assignment. + +### Parent Custodian is unreachable + +Verify the configured parent `host:port`, outbound routing, and the parent +firewall. The parent Custodian must be running and configured with its Summit +key directory. + +### Custodian root-key fetch or verification fails + +Check that the observer index, parent node key, chain ID, council address, and +parent Custodian endpoint all match the intended deployment. Do not delete an +existing root key merely to bypass a mismatch. + +### Custodian socket is missing + +Inspect the Custodian logs and confirm that the configured socket parent path is +usable. Reth must not be started in Custodian mode until the Unix socket exists. + +### Reth cannot start + +Check the Reth executable, Reth P2P key, data-directory ownership, Custodian +socket when enabled, and Reth error log. Do not delete the MDBX database as a +first troubleshooting step. + +### Summit cannot start + +Check the Summit executable, genesis readability, assignment marker, both Summit +key files, Engine API IPC availability, public P2P address, and Summit error +log. + +### Observer cannot find Summit peers + +Confirm that the genesis contains reachable initial peers or provide a valid +bootstrappers TOML. The bootstrappers file must contain the expected public keys +and reachable `IP:port` addresses. + +### RPC or metrics port is publicly exposed + +Stop the affected program and inspect: + +```text +/etc/supervisor/conf.d/seismic-observer.conf +``` + +Reth HTTP, WebSocket, Ops, and metrics and Summit RPC, admin RPC, and metrics +must bind to loopback. Only P2P and explicitly configured public services should +bind externally. + +### Deferred binary is missing or unsafe + +Install the executable at the accepted target path as a root-owned mode `0755` +regular file. Parent directories must also be root-owned and not writable by the +service user. + +### OpenResty validation fails + +Run: + +```bash +sudo openresty -t +``` + +Then inspect: + +```text +/var/log/seismic-observer-install.log +/usr/local/openresty/nginx/conf/nginx.conf +``` + +Do not start or reload OpenResty until its configuration test passes. diff --git a/install/README.md b/install/README.md index 0ac2947..b3d12f8 100644 --- a/install/README.md +++ b/install/README.md @@ -11,6 +11,9 @@ Supervisor programs, and optional OpenResty endpoint. It deliberately does not start the validator services. Follow the first-start procedure in this document after installation. +For an observer node, use `install/install-observer.sh` and follow the separate +**[Observer Installer and First-Start Guide](OBSERVER.md)**. + ## Safety model The installer is designed to avoid replacing persistent validator state: @@ -46,14 +49,18 @@ The installer does not configure cloud firewall, security-group, or host firewall rules. Configure the required inbound access before starting the validator. -| Port | Protocol | Purpose | Required exposure | -| --- | --- | --- | --- | -| `30303` | TCP and UDP | seismic-reth P2P and discovery | Public | -| `18551` | TCP and UDP | Summit consensus P2P | Public | -| `80` | TCP | HTTP redirect and ACME challenge | Public when OpenResty is enabled | -| `443` | TCP | OpenResty HTTPS endpoint | Public when OpenResty is enabled | -| `7876` | TCP | Custodian council communication | Externally reachable when Custodian is enabled; restrict sources to intended council participants | +| Port | Protocol | Purpose | Required exposure | +| ------- | ----------- | -------------------------------- | ------------------------------------------------------------------------------------------------- | +| `30303` | TCP and UDP | seismic-reth P2P and discovery | Public | +| `18551` | TCP and UDP | Summit consensus P2P | Public | +| `80` | TCP | HTTP redirect and ACME challenge | Public when OpenResty is enabled | +| `443` | TCP | OpenResty HTTPS endpoint | Public when OpenResty is enabled | +| `7876` | TCP | Custodian council communication | Externally reachable when Custodian is enabled; restrict sources to intended council participants | +When summit-checkpointer is enabled, its RPC and snapshot server binds only to +`127.0.0.1:42069`. Do not expose TCP port `42069` directly through a firewall. +When OpenResty is enabled, it provides the rate-limited and JWT-protected +`/checkpointer` HTTPS route instead. The installation log is written to: @@ -102,14 +109,17 @@ When Centralized Custodian is enabled, the installer always uses its publicly known shared default root key and does not prompt for a custom key. This makes epoch-0 purpose keys public. -The Custodian council listener defaults to `0.0.0.0:7876`. TCP port `7876` -must be reachable from outside the node for key rotation. -Configure the cloud firewall, security group, and host firewall as -needed, and restrict allowed source addresses to the intended council -participants rather than exposing the port more broadly than necessary. +The Custodian council listener defaults to `0.0.0.0:7876`. TCP port `7876` must +be reachable from outside the node for key rotation. The generated validator +Custodian program also receives the Summit key directory so it can authenticate +and serve configured observer Custodians. Configure the cloud firewall, security +group, and host firewall as needed, and restrict allowed source addresses to +intended council participants and observer hosts rather than exposing the port +more broadly than necessary. Observer root keys and plaintext epoch-key material +transit the parent-Custodian connection; use a private network or TLS tunnel. -For Summit, seismic-reth, and summit-checkpointer, supported installation modes -are: +For Summit, seismic-reth, summit-checkpointer, and Centralized Custodian, +supported installation modes are: 1. Install a supplied prebuilt executable. 2. Build from source during installation. @@ -120,22 +130,26 @@ The current source-build defaults are: ```text Summit: m/metrics seismic-reth: feat/purpose-key-rotation-reth +Checkpointer: main +Custodian: d/centralized-custodian ``` Deferred binaries must be installed at the configured target paths before the -corresponding services are started. +corresponding services are started. A prebuilt or already-present deferred +summit-checkpointer must support `--bind-address`; the generated Supervisor +program uses it to keep the checkpointer RPC on loopback. ## Persistent layout The default persistent paths are: -| State | Default path | -| --- | --- | -| Reth data | `/persistence/reth` | -| Summit data | `/persistence/summit` | -| Validator keys | `/persistence/keys` | +| State | Default path | +| --------------------------------- | -------------------------- | +| Reth data | `/persistence/reth` | +| Summit data | `/persistence/summit` | +| Validator keys | `/persistence/keys` | | Checkpointer output, when enabled | `/persistence/checkpoints` | -| Custodian data, when enabled | `/persistence/custodian` | +| Custodian data, when enabled | `/persistence/custodian` | Important files derived from those paths include: @@ -157,8 +171,8 @@ seismic-reth stores its MDBX execution database under: ``` Treat this as persistent execution-layer state. Do not delete it during an -installer rerun, and do not manually copy or modify the live database while -Reth is running. +installer rerun, and do not manually copy or modify the live database while Reth +is running. When summit-checkpointer is enabled, it uses `mdbx_copy` to produce a consistent Reth database copy. `mdbx_copy` must be built from the same vendored libmdbx @@ -192,17 +206,58 @@ enabled. Supervisor logs are written under: /var/log/seismic-validator/ ``` -When OpenResty is enabled, the installer writes: +When summit-checkpointer is enabled, the installer prompts for an absolute +configuration-file path and defaults to: + +```text +/etc/seismic/summit-checkpointer.toml +``` + +The generated Supervisor command uses the selected path. Its parent directory +hierarchy must be root-owned and must not be group- or world-writable. + +When OpenResty is enabled, the installer prompts for an absolute JWT-secret file +path and defaults to: + +```text +/etc/seismic/openresty-jwt-secret +``` + +It also writes: ```text /usr/local/openresty/nginx/conf/nginx.conf /usr/local/openresty/nginx/lua/rate_limit.lua /usr/local/openresty/nginx/lua/jwt_auth.lua -/etc/seismic/openresty-jwt-secret /etc/logrotate.d/openresty ``` -The JWT secret is root-owned and is not printed by the installer. +The generated JWT middleware reads the selected secret path. The secret is +installed as `root:nogroup` with mode `0640` and is not printed by the +installer. Its parent hierarchy must be root-owned, non-writable by group or +others, and traversable by the OpenResty worker user. Reruns reuse a secret +already at the selected path. When changing from the default to a custom path +that does not yet exist, the installer copies the existing default secret +without removing the old file; remove it only after validating the new OpenResty +configuration. + +The installer records the selected path in: + +```text +/etc/seismic/openresty-jwt-secret.path +``` + +This root-owned metadata file contains only the path, not the secret. Installer +reruns use it as the JWT-secret prompt default. Generate a one-hour bearer token +from the repository root with: + +```bash +TOKEN=$(sudo ./tools/generate-openresty-jwt.sh) +``` + +The script reads the metadata file, falls back to the standard secret path for +older installations, and prints only the token. Use `--secret-path` to override +the lookup or `--ttl-seconds` to select a lifetime up to 86400 seconds. ## First validator startup @@ -264,8 +319,7 @@ exits to the encoded address. ### 4. Save the signed deposit response as JSON -Run the following on the validator server while `summit-deposit-rpc` is -running: +Run the following on the validator server while `summit-deposit-rpc` is running: ```bash curl --fail-with-body --silent --show-error \ @@ -305,16 +359,16 @@ Do not leave the signing endpoint running after obtaining the file. ### 6. Send the signature file to Seismic operations -Send the `deposit-signature.json` to Seismic operations through a secure transfer channel. +Send the `deposit-signature.json` to Seismic operations through a secure +transfer channel. Do **not** send validator private-key files, a wallet private key, seed phrases, or JWT secrets. Do not publish the signature file in a public issue or source repository. -Seismic operations will submit the staking transaction on your behalf. Wait -for the transaction hash and confirmation that its receipt has status -`0x1` before starting the full validator. - +Seismic operations will submit the staking transaction on your behalf. Wait for +the transaction hash and confirmation that its receipt has status `0x1` before +starting the full validator. ### 7. Start the validator after staking confirmation @@ -339,29 +393,34 @@ started manually again after a server or Supervisor restart. ## OpenResty public endpoint When enabled, OpenResty terminates HTTPS, obtains certificates through -`lua-resty-auto-ssl`, applies per-client rate limiting, and proxies local Reth -and Summit endpoints. +`lua-resty-auto-ssl`, applies per-client rate limiting, and proxies local Reth, +Summit, and summit-checkpointer endpoints. -Reth HTTP and WebSocket RPC, Summit RPC, and their metrics listeners remain -bound to loopback whether or not OpenResty is enabled. When OpenResty is -disabled, these endpoints are available only from the node itself or through an -operator-managed tunnel; they are not exposed directly on public interfaces. +Reth HTTP and WebSocket RPC, Summit RPC, their metrics listeners, and the +summit-checkpointer RPC remain bound to loopback whether or not OpenResty is +enabled. When OpenResty is disabled, these endpoints are available only from the +node itself or through an operator-managed tunnel; they are not exposed directly +on public interfaces. The configured routes are: -| Public path | Local upstream | Notes | -| --- | --- | --- | -| `/` | `127.0.0.1:3000` | Grafana | -| `/staking` | `/var/www/html/staking` | Static staking UI | -| `/rpc` | `127.0.0.1:8545` | Reth HTTP JSON-RPC | -| `/ws` | `127.0.0.1:8546` | Reth WebSocket JSON-RPC | -| `/summit` | `127.0.0.1:3030` | Summit RPC | -| `/ops` | `127.0.0.1:8552` | Signature-authenticated privileged Reth RPC | -| `/prom-summit` | `127.0.0.1:9090` | Rate-limited and JWT-protected | -| `/prom-reth` | `127.0.0.1:9001` | Rate-limited and JWT-protected | - -The localhost-only `summit-deposit-rpc` service on port 3031 is deliberately -not proxied. +| Public path | Local upstream | Notes | +| --------------- | ----------------------- | ------------------------------------------------ | +| `/` | `127.0.0.1:3000` | Grafana | +| `/staking` | `/var/www/html/staking` | Static staking UI | +| `/rpc` | `127.0.0.1:8545` | Reth HTTP JSON-RPC | +| `/ws` | `127.0.0.1:8546` | Reth WebSocket JSON-RPC | +| `/summit` | `127.0.0.1:3030` | Summit RPC | +| `/ops` | `127.0.0.1:8552` | Signature-authenticated privileged Reth RPC | +| `/checkpointer` | `127.0.0.1:42069` | Rate-limited and JWT-protected RPC and snapshots | +| `/prom-summit` | `127.0.0.1:9090` | Rate-limited and JWT-protected | +| `/prom-reth` | `127.0.0.1:9001` | Rate-limited and JWT-protected | + +The `/checkpointer` route uses the same secret at the selected JWT-secret path +as the protected metrics routes. The default path is +`/etc/seismic/openresty-jwt-secret`. Clients must send +`Authorization: Bearer `. The localhost-only `summit-deposit-rpc` service +on port `3031` remains deliberately unproxied. ### Start or reload OpenResty @@ -389,6 +448,14 @@ On a normal rerun, the installer preserves existing validator keys and state, then replaces its generated OpenResty and Supervisor configuration. It does not start or reload those services. +For source installations, new checkouts fetch all remote branches. On a rerun, +the installer validates the origin and clean working tree, configures `origin` +to fetch all branches, fetches and prunes remote references, checks out or +creates the configured local branch, and merges `origin/` with +`--ff-only` before rebuilding. Dirty, diverged, or force-pushed checkouts are +rejected rather than reset. The installer does not update its own +`seismic-node-ops` checkout. + Before applying a later `supervisorctl update`, inspect running services: ```bash diff --git a/install/install-observer.sh b/install/install-observer.sh new file mode 100755 index 0000000..3b61013 --- /dev/null +++ b/install/install-observer.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +# Install and configure a Seismic observer node. +# +# The installer reuses the validator installer's shared node components while +# keeping observer assignment, parent-key provisioning, and Custodian parent +# synchronization explicit. It never starts node services. + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TEMPLATES_DIR="$SCRIPT_DIR/templates" +LOG_FILE="${LOG_FILE:-/var/log/seismic-observer-install.log}" + +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" +trap 'unexpected_error "$?" "$LINENO"' ERR +# shellcheck source=lib/preflight.sh +source "$SCRIPT_DIR/lib/preflight.sh" +# shellcheck source=lib/packages.sh +source "$SCRIPT_DIR/lib/packages.sh" +# shellcheck source=lib/openresty.sh +source "$SCRIPT_DIR/lib/openresty.sh" +# shellcheck source=lib/directories.sh +source "$SCRIPT_DIR/lib/directories.sh" +# shellcheck source=lib/observer-directories.sh +source "$SCRIPT_DIR/lib/observer-directories.sh" +# shellcheck source=lib/binaries.sh +source "$SCRIPT_DIR/lib/binaries.sh" +# shellcheck source=lib/checkpointer.sh +source "$SCRIPT_DIR/lib/checkpointer.sh" +# shellcheck source=lib/custodian.sh +source "$SCRIPT_DIR/lib/custodian.sh" +# shellcheck source=lib/keys.sh +source "$SCRIPT_DIR/lib/keys.sh" +# shellcheck source=lib/observer-keys.sh +source "$SCRIPT_DIR/lib/observer-keys.sh" +# shellcheck source=lib/supervisor.sh +source "$SCRIPT_DIR/lib/supervisor.sh" +# shellcheck source=lib/observer-supervisor.sh +source "$SCRIPT_DIR/lib/observer-supervisor.sh" +# shellcheck source=lib/configuration.sh +source "$SCRIPT_DIR/lib/configuration.sh" +# shellcheck source=lib/observer-configuration.sh +source "$SCRIPT_DIR/lib/observer-configuration.sh" +# shellcheck source=lib/observer-instructions.sh +source "$SCRIPT_DIR/lib/observer-instructions.sh" + +main() { + info "Seismic observer installer" + preflight + command -v python3 >/dev/null \ + || die "python3 is required for observer socket-address validation." + configure_observer + install_system_packages + install_openresty + setup_observer_runtime_directories + install_node_binaries + install_checkpointer + install_observer_custodian + setup_observer_keys + deploy_openresty_configuration + deploy_observer_supervisor_configuration + print_observer_manual_start_instructions + success "Observer installation complete; services were not started." +} + +main "$@" diff --git a/install/install-validator.sh b/install/install-validator.sh index 14b70d4..d76ad34 100755 --- a/install/install-validator.sh +++ b/install/install-validator.sh @@ -6,7 +6,7 @@ # validator components, prepares persistent state, and deploys service # configuration without starting validator services. -set -euo pipefail +set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TEMPLATES_DIR="$SCRIPT_DIR/templates" @@ -14,6 +14,7 @@ LOG_FILE="${LOG_FILE:-/var/log/seismic-validator-install.log}" # shellcheck source=lib/common.sh source "$SCRIPT_DIR/lib/common.sh" +trap 'unexpected_error "$?" "$LINENO"' ERR # shellcheck source=lib/preflight.sh source "$SCRIPT_DIR/lib/preflight.sh" # shellcheck source=lib/packages.sh @@ -44,7 +45,7 @@ main() { install_system_packages install_openresty setup_runtime_directories - install_validator_binaries + install_node_binaries install_checkpointer install_custodian setup_validator_keys diff --git a/install/lib/binaries.sh b/install/lib/binaries.sh index 4b41311..fe4176d 100644 --- a/install/lib/binaries.sh +++ b/install/lib/binaries.sh @@ -132,6 +132,29 @@ prepare_source_root() { || die "Could not prepare source root: $source_root" } +clean_reth_mdbx_build_artifacts() { + local source_dir=$1 + local libmdbx_dir="$source_dir/crates/storage/libmdbx-rs/mdbx-sys/libmdbx" + local tracked_status + local status + + [[ -d "$source_dir/.git" && -f "$libmdbx_dir/Makefile" ]] || return 0 + + tracked_status=$(run_as_service_user git -C "$source_dir" \ + status --porcelain --untracked-files=no) \ + || die "Could not inspect tracked seismic-reth source changes." + [[ -z "$tracked_status" ]] || return 0 + + status=$(run_as_service_user git -C "$source_dir" status --porcelain) \ + || die "Could not inspect generated seismic-reth build artifacts." + [[ -n "$status" ]] || return 0 + + info "Cleaning generated libmdbx artifacts from the seismic-reth checkout..." + run_as_service_user make -C "$libmdbx_dir" clean \ + >>"$LOG_FILE" 2>&1 \ + || die "Could not clean generated seismic-reth libmdbx artifacts; see $LOG_FILE" +} + prepare_source_checkout() { local description=$1 local repository=$2 @@ -148,7 +171,6 @@ prepare_source_checkout() { info "Cloning $description branch $branch into $source_dir..." if ! run_as_service_user git clone \ --branch "$branch" \ - --single-branch \ "$repository" \ "$source_dir" >>"$LOG_FILE" 2>&1; then die "Could not clone $description; see $LOG_FILE" @@ -164,6 +186,10 @@ prepare_source_checkout() { [[ "$origin" == "$repository" ]] \ || die "$description checkout has unexpected origin: $origin" + if [[ "$description" == "seismic-reth" ]]; then + clean_reth_mdbx_build_artifacts "$source_dir" + fi + status=$(run_as_service_user git -C "$source_dir" status --porcelain) \ || die "Could not inspect the $description working tree." if [[ -n "$status" ]]; then @@ -171,9 +197,13 @@ prepare_source_checkout() { fi info "Updating $description branch $branch with fast-forward only..." - run_as_service_user git -C "$source_dir" fetch origin "$branch" \ + run_as_service_user git -C "$source_dir" remote \ + set-branches origin '*' \ >>"$LOG_FILE" 2>&1 \ - || die "Could not fetch $description branch $branch; see $LOG_FILE" + || die "Could not configure $description to fetch all origin branches." + run_as_service_user git -C "$source_dir" fetch --prune origin \ + >>"$LOG_FILE" 2>&1 \ + || die "Could not fetch $description branches; see $LOG_FILE" if run_as_service_user git -C "$source_dir" show-ref \ --verify --quiet "refs/heads/$branch"; then @@ -283,9 +313,13 @@ install_reth_binary() { esac } -install_validator_binaries() { - section "Installing validator binaries" +install_node_binaries() { + section "Installing node binaries" install_summit_binary install_reth_binary success "Summit and seismic-reth installation methods completed." } + +install_validator_binaries() { + install_node_binaries +} diff --git a/install/lib/checkpointer.sh b/install/lib/checkpointer.sh index ad74261..9dae407 100644 --- a/install/lib/checkpointer.sh +++ b/install/lib/checkpointer.sh @@ -43,6 +43,29 @@ install_checkpointer_binary() { esac } +check_checkpointer_cli_support() { + local help_output + + [[ -x "$CHECKPOINTER_TARGET_BIN" ]] || return 1 + help_output=$("$CHECKPOINTER_TARGET_BIN" --help 2>&1) || return 1 + grep -q -- '--bind-address' <<<"$help_output" +} + +validate_checkpointer_cli_support() { + local security_error + + if [[ ! -e "$CHECKPOINTER_TARGET_BIN" && ! -L "$CHECKPOINTER_TARGET_BIN" ]]; then + return 1 + fi + if ! security_error=$(check_service_executable_security "$CHECKPOINTER_TARGET_BIN"); then + die "summit-checkpointer executable is not safe for service use: $security_error" + fi + check_checkpointer_cli_support \ + || die "Installed summit-checkpointer does not support --bind-address." + + success "summit-checkpointer command-line compatibility validated" +} + install_mdbx_copy() { local reth_source_dir local libmdbx_dir @@ -83,6 +106,12 @@ install_mdbx_copy() { if ! "$MDBX_COPY_TARGET_BIN" -V >>"$LOG_FILE" 2>&1; then die "mdbx_copy validation failed; see $LOG_FILE" fi + + info "Cleaning generated libmdbx build artifacts..." + run_as_service_user make -C "$libmdbx_dir" clean \ + >>"$LOG_FILE" 2>&1 \ + || die "Could not clean generated libmdbx build artifacts; see $LOG_FILE" + success "Reth-compatible mdbx_copy installed from $reth_source_dir" } @@ -94,6 +123,10 @@ install_checkpointer() { section "Installing summit-checkpointer" install_checkpointer_binary + if ! validate_checkpointer_cli_support; then + warn "summit-checkpointer executable is deferred and not present at $CHECKPOINTER_TARGET_BIN." + warn "Provide a compatible executable with --bind-address support before starting the checkpointer." + fi install_mdbx_copy success "summit-checkpointer binary and compatible mdbx_copy installation phase complete." } diff --git a/install/lib/common.sh b/install/lib/common.sh index 737cf65..b3235ef 100644 --- a/install/lib/common.sh +++ b/install/lib/common.sh @@ -31,3 +31,12 @@ die() { error "$*" exit 1 } + +unexpected_error() { + local exit_code=$1 + local line=$2 + + trap - ERR + error "Installer stopped unexpectedly at line $line with exit status $exit_code. See $LOG_FILE." + exit "$exit_code" +} diff --git a/install/lib/configuration.sh b/install/lib/configuration.sh index fe3c6ee..bbde5af 100644 --- a/install/lib/configuration.sh +++ b/install/lib/configuration.sh @@ -42,14 +42,14 @@ configure_service_user() { [[ "$default_user" != "root" ]] || default_user="ubuntu" while true; do - prompt SERVICE_USER "User to run validator services as" "$default_user" + prompt SERVICE_USER "User to run node services as" "$default_user" if [[ ! "$SERVICE_USER" =~ ^[a-z_][a-z0-9_-]*\$?$ ]]; then error "Invalid service username: $SERVICE_USER" continue fi if [[ "$SERVICE_USER" == "root" ]]; then - error "Validator services must not run as root." + error "Node services must not run as root." continue fi if ! id "$SERVICE_USER" >/dev/null 2>&1; then @@ -124,9 +124,9 @@ directory_conflicts_with_existing() { configured_path=${!configured_variable} [[ -n "$configured_path" ]] || continue - if [[ "$selected" == "$configured_path" \ - || "$selected" == "$configured_path/"* \ - || "$configured_path" == "$selected/"* ]]; then + if [[ "$selected" == "$configured_path" || + "$selected" == "$configured_path/"* || + "$configured_path" == "$selected/"* ]]; then error "$description and ${configured_descriptions[i]} must not be identical or nested." return 0 fi @@ -178,6 +178,92 @@ configure_directory() { print_available_disk_space "$selected" } +configure_file_path() { + local variable_name=$1 + local description=$2 + local default=$3 + local selected + local parent + local probe + local current + local owner_uid + local mode + local mode_value + local parent_safe + + while true; do + prompt "$variable_name" "$description" "$default" + selected=${!variable_name} + + if [[ "$selected" != /* ]]; then + error "$description must be an absolute path." + continue + fi + if [[ "$selected" == "/" ]]; then + error "The filesystem root cannot be used for $description." + continue + fi + if contains_unsafe_path_characters "$selected"; then + error "$description contains unsupported whitespace or shell characters." + continue + fi + if [[ -L "$selected" ]]; then + error "$description must not be a symbolic link: $selected" + continue + fi + if [[ -e "$selected" && ! -f "$selected" ]]; then + error "$description exists but is not a regular file: $selected" + continue + fi + + parent=$(dirname -- "$selected") + probe=$parent + while [[ ! -e "$probe" ]]; do + probe=$(dirname -- "$probe") + done + if [[ ! -d "$probe" ]]; then + error "$description has a non-directory parent: $probe" + continue + fi + if [[ -L "$probe" ]]; then + error "$description parent must not be a symbolic link: $probe" + continue + fi + + current=$probe + parent_safe=true + while true; do + if [[ -L "$current" ]]; then + error "$description parent chain contains a symbolic link: $current" + parent_safe=false + break + fi + owner_uid=$(stat -c %u -- "$current") + if [[ "$owner_uid" != "0" ]]; then + error "$description parent must be root-owned: $current" + parent_safe=false + break + fi + mode=$(stat -c %a -- "$current") + mode_value=$((8#$mode)) + if ((mode_value & 0022)); then + error "$description parent must not be group- or world-writable: $current" + parent_safe=false + break + fi + [[ "$current" == "/" ]] && break + current=$(dirname -- "$current") + done + [[ "$parent_safe" == true ]] || continue + + selected=$(realpath -m -- "$selected") + break + done + + printf -v "$variable_name" '%s' "$selected" + success "$description selected: $selected" +} + configure_prebuilt_binary() { local variable_name=$1 local description=$2 @@ -310,8 +396,8 @@ print_component_installation() { esac } -configure_validator_software() { - section "Validator software configuration" +configure_node_software() { + section "Node software configuration" SUMMIT_TARGET_BIN="/usr/local/bin/summit" RETH_TARGET_BIN="/usr/local/bin/seismic-reth" @@ -324,16 +410,22 @@ configure_validator_software() { configure_component_installation \ SUMMIT_INSTALL_METHOD SUMMIT_BINARY "Summit" SUMMIT_TARGET_BIN + _out "" configure_component_installation \ RETH_INSTALL_METHOD RETH_BINARY "seismic-reth" RETH_TARGET_BIN - _out "Validator software:" + _out "" + _out "Node software:" print_component_installation \ "Summit" "$SUMMIT_INSTALL_METHOD" "$SUMMIT_BINARY" "$SUMMIT_TARGET_BIN" print_component_installation \ "Seismic Reth" "$RETH_INSTALL_METHOD" "$RETH_BINARY" "$RETH_TARGET_BIN" } +configure_validator_software() { + configure_node_software +} + configure_public_endpoint() { section "Public endpoint configuration" @@ -341,6 +433,7 @@ configure_public_endpoint() { DOMAIN="" RATE_LIMIT_RPS="" RATE_LIMIT_BURST="" + OPENRESTY_JWT_SECRET_PATH=${OPENRESTY_JWT_SECRET_PATH:-/etc/seismic/openresty-jwt-secret} if ! confirm "Configure a public HTTPS endpoint with OpenResty?"; then _out "Public HTTPS endpoint: disabled" @@ -348,6 +441,12 @@ configure_public_endpoint() { fi CONFIGURE_PUBLIC_ENDPOINT=true + if [[ "${OPENRESTY_JWT_SECRET_PATH_CONFIGURED:-false}" != true ]] \ + && load_persisted_openresty_jwt_secret_path; then + OPENRESTY_JWT_SECRET_PATH=$PERSISTED_OPENRESTY_JWT_SECRET_PATH + info "Using the previously installed OpenResty JWT secret path as the default." + fi + while true; do prompt DOMAIN "Public domain" "" DOMAIN=${DOMAIN,,} @@ -384,9 +483,16 @@ configure_public_endpoint() { break done + configure_file_path \ + OPENRESTY_JWT_SECRET_PATH \ + "OpenResty JWT secret file" \ + "$OPENRESTY_JWT_SECRET_PATH" + OPENRESTY_JWT_SECRET_PATH_CONFIGURED=true + _out "Public HTTPS endpoint enabled: $CONFIGURE_PUBLIC_ENDPOINT" success "Public HTTPS endpoint configured: https://$DOMAIN" _out "Rate limit: $RATE_LIMIT_RPS requests/sec, burst $RATE_LIMIT_BURST" + _out "JWT secret: $OPENRESTY_JWT_SECRET_PATH (contents hidden)" } validate_http_url() { @@ -537,7 +643,8 @@ configure_checkpointer() { INSTALL_CHECKPOINTER=false CHECKPOINTER_TARGET_BIN="/usr/local/bin/summit-checkpointer" MDBX_COPY_TARGET_BIN="/usr/local/bin/mdbx_copy" - CHECKPOINTER_SOURCE_REF="m/dynamic-epochs-and-ckpt-chain" + CHECKPOINTER_CONFIG_PATH=${CHECKPOINTER_CONFIG_PATH:-/etc/seismic/summit-checkpointer.toml} + CHECKPOINTER_SOURCE_REF="main" CHECKPOINTER_INSTALL_METHOD="" CHECKPOINTS_DIR="" CHECKPOINTER_BINARY="" @@ -549,6 +656,10 @@ configure_checkpointer() { "Checkpointer output directory" \ "/persistence/checkpoints" \ "Stores Reth snapshots and Summit verification bundles produced by summit-checkpointer." + configure_file_path \ + CHECKPOINTER_CONFIG_PATH \ + "Checkpointer configuration file" \ + "$CHECKPOINTER_CONFIG_PATH" configure_component_installation \ CHECKPOINTER_INSTALL_METHOD \ CHECKPOINTER_BINARY \ @@ -567,6 +678,7 @@ configure_checkpointer() { if [[ "$CHECKPOINTER_INSTALL_METHOD" == "source" ]]; then _out " Checkpointer source ref: $CHECKPOINTER_SOURCE_REF" fi + _out " Checkpointer config: $CHECKPOINTER_CONFIG_PATH" print_mdbx_copy_plan fi } @@ -588,10 +700,13 @@ configure_custodian() { INSTALL_CUSTODIAN=false CUSTODIAN_DATA_DIR="" CUSTODIAN_TARGET_BIN="/usr/local/bin/seismic-centralized-custodian-service" + CUSTODIAN_INSTALL_METHOD="" + CUSTODIAN_BINARY="" CUSTODIAN_SOCKET="" COUNCIL_LISTEN="" COUNCIL_ADDRESS="" CUSTODIAN_CHAIN_ID="" + PARENT_CUSTODIAN="" CUSTODIAN_REQUIRED_SUMMIT_REF="m/metrics" CUSTODIAN_REQUIRED_RETH_REF="feat/purpose-key-rotation-reth" CUSTODIAN_SOURCE_REF="d/centralized-custodian" @@ -635,8 +750,25 @@ configure_custodian() { done success "Custodian Unix socket selected: $CUSTODIAN_SOCKET" - _out "Custodian will use the publicly known shared default root key." - warn "The shared default makes epoch-0 purpose keys public." + if [[ "${NODE_ROLE:-validator}" == "observer" ]]; then + _out "Enter the Custodian council endpoint running on the parent validator." + _out "The observer uses it to fetch or verify the root key and synchronize epoch-key deliveries." + while true; do + prompt PARENT_CUSTODIAN \ + "Parent validator Custodian council endpoint (host:port; default port 7876)" \ + "" + if validate_host_port "$PARENT_CUSTODIAN"; then + break + fi + error "Parent validator Custodian council endpoint must be host:port with a valid port." + done + _out "The observer Custodian will fetch and verify its root key through $PARENT_CUSTODIAN." + warn "The parent Custodian connection transports root-key and epoch-key material." + warn "Use a private network or protect the connection with a TLS tunnel." + else + _out "Custodian will use the publicly known shared default root key." + warn "The shared default makes epoch-0 purpose keys public." + fi while true; do prompt COUNCIL_LISTEN "Custodian council listen address" "0.0.0.0:7876" @@ -666,17 +798,32 @@ configure_custodian() { error "Custodian chain ID must be a positive integer." done + configure_component_installation \ + CUSTODIAN_INSTALL_METHOD \ + CUSTODIAN_BINARY \ + "Centralized Custodian" \ + CUSTODIAN_TARGET_BIN + warn "Custodian requires Summit compatible with $CUSTODIAN_REQUIRED_SUMMIT_REF." warn "Custodian requires seismic-reth compatible with $CUSTODIAN_REQUIRED_RETH_REF." _out "Centralized Custodian: $INSTALL_CUSTODIAN" _out " Data: $CUSTODIAN_DATA_DIR" _out " Socket: $CUSTODIAN_SOCKET" - _out " Root key: publicly known shared default" + if [[ "${NODE_ROLE:-validator}" == "observer" ]]; then + _out " Root key: fetched and verified through parent Custodian" + _out " Parent: $PARENT_CUSTODIAN" + else + _out " Root key: publicly known shared default" + fi _out " Council: $COUNCIL_LISTEN ($COUNCIL_ADDRESS)" _out " Chain ID: $CUSTODIAN_CHAIN_ID" - _out " Binary: build from source -> $CUSTODIAN_TARGET_BIN" - _out " Source ref: $CUSTODIAN_SOURCE_REF" + print_component_installation \ + "Custodian" "$CUSTODIAN_INSTALL_METHOD" \ + "$CUSTODIAN_BINARY" "$CUSTODIAN_TARGET_BIN" + if [[ "$CUSTODIAN_INSTALL_METHOD" == "source" ]]; then + _out " Source ref: $CUSTODIAN_SOURCE_REF" + fi } configure_directories() { @@ -769,6 +916,7 @@ print_configuration_summary() { if [[ "$CHECKPOINTER_INSTALL_METHOD" == "source" ]]; then _out " Source ref: $CHECKPOINTER_SOURCE_REF" fi + _out " Config: $CHECKPOINTER_CONFIG_PATH" print_mdbx_copy_plan else _out " Enabled: false" @@ -779,14 +927,23 @@ print_configuration_summary() { _out " Enabled: true" _out " Data: $CUSTODIAN_DATA_DIR" _out " Socket: $CUSTODIAN_SOCKET" - _out " Root key: publicly known shared default" + if [[ "${NODE_ROLE:-validator}" == "observer" ]]; then + _out " Root key: fetched and verified through parent Custodian" + _out " Parent Custodian: $PARENT_CUSTODIAN" + else + _out " Root key: publicly known shared default" + fi _out " Council listen: $COUNCIL_LISTEN" _out " Council address: $COUNCIL_ADDRESS" _out " Chain ID: $CUSTODIAN_CHAIN_ID" - _out " Binary: build from source -> $CUSTODIAN_TARGET_BIN" + print_component_installation \ + "Custodian" "$CUSTODIAN_INSTALL_METHOD" \ + "$CUSTODIAN_BINARY" "$CUSTODIAN_TARGET_BIN" _out " Required Summit compatibility: $CUSTODIAN_REQUIRED_SUMMIT_REF" _out " Required Reth compatibility: $CUSTODIAN_REQUIRED_RETH_REF" - _out " Custodian source ref: $CUSTODIAN_SOURCE_REF" + if [[ "$CUSTODIAN_INSTALL_METHOD" == "source" ]]; then + _out " Custodian source ref: $CUSTODIAN_SOURCE_REF" + fi else _out " Enabled: false" fi @@ -841,6 +998,7 @@ review_configuration() { } configure() { + NODE_ROLE="validator" section "Configuration" configure_service_user configure_directories diff --git a/install/lib/custodian.sh b/install/lib/custodian.sh index 39a2a22..8fd5368 100644 --- a/install/lib/custodian.sh +++ b/install/lib/custodian.sh @@ -9,25 +9,66 @@ install_custodian_binary() { local source_dir="$SERVICE_HOME/src/enclave" local build_output="$source_dir/target/release/seismic-centralized-custodian-service" - install_rust_for_service_user - prepare_source_checkout \ - "Centralized Custodian" \ - "$CUSTODIAN_REPO" \ - "$source_dir" \ - "$CUSTODIAN_SOURCE_REF" - - info "Building Centralized Custodian from $CUSTODIAN_SOURCE_REF..." - # shellcheck disable=SC2016 - if ! run_as_service_user bash -c \ - 'source "$HOME/.cargo/env"; cd "$1"; cargo build --release -p seismic-centralized-custodian-service' \ - _ "$source_dir" >>"$LOG_FILE" 2>&1; then - die "Centralized Custodian build failed; see $LOG_FILE" + case "$CUSTODIAN_INSTALL_METHOD" in + prebuilt) + install_binary_target \ + "Centralized Custodian" \ + "$CUSTODIAN_BINARY" \ + "$CUSTODIAN_TARGET_BIN" + ;; + source) + install_rust_for_service_user + prepare_source_checkout \ + "Centralized Custodian" \ + "$CUSTODIAN_REPO" \ + "$source_dir" \ + "$CUSTODIAN_SOURCE_REF" + + info "Building Centralized Custodian from $CUSTODIAN_SOURCE_REF..." + # shellcheck disable=SC2016 + if ! run_as_service_user bash -c \ + 'source "$HOME/.cargo/env"; cd "$1"; cargo build --release -p seismic-centralized-custodian-service' \ + _ "$source_dir" >>"$LOG_FILE" 2>&1; then + die "Centralized Custodian build failed; see $LOG_FILE" + fi + + install_binary_target \ + "Centralized Custodian" \ + "$build_output" \ + "$CUSTODIAN_TARGET_BIN" + ;; + deferred) + info "Centralized Custodian installation deferred; expected executable: $CUSTODIAN_TARGET_BIN" + ;; + *) + die "Unknown Centralized Custodian installation method: $CUSTODIAN_INSTALL_METHOD" + ;; + esac +} + +validate_custodian_cli_support() { + local help_output + local security_error + + if [[ ! -e "$CUSTODIAN_TARGET_BIN" && ! -L "$CUSTODIAN_TARGET_BIN" ]]; then + return 1 + fi + if ! security_error=$(check_service_executable_security "$CUSTODIAN_TARGET_BIN"); then + die "Custodian executable is not safe for service use: $security_error" + fi + + help_output=$("$CUSTODIAN_TARGET_BIN" --help 2>&1) \ + || die "Could not inspect the installed Custodian command-line interface." + grep -q -- '--summit-key-dir' <<<"$help_output" \ + || die "Installed Custodian does not support --summit-key-dir." + if [[ "${NODE_ROLE:-validator}" == "observer" ]]; then + grep -q -- '--observer' <<<"$help_output" \ + || die "Installed Custodian does not support --observer." + grep -q -- '--parent-custodian' <<<"$help_output" \ + || die "Installed Custodian does not support --parent-custodian." fi - install_binary_target \ - "Centralized Custodian" \ - "$build_output" \ - "$CUSTODIAN_TARGET_BIN" + success "Custodian command-line compatibility validated" } read_binary_file_hex() { @@ -74,6 +115,40 @@ install_custodian() { section "Installing Centralized Custodian" install_custodian_binary + if ! validate_custodian_cli_support; then + warn "Custodian executable is deferred and not present at $CUSTODIAN_TARGET_BIN." + warn "Provide a compatible executable before starting Custodian." + fi install_custodian_root_key - success "Centralized Custodian binary and persistent root-key state are ready." + success "Centralized Custodian installation and persistent root-key preparation are complete." +} + +prepare_observer_custodian_root_key() { + local target="$CUSTODIAN_DATA_DIR/root.key" + + if [[ ! -e "$target" && ! -L "$target" ]]; then + info "No observer Custodian root key is present; it will be fetched from the parent Custodian on first start." + return + fi + + validate_existing_custodian_root_key "$target" + chown "$SERVICE_USER:$SERVICE_GROUP" "$target" + chmod 0600 "$target" + success "Existing observer Custodian root key preserved for parent verification on startup" +} + +install_observer_custodian() { + if [[ "$INSTALL_CUSTODIAN" != true ]]; then + info "Centralized Custodian disabled; skipping observer Custodian installation." + return + fi + + section "Installing observer Centralized Custodian" + install_custodian_binary + if ! validate_custodian_cli_support; then + warn "Observer Custodian executable is deferred and not present at $CUSTODIAN_TARGET_BIN." + warn "Provide a compatible executable before starting Custodian." + fi + prepare_observer_custodian_root_key + success "Observer Custodian installation and persistent root-key preparation are complete." } diff --git a/install/lib/directories.sh b/install/lib/directories.sh index ec2e937..6f8dede 100644 --- a/install/lib/directories.sh +++ b/install/lib/directories.sh @@ -36,7 +36,7 @@ setup_runtime_directories() { prepare_runtime_directory "$RETH_DATA_DIR" 0750 "Reth data directory" prepare_runtime_directory "$SUMMIT_DATA_DIR" 0750 "Summit data directory" - prepare_runtime_directory "$VALIDATOR_KEYS_DIR" 0700 "Validator keys directory" + prepare_runtime_directory "$VALIDATOR_KEYS_DIR" 0700 "Node keys directory" if [[ "$INSTALL_CHECKPOINTER" == true ]]; then prepare_runtime_directory \ diff --git a/install/lib/instructions.sh b/install/lib/instructions.sh index b7e8dd5..d44f8ed 100644 --- a/install/lib/instructions.sh +++ b/install/lib/instructions.sh @@ -22,8 +22,8 @@ print_missing_service_prerequisites() { warn "seismic-reth cannot start until its P2P key exists at $RETH_P2P_KEY_PATH." missing=true fi - if [[ ! -f "$SUMMIT_KEYS_DIR/node_key.pem" \ - || ! -f "$SUMMIT_KEYS_DIR/consensus_key.pem" ]]; then + if [[ ! -f "$SUMMIT_KEYS_DIR/node_key.pem" || + ! -f "$SUMMIT_KEYS_DIR/consensus_key.pem" ]]; then warn "Summit cannot start until node_key.pem and consensus_key.pem exist under $SUMMIT_KEYS_DIR." missing=true fi @@ -38,6 +38,9 @@ print_missing_service_prerequisites() { if ! security_error=$(check_service_executable_security "$CHECKPOINTER_TARGET_BIN"); then warn "summit-checkpointer cannot start until its executable is secured at $CHECKPOINTER_TARGET_BIN: $security_error." missing=true + elif ! check_checkpointer_cli_support; then + warn "summit-checkpointer cannot start until its executable supports --bind-address: $CHECKPOINTER_TARGET_BIN." + missing=true fi if ! security_error=$(check_service_executable_security "$MDBX_COPY_TARGET_BIN"); then warn "summit-checkpointer cannot start until a compatible mdbx_copy is secured at $MDBX_COPY_TARGET_BIN: $security_error." diff --git a/install/lib/keys.sh b/install/lib/keys.sh index 4c71fd1..d92d9e0 100644 --- a/install/lib/keys.sh +++ b/install/lib/keys.sh @@ -97,10 +97,10 @@ setup_summit_validator_keys() { die "Summit validator key generation failed; see $LOG_FILE" fi - if [[ ! -s "$staging/node_key.pem" \ - || ! -s "$staging/consensus_key.pem" \ - || -L "$staging/node_key.pem" \ - || -L "$staging/consensus_key.pem" ]]; then + if [[ ! -s "$staging/node_key.pem" || + ! -s "$staging/consensus_key.pem" || + -L "$staging/node_key.pem" || + -L "$staging/consensus_key.pem" ]]; then rm -rf -- "$staging" die "Summit reported success but did not produce a complete validator key set." fi diff --git a/install/lib/observer-configuration.sh b/install/lib/observer-configuration.sh new file mode 100644 index 0000000..8c26b67 --- /dev/null +++ b/install/lib/observer-configuration.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash + +# Observer-specific interactive configuration. + +validate_observer_socket_address() { + local value=$1 + + python3 - "$value" <<'PY' >/dev/null 2>&1 +import ipaddress +import sys + +value = sys.argv[1] +try: + if value.startswith("["): + end = value.index("]") + host = value[1:end] + if value[end + 1 : end + 2] != ":": + raise ValueError + port = int(value[end + 2 :]) + else: + host, raw_port = value.rsplit(":", 1) + port = int(raw_port) + ipaddress.ip_address(host) + if not 1 <= port <= 65535: + raise ValueError +except (ValueError, IndexError): + raise SystemExit(1) +PY +} + +normalize_observer_public_key() { + local value=$1 + + value=${value#0x} + printf '%s\n' "${value,,}" +} + +configure_observer_assignment() { + local selected + + section "Observer assignment configuration" + + while true; do + prompt OBSERVER_PARENT_NODE_PUBLIC_KEY \ + "Parent validator Summit node public key" "" + if [[ "$OBSERVER_PARENT_NODE_PUBLIC_KEY" =~ ^(0x)?[0-9a-fA-F]{64}$ ]]; then + OBSERVER_PARENT_NODE_PUBLIC_KEY=$(normalize_observer_public_key \ + "$OBSERVER_PARENT_NODE_PUBLIC_KEY") + break + fi + error "Parent node public key must be a 32-byte hexadecimal value." + done + + while true; do + prompt OBSERVER_INDEX "Observer derivation index" "0" + if [[ "$OBSERVER_INDEX" =~ ^[0-9]{1,3}$ ]] \ + && ((10#$OBSERVER_INDEX <= 255)); then + OBSERVER_INDEX=$((10#$OBSERVER_INDEX)) + break + fi + error "Observer index must be an integer from 0 through 255." + done + + while true; do + prompt OBSERVER_PUBLIC_ADDRESS \ + "Observer public Summit P2P address (IP:port; default Summit port 18551)" "" + if validate_observer_socket_address "$OBSERVER_PUBLIC_ADDRESS"; then + break + fi + error "Observer public address must be an IPv4:port or [IPv6]:port socket address." + done + OBSERVER_P2P_PORT=${OBSERVER_PUBLIC_ADDRESS##*:} + + OBSERVER_BOOTSTRAPPERS_SOURCE="" + while true; do + prompt selected "Summit bootstrappers TOML path (blank for none)" "" + if [[ -z "$selected" ]]; then + break + fi + if [[ "$selected" != /* ]]; then + error "Summit bootstrappers TOML path must be absolute." + continue + fi + if contains_unsafe_path_characters "$selected"; then + error "Summit bootstrappers TOML path contains unsupported whitespace or shell characters." + continue + fi + if [[ -L "$selected" || ! -s "$selected" ]]; then + error "Summit bootstrappers TOML must be a non-empty regular file and not a symbolic link." + continue + fi + selected=$(realpath -- "$selected") + if ! run_as_service_user test -r "$selected"; then + error "Service user $SERVICE_USER cannot read the Summit bootstrappers TOML: $selected" + continue + fi + OBSERVER_BOOTSTRAPPERS_SOURCE="$selected" + break + done + + _out "Observer assignment:" + _out " Parent key: $OBSERVER_PARENT_NODE_PUBLIC_KEY" + _out " Index: $OBSERVER_INDEX" + _out " Public P2P: $OBSERVER_PUBLIC_ADDRESS" + _out " Bootstrappers: ${OBSERVER_BOOTSTRAPPERS_SOURCE:-none}" + warn "The installer does not verify whether the parent is a genesis or current validator." +} + +configure_observer_directories() { + section "Observer data directory configuration" + + configure_directory \ + RETH_DATA_DIR \ + "Reth data directory" \ + "/persistence/reth" \ + "Stores the Reth execution database, static files, and other execution-layer state." + configure_directory \ + SUMMIT_DATA_DIR \ + "Summit data directory" \ + "/persistence/summit" \ + "Stores the observer's mutable Summit consensus state." + configure_directory \ + VALIDATOR_KEYS_DIR \ + "Observer keys directory" \ + "/persistence/keys" \ + "Stores the Reth P2P identity, observer consensus key, and provisioned parent node key." + + RETH_P2P_KEY_PATH="$VALIDATOR_KEYS_DIR/reth/p2p-key" + SUMMIT_KEYS_DIR="$VALIDATOR_KEYS_DIR/summit" + OBSERVER_ASSIGNMENT_FILE="$SUMMIT_KEYS_DIR/observer-assignment" + OBSERVER_BOOTSTRAPPERS_FILE="$SUMMIT_KEYS_DIR/bootstrappers.toml" + OBSERVER_STORE_DIR="$SUMMIT_DATA_DIR" + OBSERVER_CRITICAL_LOG_DIR="/var/log/seismic-observer/critical" +} + +print_observer_configuration_summary() { + section "Observer configuration summary" + + _out "Service user: $SERVICE_USER ($SERVICE_GROUP)" + _out "Service home: $SERVICE_HOME" + _out "Directories:" + _out " Reth data: $RETH_DATA_DIR" + _out " Summit data: $SUMMIT_DATA_DIR" + _out " Observer keys: $VALIDATOR_KEYS_DIR" + _out " Summit store: $OBSERVER_STORE_DIR" + + _out "Observer assignment:" + _out " Parent key: $OBSERVER_PARENT_NODE_PUBLIC_KEY" + _out " Index: $OBSERVER_INDEX" + _out " Public P2P: $OBSERVER_PUBLIC_ADDRESS" + _out " Bootstrappers: ${OBSERVER_BOOTSTRAPPERS_SOURCE:-none}" + + _out "Network bootstrap:" + _out " Genesis: $GENESIS_PATH" + _out " Reth bootnode RPC: ${BOOTNODE_RPC:-none}" + + _out "Public endpoint:" + if [[ "$CONFIGURE_PUBLIC_ENDPOINT" == true ]]; then + _out " https://$DOMAIN" + _out " Rate limit: $RATE_LIMIT_RPS requests/sec, burst $RATE_LIMIT_BURST" + _out " JWT secret: $OPENRESTY_JWT_SECRET_PATH (contents hidden)" + else + _out " Disabled" + fi + + _out "Node software:" + print_component_installation \ + "Summit" "$SUMMIT_INSTALL_METHOD" "$SUMMIT_BINARY" "$SUMMIT_TARGET_BIN" + print_component_installation \ + "Seismic Reth" "$RETH_INSTALL_METHOD" "$RETH_BINARY" "$RETH_TARGET_BIN" + + _out "Summit-checkpointer: $INSTALL_CHECKPOINTER" + if [[ "$INSTALL_CHECKPOINTER" == true ]]; then + print_component_installation \ + "Checkpointer" "$CHECKPOINTER_INSTALL_METHOD" \ + "$CHECKPOINTER_BINARY" "$CHECKPOINTER_TARGET_BIN" + _out " Config: $CHECKPOINTER_CONFIG_PATH" + print_mdbx_copy_plan + fi + + _out "Centralized Custodian: $INSTALL_CUSTODIAN" + if [[ "$INSTALL_CUSTODIAN" == true ]]; then + _out " Parent Custodian: $PARENT_CUSTODIAN" + _out " Root key: fetched and verified through the parent" + _out " Council: $COUNCIL_LISTEN ($COUNCIL_ADDRESS)" + _out " Chain ID: $CUSTODIAN_CHAIN_ID" + print_component_installation \ + "Custodian" "$CUSTODIAN_INSTALL_METHOD" \ + "$CUSTODIAN_BINARY" "$CUSTODIAN_TARGET_BIN" + if [[ "$CUSTODIAN_INSTALL_METHOD" == "source" ]]; then + _out " Source ref: $CUSTODIAN_SOURCE_REF" + fi + fi + + print_system_package_plan +} + +review_observer_configuration() { + local selection + + while true; do + print_observer_configuration_summary + _out "" + _out "What would you like to do?" + _out " 1) Edit service user" + _out " 2) Edit directories" + _out " 3) Edit public endpoint" + _out " 4) Edit network bootstrap" + _out " 5) Edit observer assignment" + _out " 6) Edit node software" + _out " 7) Edit summit-checkpointer" + _out " 8) Edit Centralized Custodian" + _out " 9) Accept configuration" + _out " 10) Cancel" + prompt selection "Select an action" "9" + + case "$selection" in + 1) configure_service_user ;; + 2) configure_observer_directories ;; + 3) configure_public_endpoint ;; + 4) configure_network_bootstrap ;; + 5) configure_observer_assignment ;; + 6) configure_node_software ;; + 7) configure_checkpointer ;; + 8) configure_custodian ;; + 9) + if ! validate_network_bootstrap_configuration; then + warn "Network bootstrap validation failed; please configure it again." + configure_network_bootstrap + continue + fi + if confirm "Accept this configuration?"; then + success "Configuration accepted" + return + fi + ;; + 10) + info "Configuration cancelled; no installation changes were made." + exit 0 + ;; + *) error "Select a number from 1 through 10." ;; + esac + done +} + +configure_observer() { + NODE_ROLE="observer" + section "Configuration" + configure_service_user + configure_observer_directories + configure_public_endpoint + configure_network_bootstrap + configure_observer_assignment + configure_node_software + configure_checkpointer + configure_custodian + review_observer_configuration +} diff --git a/install/lib/observer-directories.sh b/install/lib/observer-directories.sh new file mode 100644 index 0000000..f523368 --- /dev/null +++ b/install/lib/observer-directories.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +# Observer runtime directories in addition to the shared node directories. + +setup_observer_runtime_directories() { + setup_runtime_directories + + if [[ -L "$OBSERVER_CRITICAL_LOG_DIR" ]]; then + die "Observer critical-log directory must not be a symbolic link: $OBSERVER_CRITICAL_LOG_DIR" + fi + install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 \ + -- "$OBSERVER_CRITICAL_LOG_DIR" +} diff --git a/install/lib/observer-instructions.sh b/install/lib/observer-instructions.sh new file mode 100644 index 0000000..c1d1314 --- /dev/null +++ b/install/lib/observer-instructions.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash + +# Observer readiness notes and manual service-start commands. + +print_missing_observer_prerequisites() { + local missing=false + local security_error + + if ! security_error=$(check_service_executable_security "$RETH_TARGET_BIN"); then + warn "seismic-reth cannot start until its executable is secured at $RETH_TARGET_BIN: $security_error." + missing=true + fi + if ! security_error=$(check_service_executable_security "$SUMMIT_TARGET_BIN"); then + warn "Summit cannot start until its executable is secured at $SUMMIT_TARGET_BIN: $security_error." + missing=true + elif ! run_as_service_user "$SUMMIT_TARGET_BIN" run --help 2>&1 \ + | grep -q -- '--observer'; then + warn "Summit does not support the required --observer option: $SUMMIT_TARGET_BIN." + missing=true + fi + if [[ ! -f "$RETH_P2P_KEY_PATH" ]]; then + warn "seismic-reth cannot start until its P2P key exists at $RETH_P2P_KEY_PATH." + missing=true + fi + if [[ ! -f "$SUMMIT_KEYS_DIR/consensus_key.pem" ]]; then + warn "Summit cannot start until the observer consensus key exists at $SUMMIT_KEYS_DIR/consensus_key.pem." + missing=true + fi + if [[ ! -f "$SUMMIT_KEYS_DIR/node_key.pem" ]]; then + warn "Summit and observer Custodian cannot start until the parent node key is provisioned at $SUMMIT_KEYS_DIR/node_key.pem." + missing=true + fi + + if [[ "$INSTALL_CUSTODIAN" == true ]] \ + && ! security_error=$(check_service_executable_security "$CUSTODIAN_TARGET_BIN"); then + warn "Custodian cannot start until its executable is secured at $CUSTODIAN_TARGET_BIN: $security_error." + missing=true + fi + + if [[ "$INSTALL_CHECKPOINTER" == true ]]; then + if ! security_error=$(check_service_executable_security "$CHECKPOINTER_TARGET_BIN"); then + warn "summit-checkpointer cannot start until its executable is secured at $CHECKPOINTER_TARGET_BIN: $security_error." + missing=true + elif ! check_checkpointer_cli_support; then + warn "summit-checkpointer cannot start until its executable supports --bind-address: $CHECKPOINTER_TARGET_BIN." + missing=true + fi + if ! security_error=$(check_service_executable_security "$MDBX_COPY_TARGET_BIN"); then + warn "summit-checkpointer cannot start until a compatible mdbx_copy is secured at $MDBX_COPY_TARGET_BIN: $security_error." + missing=true + fi + fi + + if [[ "$missing" == true ]]; then + warn "Resolve the missing prerequisites above before running the start commands." + else + success "All enabled observer service executables and keys are present." + fi +} + +print_observer_manual_start_instructions() { + section "Manual observer service start" + print_missing_observer_prerequisites + + _out "The installer did not start, enable, reread, or update observer services." + _out "Before starting the observer, provide the parent validator's private node_key.pem at:" + _out " $SUMMIT_KEYS_DIR/node_key.pem" + _out "It must be owned by $SERVICE_USER:$SERVICE_GROUP with mode 0600." + _out "" + _out "After resolving all warnings, load the Supervisor configuration:" + _out "" + _out " sudo systemctl enable --now supervisor" + _out " sudo supervisorctl reread" + _out " sudo supervisorctl update" + _out "" + if [[ "$INSTALL_CUSTODIAN" == true ]]; then + _out "Start the observer Custodian first. It will fetch or verify its root key" + _out "through the parent Custodian at $PARENT_CUSTODIAN:" + _out " sudo supervisorctl start custodian" + fi + _out " sudo supervisorctl start reth" + _out " sudo supervisorctl start summit-observer" + if [[ "$INSTALL_CHECKPOINTER" == true ]]; then + _out " sudo supervisorctl start checkpointer" + fi + + if [[ "$CONFIGURE_PUBLIC_ENDPOINT" == true ]]; then + _out "" + _out "Start or reload OpenResty explicitly:" + _out " sudo systemctl enable openresty" + _out " if sudo systemctl is-active --quiet openresty; then" + _out " sudo systemctl reload openresty" + _out " else" + _out " sudo systemctl start openresty" + _out " fi" + fi + + _out "" + _out "Inspect status with:" + _out " sudo supervisorctl status" + _out "" + warn "Supervisor programs use autostart=false and autorestart=false." + warn "Start them manually again after a server or Supervisor restart." + if [[ "$INSTALL_CUSTODIAN" == true ]]; then + warn "This host needs outbound TCP access to parent Custodian $PARENT_CUSTODIAN." + warn "The parent Custodian firewall must allow this observer's source IP." + warn "Root-key and plaintext epoch-key material transit this connection; use a private network or TLS tunnel." + fi +} diff --git a/install/lib/observer-keys.sh b/install/lib/observer-keys.sh new file mode 100644 index 0000000..a60bab6 --- /dev/null +++ b/install/lib/observer-keys.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash + +# Observer key generation, assignment preservation, and bootstrapper configuration. + +read_observer_node_public_key() { + local output + local public_key + + output=$(run_as_service_user "$SUMMIT_TARGET_BIN" keys show \ + --key-store-path "$SUMMIT_KEYS_DIR" 2>>"$LOG_FILE") \ + || return 1 + public_key=$(printf '%s\n' "$output" \ + | awk -F': ' '/Node Public Key \(ed25519\)/ {print tolower($2); exit}') + public_key=${public_key#0x} + [[ "$public_key" =~ ^[0-9a-f]{64}$ ]] || return 1 + printf '%s\n' "$public_key" +} + +validate_observer_parent_node_key() { + local node_key="$SUMMIT_KEYS_DIR/node_key.pem" + local installed_public_key + + validate_summit_key_file "$node_key" "Observer parent Summit node key" + if [[ ! -x "$SUMMIT_TARGET_BIN" ]]; then + warn "The parent node key cannot be checked against its expected public key until Summit is installed." + return + fi + + installed_public_key=$(read_observer_node_public_key) \ + || die "Could not read the public key from the provisioned observer parent node key; see $LOG_FILE" + [[ "$installed_public_key" == "$OBSERVER_PARENT_NODE_PUBLIC_KEY" ]] \ + || die "Provisioned node_key.pem does not match parent public key $OBSERVER_PARENT_NODE_PUBLIC_KEY" + success "Provisioned observer parent node key matches the configured public key" +} + +write_observer_assignment() { + local expected="$OBSERVER_PARENT_NODE_PUBLIC_KEY:$OBSERVER_INDEX" + local staging + + staging=$(mktemp "$SUMMIT_KEYS_DIR/.observer-assignment.XXXXXX") + printf '%s\n' "$expected" >"$staging" + chown "$SERVICE_USER:$SERVICE_GROUP" "$staging" + chmod 0600 "$staging" + mv -- "$staging" "$OBSERVER_ASSIGNMENT_FILE" +} + +configure_observer_bootstrapper_file() { + local staging + + if [[ -z "$OBSERVER_BOOTSTRAPPERS_SOURCE" ]]; then + if [[ -L "$OBSERVER_BOOTSTRAPPERS_FILE" ]]; then + die "Observer bootstrapper configuration must not be a symbolic link: $OBSERVER_BOOTSTRAPPERS_FILE" + fi + rm -f -- "$OBSERVER_BOOTSTRAPPERS_FILE" + return + fi + + [[ ! -L "$OBSERVER_BOOTSTRAPPERS_SOURCE" && + -s "$OBSERVER_BOOTSTRAPPERS_SOURCE" ]] \ + || die "Summit bootstrappers source became invalid: $OBSERVER_BOOTSTRAPPERS_SOURCE" + run_as_service_user test -r "$OBSERVER_BOOTSTRAPPERS_SOURCE" \ + || die "Service user cannot read the Summit bootstrappers source: $OBSERVER_BOOTSTRAPPERS_SOURCE" + if [[ -L "$OBSERVER_BOOTSTRAPPERS_FILE" ]]; then + die "Observer bootstrapper configuration must not be a symbolic link: $OBSERVER_BOOTSTRAPPERS_FILE" + fi + + staging=$(mktemp "$SUMMIT_KEYS_DIR/.bootstrappers.toml.XXXXXX") + if ! cp -- "$OBSERVER_BOOTSTRAPPERS_SOURCE" "$staging"; then + rm -f -- "$staging" + die "Could not install the Summit bootstrappers TOML." + fi + chown "$SERVICE_USER:$SERVICE_GROUP" "$staging" + chmod 0640 "$staging" + mv -- "$staging" "$OBSERVER_BOOTSTRAPPERS_FILE" + success "Summit bootstrappers TOML installed: $OBSERVER_BOOTSTRAPPERS_FILE" +} + +setup_summit_observer_keys() { + local node_key="$SUMMIT_KEYS_DIR/node_key.pem" + local consensus_key="$SUMMIT_KEYS_DIR/consensus_key.pem" + local expected_assignment="$OBSERVER_PARENT_NODE_PUBLIC_KEY:$OBSERVER_INDEX" + local existing_assignment="" + local staging + + prepare_runtime_directory "$SUMMIT_KEYS_DIR" 0700 "Summit observer keys directory" + + if [[ -e "$OBSERVER_ASSIGNMENT_FILE" || -L "$OBSERVER_ASSIGNMENT_FILE" ]]; then + [[ ! -L "$OBSERVER_ASSIGNMENT_FILE" && -f "$OBSERVER_ASSIGNMENT_FILE" ]] \ + || die "Observer assignment marker must be a regular file: $OBSERVER_ASSIGNMENT_FILE" + existing_assignment=$(<"$OBSERVER_ASSIGNMENT_FILE") + [[ "$existing_assignment" == "$expected_assignment" ]] \ + || die "Observer keys are assigned to $existing_assignment, not $expected_assignment; use separate persistent paths for a different assignment." + chown "$SERVICE_USER:$SERVICE_GROUP" "$OBSERVER_ASSIGNMENT_FILE" + chmod 0600 "$OBSERVER_ASSIGNMENT_FILE" + elif [[ -e "$node_key" || -L "$node_key" || + -e "$consensus_key" || -L "$consensus_key" ]]; then + die "Summit keys exist without an observer assignment marker; refusing to reuse or replace them." + else + write_observer_assignment + fi + + if [[ -e "$consensus_key" || -L "$consensus_key" ]]; then + validate_summit_key_file "$consensus_key" "Observer Summit consensus key" + else + if [[ ! -f "$SUMMIT_TARGET_BIN" || ! -x "$SUMMIT_TARGET_BIN" ]]; then + warn "Observer consensus key was not generated because Summit is unavailable at $SUMMIT_TARGET_BIN." + warn "Install Summit and rerun the observer installer before provisioning the parent node key." + configure_observer_bootstrapper_file + return + fi + if ! run_as_service_user "$SUMMIT_TARGET_BIN" run --help 2>&1 \ + | grep -q -- '--observer'; then + die "Installed Summit binary does not support observer mode: $SUMMIT_TARGET_BIN" + fi + + staging=$(mktemp -d /run/seismic-observer-keys.XXXXXX) + chown "$SERVICE_USER:$SERVICE_GROUP" "$staging" + chmod 0700 "$staging" + info "Generating a fresh observer-only Summit consensus key..." + if ! run_as_service_user "$SUMMIT_TARGET_BIN" keys generate \ + --key-store-path "$staging" -y >>"$LOG_FILE" 2>&1; then + rm -rf -- "$staging" + die "Observer Summit consensus-key generation failed; see $LOG_FILE" + fi + if [[ ! -s "$staging/consensus_key.pem" || + -L "$staging/consensus_key.pem" ]]; then + rm -rf -- "$staging" + die "Summit did not produce an observer consensus key." + fi + install -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0600 \ + -- "$staging/consensus_key.pem" "$consensus_key" + rm -rf -- "$staging" + success "Observer Summit consensus key generated: $consensus_key" + fi + + if [[ -e "$node_key" || -L "$node_key" ]]; then + validate_observer_parent_node_key + else + warn "Parent node_key.pem is not installed yet. Provision it securely before starting the observer or observer Custodian." + fi + + configure_observer_bootstrapper_file +} + +setup_observer_keys() { + section "Setting up observer keys" + setup_reth_p2p_key + setup_summit_observer_keys + success "Observer key phase complete." +} diff --git a/install/lib/observer-supervisor.sh b/install/lib/observer-supervisor.sh new file mode 100644 index 0000000..b8404f9 --- /dev/null +++ b/install/lib/observer-supervisor.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash + +# Observer Supervisor rendering without service activation. + +SUPERVISOR_CONFIG_PATH="/etc/supervisor/conf.d/seismic-observer.conf" +SUPERVISOR_LOG_DIR="/var/log/seismic-observer" + +validate_observer_supervisor_templates() { + local template_root="$TEMPLATES_DIR/supervisor" + local required=( + "$template_root/reth.conf" + "$template_root/observer.conf" + "$template_root/observer-custodian.conf" + "$template_root/checkpointer.conf" + "$template_root/checkpointer.toml" + ) + local path + + for path in "${required[@]}"; do + [[ -f "$path" ]] || die "Required observer Supervisor template not found: $path" + done +} + +validate_observer_supervisor_runtime_inputs() { + validate_supervisor_runtime_inputs + + [[ -f "$OBSERVER_ASSIGNMENT_FILE" && ! -L "$OBSERVER_ASSIGNMENT_FILE" ]] \ + || die "Observer assignment marker is missing: $OBSERVER_ASSIGNMENT_FILE" + [[ "$(<"$OBSERVER_ASSIGNMENT_FILE")" == "$OBSERVER_PARENT_NODE_PUBLIC_KEY:$OBSERVER_INDEX" ]] \ + || die "Observer assignment marker does not match the accepted configuration." +} + +render_observer_supervisor_program() { + local program_name=$1 + local store_dir=$2 + local template="$TEMPLATES_DIR/supervisor/observer.conf" + local conf + local bootstrappers_argument="" + + if [[ -n "$OBSERVER_BOOTSTRAPPERS_SOURCE" ]]; then + bootstrappers_argument=" --bootstrappers $OBSERVER_BOOTSTRAPPERS_FILE" + fi + + conf=$(<"$template") + conf=${conf//OBSERVER_PROGRAM_PLACEHOLDER/$program_name} + conf=${conf//SUMMIT_BINARY_PLACEHOLDER/$SUMMIT_TARGET_BIN} + conf=${conf//OBSERVER_INDEX_PLACEHOLDER/$OBSERVER_INDEX} + conf=${conf//OBSERVER_PUBLIC_ADDRESS_PLACEHOLDER/$OBSERVER_PUBLIC_ADDRESS} + conf=${conf//OBSERVER_P2P_PORT_PLACEHOLDER/$OBSERVER_P2P_PORT} + conf=${conf//GENESIS_PATH_PLACEHOLDER/$GENESIS_PATH} + conf=${conf//SUMMIT_KEYS_DIR_PLACEHOLDER/$SUMMIT_KEYS_DIR} + conf=${conf//OBSERVER_STORE_DIR_PLACEHOLDER/$store_dir} + conf=${conf//BOOTSTRAPPERS_ARGUMENT_PLACEHOLDER/$bootstrappers_argument} + conf=${conf//OBSERVER_CRITICAL_LOG_DIR_PLACEHOLDER/$OBSERVER_CRITICAL_LOG_DIR} + conf=${conf//SERVICE_USER_PLACEHOLDER/$SERVICE_USER} + conf=${conf//SUPERVISOR_LOG_DIR_PLACEHOLDER/$SUPERVISOR_LOG_DIR} + printf '%s\n' "$conf" +} + +render_observer_custodian_supervisor_config() { + local template="$TEMPLATES_DIR/supervisor/observer-custodian.conf" + local conf + + conf=$(<"$template") + conf=${conf//CUSTODIAN_BINARY_PLACEHOLDER/$CUSTODIAN_TARGET_BIN} + conf=${conf//CUSTODIAN_SOCKET_PLACEHOLDER/$CUSTODIAN_SOCKET} + conf=${conf//CUSTODIAN_ROOT_KEY_PLACEHOLDER/$CUSTODIAN_DATA_DIR/root.key} + conf=${conf//CUSTODIAN_DELIVERY_DIR_PLACEHOLDER/$CUSTODIAN_DATA_DIR/deliveries} + conf=${conf//COUNCIL_LISTEN_PLACEHOLDER/$COUNCIL_LISTEN} + conf=${conf//COUNCIL_ADDRESS_PLACEHOLDER/$COUNCIL_ADDRESS} + conf=${conf//CUSTODIAN_CHAIN_ID_PLACEHOLDER/$CUSTODIAN_CHAIN_ID} + conf=${conf//OBSERVER_INDEX_PLACEHOLDER/$OBSERVER_INDEX} + conf=${conf//PARENT_CUSTODIAN_PLACEHOLDER/$PARENT_CUSTODIAN} + conf=${conf//SUMMIT_KEYS_DIR_PLACEHOLDER/$SUMMIT_KEYS_DIR} + conf=${conf//SERVICE_USER_PLACEHOLDER/$SERVICE_USER} + conf=${conf//SUPERVISOR_LOG_DIR_PLACEHOLDER/$SUPERVISOR_LOG_DIR} + printf '%s\n' "$conf" +} + +prepare_observer_supervisor_logs() { + local names=(reth summit-observer) + local name + + [[ "$INSTALL_CHECKPOINTER" != true ]] || names+=(checkpointer) + [[ "$INSTALL_CUSTODIAN" != true ]] || names+=(custodian) + + [[ ! -L "$SUPERVISOR_LOG_DIR" ]] \ + || die "Supervisor log directory must not be a symbolic link: $SUPERVISOR_LOG_DIR" + install -d -o root -g root -m 0755 "$SUPERVISOR_LOG_DIR" + for name in "${names[@]}"; do + [[ ! -L "$SUPERVISOR_LOG_DIR/$name.log" && + ! -L "$SUPERVISOR_LOG_DIR/$name.err" ]] \ + || die "Supervisor log files must not be symbolic links for service: $name" + touch "$SUPERVISOR_LOG_DIR/$name.log" "$SUPERVISOR_LOG_DIR/$name.err" + chown root:root "$SUPERVISOR_LOG_DIR/$name.log" "$SUPERVISOR_LOG_DIR/$name.err" + chmod 0644 "$SUPERVISOR_LOG_DIR/$name.log" "$SUPERVISOR_LOG_DIR/$name.err" + done +} + +deploy_observer_supervisor_configuration() { + local staging + + section "Deploying observer Supervisor configuration" + validate_observer_supervisor_runtime_inputs + validate_observer_supervisor_templates + + BOOTNODE_ENODE="" + if [[ -n "$BOOTNODE_RPC" ]] && ! fetch_bootnode_enode; then + die "Bootnode RPC validation failed after configuration acceptance." + fi + + replace_conflicting_supervisor_config + [[ ! -L "$SUPERVISOR_CONFIG_PATH" ]] \ + || die "Supervisor target must not be a symbolic link: $SUPERVISOR_CONFIG_PATH" + [[ ! -L "$CHECKPOINTER_CONFIG_PATH" ]] \ + || die "Checkpointer configuration target must not be a symbolic link: $CHECKPOINTER_CONFIG_PATH" + + staging=$(mktemp -d) + render_reth_supervisor_config >"$staging/seismic-observer.conf" + render_observer_supervisor_program \ + "summit-observer" "$OBSERVER_STORE_DIR" \ + >>"$staging/seismic-observer.conf" + + if [[ "$INSTALL_CHECKPOINTER" == true ]]; then + render_checkpointer_supervisor_config \ + >>"$staging/seismic-observer.conf" + render_checkpointer_toml >"$staging/summit-checkpointer.toml" + fi + if [[ "$INSTALL_CUSTODIAN" == true ]]; then + render_observer_custodian_supervisor_config \ + >>"$staging/seismic-observer.conf" + fi + + if grep -R -n '_PLACEHOLDER' "$staging" >>"$LOG_FILE" 2>&1; then + rm -rf -- "$staging" + die "Rendered observer Supervisor configuration still contains placeholders; see $LOG_FILE" + fi + + prepare_observer_supervisor_logs + if [[ "$INSTALL_CHECKPOINTER" == true ]]; then + prepare_checkpointer_config_parent + install -o root -g root -m 0644 \ + "$staging/summit-checkpointer.toml" "$CHECKPOINTER_CONFIG_PATH" + else + rm -f -- "$CHECKPOINTER_CONFIG_PATH" + fi + install -o root -g root -m 0644 \ + "$staging/seismic-observer.conf" "$SUPERVISOR_CONFIG_PATH" + rm -rf -- "$staging" + + success "Observer Supervisor configuration deployed: $SUPERVISOR_CONFIG_PATH" + info "Supervisor was not started, enabled, reread, or updated." +} diff --git a/install/lib/openresty.sh b/install/lib/openresty.sh index d7c7151..7067458 100644 --- a/install/lib/openresty.sh +++ b/install/lib/openresty.sh @@ -11,7 +11,10 @@ LUA_RESTY_JWT_RELEASE="v0.1.11" LUA_RESTY_JWT_REVISION="ee1d024071f872e2b5a66eaaf9aeaf86c5bab3ed" LUA_RESTY_AUTO_SSL_VERSION="0.13.1-1" -OPENRESTY_JWT_SECRET_PATH="/etc/seismic/openresty-jwt-secret" +DEFAULT_OPENRESTY_JWT_SECRET_PATH="/etc/seismic/openresty-jwt-secret" +OPENRESTY_JWT_SECRET_PATH_FILE="/etc/seismic/openresty-jwt-secret.path" +OPENRESTY_JWT_SECRET_PATH="$DEFAULT_OPENRESTY_JWT_SECRET_PATH" +PERSISTED_OPENRESTY_JWT_SECRET_PATH="" install_pinned_openresty_lua_library() { local description=$1 @@ -70,7 +73,7 @@ install_openresty() { printf '%s\n' \ "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/openresty.gpg] https://openresty.org/package/ubuntu $(lsb_release -sc) main" \ - > /etc/apt/sources.list.d/openresty.list + >/etc/apt/sources.list.d/openresty.list if ! apt-get update >>"$LOG_FILE" 2>&1; then die "apt-get update failed after adding the OpenResty repository; see $LOG_FILE" @@ -122,8 +125,8 @@ install_openresty() { "$LUA_RESTY_JWT_RELEASE" \ "$LUA_RESTY_JWT_REVISION" - if [[ ! -f /etc/ssl/resty-auto-ssl-fallback.crt \ - || ! -f /etc/ssl/resty-auto-ssl-fallback.key ]]; then + if [[ ! -f /etc/ssl/resty-auto-ssl-fallback.crt || + ! -f /etc/ssl/resty-auto-ssl-fallback.key ]]; then info "Generating the OpenResty fallback TLS certificate..." if ! openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \ -subj '/CN=sni-support-required-for-valid-ssl' \ @@ -141,31 +144,180 @@ install_openresty() { success "OpenResty dependencies are ready; configuration has not been deployed or started." } +load_persisted_openresty_jwt_secret_path() { + local owner_uid + local mode + local mode_value + local lines=() + local selected + local normalized + + PERSISTED_OPENRESTY_JWT_SECRET_PATH="" + if [[ ! -e "$OPENRESTY_JWT_SECRET_PATH_FILE" && ! -L "$OPENRESTY_JWT_SECRET_PATH_FILE" ]]; then + return 1 + fi + + [[ ! -L "$OPENRESTY_JWT_SECRET_PATH_FILE" ]] \ + || die "OpenResty JWT secret path metadata must not be a symbolic link: $OPENRESTY_JWT_SECRET_PATH_FILE" + [[ -f "$OPENRESTY_JWT_SECRET_PATH_FILE" ]] \ + || die "OpenResty JWT secret path metadata is not a regular file: $OPENRESTY_JWT_SECRET_PATH_FILE" + owner_uid=$(stat -c %u -- "$OPENRESTY_JWT_SECRET_PATH_FILE") \ + || die "Could not inspect OpenResty JWT secret path metadata ownership." + [[ "$owner_uid" == "0" ]] \ + || die "OpenResty JWT secret path metadata must be root-owned." + mode=$(stat -c %a -- "$OPENRESTY_JWT_SECRET_PATH_FILE") \ + || die "Could not inspect OpenResty JWT secret path metadata permissions." + mode_value=$((8#$mode)) + ((!(mode_value & 0022))) \ + || die "OpenResty JWT secret path metadata must not be group- or world-writable." + + mapfile -t lines <"$OPENRESTY_JWT_SECRET_PATH_FILE" + ((${#lines[@]} == 1)) \ + || die "OpenResty JWT secret path metadata must contain exactly one line." + selected=${lines[0]} + [[ "$selected" == /* && "$selected" != "/" ]] \ + || die "OpenResty JWT secret path metadata does not contain a valid absolute path." + normalized=$(realpath -m -- "$selected") + [[ "$normalized" == "$selected" ]] \ + || die "OpenResty JWT secret path metadata is not normalized: $selected" + + PERSISTED_OPENRESTY_JWT_SECRET_PATH=$selected + return 0 +} + +persist_openresty_jwt_secret_path() { + local parent + local resolved_parent + local current + local owner_uid + local mode + local mode_value + local staging + + parent=$(dirname -- "$OPENRESTY_JWT_SECRET_PATH_FILE") + resolved_parent=$(realpath -m -- "$parent") + [[ "$resolved_parent" == "$parent" ]] \ + || die "OpenResty JWT secret path metadata parent must not contain symbolic links: $parent" + + if [[ ! -e "$parent" ]]; then + install -d -o root -g root -m 0755 -- "$parent" + fi + [[ -d "$parent" && ! -L "$parent" ]] \ + || die "OpenResty JWT secret path metadata parent is not a safe directory: $parent" + + current=$parent + while true; do + [[ ! -L "$current" ]] \ + || die "OpenResty JWT secret path metadata parent chain contains a symbolic link: $current" + owner_uid=$(stat -c %u -- "$current") \ + || die "Could not inspect OpenResty JWT secret path metadata parent ownership: $current" + [[ "$owner_uid" == "0" ]] \ + || die "OpenResty JWT secret path metadata parent must be root-owned: $current" + mode=$(stat -c %a -- "$current") \ + || die "Could not inspect OpenResty JWT secret path metadata parent permissions: $current" + mode_value=$((8#$mode)) + ((!(mode_value & 0022))) \ + || die "OpenResty JWT secret path metadata parent must not be group- or world-writable: $current" + [[ "$current" == "/" ]] && break + current=$(dirname -- "$current") + done + + staging=$(mktemp "$parent/.openresty-jwt-secret-path.XXXXXX") + chmod 0600 "$staging" + printf '%s\n' "$OPENRESTY_JWT_SECRET_PATH" >"$staging" + chown root:root "$staging" + chmod 0644 "$staging" + mv -- "$staging" "$OPENRESTY_JWT_SECRET_PATH_FILE" +} + +prepare_openresty_jwt_secret_parent() { + local parent + local resolved_parent + local current + local owner_uid + local mode + local mode_value + + parent=$(dirname -- "$OPENRESTY_JWT_SECRET_PATH") + resolved_parent=$(realpath -m -- "$parent") + [[ "$resolved_parent" == "$parent" ]] \ + || die "OpenResty JWT secret parent must not contain symbolic links: $parent" + + if [[ ! -e "$parent" ]]; then + install -d -o root -g root -m 0755 -- "$parent" + fi + [[ -d "$parent" && ! -L "$parent" ]] \ + || die "OpenResty JWT secret parent is not a safe directory: $parent" + + current=$parent + while true; do + [[ ! -L "$current" ]] \ + || die "OpenResty JWT secret parent chain contains a symbolic link: $current" + owner_uid=$(stat -c %u -- "$current") \ + || die "Could not inspect OpenResty JWT secret parent ownership: $current" + [[ "$owner_uid" == "0" ]] \ + || die "OpenResty JWT secret parent must be root-owned: $current" + mode=$(stat -c %a -- "$current") \ + || die "Could not inspect OpenResty JWT secret parent permissions: $current" + mode_value=$((8#$mode)) + ((!(mode_value & 0022))) \ + || die "OpenResty JWT secret parent must not be group- or world-writable: $current" + [[ "$current" == "/" ]] && break + current=$(dirname -- "$current") + done + + command -v runuser >/dev/null 2>&1 \ + || die "runuser is required to validate OpenResty JWT secret access." + runuser -u nobody -- test -x "$parent" \ + || die "OpenResty worker user cannot traverse JWT secret parent: $parent" +} + setup_openresty_jwt_secret() { local legacy_lua="/usr/local/openresty/nginx/lua/jwt_auth.lua" local legacy_secret="" + local secret_parent local staging - install -d -o root -g root -m 0755 /etc/seismic + prepare_openresty_jwt_secret_parent + secret_parent=$(dirname -- "$OPENRESTY_JWT_SECRET_PATH") if [[ -e "$OPENRESTY_JWT_SECRET_PATH" || -L "$OPENRESTY_JWT_SECRET_PATH" ]]; then [[ ! -L "$OPENRESTY_JWT_SECRET_PATH" ]] \ || die "OpenResty JWT secret must not be a symbolic link: $OPENRESTY_JWT_SECRET_PATH" + [[ -f "$OPENRESTY_JWT_SECRET_PATH" ]] \ + || die "OpenResty JWT secret exists but is not a regular file: $OPENRESTY_JWT_SECRET_PATH" [[ -s "$OPENRESTY_JWT_SECRET_PATH" ]] \ || die "OpenResty JWT secret exists but is empty: $OPENRESTY_JWT_SECRET_PATH" chown root:nogroup "$OPENRESTY_JWT_SECRET_PATH" chmod 0640 "$OPENRESTY_JWT_SECRET_PATH" + runuser -u nobody -- test -r "$OPENRESTY_JWT_SECRET_PATH" \ + || die "OpenResty worker user cannot read the JWT secret." info "Reusing the existing OpenResty JWT secret." return fi - if [[ -f "$legacy_lua" && ! -L "$legacy_lua" ]]; then + if [[ "$OPENRESTY_JWT_SECRET_PATH" != "$DEFAULT_OPENRESTY_JWT_SECRET_PATH" ]] \ + && [[ -e "$DEFAULT_OPENRESTY_JWT_SECRET_PATH" || -L "$DEFAULT_OPENRESTY_JWT_SECRET_PATH" ]]; then + [[ ! -L "$DEFAULT_OPENRESTY_JWT_SECRET_PATH" ]] \ + || die "Default OpenResty JWT secret must not be a symbolic link: $DEFAULT_OPENRESTY_JWT_SECRET_PATH" + [[ -f "$DEFAULT_OPENRESTY_JWT_SECRET_PATH" ]] \ + || die "Default OpenResty JWT secret exists but is not a regular file: $DEFAULT_OPENRESTY_JWT_SECRET_PATH" + [[ -s "$DEFAULT_OPENRESTY_JWT_SECRET_PATH" ]] \ + || die "Default OpenResty JWT secret exists but is empty: $DEFAULT_OPENRESTY_JWT_SECRET_PATH" + elif [[ -f "$legacy_lua" && ! -L "$legacy_lua" ]]; then legacy_secret=$(sed -n 's/.*local JWT_SECRET = "\([^"]*\)".*/\1/p' "$legacy_lua" | head -n 1) fi - staging=$(mktemp /etc/seismic/.openresty-jwt-secret.XXXXXX) + staging=$(mktemp "$secret_parent/.openresty-jwt-secret.XXXXXX") chmod 0600 "$staging" - if [[ -n "$legacy_secret" ]]; then + if [[ "$OPENRESTY_JWT_SECRET_PATH" != "$DEFAULT_OPENRESTY_JWT_SECRET_PATH" ]] \ + && [[ -s "$DEFAULT_OPENRESTY_JWT_SECRET_PATH" ]]; then + if ! cp -- "$DEFAULT_OPENRESTY_JWT_SECRET_PATH" "$staging"; then + rm -f -- "$staging" + die "Could not copy the existing default JWT secret to the selected path." + fi + info "Copying the existing default JWT secret to the selected path." + elif [[ -n "$legacy_secret" ]]; then printf '%s\n' "$legacy_secret" >"$staging" info "Migrating the existing embedded OpenResty JWT secret." elif ! openssl rand -base64 32 >"$staging"; then @@ -182,6 +334,8 @@ setup_openresty_jwt_secret() { chown root:nogroup "$staging" chmod 0640 "$staging" mv -- "$staging" "$OPENRESTY_JWT_SECRET_PATH" + runuser -u nobody -- test -r "$OPENRESTY_JWT_SECRET_PATH" \ + || die "OpenResty worker user cannot read the installed JWT secret." success "OpenResty JWT secret stored securely; its value was not logged." } @@ -221,7 +375,9 @@ deploy_openresty_configuration() { -e "s|RATE_LIMIT_RPS_PLACEHOLDER|$RATE_LIMIT_RPS|g" \ -e "s|RATE_LIMIT_BURST_PLACEHOLDER|$RATE_LIMIT_BURST|g" \ "$template_root/lua/rate_limit.lua" >"$staging/rate_limit.lua" \ - || ! cp "$template_root/lua/jwt_auth.lua" "$staging/jwt_auth.lua"; then + || ! sed \ + "s|OPENRESTY_JWT_SECRET_PATH_PLACEHOLDER|$OPENRESTY_JWT_SECRET_PATH|g" \ + "$template_root/lua/jwt_auth.lua" >"$staging/jwt_auth.lua"; then rm -rf -- "$staging" die "Could not render the OpenResty configuration templates." fi @@ -260,6 +416,7 @@ deploy_openresty_configuration() { die "Installed OpenResty configuration validation failed; see $LOG_FILE" fi + persist_openresty_jwt_secret_path success "OpenResty configuration deployed for https://$DOMAIN." info "OpenResty was not started, enabled, or reloaded." } diff --git a/install/lib/packages.sh b/install/lib/packages.sh index 2d4bc95..2936776 100644 --- a/install/lib/packages.sh +++ b/install/lib/packages.sh @@ -8,25 +8,34 @@ collect_system_packages() { local needs_git=false SYSTEM_PACKAGES=( - supervisor curl jq ca-certificates openssl ) - if [[ "$SUMMIT_INSTALL_METHOD" == "source" \ - || "$RETH_INSTALL_METHOD" == "source" \ - || ("$INSTALL_CHECKPOINTER" == true \ - && "$CHECKPOINTER_INSTALL_METHOD" == "source") \ - || "$INSTALL_CUSTODIAN" == true ]]; then + # Preserve the package and daemon lifecycle on installer reruns. Installing + # Supervisor for the first time is safe because Seismic program files have + # not been deployed yet; an already-installed package is deliberately not + # upgraded or reinstalled here. + if ! dpkg-query -W -f='${Status}' supervisor 2>/dev/null \ + | grep -q '^install ok installed$'; then + SYSTEM_PACKAGES=(supervisor "${SYSTEM_PACKAGES[@]}") + fi + + if [[ "$SUMMIT_INSTALL_METHOD" == "source" || + "$RETH_INSTALL_METHOD" == "source" || + ("$INSTALL_CHECKPOINTER" == true && + "$CHECKPOINTER_INSTALL_METHOD" == "source") || + ("$INSTALL_CUSTODIAN" == true && + "$CUSTODIAN_INSTALL_METHOD" == "source") ]]; then needs_source_build=true needs_git=true fi - if [[ "$INSTALL_CHECKPOINTER" == true \ - && ("$RETH_INSTALL_METHOD" == "source" \ - || -n "$RETH_SOURCE_DIR_FOR_MDBX") ]]; then + if [[ "$INSTALL_CHECKPOINTER" == true && + ("$RETH_INSTALL_METHOD" == "source" || + -n "$RETH_SOURCE_DIR_FOR_MDBX") ]]; then needs_mdbx_build=true fi diff --git a/install/lib/supervisor.sh b/install/lib/supervisor.sh index 5439eae..cf92373 100644 --- a/install/lib/supervisor.sh +++ b/install/lib/supervisor.sh @@ -71,7 +71,8 @@ validate_supervisor_runtime_inputs() { validate_supervisor_templates() { local template_root="$TEMPLATES_DIR/supervisor" local required=( - "$template_root/validator.conf" + "$template_root/reth.conf" + "$template_root/summit-validator.conf" "$template_root/deposit-rpc.conf" "$template_root/checkpointer.conf" "$template_root/custodian.conf" @@ -84,13 +85,13 @@ validate_supervisor_templates() { done } -render_validator_supervisor_config() { - local template="$TEMPLATES_DIR/supervisor/validator.conf" +render_reth_supervisor_config() { + local template="$TEMPLATES_DIR/supervisor/reth.conf" local conf local bootnode_argument="" local purpose_key_arguments="--seismic.purpose-keys-source built-in" local rpc_bind="127.0.0.1" - local summit_bind="127.0.0.1" + [[ -z "$BOOTNODE_ENODE" ]] \ || bootnode_argument="--bootnodes $BOOTNODE_ENODE" if [[ "$INSTALL_CUSTODIAN" == true ]]; then @@ -105,6 +106,17 @@ render_validator_supervisor_config() { conf=${conf//RETH_DATA_DIR_PLACEHOLDER/$RETH_DATA_DIR} conf=${conf//PURPOSE_KEYS_ARGUMENTS_PLACEHOLDER/$purpose_key_arguments} conf=${conf//BOOTNODE_ARGUMENT_PLACEHOLDER/$bootnode_argument} + conf=${conf//SERVICE_USER_PLACEHOLDER/$SERVICE_USER} + conf=${conf//SUPERVISOR_LOG_DIR_PLACEHOLDER/$SUPERVISOR_LOG_DIR} + printf '%s\n' "$conf" +} + +render_summit_validator_supervisor_config() { + local template="$TEMPLATES_DIR/supervisor/summit-validator.conf" + local conf + local summit_bind="127.0.0.1" + + conf=$(<"$template") conf=${conf//SUMMIT_BINARY_PLACEHOLDER/$SUMMIT_TARGET_BIN} conf=${conf//GENESIS_PATH_PLACEHOLDER/$GENESIS_PATH} conf=${conf//SUMMIT_KEYS_DIR_PLACEHOLDER/$SUMMIT_KEYS_DIR} @@ -116,6 +128,11 @@ render_validator_supervisor_config() { printf '%s\n' "$conf" } +render_validator_supervisor_config() { + render_reth_supervisor_config + render_summit_validator_supervisor_config +} + render_deposit_rpc_supervisor_config() { local template="$TEMPLATES_DIR/supervisor/deposit-rpc.conf" local conf @@ -153,6 +170,7 @@ render_custodian_supervisor_config() { conf=${conf//COUNCIL_LISTEN_PLACEHOLDER/$COUNCIL_LISTEN} conf=${conf//COUNCIL_ADDRESS_PLACEHOLDER/$COUNCIL_ADDRESS} conf=${conf//CUSTODIAN_CHAIN_ID_PLACEHOLDER/$CUSTODIAN_CHAIN_ID} + conf=${conf//SUMMIT_KEYS_DIR_PLACEHOLDER/$SUMMIT_KEYS_DIR} conf=${conf//SERVICE_USER_PLACEHOLDER/$SERVICE_USER} conf=${conf//SUPERVISOR_LOG_DIR_PLACEHOLDER/$SUPERVISOR_LOG_DIR} printf '%s\n' "$conf" @@ -172,16 +190,81 @@ render_checkpointer_toml() { } find_conflicting_supervisor_config() { + local config_dir local path + local found=false + config_dir=$(dirname -- "$SUPERVISOR_CONFIG_PATH") while IFS= read -r -d '' path; do [[ "$path" == "$SUPERVISOR_CONFIG_PATH" ]] && continue - if grep -Eq '^\[program:(reth|summit|summit-deposit-rpc|checkpointer|custodian)\]' "$path"; then + if grep -Eq '^\[program:(reth|summit|summit-observer|summit-observer-checkpoint|summit-deposit-rpc|checkpointer|custodian)\]' "$path"; then printf '%s\n' "$path" - return 0 + found=true fi - done < <(find /etc/supervisor/conf.d -maxdepth 1 -type f -print0) - return 1 + done < <(find "$config_dir" -maxdepth 1 -type f -print0) + + [[ "$found" == true ]] +} + +replace_conflicting_supervisor_config() { + local conflicts=() + local path + + mapfile -t conflicts < <(find_conflicting_supervisor_config || true) + ((${#conflicts[@]} > 0)) || return 0 + + warn "Existing Supervisor files define Seismic services:" + for path in "${conflicts[@]}"; do + warn " $path" + done + + if ! confirm "Replace these files with $SUPERVISOR_CONFIG_PATH?"; then + die "Existing Supervisor configuration was not replaced." + fi + + for path in "${conflicts[@]}"; do + [[ ! -L "$path" && -f "$path" ]] \ + || die "Conflicting Supervisor path changed before replacement: $path" + info "Removing conflicting Supervisor configuration: $path" + rm -f -- "$path" + done +} + +prepare_checkpointer_config_parent() { + local parent + local resolved_parent + local current + local owner_uid + local mode + local mode_value + + parent=$(dirname -- "$CHECKPOINTER_CONFIG_PATH") + resolved_parent=$(realpath -m -- "$parent") + [[ "$resolved_parent" == "$parent" ]] \ + || die "Checkpointer configuration parent must not contain symbolic links: $parent" + + if [[ ! -e "$parent" ]]; then + install -d -o root -g root -m 0755 -- "$parent" + fi + [[ -d "$parent" && ! -L "$parent" ]] \ + || die "Checkpointer configuration parent is not a safe directory: $parent" + + current=$parent + while true; do + [[ ! -L "$current" ]] \ + || die "Checkpointer configuration parent chain contains a symbolic link: $current" + owner_uid=$(stat -c %u -- "$current") \ + || die "Could not inspect checkpointer configuration parent ownership: $current" + [[ "$owner_uid" == "0" ]] \ + || die "Checkpointer configuration parent must be root-owned: $current" + mode=$(stat -c %a -- "$current") \ + || die "Could not inspect checkpointer configuration parent permissions: $current" + mode_value=$((8#$mode)) + ((!(mode_value & 0022))) \ + || die "Checkpointer configuration parent must not be group- or world-writable: $current" + [[ "$current" == "/" ]] && break + current=$(dirname -- "$current") + done } prepare_supervisor_logs() { @@ -195,8 +278,8 @@ prepare_supervisor_logs() { || die "Supervisor log directory must not be a symbolic link: $SUPERVISOR_LOG_DIR" install -d -o root -g root -m 0755 "$SUPERVISOR_LOG_DIR" for name in "${names[@]}"; do - [[ ! -L "$SUPERVISOR_LOG_DIR/$name.log" \ - && ! -L "$SUPERVISOR_LOG_DIR/$name.err" ]] \ + [[ ! -L "$SUPERVISOR_LOG_DIR/$name.log" && + ! -L "$SUPERVISOR_LOG_DIR/$name.err" ]] \ || die "Supervisor log files must not be symbolic links for service: $name" touch "$SUPERVISOR_LOG_DIR/$name.log" "$SUPERVISOR_LOG_DIR/$name.err" chown root:root "$SUPERVISOR_LOG_DIR/$name.log" "$SUPERVISOR_LOG_DIR/$name.err" @@ -206,7 +289,6 @@ prepare_supervisor_logs() { deploy_supervisor_configuration() { local staging - local conflict section "Deploying Supervisor configuration" validate_supervisor_runtime_inputs @@ -217,9 +299,7 @@ deploy_supervisor_configuration() { die "Bootnode RPC validation failed after configuration acceptance." fi - conflict=$(find_conflicting_supervisor_config || true) - [[ -z "$conflict" ]] \ - || die "Another Supervisor file already defines Seismic services: $conflict" + replace_conflicting_supervisor_config [[ ! -L "$SUPERVISOR_CONFIG_PATH" ]] \ || die "Supervisor target must not be a symbolic link: $SUPERVISOR_CONFIG_PATH" [[ ! -L "$CHECKPOINTER_CONFIG_PATH" ]] \ @@ -227,7 +307,8 @@ deploy_supervisor_configuration() { staging=$(mktemp -d) render_deposit_rpc_supervisor_config >"$staging/seismic-validator.conf" - render_validator_supervisor_config >>"$staging/seismic-validator.conf" + render_reth_supervisor_config >>"$staging/seismic-validator.conf" + render_summit_validator_supervisor_config >>"$staging/seismic-validator.conf" if [[ "$INSTALL_CHECKPOINTER" == true ]]; then render_checkpointer_supervisor_config \ >>"$staging/seismic-validator.conf" @@ -244,8 +325,8 @@ deploy_supervisor_configuration() { fi prepare_supervisor_logs - install -d -o root -g root -m 0755 /etc/seismic if [[ "$INSTALL_CHECKPOINTER" == true ]]; then + prepare_checkpointer_config_parent install -o root -g root -m 0644 \ "$staging/summit-checkpointer.toml" "$CHECKPOINTER_CONFIG_PATH" else diff --git a/install/templates/openresty/lua/jwt_auth.lua b/install/templates/openresty/lua/jwt_auth.lua index e648296..375bd1c 100644 --- a/install/templates/openresty/lua/jwt_auth.lua +++ b/install/templates/openresty/lua/jwt_auth.lua @@ -1,6 +1,6 @@ local jwt = require "resty.jwt" -local secret_file, secret_error = io.open("/etc/seismic/openresty-jwt-secret", "r") +local secret_file, secret_error = io.open("OPENRESTY_JWT_SECRET_PATH_PLACEHOLDER", "r") if not secret_file then ngx.log(ngx.ERR, "could not open JWT secret: ", secret_error) return ngx.exit(500) diff --git a/install/templates/openresty/nginx.conf b/install/templates/openresty/nginx.conf index 356f617..2c08c86 100644 --- a/install/templates/openresty/nginx.conf +++ b/install/templates/openresty/nginx.conf @@ -90,6 +90,23 @@ http { proxy_pass http://localhost:8552; } + # Summit checkpointer RPC and snapshot downloads + # (rate limited + JWT protected) + location ~ ^/checkpointer(?:/|$) { + access_by_lua_block { + local rate_limit = loadfile("/usr/local/openresty/nginx/lua/rate_limit.lua") + rate_limit() + local jwt_auth = loadfile("/usr/local/openresty/nginx/lua/jwt_auth.lua") + jwt_auth() + } + rewrite ^/checkpointer/?(.*)$ /$1 break; + proxy_set_header Authorization ""; + proxy_buffering off; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + proxy_pass http://127.0.0.1:42069; + } + # Prometheus Summit (rate limited + JWT protected) location /prom-summit { access_by_lua_block { diff --git a/install/templates/supervisor/checkpointer.conf b/install/templates/supervisor/checkpointer.conf index bb482ee..656cc51 100644 --- a/install/templates/supervisor/checkpointer.conf +++ b/install/templates/supervisor/checkpointer.conf @@ -1,5 +1,5 @@ [program:checkpointer] -command=CHECKPOINTER_BINARY_PLACEHOLDER --config CHECKPOINTER_CONFIG_PLACEHOLDER +command=CHECKPOINTER_BINARY_PLACEHOLDER --config CHECKPOINTER_CONFIG_PLACEHOLDER --bind-address 127.0.0.1 --port 42069 user=SERVICE_USER_PLACEHOLDER autostart=false autorestart=false diff --git a/install/templates/supervisor/custodian.conf b/install/templates/supervisor/custodian.conf index 1f7309b..6929967 100644 --- a/install/templates/supervisor/custodian.conf +++ b/install/templates/supervisor/custodian.conf @@ -6,6 +6,7 @@ command=CUSTODIAN_BINARY_PLACEHOLDER --council-listen COUNCIL_LISTEN_PLACEHOLDER --council-address COUNCIL_ADDRESS_PLACEHOLDER --chain-id CUSTODIAN_CHAIN_ID_PLACEHOLDER + --summit-key-dir SUMMIT_KEYS_DIR_PLACEHOLDER --allow SERVICE_USER_PLACEHOLDER:tx-io,rng user=SERVICE_USER_PLACEHOLDER autostart=false diff --git a/install/templates/supervisor/observer-custodian.conf b/install/templates/supervisor/observer-custodian.conf new file mode 100644 index 0000000..eccc0c2 --- /dev/null +++ b/install/templates/supervisor/observer-custodian.conf @@ -0,0 +1,25 @@ +[program:custodian] +command=CUSTODIAN_BINARY_PLACEHOLDER + --socket CUSTODIAN_SOCKET_PLACEHOLDER + --root-key-file CUSTODIAN_ROOT_KEY_PLACEHOLDER + --delivery-dir CUSTODIAN_DELIVERY_DIR_PLACEHOLDER + --council-listen COUNCIL_LISTEN_PLACEHOLDER + --council-address COUNCIL_ADDRESS_PLACEHOLDER + --chain-id CUSTODIAN_CHAIN_ID_PLACEHOLDER + --observer OBSERVER_INDEX_PLACEHOLDER + --parent-custodian PARENT_CUSTODIAN_PLACEHOLDER + --summit-key-dir SUMMIT_KEYS_DIR_PLACEHOLDER + --allow SERVICE_USER_PLACEHOLDER:tx-io,rng +user=SERVICE_USER_PLACEHOLDER +autostart=false +autorestart=false +startsecs=3 +priority=100 +stopasgroup=true +killasgroup=true +stdout_logfile=SUPERVISOR_LOG_DIR_PLACEHOLDER/custodian.log +stderr_logfile=SUPERVISOR_LOG_DIR_PLACEHOLDER/custodian.err +stdout_logfile_maxbytes=200MB +stderr_logfile_maxbytes=200MB +stdout_logfile_backups=5 +stderr_logfile_backups=5 diff --git a/install/templates/supervisor/observer.conf b/install/templates/supervisor/observer.conf new file mode 100644 index 0000000..2b654a9 --- /dev/null +++ b/install/templates/supervisor/observer.conf @@ -0,0 +1,30 @@ +[program:OBSERVER_PROGRAM_PLACEHOLDER] +command=SUMMIT_BINARY_PLACEHOLDER + run + --observer OBSERVER_INDEX_PLACEHOLDER + --ip OBSERVER_PUBLIC_ADDRESS_PLACEHOLDER + --port OBSERVER_P2P_PORT_PLACEHOLDER + --genesis-path GENESIS_PATH_PLACEHOLDER + --key-store-path SUMMIT_KEYS_DIR_PLACEHOLDER + --store-path OBSERVER_STORE_DIR_PLACEHOLDER + --engine-ipc-path /tmp/reth_engine_api.ipc + --rpc-ip 127.0.0.1 + --rpc-port 3030 + --admin-rpc-port 3031 + --prom-ip 127.0.0.1 + --prom-port 9090 +BOOTSTRAPPERS_ARGUMENT_PLACEHOLDER + --critical-log-dir OBSERVER_CRITICAL_LOG_DIR_PLACEHOLDER +user=SERVICE_USER_PLACEHOLDER +autostart=false +autorestart=false +startsecs=3 +priority=300 +stopasgroup=true +killasgroup=true +stdout_logfile=SUPERVISOR_LOG_DIR_PLACEHOLDER/OBSERVER_PROGRAM_PLACEHOLDER.log +stderr_logfile=SUPERVISOR_LOG_DIR_PLACEHOLDER/OBSERVER_PROGRAM_PLACEHOLDER.err +stdout_logfile_maxbytes=200MB +stderr_logfile_maxbytes=200MB +stdout_logfile_backups=5 +stderr_logfile_backups=5 diff --git a/install/templates/supervisor/validator.conf b/install/templates/supervisor/reth.conf similarity index 62% rename from install/templates/supervisor/validator.conf rename to install/templates/supervisor/reth.conf index 74cb726..e8a8b87 100644 --- a/install/templates/supervisor/validator.conf +++ b/install/templates/supervisor/reth.conf @@ -18,7 +18,6 @@ command=RETH_BINARY_PLACEHOLDER --ipcdisable --auth-ipc --auth-ipc.path /tmp/reth_engine_api.ipc - --rpc.enable-storage-apis PURPOSE_KEYS_ARGUMENTS_PLACEHOLDER --disable-discv4-discovery --disable-dns-discovery @@ -41,25 +40,3 @@ stdout_logfile_maxbytes=200MB stderr_logfile_maxbytes=200MB stdout_logfile_backups=5 stderr_logfile_backups=5 - -[program:summit] -command=SUMMIT_BINARY_PLACEHOLDER - run - --genesis-path GENESIS_PATH_PLACEHOLDER - --key-store-path SUMMIT_KEYS_DIR_PLACEHOLDER - --store-path SUMMIT_DATA_DIR_PLACEHOLDER - --rpc-ip SUMMIT_RPC_BIND_PLACEHOLDER - --prom-ip SUMMIT_PROM_BIND_PLACEHOLDER -user=SERVICE_USER_PLACEHOLDER -autostart=false -autorestart=false -startsecs=3 -priority=300 -stopasgroup=true -killasgroup=true -stdout_logfile=SUPERVISOR_LOG_DIR_PLACEHOLDER/summit.log -stderr_logfile=SUPERVISOR_LOG_DIR_PLACEHOLDER/summit.err -stdout_logfile_maxbytes=200MB -stderr_logfile_maxbytes=200MB -stdout_logfile_backups=5 -stderr_logfile_backups=5 diff --git a/install/templates/supervisor/summit-validator.conf b/install/templates/supervisor/summit-validator.conf new file mode 100644 index 0000000..3a4bada --- /dev/null +++ b/install/templates/supervisor/summit-validator.conf @@ -0,0 +1,21 @@ +[program:summit] +command=SUMMIT_BINARY_PLACEHOLDER + run + --genesis-path GENESIS_PATH_PLACEHOLDER + --key-store-path SUMMIT_KEYS_DIR_PLACEHOLDER + --store-path SUMMIT_DATA_DIR_PLACEHOLDER + --rpc-ip SUMMIT_RPC_BIND_PLACEHOLDER + --prom-ip SUMMIT_PROM_BIND_PLACEHOLDER +user=SERVICE_USER_PLACEHOLDER +autostart=false +autorestart=false +startsecs=3 +priority=300 +stopasgroup=true +killasgroup=true +stdout_logfile=SUPERVISOR_LOG_DIR_PLACEHOLDER/summit.log +stderr_logfile=SUPERVISOR_LOG_DIR_PLACEHOLDER/summit.err +stdout_logfile_maxbytes=200MB +stderr_logfile_maxbytes=200MB +stdout_logfile_backups=5 +stderr_logfile_backups=5 diff --git a/tools/generate-openresty-jwt.sh b/tools/generate-openresty-jwt.sh new file mode 100755 index 0000000..20e067f --- /dev/null +++ b/tools/generate-openresty-jwt.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +DEFAULT_SECRET_PATH="/etc/seismic/openresty-jwt-secret" +SECRET_PATH_FILE=${OPENRESTY_JWT_SECRET_PATH_FILE:-/etc/seismic/openresty-jwt-secret.path} +SECRET_PATH="" +TTL_SECONDS=3600 +SUBJECT="seismic-operator" + +usage() { + cat <&2 + exit 1 +} + +contains_control_characters() { + [[ "$1" =~ [[:cntrl:]] ]] +} + +validate_root_owned_nonwritable_file() { + local description=$1 + local path=$2 + local owner_uid + local mode + local mode_value + + [[ ! -L "$path" ]] || die "$description must not be a symbolic link: $path" + [[ -f "$path" ]] || die "$description is not a regular file: $path" + owner_uid=$(stat -c %u -- "$path") || die "Could not inspect $description ownership." + [[ "$owner_uid" == "0" ]] || die "$description must be root-owned: $path" + mode=$(stat -c %a -- "$path") || die "Could not inspect $description permissions." + mode_value=$((8#$mode)) + ((!(mode_value & 0022))) || die "$description must not be group- or world-writable: $path" +} + +read_installed_secret_path() { + local lines=() + local selected + + validate_root_owned_nonwritable_file "JWT secret path metadata" "$SECRET_PATH_FILE" + mapfile -t lines <"$SECRET_PATH_FILE" + ((${#lines[@]} == 1)) || die "JWT secret path metadata must contain exactly one line." + selected=${lines[0]} + [[ "$selected" == /* && "$selected" != "/" ]] \ + || die "JWT secret path metadata does not contain a valid absolute path." + [[ $(realpath -m -- "$selected") == "$selected" ]] \ + || die "JWT secret path metadata is not normalized: $selected" + SECRET_PATH=$selected +} + +validate_secret_file_security() { + local mode + local mode_value + + validate_root_owned_nonwritable_file "JWT secret" "$SECRET_PATH" + mode=$(stat -c %a -- "$SECRET_PATH") || die "Could not inspect JWT secret permissions." + mode_value=$((8#$mode)) + ((!(mode_value & 0007))) \ + || die "JWT secret must not be accessible by users outside its owner and group: $SECRET_PATH" +} + +validate_secret_parent_chain() { + local parent + local resolved_parent + local current + local owner_uid + local mode + local mode_value + + parent=$(dirname -- "$SECRET_PATH") + resolved_parent=$(realpath -m -- "$parent") + [[ "$resolved_parent" == "$parent" ]] \ + || die "JWT secret parent must not contain symbolic links: $parent" + + current=$parent + while true; do + [[ -d "$current" && ! -L "$current" ]] \ + || die "JWT secret parent chain contains an unsafe directory: $current" + owner_uid=$(stat -c %u -- "$current") \ + || die "Could not inspect JWT secret parent ownership: $current" + [[ "$owner_uid" == "0" ]] \ + || die "JWT secret parent must be root-owned: $current" + mode=$(stat -c %a -- "$current") \ + || die "Could not inspect JWT secret parent permissions: $current" + mode_value=$((8#$mode)) + ((!(mode_value & 0022))) \ + || die "JWT secret parent must not be group- or world-writable: $current" + [[ "$current" == "/" ]] && break + current=$(dirname -- "$current") + done +} + +while (($# > 0)); do + case "$1" in + --secret-path) + (($# >= 2)) || die "--secret-path requires a value." + SECRET_PATH=$2 + shift 2 + ;; + --ttl-seconds) + (($# >= 2)) || die "--ttl-seconds requires a value." + TTL_SECONDS=$2 + shift 2 + ;; + --subject) + (($# >= 2)) || die "--subject requires a value." + SUBJECT=$2 + shift 2 + ;; + --help | -h) + usage + exit 0 + ;; + *) + die "Unknown option: $1" + ;; + esac +done + +command -v python3 >/dev/null 2>&1 || die "python3 is required." +command -v realpath >/dev/null 2>&1 || die "realpath is required." + +[[ "$TTL_SECONDS" =~ ^[1-9][0-9]*$ ]] \ + || die "--ttl-seconds must be a positive integer." +((TTL_SECONDS <= 86400)) || die "--ttl-seconds must not exceed 86400." +[[ -n "$SUBJECT" ]] || die "--subject must not be empty." +((${#SUBJECT} <= 128)) || die "--subject must not exceed 128 characters." +contains_control_characters "$SUBJECT" && die "--subject must not contain control characters." + +if [[ -z "$SECRET_PATH" ]]; then + if [[ -e "$SECRET_PATH_FILE" || -L "$SECRET_PATH_FILE" ]]; then + read_installed_secret_path + else + SECRET_PATH=$DEFAULT_SECRET_PATH + fi +fi + +[[ "$SECRET_PATH" == /* && "$SECRET_PATH" != "/" ]] \ + || die "JWT secret path must be an absolute file path." +NORMALIZED_SECRET_PATH=$(realpath -m -- "$SECRET_PATH") +[[ "$NORMALIZED_SECRET_PATH" == "$SECRET_PATH" ]] \ + || die "JWT secret path must be normalized and must not contain symbolic links: $SECRET_PATH" +validate_secret_parent_chain +validate_secret_file_security +[[ -s "$SECRET_PATH" ]] || die "JWT secret is empty: $SECRET_PATH" +[[ -r "$SECRET_PATH" ]] \ + || die "JWT secret is not readable; run this command with sudo: $SECRET_PATH" + +python3 - "$SECRET_PATH" "$TTL_SECONDS" "$SUBJECT" <<'PY' +import base64 +import hashlib +import hmac +import json +import sys +import time +from pathlib import Path + + +def base64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +secret_path = Path(sys.argv[1]) +ttl_seconds = int(sys.argv[2]) +subject = sys.argv[3] +secret = secret_path.read_bytes().rstrip() +if not secret: + raise SystemExit("JWT secret is empty after trimming trailing whitespace") + +now = int(time.time()) +header = {"alg": "HS256", "typ": "JWT"} +payload = { + "sub": subject, + "iat": now, + "nbf": now - 5, + "exp": now + ttl_seconds, +} +encoded_header = base64url(json.dumps(header, separators=(",", ":")).encode()) +encoded_payload = base64url(json.dumps(payload, separators=(",", ":")).encode()) +signing_input = f"{encoded_header}.{encoded_payload}".encode() +signature = hmac.new(secret, signing_input, hashlib.sha256).digest() +print(f"{encoded_header}.{encoded_payload}.{base64url(signature)}") +PY