From 2e7176b79bf7edd223dbe446279de73b388269b3 Mon Sep 17 00:00:00 2001 From: 0x3639 <0x3639@protonmail.com> Date: Sat, 12 Sep 2026 20:25:28 -0500 Subject: [PATCH 1/2] Harden security after Codex audit and move hosting to Coolify Seven rounds of Codex Daybreak (xhigh) security review, each verified and fixed. Highlights: Server - Refuse to start in production without a strong APP_SECRET; data dir 0700 and state file 0600, enforced fail-closed for existing installs. - Login: per-account+address and per-address attempt limits counted at admission, bounded concurrency, dummy scrypt for unknown users, length caps; CLI enforces the same password policy. - Seed probe: fail-closed public-IP check (IPv4 special ranges, IPv6 only 2000::/3 minus specials), no redirects, 400 on blocked targets. - Repository policy: https-only, no credentials, ALLOWED_REPO_HOSTS and ALLOWED_REPOS allowlists, git check-ref-format emulation, full 40-hex commit pins; enforced on save, at publish (atomically, with a 409 on any concurrent change to published inputs), and at the manifest. - Every published release carries both commit pins; empty pins are resolved from the ref via git smart HTTP (strict pkt-line parser, bounded body, exact media type). - Public responses redact repository credentials; no-store on /api and downloads; nosniff/frame/referrer headers; PUBLIC_URL and per-peer trust-proxy handling for generated origins. - Node status history stores counts only and at most one sample/50s. - Pillar configs bind RPC to loopback with no browser origins; seed and base configs stay public for the explorer and faucet. Node bootstrap agent - systemd ExecStartPre gate (znn-testnet-verify-znnd) refuses to start a znnd whose embedded git revision differs from the pinned commit, was built from a modified tree, or when no release has been applied. - Deployment repo and go-zenon are checked out at exactly the pinned commit (fetch by hash, or the ref's history as fallback); go-zenon is built from a local file:// checkout so releases stay installable after the branch moves. - Transient failures retry via cron; only proven integrity failures are sticky (--retry clears them). Atomic pin and artifact writes, errexit kept active inside install_release, cron persists custom paths. Container and deployment - Node 24, npm ci, runs as the node user via an entrypoint that fixes volume ownership before dropping privileges. - Standalone Caddy binds 127.0.0.1 by default; devnet ports too. - Replace the Portainer stack with docker-compose.coolify.yml; README documents Coolify setup, runtime variables, repository policy, and verification. Dependencies: audit fixes plus ws and qs overrides. Tests: 46 tests (npm test) covering policy, refs, limiter, IP guard, snapshot keys, and the generated bash verifier/agent helpers, including a real Go build through the pinned checkout. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RjTkfrj9Pkdo1XMD2gkJac --- Dockerfile | 10 +- README.md | 215 ++++---- docker-compose.coolify.yml | 51 ++ docker-compose.portainer.yml | 48 -- docker-compose.yml | 12 +- docker/entrypoint.sh | 16 + package-lock.json | 89 ++-- package.json | 7 +- scripts/create-four-node-devnet.mjs | 35 +- src/server/accounts.ts | 10 + src/server/auth.ts | 9 +- src/server/bootstrap-script.test.ts | 304 ++++++++++++ src/server/bootstrap-script.ts | 726 ++++++++++++++++++++++++++++ src/server/crypto.ts | 20 +- src/server/genesis.ts | 36 +- src/server/git-refs.test.ts | 93 ++++ src/server/git-refs.ts | 108 +++++ src/server/index.ts | 723 ++++++++++++--------------- src/server/rate-limit.test.ts | 43 ++ src/server/rate-limit.ts | 81 ++++ src/server/repo-policy.test.ts | 126 +++++ src/server/repo-policy.ts | 168 +++++++ src/server/seeders.test.ts | 32 ++ src/server/seeders.ts | 89 ++++ src/server/settings.test.ts | 77 +++ src/server/settings.ts | 47 ++ src/server/storage.ts | 29 +- src/shared/types.ts | 10 + src/web/App.tsx | 55 ++- tsconfig.server.json | 12 +- tsconfig.test.json | 11 + 31 files changed, 2602 insertions(+), 690 deletions(-) create mode 100644 docker-compose.coolify.yml delete mode 100644 docker-compose.portainer.yml create mode 100755 docker/entrypoint.sh create mode 100644 src/server/bootstrap-script.test.ts create mode 100644 src/server/bootstrap-script.ts create mode 100644 src/server/git-refs.test.ts create mode 100644 src/server/git-refs.ts create mode 100644 src/server/rate-limit.test.ts create mode 100644 src/server/rate-limit.ts create mode 100644 src/server/repo-policy.test.ts create mode 100644 src/server/repo-policy.ts create mode 100644 src/server/seeders.test.ts create mode 100644 src/server/settings.test.ts create mode 100644 src/server/settings.ts create mode 100644 tsconfig.test.json diff --git a/Dockerfile b/Dockerfile index 17aee4b..928cb4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,14 @@ -FROM node:20-bookworm-slim AS build +FROM node:24-bookworm-slim AS build WORKDIR /app COPY package*.json ./ -RUN npm install +RUN npm ci COPY . . RUN npm run build RUN npm prune --omit=dev -FROM node:20-bookworm-slim AS runtime +FROM node:24-bookworm-slim AS runtime WORKDIR /app ENV NODE_ENV=production @@ -18,8 +18,10 @@ ENV DATA_DIR=/app/data COPY --from=build /app/package.json ./package.json COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/dist ./dist +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh -RUN mkdir -p /app/data +RUN mkdir -p /app/data && chown node:node /app/data && chmod 700 /app/data && chmod +x /usr/local/bin/entrypoint.sh EXPOSE 8787 +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["node", "dist/server/server/index.js"] diff --git a/README.md b/README.md index 68ea73d..fc78ba5 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ The backend is Node/Express. The frontend is React/Vite and follows the dark, co ## Important Security Notes -Set `APP_SECRET` before using the app outside local testing. It is used as the server-side encryption key for sensitive stored wallet package secrets, especially wallet passwords. +Set `APP_SECRET` before using the app outside local testing. It is used as the server-side encryption key for sensitive stored wallet package secrets, especially wallet passwords. When `NODE_ENV=production` the server refuses to start unless `APP_SECRET` is set to at least 16 characters. Do not change `APP_SECRET` after operators register pillars. Existing encrypted wallet package secrets will no longer decrypt correctly if the secret changes. @@ -47,9 +47,9 @@ The generated `data/`, `dist/`, `node_modules/`, and `devnet/four-node/` directo ## Requirements -- Node.js 20 or newer for local development. +- Node.js 20 or newer for local development (the container image uses Node 24). - Docker and Docker Compose for container deployment. -- A Caddy Docker Proxy network named `root_proxy-net` when using the Portainer compose file. +- A Coolify instance for the hosted deployment (it builds the image from this repository and terminates TLS). ## Local Development @@ -68,12 +68,45 @@ Useful commands: ```bash npm run typecheck +npm test npm run build npm run account -- list npm run account -- create-admin --username admin --password "change-me" npm run account -- create-user --username pillar-a --password "change-me" ``` +## Runtime Environment Variables + +| Variable | Default | Purpose | +| --- | --- | --- | +| `APP_SECRET` | none (required in production) | Encryption key for stored wallet passwords, node keys, and status tokens. | +| `DATA_DIR` | `./data` | Location of `app-state.json`. Created with mode `0700`; the state file is written with mode `0600`. | +| `PUBLIC_URL` | none | Fixed public origin (for example `https://testnet.example.com`) used in generated bootstrap scripts and manifests. When unset the origin is derived from the request. | +| `TRUST_PROXY` | `loopback` | Express `trust proxy` setting. Controls which peers may set `X-Forwarded-*` (and therefore the client address used by login rate limiting). The compose files set `uniquelocal` because Caddy reaches the app over a private Docker network; any peer in the trusted range can forge forwarded addresses, so keep it as narrow as your topology allows. | +| `COOKIE_SECURE` | `false` | Set to `true` when served over HTTPS so the session cookie is only sent over TLS. | +| `ALLOWED_REPO_HOSTS` | `github.com` | Comma-separated hosts that release repositories may live on. | +| `ALLOWED_REPOS` | the default go-zenon and deployment repositories | Comma-separated repository URLs an admin may publish (host compared case-insensitively, path exactly). Add forks here to allow them; set to `*` to allow any repository on an allowed host. | +| `GO_ZENON_COMMIT`, `DEPLOYMENT_COMMIT` | none | Default commit pins for fresh installs (full 40-character hashes). | + +The container runs the server as the unprivileged `node` user. The entrypoint starts as root only long enough to fix ownership of the data volume, so volumes created by earlier root-only images keep working without manual changes. + +Login is rate limited: 10 attempts per account from one address, or 50 attempts from one address, within 15 minutes; further attempts return `429` until the window expires. At most 8 password checks run concurrently. + +## Release Repository Policy + +Operator nodes clone and run the published repositories as root, so the server only accepts release settings that pass the repository policy: `https://` URLs without embedded credentials, on a host in `ALLOWED_REPO_HOSTS`, and (unless `ALLOWED_REPOS=*`) exactly one of the URLs in `ALLOWED_REPOS`. Refs must satisfy the same rules as `git check-ref-format --branch`. The policy is enforced when settings are saved and again when a release is published; a previously published release that violates it is withheld from nodes. The admin settings form shows the active policy. + +Every published release is immutable. Commit pins are full 40-character hashes; a pin left empty in the settings is resolved to the ref's current commit at publish time (by reading the repository's advertised refs over HTTPS), so the published plan always carries both pins and publishing fails if a ref cannot be resolved. Nodes then verify them: + +- **Deployment commit.** The agent clones the deployment repository, fetches and checks out exactly the pinned commit (so the release stays installable after the branch moves forward), and refuses to run anything from it if the checkout cannot be moved to that commit. A pin that is no longer reachable from the ref (for example after a force-push) can only be installed if the Git host serves commits by hash, which GitHub does; otherwise the release fails verification and a new one must be published. +- **go-zenon commit.** The agent checks out go-zenon at the pinned commit locally and points the deployment script at that checkout, so the build is reproducible. The bootstrap also installs a systemd `ExecStartPre` hook (`znn-testnet-verify-znnd`) on the node service: before every start it reads the git revision Go embeds in the `znnd` binary (`go version -m`) and refuses to start unless it equals the pin and the metadata declares an unmodified tree. It also refuses to start when no release has been applied by the agent yet. The agent confirms the hook is active and runs the same check after the build; on failure it moves the binary aside as `znnd.unverified`, leaves the service stopped, and reports "Install failed" with the reason in the admin Node Status panel. A binary installed before pins were verified is rebuilt and verified the next time the agent runs. + +Verification failures are recorded and not retried until a new release is published or an operator runs `znn-testnet-agent --retry` on the node, which clears the record and retries immediately. Other failures (network errors, a failed build) are retried by cron every minute. + +Upgrade order: publish a release with this version of the builder before re-running the bootstrap on existing nodes, since the new agent only accepts pinned releases and the start-time hook refuses to start a node that has not applied one. Pillar configs generated by this version bind RPC to loopback; anything that queried a pillar's RPC directly must use the seed node instead. + +Generated pillar configs bind RPC (ports `35997` and `35998`) to `127.0.0.1` with no browser origins; only the local bootstrap agent needs them. Seed / non-producing node configs keep RPC on all interfaces with `*` origins so the explorer and faucet can reach them. + ## Release Target Configuration The node bootstrap script reads its active install target from the admin settings. Fresh installs use these environment variables as defaults: @@ -95,10 +128,10 @@ Use `docker-compose.yml` when you want the repo to run its own Caddy container. APP_SECRET="$(openssl rand -hex 32)" docker compose up -d --build ``` -Create the first admin account inside the app container: +Create the first admin account inside the app container. Run it as the `node` user so the state file stays owned by the service account: ```bash -docker compose exec app node dist/server/server/cli.js create-admin --username admin +docker compose exec --user node app node dist/server/server/cli.js create-admin --username admin ``` Open the app: @@ -107,153 +140,71 @@ Open the app: http://localhost:8080 ``` +The bundled Caddy serves plain HTTP and listens on `127.0.0.1` only. To expose it on other interfaces set `HTTP_BIND=0.0.0.0`, and put a TLS-terminating proxy in front of it before sending real credentials through it (set `COOKIE_SECURE=true` once TLS is in place). + The standalone stack contains: - `app`: the Node/React application on internal port `8787`. - `caddy`: a bundled reverse proxy exposed on `${HTTP_PORT:-8080}`. - `testnet-data`: persistent app state mounted at `/app/data`. -## Portainer With Existing Caddy - -Use `docker-compose.portainer.yml` when an existing Caddy Docker Proxy stack already handles TLS certificates and routing. This stack runs only the app container and attaches it to the external `root_proxy-net` network. +## Coolify -### Prerequisites +Use `docker-compose.coolify.yml` to run the hosted testnet builder on Coolify. Coolify builds the image from this repository, terminates TLS with its own proxy, and routes the domain you configure to the app container. The compose file publishes no host ports: only Coolify's proxy reaches the container, over the Docker network Coolify creates for this resource. -Before creating the Portainer stack, confirm: +### Create The Resource -- DNS for your testnet host points at the server running Caddy. -- Your existing Caddy Docker Proxy stack is running. -- Caddy Docker Proxy is connected to the Docker network named `root_proxy-net`. -- The Docker network `root_proxy-net` exists before this stack starts. +In Coolify: -If the proxy network does not exist yet, create it on the Docker host: +1. Open the project and environment you want to deploy into. +2. Click **New Resource** and choose **Docker Compose** from a **Git repository** (public repository, or a connected GitHub App for private ones). +3. Repository: `https://github.com/0x3639/testnet.git`, branch `main`. +4. Docker Compose location: `docker-compose.coolify.yml`. +5. After Coolify loads the compose file, open the `app` service and set its **Domain** to the public URL, for example `https://testnet.zenon.info`. +6. Set the environment variables below. +7. Click **Deploy**. -```bash -docker network create root_proxy-net -``` - -If Caddy is already running from another stack, make sure that Caddy service also joins `root_proxy-net`. The testnet builder does not publish any host ports in Portainer mode; Caddy reaches it over this shared Docker network. - -### Create The Stack From Git - -The recommended Portainer setup is a Git repository stack. This lets Portainer clone the repository and use `build.context: .` from `docker-compose.portainer.yml`. - -In Portainer: - -1. Open **Stacks**. -2. Click **Add stack**. -3. Name the stack, for example `zenon-testnet-builder`. -4. Select **Git Repository** as the build method. -5. Repository URL: +Leave **Connect To Predefined Network** off. It is not needed, and keeping the resource on its own network means nothing except Coolify's proxy can reach the app or forge proxy headers. - ```text - https://github.com/0x3639/testnet.git - ``` +### Environment Variables -6. Repository reference: +Coolify shows every `${VARIABLE}` from the compose file in the resource's **Environment Variables** tab. Set: - ```text - refs/heads/main - ``` - -7. Compose path: - - ```text - docker-compose.portainer.yml - ``` - -8. Add the environment variables below. -9. Click **Deploy the stack**. - -Set these stack environment variables: - -- `APP_SECRET`: a stable secret, for example the output of `openssl rand -hex 32`. -- `TESTNET_HOST`: the public host Caddy should route, for example `testnet.zenon.info`. +- `APP_SECRET`: required. A stable secret, for example `openssl rand -hex 32`. Never change it after operators register pillars; it encrypts stored wallet package secrets. +- `PUBLIC_URL`: optional. Defaults to `SERVICE_URL_APP`, which Coolify fills with the domain set on the `app` service. Set it explicitly only if the public origin differs. +- `TRUST_PROXY`: optional, defaults to `uniquelocal` so Coolify's proxy may set forwarded headers. - `TZ`: optional, defaults to `Etc/UTC`. -- `GO_ZENON_REPO`: optional initial default, defaults to `https://github.com/zenon-network/go-zenon.git`. -- `GO_ZENON_REF`: optional initial default, defaults to `master`. -- `DEPLOYMENT_REPO`: optional initial default, defaults to `https://github.com/hypercore-one/deployment.git`. -- `DEPLOYMENT_REF`: optional initial default, defaults to `main`. +- `GO_ZENON_REPO`, `GO_ZENON_REF`, `GO_ZENON_COMMIT`, `DEPLOYMENT_REPO`, `DEPLOYMENT_REF`, `DEPLOYMENT_COMMIT`: optional initial release defaults; see [Release Target Configuration](#release-target-configuration). +- `ALLOWED_REPO_HOSTS`, `ALLOWED_REPOS`: optional repository policy; see [Release Repository Policy](#release-repository-policy). -Example values: +`COOKIE_SECURE` is fixed to `true` in this compose file because Coolify serves the app over HTTPS. -```text -APP_SECRET=replace-with-a-long-random-secret -TESTNET_HOST=testnet.zenon.info -TZ=Etc/UTC -``` - -You can generate `APP_SECRET` on any trusted machine: - -```bash -openssl rand -hex 32 -``` - -Keep this value somewhere safe. Do not change it after operators register pillars because it encrypts stored wallet package secrets. - -The Portainer stack uses these Caddy Docker Proxy labels: - -```yaml -caddy: ${TESTNET_HOST:-testnet.zenon.info} -caddy.encode: zstd gzip -caddy.reverse_proxy: "{{upstreams 8787}}" -``` - -After deployment, Caddy should route: +After deployment the proxy routes: ```text -https:// -https:///genesis.json -https:///config.json +https:// +https:///genesis.json +https:///config.json +https:///node-plan.json ``` The JSON files return `404` until an admin publishes them from the app. ### Create The First Admin -After the stack is running, create the first admin account from the `testnet-builder` container. - -In Portainer: - -1. Open **Containers**. -2. Open the `testnet-builder` container from the stack. -3. Open **Console**. -4. Connect with `/bin/sh`. -5. Run: +Open the `app` container's **Terminal** in Coolify (or `docker exec -it sh` on the server) and run the CLI as the `node` user so the state file stays owned by the service account: ```bash -node dist/server/server/cli.js create-admin --username admin +setpriv --reuid=node --regid=node --init-groups node dist/server/server/cli.js create-admin --username admin ``` -The command prints the generated password once. Save it before closing the console. +The command prints the generated password once. Save it before closing the terminal. You can also set the initial password yourself with `--password "replace-this-password"`. -You can also set the initial password yourself: +Then open `https://`, sign in as `admin`, create operator accounts, collect pillar and seed-node registrations, finalize, and publish. -```bash -node dist/server/server/cli.js create-admin --username admin --password "replace-this-password" -``` - -Then open: - -```text -https:// -``` - -Sign in as `admin`, create operator accounts, collect pillar and seed-node registrations, finalize, and publish. +### Updating -### Updating The Stack - -When new commits are pushed to `main`: - -1. Open the stack in Portainer. -2. Pull and redeploy the Git stack. -3. Keep the same persistent volume and the same `APP_SECRET`. - -The app stores state in the named volume `zenon_testnet_builder_data` at `/app/data`. - -### Web Editor Alternative - -If you create a Portainer stack with the Web Editor instead of the Git Repository method, `build.context: .` will not have the repository files unless you provide them another way. For Web Editor deployments, build and publish an image first, then remove the `build:` block and set `image:` to your published image. +Push to `main` and redeploy from Coolify (or enable automatic deployments on push). Keep the same persistent volume and the same `APP_SECRET`. The app stores state in the named volume `testnet-data` at `/app/data`; the container starts as root only long enough to fix that volume's ownership before dropping to the `node` user. ## Admin Workflow @@ -300,7 +251,7 @@ Seed-node packages do not include pillar, reward, or producer wallets. The seed After registering a pillar or seed node, the operator page shows a copyable command shaped like this: ```bash -curl -fsSL "https:///api/bootstrap/install.sh" | sudo env ZNN_BOOTSTRAP_TOKEN="" ZNN_TESTNET_URL="https://" bash +curl -fsSL "https:///api/bootstrap/install.sh" | sudo env ZNN_BOOTSTRAP_TOKEN="" ZNN_TESTNET_URL="https://" bash ``` Run it on the node host. The script is intended for the same Linux/systemd style environment supported by `hypercore-one/deployment`. @@ -343,7 +294,7 @@ Each registered pillar or managed seed node receives a private node status token Heartbeat reports are sent with a bearer token: ```bash -curl -fsS -X POST "https:///api/bootstrap/status" \ +curl -fsS -X POST "https:///api/bootstrap/status" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ @@ -388,12 +339,12 @@ http://localhost:8080/config.json http://localhost:8080/node-plan.json ``` -Portainer/Caddy: +Coolify: ```text -https:///genesis.json -https:///config.json -https:///node-plan.json +https:///genesis.json +https:///config.json +https:///node-plan.json ``` Publishing stores a snapshot. If settings, seeders, bootstrap peers, pillars, finalized genesis data, release target values, or the wipe flag change later, save them as draft changes first, then click **Publish Release** again to update the public files and node plan. Saving settings alone does not force an upgrade or wipe. @@ -443,18 +394,14 @@ The generated token supply is reconciled against the genesis balances and embedd The helper script `scripts/create-four-node-devnet.mjs` can exercise the builder and generate a local four-node devnet package under `devnet/four-node/`. -Start the standalone builder first and make sure the admin login exists. The script defaults to: - -- builder URL: `http://127.0.0.1:8080` -- admin username: `admin` -- admin password: `admin-pass-123` +Start the standalone builder first and make sure the admin login exists. The script defaults to builder URL `http://127.0.0.1:8080` and admin username `admin`; `ADMIN_PASSWORD` has no default and must be supplied. Run: ```bash BUILDER_URL=http://127.0.0.1:8080 \ ADMIN_USERNAME=admin \ -ADMIN_PASSWORD=admin-pass-123 \ +ADMIN_PASSWORD="" \ node scripts/create-four-node-devnet.mjs ``` @@ -468,8 +415,8 @@ src/web/ React admin/operator interface src/shared/ Shared TypeScript types scripts/create-four-node-devnet.mjs docker/caddy/Caddyfile Standalone Docker Caddy config -docker-compose.yml Standalone app + Caddy stack -docker-compose.portainer.yml App-only stack for existing Caddy Docker Proxy +docker-compose.yml Standalone app + Caddy stack (local / single host) +docker-compose.coolify.yml App-only stack for Coolify (TLS and routing by Coolify's proxy) ``` ## API Endpoints diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml new file mode 100644 index 0000000..9de8733 --- /dev/null +++ b/docker-compose.coolify.yml @@ -0,0 +1,51 @@ +# Coolify deployment (Docker Compose resource from this Git repository). +# +# Coolify's proxy (Traefik by default) terminates TLS and routes the domain you set on the `app` +# service in the Coolify UI to the exposed port. Do not publish host ports here; Coolify reaches +# the container over the resource's own Docker network, so only Coolify's proxy and this service +# share it. The magic variable SERVICE_URL_APP is filled by Coolify with that domain's URL. +services: + app: + build: + context: . + restart: unless-stopped + environment: + NODE_ENV: production + TZ: ${TZ:-Etc/UTC} + PORT: 8787 + DATA_DIR: /app/data + # Required. Encrypts stored wallet passwords, node keys, and status tokens. Never change it + # after operators have registered. Generate with: openssl rand -hex 32 + APP_SECRET: ${APP_SECRET:?Set APP_SECRET in the Coolify environment variables} + # Coolify serves the app over HTTPS, so the session cookie is TLS-only. + COOKIE_SECURE: "true" + # Fixed public origin used in generated bootstrap scripts and manifests. Defaults to the + # domain configured for this service in Coolify. + PUBLIC_URL: ${PUBLIC_URL:-${SERVICE_URL_APP}} + # Coolify's proxy reaches the app over a private Docker network dedicated to this resource. + TRUST_PROXY: ${TRUST_PROXY:-uniquelocal} + GO_ZENON_REPO: ${GO_ZENON_REPO:-https://github.com/zenon-network/go-zenon.git} + GO_ZENON_REF: ${GO_ZENON_REF:-master} + GO_ZENON_COMMIT: ${GO_ZENON_COMMIT:-} + DEPLOYMENT_REPO: ${DEPLOYMENT_REPO:-https://github.com/hypercore-one/deployment.git} + DEPLOYMENT_REF: ${DEPLOYMENT_REF:-main} + DEPLOYMENT_COMMIT: ${DEPLOYMENT_COMMIT:-} + ALLOWED_REPO_HOSTS: ${ALLOWED_REPO_HOSTS:-github.com} + ALLOWED_REPOS: ${ALLOWED_REPOS:-} + expose: + - "8787" + volumes: + - testnet-data:/app/data + healthcheck: + test: + [ + "CMD-SHELL", + "node -e \"fetch('http://127.0.0.1:8787/api/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" + ] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + +volumes: + testnet-data: diff --git a/docker-compose.portainer.yml b/docker-compose.portainer.yml deleted file mode 100644 index 2823325..0000000 --- a/docker-compose.portainer.yml +++ /dev/null @@ -1,48 +0,0 @@ -services: - testnet-builder: - build: - context: . - image: zenon-testnet-builder:latest - restart: unless-stopped - environment: - NODE_ENV: production - TZ: ${TZ:-Etc/UTC} - PORT: 8787 - DATA_DIR: /app/data - APP_SECRET: ${APP_SECRET:?Set APP_SECRET in the Portainer stack environment} - COOKIE_SECURE: "true" - GO_ZENON_REPO: ${GO_ZENON_REPO:-https://github.com/zenon-network/go-zenon.git} - GO_ZENON_REF: ${GO_ZENON_REF:-master} - DEPLOYMENT_REPO: ${DEPLOYMENT_REPO:-https://github.com/hypercore-one/deployment.git} - DEPLOYMENT_REF: ${DEPLOYMENT_REF:-main} - volumes: - - zenon_testnet_builder_data:/app/data - expose: - - "8787" - networks: - - root_proxy-net - labels: - - "caddy=${TESTNET_HOST:-testnet.zenon.info}" - - "caddy.encode=zstd gzip" - - "caddy.reverse_proxy={{upstreams 8787}}" - healthcheck: - test: - [ - "CMD-SHELL", - "node -e \"fetch('http://127.0.0.1:8787/api/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" - ] - interval: 60s - timeout: 10s - retries: 3 - start_period: 30s - deploy: - mode: replicated - replicas: 1 - -networks: - root_proxy-net: - name: root_proxy-net - external: true - -volumes: - zenon_testnet_builder_data: diff --git a/docker-compose.yml b/docker-compose.yml index eb40dc8..9f0bb91 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,14 @@ services: GO_ZENON_REF: ${GO_ZENON_REF:-master} DEPLOYMENT_REPO: ${DEPLOYMENT_REPO:-https://github.com/hypercore-one/deployment.git} DEPLOYMENT_REF: ${DEPLOYMENT_REF:-main} + PUBLIC_URL: ${PUBLIC_URL:-} + ALLOWED_REPO_HOSTS: ${ALLOWED_REPO_HOSTS:-github.com} + DEPLOYMENT_COMMIT: ${DEPLOYMENT_COMMIT:-} + GO_ZENON_COMMIT: ${GO_ZENON_COMMIT:-} + # Caddy reaches the app over the private Docker network; only peers in private ranges may set X-Forwarded-*. + TRUST_PROXY: ${TRUST_PROXY:-uniquelocal} + ALLOWED_REPOS: ${ALLOWED_REPOS:-} + COOKIE_SECURE: ${COOKIE_SECURE:-false} volumes: - testnet-data:/app/data @@ -19,7 +27,9 @@ services: depends_on: - app ports: - - "${HTTP_PORT:-8080}:80" + # This stack serves plain HTTP, so it only listens on localhost by default. Put a TLS + # terminating proxy in front and set HTTP_BIND=0.0.0.0 to expose it on other interfaces. + - "${HTTP_BIND:-127.0.0.1}:${HTTP_PORT:-8080}:80" volumes: - ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..cd41b4f --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Runs the app as the unprivileged "node" user. When the container starts as root (the default, +# so that a data volume created by an older root-only image keeps working), fix ownership of the +# data directory and then drop privileges before starting Node. +set -eu + +DATA_DIR="${DATA_DIR:-/app/data}" +mkdir -p "$DATA_DIR" + +if [ "$(id -u)" = "0" ]; then + chown -R node:node "$DATA_DIR" + chmod 700 "$DATA_DIR" + exec setpriv --reuid=node --regid=node --init-groups "$@" +fi + +exec "$@" diff --git a/package-lock.json b/package-lock.json index 9614705..16e9134 100644 --- a/package-lock.json +++ b/package-lock.json @@ -778,9 +778,9 @@ } }, "node_modules/@open-rpc/client-js": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@open-rpc/client-js/-/client-js-2.0.0.tgz", - "integrity": "sha512-knfuwdPPVJlmNIgvjvrgbwaU+eF0pVCb6V7/c9OH/UYW1jAPE2wrKL3FOa3qpXlVpAF516syYrO9h0s402zXyA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-rpc/client-js/-/client-js-2.1.0.tgz", + "integrity": "sha512-Rvk8rKHRfQhIAWjnkz2BxssYbffkTnIboN9txfAmoQxfiVWrsThRDIaC8QF3AhDuc0NXvxuFnQlKhTad7Pn0yw==", "license": "Apache-2.0", "dependencies": { "isomorphic-ws": "5.0.0", @@ -1469,9 +1469,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.38", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", - "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "version": "2.11.22", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", + "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -1493,9 +1493,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -1506,7 +1506,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.15.1", + "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -1609,9 +1609,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "funding": [ { "type": "opencollective", @@ -1628,11 +1628,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -1742,9 +1742,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -2032,9 +2032,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.376", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", - "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", "license": "ISC" }, "node_modules/elliptic": { @@ -2827,9 +2827,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.13", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", - "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "funding": [ { "type": "github", @@ -2874,9 +2874,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.48", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", - "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", "license": "MIT", "engines": { "node": ">=18" @@ -2988,9 +2988,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", @@ -3007,7 +3007,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3055,12 +3055,13 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -3653,9 +3654,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "funding": [ { "type": "opencollective", @@ -4286,9 +4287,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index f888c18..e4a5396 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "build:web": "vite build", "start": "node dist/server/server/index.js", "account": "tsx src/server/cli.ts", - "typecheck": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.web.json --noEmit" + "typecheck": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.test.json --noEmit && tsc -p tsconfig.web.json --noEmit", + "test": "tsx --test src/server/*.test.ts" }, "dependencies": { "@vitejs/plugin-react": "^4.3.4", @@ -37,5 +38,9 @@ "tsx": "^4.19.2", "typescript": "^5.7.2", "vite": "^6.0.3" + }, + "overrides": { + "ws": "^8.21.0", + "qs": "^6.16.0" } } diff --git a/scripts/create-four-node-devnet.mjs b/scripts/create-four-node-devnet.mjs index 7751af2..e75b043 100644 --- a/scripts/create-four-node-devnet.mjs +++ b/scripts/create-four-node-devnet.mjs @@ -5,7 +5,14 @@ import JSZip from "jszip"; const BASE_URL = process.env.BUILDER_URL ?? "http://127.0.0.1:8080"; const ADMIN_USERNAME = process.env.ADMIN_USERNAME ?? "admin"; -const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? "admin-pass-123"; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; +if (!ADMIN_PASSWORD) { + console.error("Set ADMIN_PASSWORD to the builder admin password (no default is provided)."); + process.exit(1); +} +// Generated files contain private keys, wallet passwords, and operator logins. +const SECRET_FILE = { mode: 0o600 }; +const SECRET_DIR = { recursive: true, mode: 0o700 }; const OUT_DIR = path.resolve("devnet", "four-node"); const DEVNET_DIR = path.join(OUT_DIR, "devnet"); const OPERATORS_DIR = path.join(OUT_DIR, "operators"); @@ -329,19 +336,20 @@ async function main() { overview = (await request("/api/admin/overview", {}, adminCookie)).body; await rm(OUT_DIR, { recursive: true, force: true }); - await mkdir(DEVNET_DIR, { recursive: true }); - await mkdir(OPERATORS_DIR, { recursive: true }); + await mkdir(OUT_DIR, SECRET_DIR); + await mkdir(DEVNET_DIR, SECRET_DIR); + await mkdir(OPERATORS_DIR, SECRET_DIR); await writeFile(path.join(DEVNET_DIR, "genesis.json"), pretty(overview.genesis)); const configs = { seed: seedNodeConfig() }; const seedDir = path.join(DEVNET_DIR, seedNode.role); - await mkdir(seedDir, { recursive: true }); + await mkdir(seedDir, SECRET_DIR); await writeFile(path.join(seedDir, "config.json"), pretty(configs.seed)); - await writeFile(path.join(seedDir, "network-private-key"), seedNode.nodeKey.privateKey); + await writeFile(path.join(seedDir, "network-private-key"), seedNode.nodeKey.privateKey, SECRET_FILE); for (const role of roles) { const packageResponse = await request("/api/pillar/package", {}, role.userCookie); - await writeFile(path.join(OPERATORS_DIR, `${role.pillarName}-pillar-package.zip`), packageResponse.body); + await writeFile(path.join(OPERATORS_DIR, `${role.pillarName}-pillar-package.zip`), packageResponse.body, SECRET_FILE); const zip = await JSZip.loadAsync(packageResponse.body); const packageConfig = JSON.parse(await zip.file("config.json").async("string")); @@ -350,10 +358,10 @@ async function main() { configs[role.role] = config; const roleDir = path.join(DEVNET_DIR, role.role); - await mkdir(path.join(roleDir, "wallet"), { recursive: true }); - await writeFile(path.join(roleDir, "config.json"), pretty(config)); - await writeFile(path.join(roleDir, "network-private-key"), role.nodeKey.privateKey); - await writeFile(path.join(roleDir, "wallet", "producer.json"), pretty(producerWallet)); + await mkdir(path.join(roleDir, "wallet"), SECRET_DIR); + await writeFile(path.join(roleDir, "config.json"), pretty(config), SECRET_FILE); + await writeFile(path.join(roleDir, "network-private-key"), role.nodeKey.privateKey, SECRET_FILE); + await writeFile(path.join(roleDir, "wallet", "producer.json"), pretty(producerWallet), SECRET_FILE); } const genesisChecks = validateGenesis(overview.genesis, roles.map((role) => role.pillar)); @@ -380,8 +388,8 @@ ${roles environment: ZNND_ROLE: ${role.role} ports: - - "${role.httpPort}:35997" - - "${role.wsPort}:35998" + - "127.0.0.1:${role.httpPort}:35997" + - "127.0.0.1:${role.wsPort}:35998" volumes: - ${role.role}-data:/root/.znn networks: @@ -403,7 +411,8 @@ ${roles.concat(seedNode).map((role) => ` ${role.role}-data:`).join("\n")} ); await writeFile( path.join(OUT_DIR, "operator-logins.txt"), - roles.map((role) => `${role.username}\t${role.password}\t${BASE_URL}`).join("\n") + "\n" + roles.map((role) => `${role.username}\t${role.password}\t${BASE_URL}`).join("\n") + "\n", + SECRET_FILE ); await writeFile( path.join(OUT_DIR, "summary.json"), diff --git a/src/server/accounts.ts b/src/server/accounts.ts index c0641b1..fad2720 100644 --- a/src/server/accounts.ts +++ b/src/server/accounts.ts @@ -2,9 +2,18 @@ import { hashPassword, randomId, randomPassword } from "./crypto.js"; import { updateState } from "./storage.js"; import type { AuthUser, Role } from "../shared/types.js"; +export const PASSWORD_MIN_LENGTH = 8; +export const PASSWORD_MAX_LENGTH = 200; + +export function assertPasswordPolicy(password: string): void { + if (password.length < PASSWORD_MIN_LENGTH) throw new Error(`Password must be at least ${PASSWORD_MIN_LENGTH} characters`); + if (password.length > PASSWORD_MAX_LENGTH) throw new Error(`Password must be at most ${PASSWORD_MAX_LENGTH} characters`); +} + export async function createAccount(username: string, role: Role, password = randomPassword()): Promise<{ user: AuthUser; password: string }> { const normalized = username.trim(); if (!normalized) throw new Error("Username is required"); + assertPasswordPolicy(password); const passwordHash = await hashPassword(password); const user = await updateState((state) => { @@ -30,6 +39,7 @@ export async function createAccount(username: string, role: Role, password = ran } export async function resetAccountPassword(userId: string, password: string, keepActiveSessionUserId?: string): Promise { + assertPasswordPolicy(password); const passwordHash = await hashPassword(password); return updateState((state) => { const user = state.users.find((candidate) => candidate.id === userId); diff --git a/src/server/auth.ts b/src/server/auth.ts index a8cef6f..9e02f1b 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -5,6 +5,9 @@ import type { AuthUser, Role, StoredSession, StoredUser } from "../shared/types. const SESSION_COOKIE = "zenon_session"; const SESSION_DAYS = 7; +// A syntactically valid scrypt record that no password matches. Verifying against it when the +// username is unknown keeps login timing the same for known and unknown accounts. +const DUMMY_PASSWORD_HASH = `scrypt:${"00".repeat(16)}:${"00".repeat(64)}`; export interface AuthedRequest extends Request { user: AuthUser; @@ -21,10 +24,8 @@ export function publicUser(user: StoredUser): AuthUser { export async function login(username: string, password: string): Promise<{ token: string; user: AuthUser } | null> { const state = await readState(); const user = state.users.find((candidate) => candidate.username.toLowerCase() === username.toLowerCase()); - if (!user) return null; - - const ok = await verifyPassword(password, user.passwordHash); - if (!ok) return null; + const ok = await verifyPassword(password, user?.passwordHash ?? DUMMY_PASSWORD_HASH); + if (!user || !ok) return null; const token = randomId(32); const now = new Date(); diff --git a/src/server/bootstrap-script.test.ts b/src/server/bootstrap-script.test.ts new file mode 100644 index 0000000..cf60ad3 --- /dev/null +++ b/src/server/bootstrap-script.test.ts @@ -0,0 +1,304 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { before, describe, it } from "node:test"; +import { bootstrapInstallScript } from "./bootstrap-script.js"; + +/** + * Exercises the bash emitted for operator nodes: the systemd start-time verifier and the agent's + * helper functions, against fake toolchains and real git repositories. + */ +const script = bootstrapInstallScript("https://builder.example.test"); +const GOOD = "9cde165877a1e4ff47d0df6cf8b8a65b121d550c"; +const OTHER = "0123456789abcdef0123456789abcdef01234567"; + +function heredoc(marker: string): string { + const lines = script.split("\n"); + const start = lines.findIndex((line) => line.includes(`<<'${marker}'`)); + const end = lines.indexOf(marker, start + 1); + assert.ok(start >= 0 && end > start, `heredoc ${marker} not found`); + return lines.slice(start + 1, end).join("\n") + "\n"; +} + +function bashFunction(source: string, name: string): string { + const match = new RegExp(`^${name}\\(\\) \\{\\n[\\s\\S]*?^\\}\\n`, "m").exec(source); + assert.ok(match, `function ${name} not found`); + return match[0]; +} + +const hasBash = !spawnSync("bash", ["--version"]).error; +const hasGit = !spawnSync("git", ["--version"]).error && spawnSync("git", ["--version"]).status === 0; + +describe("generated bootstrap script", { skip: !hasBash && "bash not available" }, () => { + const verifyScript = heredoc("VERIFY"); + const agent = heredoc("AGENT"); + let root: string; + let bin: string; + let deploy: string; + let state: string; + let verifyPath: string; + let helpersPath: string; + + before(() => { + root = mkdtempSync(path.join(tmpdir(), "znn-bootstrap-test-")); + bin = path.join(root, "bin"); + deploy = path.join(root, "deploy"); + state = path.join(root, "state"); + for (const dir of [bin, path.join(deploy, "go", "bin"), state]) mkdirSync(dir, { recursive: true }); + verifyPath = path.join(root, "verify.sh"); + writeFileSync(verifyPath, verifyScript, { mode: 0o755 }); + helpersPath = path.join(root, "helpers.sh"); + writeFileSync( + helpersPath, + ["verify_gate_active", "checkout_pinned", "write_expected_commit", "binary_fingerprint", "quarantine_binary", "record_install_failure", "verify_znnd_build", "fetch_artifact"] + .map((name) => bashFunction(agent, name)) + .join("\n") + ); + writeFileSync(path.join(bin, "znnd"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + }); + + function fakeGo(metadata: string[]): void { + const body = ["$2: go1.23.0", "\tpath\tgithub.com/zenon-network/go-zenon/cmd/znnd", ...metadata.map((line) => `\tbuild\t${line}`)].join("\\n"); + writeFileSync(path.join(deploy, "go", "bin", "go"), `#!/bin/sh\nprintf '${body}\\n' "$2"\n`, { mode: 0o755 }); + } + + function fakeSystemctl(execStartPre: string, reloadExit = 0): void { + writeFileSync( + path.join(bin, "systemctl"), + `#!/bin/sh\necho "systemctl $*" >> "${root}/systemctl.log"\ncase "$1" in\n daemon-reload) exit ${reloadExit} ;;\n show) echo "${execStartPre}" ;;\n cat) exit 0 ;;\nesac\nexit 0\n`, + { mode: 0o755 } + ); + } + + function env(extra: Record = {}): NodeJS.ProcessEnv { + return { + ...process.env, + PATH: `${bin}:${process.env.PATH ?? ""}`, + ZNN_AGENT_STATE_DIR: state, + ZNN_DEPLOYMENT_DIR: deploy, + ...extra + }; + } + + function verify(args: string[] = []): { status: number | null; out: string } { + const result = spawnSync("bash", [verifyPath, ...args], { env: env(), encoding: "utf8" }); + return { status: result.status, out: `${result.stdout}${result.stderr}` }; + } + + function runHelpers(body: string, extraEnv: Record = {}): { status: number | null; out: string } { + const result = spawnSync("bash", ["-c", `set -uo pipefail; SERVICE_NAME=go-zenon; DEPLOYMENT_DIR=${JSON.stringify(deploy)}; STATE_DIR=${JSON.stringify(state)}; INSTALL_STATE_FILE=${JSON.stringify(path.join(state, "install-state.json"))}; source ${JSON.stringify(helpersPath)}; ${body}`], { + env: env(extraEnv), + encoding: "utf8" + }); + return { status: result.status, out: `${result.stdout}${result.stderr}` }; + } + + describe("znn-testnet-verify-znnd", () => { + it("refuses to start when no release has been applied (expected file missing)", () => { + rmSync(path.join(state, "expected-znnd-commit"), { force: true }); + fakeGo([`vcs.revision=${GOOD}`, "vcs.modified=false"]); + const result = verify(); + assert.equal(result.status, 1); + assert.match(result.out, /no release has been applied/); + }); + + it("refuses to start when the expected file is empty", () => { + writeFileSync(path.join(state, "expected-znnd-commit"), "\n"); + const result = verify(); + assert.equal(result.status, 1); + assert.match(result.out, /is empty; refusing/); + }); + + it("accepts exactly the pinned revision with an unmodified tree, from file or argument", () => { + fakeGo([`vcs.revision=${GOOD}`, "vcs.modified=false"]); + writeFileSync(path.join(state, "expected-znnd-commit"), `${GOOD}\n`); + const fromFile = verify(); + assert.equal(fromFile.status, 0); + assert.match(fromFile.out, new RegExp(`revision=${GOOD}`)); + assert.equal(verify([GOOD.toUpperCase()]).status, 0); + }); + + it("rejects short pins, mismatches, and bad or missing modification metadata", () => { + fakeGo([`vcs.revision=${GOOD}`, "vcs.modified=false"]); + assert.equal(verify(["9cde1658"]).status, 1); + assert.match(verify([OTHER]).out, /does not match the pinned commit/); + fakeGo([`vcs.revision=${GOOD}`, "vcs.modified=true"]); + assert.match(verify([GOOD]).out, /vcs.modified=false/); + fakeGo([`vcs.revision=${GOOD}`]); + assert.equal(verify([GOOD]).status, 1); + fakeGo([`vcs.revision=${GOOD}`, "vcs.modified=false", "vcs.modified=false"]); + assert.equal(verify([GOOD]).status, 1); + fakeGo(["vcs.modified=false"]); + assert.match(verify([GOOD]).out, /no embedded git revision/); + }); + + it("fails closed without a toolchain or binary", () => { + rmSync(path.join(deploy, "go", "bin", "go"), { force: true }); + assert.match(verify([GOOD]).out, /go toolchain not found/); + fakeGo([`vcs.revision=${GOOD}`, "vcs.modified=false"]); + chmodSync(path.join(bin, "znnd"), 0o644); + assert.match(verify([GOOD]).out, /znnd binary not found/); + chmodSync(path.join(bin, "znnd"), 0o755); + }); + }); + + describe("agent helpers", () => { + it("verify_gate_active requires the ExecStartPre hook and a working daemon-reload", () => { + fakeSystemctl("{ path=/usr/local/bin/znn-testnet-verify-znnd ; argv[]=/usr/local/bin/znn-testnet-verify-znnd }"); + assert.equal(runHelpers("verify_gate_active").status, 0); + fakeSystemctl(""); + assert.match(runHelpers("verify_gate_active").out, /does not run znn-testnet-verify-znnd/); + fakeSystemctl("{ path=/usr/local/bin/znn-testnet-verify-znnd }", 1); + assert.match(runHelpers("verify_gate_active").out, /daemon-reload failed/); + }); + + it("record_install_failure and quarantine_binary stop the service and move the binary aside", () => { + fakeSystemctl("x"); + writeFileSync(path.join(bin, "znnd"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const result = runHelpers('record_install_failure "key1" "evt1" "boom"; quarantine_binary'); + assert.equal(result.status, 0); + const installState = JSON.parse(readFileSync(path.join(state, "install-state.json"), "utf8")); + assert.equal(installState.failedKey, "key1"); + assert.equal(installState.lastError, "boom"); + assert.equal(existsSync(path.join(bin, "znnd")), false); + assert.equal(existsSync(path.join(bin, "znnd.unverified")), true); + assert.match(readFileSync(path.join(root, "systemctl.log"), "utf8"), /systemctl stop go-zenon/); + writeFileSync(path.join(bin, "znnd"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + }); + + it("--retry clears a recorded failure", () => { + writeFileSync(path.join(state, "install-state.json"), JSON.stringify({ failedKey: "k", lastError: "x", failedAt: "t", binaryKey: "b" })); + const retry = /if \[\[ "\$\{1:-\}" == "--retry" \]\]; then\n[\s\S]*?\nfi\n/.exec(agent); + assert.ok(retry, "retry block not found"); + const result = spawnSync("bash", ["-c", `set -euo pipefail; INSTALL_STATE_FILE=${JSON.stringify(path.join(state, "install-state.json"))}; set -- --retry; ${retry[0]}`], { env: env(), encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(readFileSync(path.join(state, "install-state.json"), "utf8")), { binaryKey: "b" }); + }); + + it("write_expected_commit replaces the pin atomically with mode 0600 and refuses empty pins", () => { + const target = path.join(state, "expected-znnd-commit"); + writeFileSync(target, `${OTHER}\n`); + assert.equal(runHelpers(`write_expected_commit ""`).status, 1); + assert.equal(runHelpers(`write_expected_commit 9cde1658`).status, 1); + assert.equal(readFileSync(target, "utf8").trim(), OTHER); + const ok = runHelpers(`write_expected_commit ${GOOD}`); + assert.equal(ok.status, 0, ok.out); + assert.equal(readFileSync(target, "utf8").trim(), GOOD); + assert.equal(statSync(target).mode & 0o777, 0o600); + assert.deepEqual(readdirSync(state).filter((name) => name.startsWith(".expected-znnd-commit.")), []); + }); + + it("binary_fingerprint distinguishes a rebuilt binary from a leftover one", () => { + writeFileSync(path.join(bin, "znnd"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const first = runHelpers("binary_fingerprint"); + assert.equal(first.status, 0); + assert.match(first.out.trim(), /^[0-9a-f]{64}$/); + assert.equal(runHelpers("binary_fingerprint").out, first.out); + writeFileSync(path.join(bin, "znnd"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + assert.notEqual(runHelpers("binary_fingerprint").out, first.out); + rmSync(path.join(bin, "znnd")); + assert.equal(runHelpers("binary_fingerprint").out.trim(), ""); + writeFileSync(path.join(bin, "znnd"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + }); + + it("fetch_artifact never leaves a truncated file behind", () => { + const dest = path.join(root, "artifact.json"); + writeFileSync(dest, "previous"); + const failing = runHelpers(`auth_get() { return 22; }; fetch_artifact https://x/y ${JSON.stringify(dest)}`); + assert.equal(failing.status, 1); + assert.equal(readFileSync(dest, "utf8"), "previous"); + const ok = runHelpers(`auth_get() { echo '{"ok":true}'; }; fetch_artifact https://x/y ${JSON.stringify(dest)}`); + assert.equal(ok.status, 0); + assert.equal(readFileSync(dest, "utf8").trim(), '{"ok":true}'); + }); + + describe("checkout_pinned", { skip: !hasGit && "git not available" }, () => { + function git(cwd: string, ...args: string[]): string { + const result = spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "advice.detachedHead=false", ...args], { cwd, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + } + + it("checks out exactly the pinned commit even after the branch moves, and the checkout is re-clonable", () => { + const repo = path.join(root, "origin.git"); + mkdirSync(repo); + git(repo, "init", "-q", "-b", "main"); + git(repo, "config", "uploadpack.allowAnySHA1InWant", "true"); + git(repo, "commit", "-q", "--allow-empty", "-m", "one"); + const pinned = git(repo, "rev-parse", "HEAD"); + git(repo, "commit", "-q", "--allow-empty", "-m", "two"); + const moved = git(repo, "rev-parse", "HEAD"); + assert.notEqual(pinned, moved); + + const dest = path.join(root, "checkout"); + const result = runHelpers(`checkout_pinned "file://${repo}" main ${pinned} ${JSON.stringify(dest)}`); + assert.equal(result.status, 0, result.out); + assert.equal(git(dest, "rev-parse", "HEAD"), pinned); + assert.equal(git(dest, "rev-parse", "--abbrev-ref", "HEAD"), "pinned"); + + // The deployment script clones this checkout by branch name; the commit must survive. + const clone = path.join(root, "clone"); + git(root, "clone", "-q", "-b", "pinned", `file://${dest}`, clone); + assert.equal(git(clone, "rev-parse", "HEAD"), pinned); + + const unpinned = runHelpers(`checkout_pinned "file://${repo}" main "" ${JSON.stringify(dest)}`); + assert.equal(unpinned.status, 0, unpinned.out); + assert.equal(git(dest, "rev-parse", "HEAD"), moved); + + // A pin that is not reachable from the ref is an integrity failure (exit 2), not a retry. + const missing = runHelpers(`checkout_pinned "file://${repo}" main ${OTHER} ${JSON.stringify(dest)}`); + assert.equal(missing.status, 2, missing.out); + // An unreachable repository is transient (exit 1). + const unreachable = runHelpers(`checkout_pinned "file://${root}/does-not-exist" main ${pinned} ${JSON.stringify(dest)}`); + assert.equal(unreachable.status, 1, unreachable.out); + }); + + it("falls back to fetching the ref's history when the server refuses fetch-by-hash", () => { + const repo = path.join(root, "strict.git"); + mkdirSync(repo); + git(repo, "init", "-q", "-b", "main"); + git(repo, "commit", "-q", "--allow-empty", "-m", "one"); + const pinned = git(repo, "rev-parse", "HEAD"); + git(repo, "commit", "-q", "--allow-empty", "-m", "two"); + const dest = path.join(root, "strict-checkout"); + // Protocol v0 upload-pack refuses unadvertised objects unless allow*SHA1InWant is enabled, + // which is the behavior of a conservative git server. + const strict = { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "protocol.version", GIT_CONFIG_VALUE_0: "0" }; + const result = runHelpers(`checkout_pinned "file://${repo}" main ${pinned} ${JSON.stringify(dest)}`, strict); + assert.equal(result.status, 0, result.out); + assert.match(result.out, /full history/); + assert.equal(git(dest, "rev-parse", "HEAD"), pinned); + }); + + it("a real Go build from the file:// checkout embeds the pin and passes the verifier", { skip: spawnSync("go", ["version"]).status !== 0 && "go not available" }, () => { + const repo = path.join(root, "gomod.git"); + mkdirSync(repo); + git(repo, "init", "-q", "-b", "main"); + writeFileSync(path.join(repo, "go.mod"), "module example.test/znnd\n\ngo 1.21\n"); + writeFileSync(path.join(repo, "main.go"), "package main\n\nfunc main() {}\n"); + git(repo, "add", "."); + git(repo, "commit", "-q", "-m", "one"); + const pinned = git(repo, "rev-parse", "HEAD"); + git(repo, "commit", "-q", "--allow-empty", "-m", "two"); + const dest = path.join(root, "go-zenon-pinned"); + assert.equal(runHelpers(`checkout_pinned "file://${repo}" main ${pinned} ${JSON.stringify(dest)}`).status, 0); + // Simulate zenon.sh: clone the local checkout by branch name and build inside it. + const clone = path.join(root, "go-build"); + git(root, "clone", "-q", "-b", "pinned", `file://${dest}`, clone); + const build = spawnSync("go", ["build", "-o", path.join(bin, "znnd"), "."], { cwd: clone, encoding: "utf8", env: { ...process.env, GOFLAGS: "-mod=mod", GOTOOLCHAIN: "local" } }); + assert.equal(build.status, 0, build.stderr); + // Point the verifier at the real toolchain and binary. + rmSync(path.join(deploy, "go", "bin", "go"), { force: true }); + const goBin = spawnSync("sh", ["-c", "command -v go"], { encoding: "utf8" }).stdout.trim(); + writeFileSync(path.join(deploy, "go", "bin", "go"), `#!/bin/sh\nexec ${JSON.stringify(goBin)} "$@"\n`, { mode: 0o755 }); + const ok = verify([pinned]); + assert.equal(ok.status, 0, ok.out); + assert.match(ok.out, new RegExp(`revision=${pinned}`)); + assert.match(verify([OTHER]).out, /does not match/); + writeFileSync(path.join(bin, "znnd"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + }); + }); + }); +}); diff --git a/src/server/bootstrap-script.ts b/src/server/bootstrap-script.ts new file mode 100644 index 0000000..5f0ded0 --- /dev/null +++ b/src/server/bootstrap-script.ts @@ -0,0 +1,726 @@ +/** + * Generates the operator bootstrap installer served at /api/bootstrap/install.sh. It installs a + * cron-driven agent that applies published releases and a systemd ExecStartPre gate that refuses + * to start a znnd binary whose embedded git revision differs from the pinned commit. + * + * The body is a JS template literal: `\${` produces a literal `${` in the emitted bash. + */ +export function bootstrapInstallScript(origin: string): string { + return `#!/usr/bin/env bash +set -euo pipefail + +if [[ "$EUID" -ne 0 ]]; then + echo "Run this script as root, usually via sudo." >&2 + exit 1 +fi + +: "\${ZNN_BOOTSTRAP_TOKEN:?Set ZNN_BOOTSTRAP_TOKEN to the node bootstrap token from the testnet builder.}" + +BASE_URL="\${ZNN_TESTNET_URL:-${origin}}" +ZNN_DIR="\${ZNN_DIR:-/root/.znn}" +DEPLOYMENT_DIR="\${ZNN_DEPLOYMENT_DIR:-/opt/zenon-deployment}" +DEPLOYMENT_MIN_CPU_CORES="\${ZNN_DEPLOYMENT_MIN_CPU_CORES:-2}" +SERVICE_NAME="\${ZNN_SERVICE_NAME:-go-zenon}" +RPC_URL="\${ZNN_RPC_URL:-http://127.0.0.1:35997}" +BOOTSTRAP_TRACE="\${ZNN_BOOTSTRAP_TRACE:-0}" + +if ! [[ "$DEPLOYMENT_MIN_CPU_CORES" =~ ^[0-9]+$ ]] || (( DEPLOYMENT_MIN_CPU_CORES < 1 )); then + DEPLOYMENT_MIN_CPU_CORES=2 +fi + +if command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl git jq util-linux +fi + +STATE_DIR="\${ZNN_AGENT_STATE_DIR:-/var/lib/znn-testnet-agent}" +mkdir -p "$STATE_DIR" +chmod 700 "$STATE_DIR" + +# systemd runs this before every start of the node service. It refuses to let znnd start unless +# the binary's embedded git revision is exactly the commit pinned by the published release. +cat > /usr/local/bin/znn-testnet-verify-znnd <<'VERIFY' +#!/usr/bin/env bash +set -euo pipefail +STATE_DIR="\${ZNN_AGENT_STATE_DIR:-/var/lib/znn-testnet-agent}" +DEPLOYMENT_DIR="\${ZNN_DEPLOYMENT_DIR:-/opt/zenon-deployment}" +expected="\${1:-}" +if [[ -z "$expected" ]]; then + # No explicit argument: systemd is asking. The agent writes this file before it applies any + # release; if it is missing, no release has been applied on this node and nothing may start. + if [[ ! -r "$STATE_DIR/expected-znnd-commit" ]]; then + echo "no release has been applied by the testnet agent yet ($STATE_DIR/expected-znnd-commit is missing); refusing to start" >&2 + exit 1 + fi + expected="$(tr -d '[:space:]' < "$STATE_DIR/expected-znnd-commit")" + if [[ -z "$expected" ]]; then + echo "$STATE_DIR/expected-znnd-commit is empty; refusing to start" >&2 + exit 1 + fi +fi +expected="$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]')" +if ! [[ "$expected" =~ ^[0-9a-f]{40}$ ]]; then + echo "pinned commit is not a full 40-character hash: $expected" >&2 + exit 1 +fi +binary="$(command -v znnd || true)" +[[ -n "$binary" ]] || binary=/usr/local/bin/znnd +if [[ ! -x "$binary" ]]; then + echo "znnd binary not found at $binary" >&2 + exit 1 +fi +go_bin="$DEPLOYMENT_DIR/go/bin/go" +if [[ ! -x "$go_bin" ]]; then + echo "go toolchain not found at $go_bin; cannot read build metadata" >&2 + exit 1 +fi +info="$("$go_bin" version -m "$binary" 2>/dev/null || true)" +revision="$(printf '%s\\n' "$info" | awk '$1 == "build" && $2 ~ /^vcs\\.revision=/ { sub(/^vcs\\.revision=/, "", $2); print $2; exit }' | tr '[:upper:]' '[:lower:]')" +modified_lines="$(printf '%s\\n' "$info" | awk '$1 == "build" && $2 ~ /^vcs\\.modified=/ { sub(/^vcs\\.modified=/, "", $2); print $2 }')" +modified_count="$(printf '%s' "$modified_lines" | grep -c . || true)" +if ! [[ "$revision" =~ ^[0-9a-f]{40}$ ]]; then + echo "znnd binary has no embedded git revision; refusing to start an unverifiable build" >&2 + exit 1 +fi +if [[ "$modified_count" != "1" || "$modified_lines" != "false" ]]; then + echo "znnd binary build metadata does not declare exactly vcs.modified=false (revision $revision); refusing to start it" >&2 + exit 1 +fi +if [[ "$revision" != "$expected" ]]; then + echo "znnd binary revision $revision does not match the pinned commit $expected; refusing to start it" >&2 + exit 1 +fi +echo "revision=$revision" +VERIFY +chmod 755 /usr/local/bin/znn-testnet-verify-znnd + +mkdir -p "/etc/systemd/system/$SERVICE_NAME.service.d" +cat > "/etc/systemd/system/$SERVICE_NAME.service.d/10-znn-testnet-verify.conf" </dev/null 2>&1; then + # Fatal on failure: without a reload the ExecStartPre gate would not be active. + systemctl daemon-reload +fi + +cat > /usr/local/bin/znn-testnet-agent <<'AGENT' +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="\${ZNN_AGENT_ENV_FILE:-/etc/cron.d/znn-testnet-agent}" +if [[ -z "\${ZNN_BOOTSTRAP_TOKEN:-}" && -r "$ENV_FILE" ]]; then + while IFS='=' read -r key value; do + case "$key" in + ZNN_BOOTSTRAP_TOKEN|ZNN_TESTNET_URL|ZNN_DIR|ZNN_DEPLOYMENT_DIR|ZNN_DEPLOYMENT_MIN_CPU_CORES|ZNN_RPC_URL|ZNN_SERVICE_NAME|ZNN_AGENT_STATE_DIR|ZNN_BOOTSTRAP_TRACE) + [[ -n "$value" ]] && export "$key=$value" + ;; + esac + done < <(grep -E '^(ZNN_BOOTSTRAP_TOKEN|ZNN_TESTNET_URL|ZNN_DIR|ZNN_DEPLOYMENT_DIR|ZNN_DEPLOYMENT_MIN_CPU_CORES|ZNN_RPC_URL|ZNN_SERVICE_NAME|ZNN_AGENT_STATE_DIR|ZNN_BOOTSTRAP_TRACE)=' "$ENV_FILE" || true) +fi + +: "\${ZNN_BOOTSTRAP_TOKEN:?Missing ZNN_BOOTSTRAP_TOKEN.}" + +BASE_URL="\${ZNN_TESTNET_URL:-${origin}}" +ZNN_DIR="\${ZNN_DIR:-/root/.znn}" +DEPLOYMENT_DIR="\${ZNN_DEPLOYMENT_DIR:-/opt/zenon-deployment}" +DEPLOYMENT_MIN_CPU_CORES="\${ZNN_DEPLOYMENT_MIN_CPU_CORES:-2}" +RPC_URL="\${ZNN_RPC_URL:-http://127.0.0.1:35997}" +SERVICE_NAME="\${ZNN_SERVICE_NAME:-go-zenon}" +STATE_DIR="\${ZNN_AGENT_STATE_DIR:-/var/lib/znn-testnet-agent}" +BOOTSTRAP_TRACE="\${ZNN_BOOTSTRAP_TRACE:-0}" +INSTALL_STATE_FILE="$STATE_DIR/install-state.json" +STATUS_FILE="$STATE_DIR/status.json" + +mkdir -p "$STATE_DIR" +chmod 700 "$STATE_DIR" + +if [[ "\${1:-}" == "--retry" ]]; then + # Operator recovery: forget a recorded verification failure so the current release is retried. + if [[ -s "$INSTALL_STATE_FILE" ]]; then + jq 'del(.failedKey, .lastError, .failedAt)' "$INSTALL_STATE_FILE" > "$INSTALL_STATE_FILE.tmp" && mv "$INSTALL_STATE_FILE.tmp" "$INSTALL_STATE_FILE" + fi + echo "Cleared recorded release failure; the next run will retry the current release." +fi + +if ! [[ "$DEPLOYMENT_MIN_CPU_CORES" =~ ^[0-9]+$ ]] || (( DEPLOYMENT_MIN_CPU_CORES < 1 )); then + DEPLOYMENT_MIN_CPU_CORES=2 +fi + +auth_get() { + curl -fsSL -H "Authorization: Bearer $ZNN_BOOTSTRAP_TOKEN" "$1" +} + +fetch_artifact() { + # Downloads to a temporary file and moves it into place so a failed download never leaves a + # truncated artifact behind. + local url="$1" dest="$2" tmp + tmp="$(mktemp "$dest.XXXXXX")" || return 1 + if ! auth_get "$url" > "$tmp" || [[ ! -s "$tmp" ]]; then + rm -f "$tmp" + echo "Failed to download $url" >&2 + return 1 + fi + mv -f "$tmp" "$dest" +} + +try_auth_get() { + local tmp code + tmp="$(mktemp)" + code="$(curl -sS -H "Authorization: Bearer $ZNN_BOOTSTRAP_TOKEN" -w "%{http_code}" -o "$tmp" "$1" || true)" + if [[ "$code" == "200" ]]; then + cat "$tmp" + rm -f "$tmp" + return 0 + fi + rm -f "$tmp" + return 1 +} + +rpc() { + curl -fs --max-time 5 -H "Content-Type: application/json" \\ + -d "{\\"jsonrpc\\":\\"2.0\\",\\"id\\":1,\\"method\\":\\"$1\\",\\"params\\":[]}" \\ + "$RPC_URL" 2>/dev/null | jq -c '.result // {}' +} + +wipe_data_dir() { + local item base + mkdir -p "$ZNN_DIR" + shopt -s dotglob nullglob + for item in "$ZNN_DIR"/*; do + base="$(basename "$item")" + case "$base" in + wallet|genesis.json|config.json|network-private-key) + continue + ;; + esac + rm -rf -- "$item" + done + shopt -u dotglob nullglob +} + +verify_gate_active() { + # Confirms systemd will run the verifier before every start of the node service. + if ! command -v systemctl >/dev/null 2>&1; then + echo "systemctl is not available; cannot enforce the start-time verification gate" + return 1 + fi + if ! systemctl daemon-reload >/dev/null 2>&1; then + echo "systemctl daemon-reload failed; cannot enforce the start-time verification gate" + return 1 + fi + local pre + pre="$(systemctl show -p ExecStartPre --value "$SERVICE_NAME" 2>/dev/null || true)" + if [[ "$pre" != *"znn-testnet-verify-znnd"* ]]; then + echo "the $SERVICE_NAME unit does not run znn-testnet-verify-znnd before start; re-run the bootstrap installer" + return 1 + fi + return 0 +} + +checkout_pinned() { + # Clones $1 at ref $2 into $4 and, when a commit $3 is given, moves the checkout to exactly that + # commit (fetching it by hash if the ref has moved on, or the ref's full history when the server + # does not serve commits by hash). Leaves a local branch "pinned" on it so the checkout can be + # cloned again by branch name. + # Exit codes: 0 ok; 1 transient (network/clone) failure, safe to retry; 2 the pinned commit is + # not obtainable from the ref, which is an integrity failure and is not retried automatically. + local repo="$1" ref="$2" commit="$3" dest="$4" head + rm -rf "$dest" + git clone --depth 1 --branch "$ref" -- "$repo" "$dest" || return 1 + if [[ -z "$commit" ]]; then + return 0 + fi + head="$(git -C "$dest" rev-parse HEAD 2>/dev/null | tr '[:upper:]' '[:lower:]' || true)" + if [[ "$head" != "$commit" ]]; then + echo "Ref $ref is at \${head:-unknown}; fetching pinned commit $commit" + if ! git -C "$dest" fetch --depth 1 origin "$commit"; then + echo "Server did not serve the commit by hash; fetching the full history of $ref" + git -C "$dest" fetch --unshallow origin "$ref" || return 1 + fi + if ! git -C "$dest" cat-file -e "$commit^{commit}" 2>/dev/null; then + echo "Pinned commit $commit is not reachable from $ref in $repo" + return 2 + fi + # The commit is present; a failure from here on is a local problem, not a bad pin. + git -C "$dest" checkout --quiet --detach "$commit" || return 1 + fi + head="$(git -C "$dest" rev-parse HEAD 2>/dev/null | tr '[:upper:]' '[:lower:]' || true)" + if [[ "$head" != "$commit" ]]; then + echo "Checkout of $repo ended at \${head:-unknown}, not the pinned commit $commit" + return 2 + fi + git -C "$dest" checkout --quiet -B pinned "$commit" || return 1 + return 0 +} + +write_expected_commit() { + # Atomically replaces the pin the systemd verifier reads, so a concurrent start never observes a + # truncated file. Refuses to record an empty pin: current releases always carry one. + local commit="$1" tmp + if ! [[ "$commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "Refusing to record an invalid or empty commit pin: '$commit'" >&2 + return 1 + fi + tmp="$(umask 077 && mktemp "$STATE_DIR/.expected-znnd-commit.XXXXXX")" || return 1 + if ! printf '%s\\n' "$commit" > "$tmp" || ! chmod 600 "$tmp" || ! mv -f "$tmp" "$STATE_DIR/expected-znnd-commit"; then + rm -f "$tmp" + return 1 + fi +} + +binary_fingerprint() { + # SHA-256 of the installed znnd binary, or empty when there is none. Used to tell a binary the + # current run built apart from one left over from an earlier release. + local binary + binary="$(command -v znnd || true)" + [[ -n "$binary" ]] || binary=/usr/local/bin/znnd + [[ -f "$binary" ]] || return 0 + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$binary" | cut -d ' ' -f 1 + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$binary" | cut -d ' ' -f 1 + else + stat -c '%s-%Y' "$binary" 2>/dev/null || stat -f '%z-%m' "$binary" + fi +} + +verify_znnd_build() { + # Runs the same check systemd applies before every start (see znn-testnet-verify-znnd) and + # returns its diagnostic on stdout so it can be reported. + local expected="$1" output + if [[ ! -x /usr/local/bin/znn-testnet-verify-znnd ]]; then + echo "znn-testnet-verify-znnd is not installed; re-run the bootstrap installer" + return 1 + fi + if output="$(/usr/local/bin/znn-testnet-verify-znnd "$expected" 2>&1)"; then + printf '%s\\n' "$output" | sed -n 's/^revision=//p' + return 0 + fi + printf '%s\\n' "$output" | tail -n 1 + return 1 +} + +quarantine_binary() { + local binary + if command -v systemctl >/dev/null 2>&1; then + systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true + fi + binary="$(command -v znnd || true)" + [[ -n "$binary" ]] || binary="/usr/local/bin/znnd" + if [[ -f "$binary" ]]; then + mv -f "$binary" "$binary.unverified" + echo "Moved unverified binary to $binary.unverified; the service will not start it." >&2 + fi +} + +record_install_failure() { + local failed_key="$1" event_id="$2" message="$3" + echo "$message" >&2 + jq -n \\ + --arg failedKey "$failed_key" \\ + --arg eventId "$event_id" \\ + --arg lastError "$message" \\ + --arg failedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \\ + '{ failedKey: $failedKey, eventId: $eventId, lastError: $lastError, failedAt: $failedAt }' > "$INSTALL_STATE_FILE" +} + +patch_deployment_preflight() { + local preflight_file="$DEPLOYMENT_DIR/lib/preflight.sh" + [[ -f "$preflight_file" ]] || return 0 + + sed -i -E "s/cores < [0-9]+/cores < $DEPLOYMENT_MIN_CPU_CORES/" "$preflight_file" || return 1 + sed -i -E "s/Minimum [0-9]+ required\\./Minimum $DEPLOYMENT_MIN_CPU_CORES required./" "$preflight_file" || return 1 + sed -i -E '/mem_total_gb < [0-9]+/,/fi/ s/error_log "Total RAM \\$\\{mem_total_gb\\}GiB detected\\. Minimum [0-9]+GiB required\\."/warn_log "Total RAM \\\${mem_total_gb}GiB detected. 4GiB recommended for go-zenon builds."/' "$preflight_file" || return 1 + sed -i -E '/mem_total_gb < [0-9]+/,/fi/ s/^[[:space:]]*return 1[[:space:]]*$/:/' "$preflight_file" || return 1 + echo "Deployment pre-flight patch:" + echo " CPU minimum: $DEPLOYMENT_MIN_CPU_CORES core(s)" + echo " RAM check: warning only; 4GiB remains recommended" + if [[ "$BOOTSTRAP_TRACE" == "1" || "$BOOTSTRAP_TRACE" == "true" ]]; then + echo "Deployment pre-flight patch trace:" + grep -E 'cores <|Minimum [0-9]+ required|mem_total_gb <|Total RAM|4GiB recommended' "$preflight_file" | sed 's/^/ /' || true + fi +} + +install_release() { + local manifest="$1" + local event_id node_type go_repo go_ref go_commit deployment_repo deployment_ref deployment_commit genesis_url config_url producer_url producer_password_url network_private_key_url wipe_data apply_at desired_key installed_key binary_key installed_binary_key installed_verified binary_missing artifacts_ready failed_key verify_output deploy_ok gate_output checkout_output checkout_rc build_repo build_ref binary_before binary_after + + event_id="$(printf '%s' "$manifest" | jq -r '.eventId')" + node_type="$(printf '%s' "$manifest" | jq -r '.nodeType // "pillar"')" + go_repo="$(printf '%s' "$manifest" | jq -r '.goZenon.repoUrl')" + go_ref="$(printf '%s' "$manifest" | jq -r '.goZenon.ref')" + go_commit="$(printf '%s' "$manifest" | jq -r '.goZenon.commit // empty' | tr '[:upper:]' '[:lower:]')" + deployment_repo="$(printf '%s' "$manifest" | jq -r '.deployment.repoUrl')" + deployment_ref="$(printf '%s' "$manifest" | jq -r '.deployment.ref')" + deployment_commit="$(printf '%s' "$manifest" | jq -r '.deployment.commit // empty' | tr '[:upper:]' '[:lower:]')" + wipe_data="$(printf '%s' "$manifest" | jq -r '.actions.wipeData // false')" + apply_at="$(printf '%s' "$manifest" | jq -r '.actions.applyAt // empty')" + genesis_url="$(printf '%s' "$manifest" | jq -r '.genesisUrl')" + config_url="$(printf '%s' "$manifest" | jq -r '.configUrl')" + producer_url="$(printf '%s' "$manifest" | jq -r '.producerKeyFileUrl // empty')" + producer_password_url="$(printf '%s' "$manifest" | jq -r '.producerPasswordUrl // empty')" + network_private_key_url="$(printf '%s' "$manifest" | jq -r '.networkPrivateKeyUrl // empty')" + desired_key="$(printf '%s' "$manifest" | jq -r '[.eventId, (.nodeType // "pillar"), .goZenon.repoUrl, .goZenon.ref, (.goZenon.commit // ""), .deployment.repoUrl, .deployment.ref, (.deployment.commit // ""), (.actions.wipeData // false), (.actions.applyAt // "")] | @tsv')" + binary_key="$(printf '%s' "$manifest" | jq -r '[.goZenon.repoUrl, .goZenon.ref, (.goZenon.commit // ""), .deployment.repoUrl, .deployment.ref, (.deployment.commit // "")] | @tsv')" + installed_key="$(jq -r '.desiredKey // empty' "$INSTALL_STATE_FILE" 2>/dev/null || true)" + installed_binary_key="$(jq -r '.binaryKey // empty' "$INSTALL_STATE_FILE" 2>/dev/null || true)" + installed_verified="$(jq -r '.verifiedCommit // empty' "$INSTALL_STATE_FILE" 2>/dev/null || true)" + failed_key="$(jq -r '.failedKey // empty' "$INSTALL_STATE_FILE" 2>/dev/null || true)" + if [[ -n "$failed_key" && "$failed_key" == "$desired_key" ]]; then + echo "Release $event_id previously failed verification; waiting for a new release." >&2 + return 1 + fi + binary_missing=false + if ! command -v znnd >/dev/null 2>&1; then + binary_missing=true + fi + # A binary installed by an earlier agent that never verified pins must be rebuilt and verified. + if [[ -n "$go_commit" && "$installed_verified" != "$go_commit" ]]; then + binary_missing=true + fi + + # Written before any deployment step so the systemd ExecStartPre gate applies to the very first + # start of a freshly built binary. Explicitly fatal: without it nothing may start. + if ! write_expected_commit "$go_commit"; then + echo "Could not record the pinned commit in $STATE_DIR/expected-znnd-commit" >&2 + return 1 + fi + + artifacts_ready=false + if [[ -s "$ZNN_DIR/genesis.json" && -s "$ZNN_DIR/config.json" ]]; then + if [[ "$node_type" == "seed" && -s "$ZNN_DIR/network-private-key" ]]; then + artifacts_ready=true + elif [[ "$node_type" != "seed" && -s "$ZNN_DIR/wallet/producer.json" && -s "$ZNN_DIR/wallet/producer-password.txt" ]]; then + artifacts_ready=true + fi + fi + + if [[ "$desired_key" == "$installed_key" && "$artifacts_ready" == "true" ]]; then + if [[ -z "$go_commit" || "$installed_verified" == "$go_commit" ]]; then + return 0 + fi + fi + + if command -v systemctl >/dev/null 2>&1; then + systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true + fi + + if [[ "$binary_key" != "$installed_binary_key" || "$binary_missing" == "true" ]]; then + # If the unit already exists, the start-time gate must be active before anything is rebuilt. + if command -v systemctl >/dev/null 2>&1 && systemctl cat "$SERVICE_NAME" >/dev/null 2>&1; then + if ! gate_output="$(verify_gate_active)"; then + record_install_failure "$desired_key" "$event_id" "$gate_output" + return 1 + fi + fi + + # The deployment scripts run as root from this checkout, so it is moved to exactly the pinned + # commit (or rejected) before any of them run. + checkout_output="$(checkout_pinned "$deployment_repo" "$deployment_ref" "$deployment_commit" "$DEPLOYMENT_DIR" 2>&1)" && checkout_rc=0 || checkout_rc=$? + if (( checkout_rc != 0 )); then + rm -rf "$DEPLOYMENT_DIR" + if (( checkout_rc == 2 )); then + record_install_failure "$desired_key" "$event_id" "Deployment repository checkout failed: $(printf '%s' "$checkout_output" | tail -n 1)" + else + echo "Deployment repository checkout failed (will retry): $(printf '%s' "$checkout_output" | tail -n 1)" >&2 + fi + return 1 + fi + printf '%s\\n' "$checkout_output" + chmod +x "$DEPLOYMENT_DIR/zenon.sh" || return 1 + patch_deployment_preflight || return 1 + + # go-zenon is checked out at the pinned commit locally and the deployment script is pointed at + # that checkout, so the build is reproducible even after the upstream ref moves. A clone of the + # local checkout keeps the same commit hashes, so the embedded vcs.revision is the pin. + build_repo="$go_repo" + build_ref="$go_ref" + if [[ -n "$go_commit" ]]; then + checkout_output="$(checkout_pinned "$go_repo" "$go_ref" "$go_commit" "$DEPLOYMENT_DIR/go-zenon-pinned" 2>&1)" && checkout_rc=0 || checkout_rc=$? + if (( checkout_rc != 0 )); then + if (( checkout_rc == 2 )); then + record_install_failure "$desired_key" "$event_id" "go-zenon checkout failed: $(printf '%s' "$checkout_output" | tail -n 1)" + else + echo "go-zenon checkout failed (will retry): $(printf '%s' "$checkout_output" | tail -n 1)" >&2 + fi + return 1 + fi + printf '%s\\n' "$checkout_output" + build_repo="file://$DEPLOYMENT_DIR/go-zenon-pinned" + build_ref="pinned" + fi + + # zenon.sh builds, installs, and starts the service. The systemd drop-in installed by the + # bootstrap runs znn-testnet-verify-znnd before any start, so a binary whose embedded revision + # does not match the pinned commit is never executed, even by the deployment script. + cd "$DEPLOYMENT_DIR" || return 1 + binary_before="$(binary_fingerprint)" + deploy_ok=true + if ! ./zenon.sh --deploy zenon "$build_repo" "$build_ref"; then + deploy_ok=false + fi + binary_after="$(binary_fingerprint)" + + if command -v systemctl >/dev/null 2>&1; then + systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true + fi + + # A deployment that produced no unit or no binary failed before anything could run; that is a + # build or environment problem and is retried by cron, never recorded as a verification failure. + if [[ "$deploy_ok" != "true" ]]; then + echo "zenon.sh deployment failed. Last deployment log lines:" >&2 + tail -120 "$DEPLOYMENT_DIR/.znnsh.log" >&2 2>/dev/null || true + fi + if ! command -v systemctl >/dev/null 2>&1 || ! systemctl cat "$SERVICE_NAME" >/dev/null 2>&1; then + echo "The $SERVICE_NAME unit does not exist after deployment; will retry." >&2 + return 1 + fi + if ! command -v znnd >/dev/null 2>&1; then + echo "No znnd binary was installed by the deployment; will retry." >&2 + return 1 + fi + if [[ "$deploy_ok" != "true" && "$binary_after" == "$binary_before" ]]; then + # The build failed before replacing the previous release's binary. It stays stopped (the + # start-time gate holds the new pin) and cron retries; nothing about it is a verification result. + echo "Deployment failed before replacing the existing znnd binary; leaving the service stopped and retrying." >&2 + return 1 + fi + + # This run produced a unit and a binary, so from here on a problem means the binary must not + # run: the gate must be active and the binary must match the pin before it is ever restarted. + if ! gate_output="$(verify_gate_active)"; then + record_install_failure "$desired_key" "$event_id" "$gate_output" + quarantine_binary + return 1 + fi + + if [[ -n "$go_commit" ]]; then + if ! verify_output="$(verify_znnd_build "$go_commit")"; then + record_install_failure "$desired_key" "$event_id" "$verify_output" + quarantine_binary + return 1 + fi + echo "Verified znnd build commit $verify_output matches the published pin." + fi + + if [[ "$deploy_ok" != "true" ]]; then + # Verified binary, but the deployment reported a failure (for example its own start step); + # leave the service stopped and let cron retry. + return 1 + fi + fi + + if [[ "$wipe_data" == "true" ]]; then + wipe_data_dir + fi + + mkdir -p "$ZNN_DIR/wallet" || return 1 + fetch_artifact "$genesis_url" "$ZNN_DIR/genesis.json" || return 1 + fetch_artifact "$config_url" "$ZNN_DIR/config.json" || return 1 + if [[ -n "$producer_url" ]]; then + fetch_artifact "$producer_url" "$ZNN_DIR/wallet/producer.json" || return 1 + fi + if [[ -n "$producer_password_url" ]]; then + fetch_artifact "$producer_password_url" "$ZNN_DIR/wallet/producer-password.txt" || return 1 + fi + if [[ -n "$network_private_key_url" ]]; then + fetch_artifact "$network_private_key_url" "$ZNN_DIR/network-private-key" || return 1 + fi + + chmod 700 "$ZNN_DIR" "$ZNN_DIR/wallet" || return 1 + chmod 600 "$ZNN_DIR/genesis.json" "$ZNN_DIR/config.json" || return 1 + for secret in "$ZNN_DIR/wallet/producer.json" "$ZNN_DIR/wallet/producer-password.txt" "$ZNN_DIR/network-private-key"; do + if [[ -f "$secret" ]]; then + chmod 600 "$secret" || return 1 + fi + done + + if command -v systemctl >/dev/null 2>&1; then + if ! systemctl restart "$SERVICE_NAME"; then + echo "Failed to restart $SERVICE_NAME; see: journalctl -u $SERVICE_NAME" >&2 + return 1 + fi + fi + + jq -n \\ + --arg desiredKey "$desired_key" \\ + --arg binaryKey "$binary_key" \\ + --arg eventId "$event_id" \\ + --arg installedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \\ + --arg goRepo "$go_repo" \\ + --arg goRef "$go_ref" \\ + --arg goCommit "$go_commit" \\ + --arg deploymentRepo "$deployment_repo" \\ + --arg deploymentRef "$deployment_ref" \\ + --arg deploymentCommit "$deployment_commit" \\ + --arg verifiedCommit "$go_commit" \\ + --arg nodeType "$node_type" \\ + --arg applyAt "$apply_at" \\ + --argjson wipeData "$wipe_data" \\ + '{ + desiredKey: $desiredKey, + binaryKey: $binaryKey, + eventId: $eventId, + installedAt: $installedAt, + nodeType: $nodeType, + goZenon: { repoUrl: $goRepo, ref: $goRef, commit: $goCommit }, + deployment: { repoUrl: $deploymentRepo, ref: $deploymentRef, commit: $deploymentCommit }, + verifiedCommit: $verifiedCommit, + actions: ({ wipeData: $wipeData } + (if $applyAt == "" then {} else { applyAt: $applyAt } end)) + }' > "$INSTALL_STATE_FILE.tmp" || return 1 + mv -f "$INSTALL_STATE_FILE.tmp" "$INSTALL_STATE_FILE" || return 1 +} + +report_status() { + local manifest="\${1:-}" + local waiting="\${2:-false}" + local event_id go_repo go_ref go_commit sync_json network_json process_json service_active logs error_count warn_count recent_json payload last_error + + if [[ -n "$manifest" ]]; then + event_id="$(printf '%s' "$manifest" | jq -r '.eventId')" + go_repo="$(printf '%s' "$manifest" | jq -r '.goZenon.repoUrl')" + go_ref="$(printf '%s' "$manifest" | jq -r '.goZenon.ref')" + go_commit="$(printf '%s' "$manifest" | jq -r '.goZenon.commit // empty')" + else + event_id="waiting-for-release" + go_repo="" + go_ref="" + go_commit="" + fi + + last_error="$(jq -r '.lastError // empty' "$INSTALL_STATE_FILE" 2>/dev/null || true)" + sync_json="$(rpc stats.syncInfo || echo '{}')" + network_json="$(rpc stats.networkInfo || echo '{}')" + process_json="$(rpc stats.processInfo || echo '{}')" + service_active=false + if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet "$SERVICE_NAME"; then + service_active=true + fi + + logs="$(journalctl -u "$SERVICE_NAME" --since '1 minute ago' --no-pager 2>/dev/null | grep -Eai 'error|warn|panic|fatal|failed|exception' | tail -20 || true)" + error_count="$(printf '%s\\n' "$logs" | grep -Eai 'error|panic|fatal|failed|exception' | grep -c . || true)" + warn_count="$(printf '%s\\n' "$logs" | grep -Eai 'warn' | grep -c . || true)" + recent_json="$(printf '%s\\n' "$logs" | jq -R . | jq -s .)" + + payload="$(jq -n \\ + --arg eventId "$event_id" \\ + --arg reportedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \\ + --arg hostname "$(hostname)" \\ + --arg goRepo "$go_repo" \\ + --arg goRef "$go_ref" \\ + --arg goCommit "$go_commit" \\ + --arg lastError "$last_error" \\ + --argjson serviceActive "$service_active" \\ + --argjson waiting "$waiting" \\ + --argjson sync "$sync_json" \\ + --argjson network "$network_json" \\ + --argjson process "$process_json" \\ + --argjson errors "$error_count" \\ + --argjson warnings "$warn_count" \\ + --argjson recent "$recent_json" \\ + '{ + eventId: $eventId, + reportedAt: $reportedAt, + node: ({ + hostname: $hostname, + serviceActive: $serviceActive, + waitingForRelease: $waiting, + installedRepo: $goRepo, + installedRef: $goRef, + installedCommit: $goCommit + } + (if $lastError == "" then {} else { lastError: $lastError } end)), + sync: ({ + } + (if ($sync.state // null) == null then {} else { state: $sync.state } end) + + (if ($sync.currentHeight // null) == null then {} else { currentHeight: $sync.currentHeight } end) + + (if ($sync.targetHeight // null) == null then {} else { targetHeight: $sync.targetHeight } end)), + network: ({ + peerCount: (($network.peers // []) | length) + } + (if ($network.self.publicKey // null) == null then {} else { selfPublicKey: $network.self.publicKey } end) + + (if ($network.self.ip // null) == null then {} else { selfIp: $network.self.ip } end) + + { + peers: (($network.peers // []) | map( + {} + + (if (.publicKey // null) == null then {} else { publicKey: .publicKey } end) + + (if (.ip // null) == null then {} else { ip: .ip } end) + + (if (.name // null) == null then {} else { name: .name } end) + + (if (.version // null) == null then {} else { version: .version } end) + ) | .[0:20]) + }), + process: ({ + } + (if ($process.version // null) == null then {} else { version: $process.version } end) + + (if ($process.commit // null) == null then {} else { commit: $process.commit } end)), + logs: { + errorCountLastMinute: $errors, + warningCountLastMinute: $warnings, + recent: $recent + } + }')" + + curl -fsS -X POST "$BASE_URL/api/bootstrap/status" \\ + -H "Authorization: Bearer $ZNN_BOOTSTRAP_TOKEN" \\ + -H "Content-Type: application/json" \\ + -d "$payload" >/dev/null || true + + printf '%s\\n' "$payload" > "$STATUS_FILE" +} + +# install_release runs as a plain statement so that errexit stays active inside it: any +# unhandled failure aborts the run and this trap reports it, instead of continuing with partial +# state. (A function called inside "if !" would have errexit suppressed throughout its body.) +manifest_for_report="" +on_exit() { + local rc=$? + if (( rc != 0 )); then + echo "Bootstrap agent run failed (exit $rc)." >&2 + report_status "$manifest_for_report" false || true + fi +} +trap on_exit EXIT + +manifest="$(try_auth_get "$BASE_URL/api/bootstrap/manifest" || true)" +if [[ -z "$manifest" ]]; then + report_status "" true + echo "No published release is available yet. Waiting for Publish Release." + exit 0 +fi +manifest_for_report="$manifest" + +apply_at="$(printf '%s' "$manifest" | jq -r '.actions.applyAt // empty')" +if [[ -n "$apply_at" ]]; then + apply_at_epoch="$(date -u -d "$apply_at" +%s 2>/dev/null || echo 0)" + now_epoch="$(date -u +%s)" + if [[ "$apply_at_epoch" =~ ^[0-9]+$ ]] && (( apply_at_epoch > now_epoch )); then + report_status "$manifest" true + echo "Published release applies at $apply_at. Waiting." + exit 0 + fi +fi + +install_release "$manifest" +report_status "$manifest" false +AGENT + +chmod 700 /usr/local/bin/znn-testnet-agent + +cat > /etc/cron.d/znn-testnet-agent <= MIN_SECRET_LENGTH) return configured; + + if (process.env.NODE_ENV === "production") { + throw new Error( + configured + ? `APP_SECRET must be at least ${MIN_SECRET_LENGTH} characters. Generate one with: openssl rand -hex 32` + : "APP_SECRET is not set. Refusing to start in production with the development secret. Generate one with: openssl rand -hex 32" + ); + } + + if (configured) return configured; console.warn("APP_SECRET is not set; using the development secret. Set APP_SECRET before using this outside local testing."); + return DEV_SECRET; } +const SECRET = resolveSecret(); + function keyFromSecret(): Buffer { return createHash("sha256").update(SECRET).digest(); } diff --git a/src/server/genesis.ts b/src/server/genesis.ts index 56990fd..9fc994c 100644 --- a/src/server/genesis.ts +++ b/src/server/genesis.ts @@ -218,17 +218,31 @@ export function buildNodeConfig( Password: producerPassword ?? "" } : undefined, - RPC: { - EnableHTTP: true, - EnableWS: true, - HTTPHost: "0.0.0.0", - HTTPPort: 35997, - WSHost: "0.0.0.0", - WSPort: 35998, - HTTPCors: ["*"], - WSOrigins: ["*"], - Endpoints: ["ledger", "stats", "embedded", "subscribe"] - }, + // Pillars only need RPC for the local bootstrap agent, so their RPC listens on loopback with no + // browser origins. Seed / non-producing nodes serve the explorer and faucet and stay public. + RPC: pillar + ? { + EnableHTTP: true, + EnableWS: true, + HTTPHost: "127.0.0.1", + HTTPPort: 35997, + WSHost: "127.0.0.1", + WSPort: 35998, + HTTPCors: [], + WSOrigins: [], + Endpoints: ["ledger", "stats", "embedded", "subscribe"] + } + : { + EnableHTTP: true, + EnableWS: true, + HTTPHost: "0.0.0.0", + HTTPPort: 35997, + WSHost: "0.0.0.0", + WSPort: 35998, + HTTPCors: ["*"], + WSOrigins: ["*"], + Endpoints: ["ledger", "stats", "embedded", "subscribe"] + }, Net: { ListenHost: "0.0.0.0", ListenPort: 35995, diff --git a/src/server/git-refs.test.ts b/src/server/git-refs.test.ts new file mode 100644 index 0000000..b5fd225 --- /dev/null +++ b/src/server/git-refs.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { isUploadPackAdvertisement, MAX_ADVERTISEMENT_BYTES, parseUploadPackRefs, pickRef, resolveGitRef, uploadPackRefsUrl } from "./git-refs.js"; + +const A = "a".repeat(40); +const B = "b".repeat(40); +const C = "c".repeat(40); + +function pkt(line: string): string { + return `${(line.length + 4).toString(16).padStart(4, "0")}${line}`; +} + +const advertisement = + pkt("# service=git-upload-pack\n") + + "0000" + + pkt(`${A} HEAD\0multi_ack symref=HEAD:refs/heads/master agent=git/2.x\n`) + + pkt(`${A} refs/heads/master\n`) + + pkt(`${B} refs/heads/release/v0.0.9\n`) + + pkt(`${C} refs/tags/v1.0\n`) + + pkt(`${B} refs/tags/v1.0^{}\n`) + + "0000"; + +describe("git smart HTTP ref resolution", () => { + it("parses pkt-line ref advertisements", () => { + const refs = parseUploadPackRefs(advertisement); + assert.equal(refs.get("refs/heads/master"), A); + assert.equal(refs.get("refs/heads/release/v0.0.9"), B); + assert.equal(refs.get("refs/tags/v1.0^{}"), B); + assert.equal(refs.get("HEAD"), A); + }); + + it("prefers branches, then peeled tags", () => { + const refs = parseUploadPackRefs(advertisement); + assert.equal(pickRef(refs, "master"), A); + assert.equal(pickRef(refs, "release/v0.0.9"), B); + assert.equal(pickRef(refs, "v1.0"), B); + assert.equal(pickRef(refs, "missing"), undefined); + }); + + it("builds the info/refs URL with and without .git", () => { + assert.equal(uploadPackRefsUrl("https://github.com/a/b.git"), "https://github.com/a/b.git/info/refs?service=git-upload-pack"); + assert.equal(uploadPackRefsUrl("https://github.com/a/b/"), "https://github.com/a/b.git/info/refs?service=git-upload-pack"); + }); + + it("rejects truncated packets, bad lengths, and trailing bytes", () => { + const truncated = pkt("# service=git-upload-pack\n") + "0000" + `0031${A} refs/heads/master\n`.slice(0, 20); + assert.throws(() => parseUploadPackRefs(truncated), /truncated/); + assert.throws(() => parseUploadPackRefs(`0005${A} refs/heads/x\n`), /truncated|Malformed/); + assert.throws(() => parseUploadPackRefs("0002"), /Unsupported/); + assert.throws(() => parseUploadPackRefs("zzzz"), /Malformed pkt-line length/); + assert.throws(() => parseUploadPackRefs(advertisement + "ab"), /trailing|terminal flush/); + assert.throws(() => parseUploadPackRefs(""), /missing terminal flush/); + }); + + it("requires a terminal flush and rejects packets after it", () => { + const noFinalFlush = pkt("# service=git-upload-pack\n") + "0000" + pkt(`${A} refs/heads/master\n`); + assert.throws(() => parseUploadPackRefs(noFinalFlush), /missing terminal flush/); + const afterFlush = advertisement + pkt(`${C} refs/heads/master\n`) + "0000"; + assert.throws(() => parseUploadPackRefs(afterFlush), /after the terminal flush/); + // Servers that omit the service header are still parsed. + assert.equal(parseUploadPackRefs(pkt(`${A} refs/heads/master\n`) + "0000").get("refs/heads/master"), A); + }); + + it("matches the media type exactly, ignoring case and parameters", () => { + assert.equal(isUploadPackAdvertisement("application/x-git-upload-pack-advertisement"), true); + assert.equal(isUploadPackAdvertisement("Application/X-Git-Upload-Pack-Advertisement; charset=utf-8"), true); + assert.equal(isUploadPackAdvertisement("application/x-git-upload-pack-advertisementevil"), false); + assert.equal(isUploadPackAdvertisement("text/html"), false); + assert.equal(isUploadPackAdvertisement(null), false); + }); + + it("does not let a tag shadow a branch and handles lightweight tags", () => { + const refs = parseUploadPackRefs(pkt(`${A} refs/heads/v1.0\n`) + pkt(`${C} refs/tags/v1.0\n`) + pkt(`${B} refs/tags/v1.0^{}\n`) + pkt(`${C} refs/tags/light\n`) + "0000"); + assert.equal(pickRef(refs, "v1.0"), A); + assert.equal(pickRef(refs, "light"), C); + }); + + it("resolves through fetch and fails on missing refs, errors, wrong content type, or oversized bodies", async () => { + const headers = { "content-type": "application/x-git-upload-pack-advertisement" }; + const fake = (async (input: string | URL | Request, init?: RequestInit) => { + assert.equal(init?.redirect, "error"); + return new Response(advertisement, { status: 200, headers }); + }) as typeof fetch; + assert.equal(await resolveGitRef("https://github.com/a/b.git", "master", fake), A); + await assert.rejects(resolveGitRef("https://github.com/a/b.git", "nope", fake), /not found/); + const failing = (async () => new Response("", { status: 404, headers })) as typeof fetch; + await assert.rejects(resolveGitRef("https://github.com/a/b.git", "master", failing), /HTTP 404/); + const html = (async () => new Response("", { status: 200, headers: { "content-type": "text/html" } })) as typeof fetch; + await assert.rejects(resolveGitRef("https://github.com/a/b.git", "master", html), /content type/); + const huge = (async () => new Response("x".repeat(MAX_ADVERTISEMENT_BYTES + 1), { status: 200, headers })) as typeof fetch; + await assert.rejects(resolveGitRef("https://github.com/a/b.git", "master", huge), /too large/); + }); +}); diff --git a/src/server/git-refs.ts b/src/server/git-refs.ts new file mode 100644 index 0000000..ac5a78d --- /dev/null +++ b/src/server/git-refs.ts @@ -0,0 +1,108 @@ +/** + * Resolves a branch or tag to a commit hash by reading the repository's advertised refs over the + * git smart HTTP protocol (GET /info/refs?service=git-upload-pack). This needs no git binary + * and works with GitHub, GitLab, Gitea, and plain git-http-backend hosts. + */ +const SHA_PATTERN = /^[0-9a-f]{40}$/; +/** Upper bound on an advertisement we are willing to parse (go-zenon's is a few KB). */ +export const MAX_ADVERTISEMENT_BYTES = 8 * 1024 * 1024; + +/** + * Strict pkt-line parser for a v0 ref advertisement: every packet must be fully present, special + * lengths other than the flush packet (0000) are rejected, the ref list must end with a terminal + * flush, and nothing may follow it. Smart HTTP servers send a "# service=git-upload-pack" line and + * a flush first; both are accepted but not required, so servers that omit them are still parsed. + */ +export function parseUploadPackRefs(text: string): Map { + const refs = new Map(); + let offset = 0; + let phase: "header" | "refs" = "header"; + let terminated = false; + while (offset < text.length) { + if (offset + 4 > text.length) throw new Error("Malformed pkt-line response: trailing bytes"); + const header = text.slice(offset, offset + 4); + if (!/^[0-9a-fA-F]{4}$/.test(header)) throw new Error("Malformed pkt-line length"); + const length = Number.parseInt(header, 16); + if (length === 0) { + offset += 4; + if (phase === "header") { + phase = "refs"; // flush that ends the service header + continue; + } + terminated = true; + if (offset !== text.length) throw new Error("Malformed pkt-line response: data after the terminal flush"); + break; + } + if (length < 4) throw new Error(`Unsupported pkt-line length ${header}`); + if (offset + length > text.length) throw new Error("Malformed pkt-line response: truncated packet"); + const line = text.slice(offset + 4, offset + length); + offset += length; + if (phase === "header") { + if (line.startsWith("#")) continue; // "# service=git-upload-pack" + phase = "refs"; // servers that omit the service header go straight to refs + } + const payload = line.split("\0")[0].replace(/\n$/, ""); + const space = payload.indexOf(" "); + if (space !== 40) continue; + const sha = payload.slice(0, 40).toLowerCase(); + const name = payload.slice(41); + if (!SHA_PATTERN.test(sha) || !name) continue; + refs.set(name, sha); + } + if (!terminated) throw new Error("Malformed pkt-line response: missing terminal flush"); + return refs; +} + +/** Exact media-type comparison, ignoring case and parameters such as charset. */ +export function isUploadPackAdvertisement(contentType: string | null): boolean { + const essence = (contentType ?? "").split(";")[0].trim().toLowerCase(); + return essence === "application/x-git-upload-pack-advertisement"; +} + +/** Mirrors `git clone --branch`: a branch wins over a tag of the same name; tags are peeled. */ +export function pickRef(refs: Map, ref: string): string | undefined { + return refs.get(`refs/heads/${ref}`) ?? refs.get(`refs/tags/${ref}^{}`) ?? refs.get(`refs/tags/${ref}`); +} + +export function uploadPackRefsUrl(repoUrl: string): string { + const base = repoUrl.trim().replace(/\/+$/, ""); + return `${base}${base.endsWith(".git") ? "" : ".git"}/info/refs?service=git-upload-pack`; +} + +async function readBounded(response: Response, limit: number): Promise { + const declared = Number(response.headers.get("content-length") ?? "0"); + if (declared > limit) throw new Error("Ref advertisement is too large"); + if (!response.body) return Buffer.alloc(0); + const chunks: Uint8Array[] = []; + let total = 0; + const reader = response.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > limit) { + await reader.cancel().catch(() => undefined); + throw new Error("Ref advertisement is too large"); + } + chunks.push(value); + } + return Buffer.concat(chunks); +} + +export async function resolveGitRef(repoUrl: string, ref: string, fetchImpl: typeof fetch = fetch): Promise { + const url = uploadPackRefsUrl(repoUrl); + const response = await fetchImpl(url, { + redirect: "error", + headers: { "User-Agent": "zenon-testnet-builder", Accept: "*/*" }, + signal: AbortSignal.timeout(15_000) + }); + if (!response.ok) throw new Error(`HTTP ${response.status} from ${url}`); + const contentType = response.headers.get("content-type"); + if (!isUploadPackAdvertisement(contentType)) { + throw new Error(`Unexpected content type '${contentType ?? ""}' from ${url}; not a git smart HTTP endpoint`); + } + const refs = parseUploadPackRefs((await readBounded(response, MAX_ADVERTISEMENT_BYTES)).toString("latin1")); + const sha = pickRef(refs, ref); + if (!sha) throw new Error(`Ref '${ref}' was not found in ${repoUrl}`); + return sha; +} diff --git a/src/server/index.ts b/src/server/index.ts index 401fb89..13fcfd2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,13 +4,18 @@ import { createECDH } from "node:crypto"; import path from "node:path"; import { z } from "zod"; import { clearSessionCookie, login, logout, requireAuth, sessionTokenFromRequest, setSessionCookie, type AuthedRequest } from "./auth.js"; -import { createAccount, resetAccountPassword } from "./accounts.js"; +import { createAccount, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, resetAccountPassword } from "./accounts.js"; import { decryptText, encryptText, randomId, sha256 } from "./crypto.js"; import { buildGenesis, buildNodeConfig, readiness, toPublicPillar } from "./genesis.js"; import { buildPillarPackage, buildSeedNodePackage, buildSporkPackage } from "./packages.js"; import { enodeFromPublicKey, multiaddrFromEnode, multiaddrFromPublicKey } from "./libp2p.js"; -import { probeSeedNode, validateSeedNodeIp } from "./seeders.js"; -import { readState, updateState } from "./storage.js"; +import { bootstrapInstallScript } from "./bootstrap-script.js"; +import { resolveGitRef } from "./git-refs.js"; +import { publishInputsKey, settingsSnapshot } from "./settings.js"; +import { AttemptLimiter } from "./rate-limit.js"; +import { checkCommit, checkGitRef, checkRepoUrl, loadRepoPolicy, redactUrl, releasePolicyErrors } from "./repo-policy.js"; +import { isPublicIp, probeSeedNode, validateSeedNodeIp } from "./seeders.js"; +import { DEFAULT_DEPLOYMENT_REPO, DEFAULT_GO_ZENON_REPO, readState, updateState } from "./storage.js"; import { createWallet, toStoredWallet } from "./wallets.js"; import type { AppState, @@ -32,12 +37,70 @@ const PUBLIC_GENESIS_PATH = "/genesis.json"; const PUBLIC_CONFIG_PATH = "/config.json"; const PUBLIC_NODE_PLAN_PATH = "/node-plan.json"; const NODE_STATUS_HISTORY_LIMIT = 24 * 60; +// Nodes report once a minute. Samples that arrive faster than this only refresh `latest`, so a +// misbehaving token holder cannot grow the on-disk history faster than a well-behaved agent. +const NODE_STATUS_HISTORY_MIN_INTERVAL_MS = 50_000; +// Optional fixed public origin (e.g. https://testnet.example.com) used in generated scripts and +// manifests instead of trusting Host / X-Forwarded-* request headers. +const PUBLIC_URL = normalizePublicUrl(process.env.PUBLIC_URL); +// Which upstream proxies may set X-Forwarded-* (express "trust proxy" setting). Defaults to +// loopback only; the compose stacks set TRUST_PROXY=uniquelocal because their Caddy reaches the app +// over a private Docker network. Anything in the trusted range can forge forwarded addresses, so +// keep it as narrow as the deployment allows. +const TRUST_PROXY = parseTrustProxy(process.env.TRUST_PROXY); + +// Which repositories operator nodes may be told to clone and execute as root. +const REPO_POLICY = loadRepoPolicy(process.env, [DEFAULT_GO_ZENON_REPO, DEFAULT_DEPLOYMENT_REPO]); + +// Login attempts are counted per (account, client address) so a remote guesser cannot lock the +// real admin out from another address, plus a looser per-address cap and a bound on how many +// password checks may run at once (each one is a deliberately expensive scrypt). +const LOGIN_WINDOW_MS = 15 * 60_000; +const loginLimiterByAccountAndAddress = new AttemptLimiter({ maxAttempts: 10, windowMs: LOGIN_WINDOW_MS }); +const loginLimiterByAddress = new AttemptLimiter({ maxAttempts: 50, windowMs: LOGIN_WINDOW_MS }); +const MAX_CONCURRENT_LOGINS = 8; +let loginsInFlight = 0; const loginSchema = z.object({ - username: z.string().min(1), - password: z.string().min(1) + username: z.string().min(1).max(200), + password: z.string().min(1).max(PASSWORD_MAX_LENGTH) }); +// Values that end up as arguments to `git clone` and the deployment script on operator nodes. +const repoUrlSchema = z + .string() + .trim() + .min(1) + .max(300) + .superRefine((value, context) => { + const check = checkRepoUrl(value, REPO_POLICY); + if (!check.ok) context.addIssue({ code: z.ZodIssueCode.custom, message: check.reason }); + }); +const gitRefSchema = z + .string() + .trim() + .min(1) + .max(160) + .superRefine((value, context) => { + const check = checkGitRef(value); + if (!check.ok) context.addIssue({ code: z.ZodIssueCode.custom, message: check.reason }); + }); +const gitCommitSchema = z + .string() + .trim() + .transform((value) => value.toLowerCase()) + .superRefine((value, context) => { + const check = checkCommit(value); + if (!check.ok) context.addIssue({ code: z.ZodIssueCode.custom, message: check.reason }); + }); +const optionalCommitSchema = z + .string() + .trim() + .max(80) + .optional() + .transform((value) => value || undefined) + .pipe(gitCommitSchema.optional()); + const nodeNameSchema = z .string() .trim() @@ -65,7 +128,10 @@ const usernameSchema = z .max(40) .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "Use letters, numbers, dots, underscores, or hyphens"); -const passwordSchema = z.string().min(8, "Password must be at least 8 characters").max(200); +const passwordSchema = z + .string() + .min(PASSWORD_MIN_LENGTH, `Password must be at least ${PASSWORD_MIN_LENGTH} characters`) + .max(PASSWORD_MAX_LENGTH); const accountCreateSchema = z.object({ username: usernameSchema, @@ -84,11 +150,12 @@ const settingsSchema = z.object({ minPillars: z.number().int().min(1).max(100), genesisTimestampSec: z.number().int().positive(), releaseApplyAtSec: z.number().int().positive().optional(), - goZenonRepo: z.string().trim().min(1).max(300), - goZenonRef: z.string().trim().min(1).max(160), - goZenonCommit: z.string().trim().max(80).optional(), - deploymentRepo: z.string().trim().min(1).max(300), - deploymentRef: z.string().trim().min(1).max(160), + goZenonRepo: repoUrlSchema, + goZenonRef: gitRefSchema, + goZenonCommit: optionalCommitSchema, + deploymentRepo: repoUrlSchema, + deploymentRef: gitRefSchema, + deploymentCommit: optionalCommitSchema, wipeDataOnPublish: z.boolean().default(false), seeders: z.array(z.string().trim().min(1)).max(100), bootstrapPeers: z.array(z.string().trim().min(1)).max(100).optional(), @@ -115,7 +182,11 @@ const settingsSchema = z.object({ }); const seedNodeProbeSchema = z.object({ - ip: z.string().trim().refine(validateSeedNodeIp, "Seed node must be an IP address"), + ip: z + .string() + .trim() + .refine(validateSeedNodeIp, "Seed node must be an IP address") + .refine(isPublicIp, "Seed node must have a public IP address; loopback, private, and link-local addresses cannot be probed"), rpcPort: z.number().int().min(1).max(65535).default(35997), p2pPort: z.number().int().min(1).max(65535).default(35995) }); @@ -153,7 +224,8 @@ const nodeStatusReportSchema = z.object({ installedRef: optionalNullableText(256), installedCommit: optionalNullableText(256), genesisSha256: optionalNullableText(256), - configSha256: optionalNullableText(256) + configSha256: optionalNullableText(256), + lastError: optionalNullableText(512) }) .optional(), sync: z @@ -377,6 +449,7 @@ async function createSeedNode(userId: string, nodeName: string, publicIp: string function sendDownload(response: express.Response, filename: string, contentType: string, body: Buffer | string): void { response.setHeader("Content-Type", contentType); response.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + response.setHeader("Cache-Control", "private, no-store"); response.send(body); } @@ -411,14 +484,6 @@ function publishedInfo(published?: PublishedArtifacts): PublishedArtifactsInfo | }; } -function cloneJson(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function settingsSnapshot(settings: NetworkSettings): NetworkSettingsSnapshot { - const { sporkWallet: _sporkWallet, ...snapshot } = settings; - return cloneJson(snapshot); -} function genesisSettingsKey(settings: NetworkSettings): string { return JSON.stringify({ @@ -434,11 +499,42 @@ function genesisSettingsKey(settings: NetworkSettings): string { }); } +function normalizePublicUrl(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error(`PUBLIC_URL is not a valid URL: ${trimmed}`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("PUBLIC_URL must use http or https"); + return url.origin; +} + +function parseTrustProxy(value: string | undefined): boolean | string | number { + const trimmed = value?.trim(); + if (!trimmed) return "loopback"; + if (trimmed === "true") return true; + if (trimmed === "false") return false; + if (/^\d+$/.test(trimmed)) return Number(trimmed); + return trimmed; +} + +// Host header values are interpolated into generated shell scripts, so only accept the characters +// a hostname, IPv6 literal, or port can contain. +const HOST_HEADER_PATTERN = /^[A-Za-z0-9.\-\[\]:]{1,253}$/; + function requestOrigin(request: express.Request): string { - const forwardedProto = request.get("x-forwarded-proto")?.split(",")[0]?.trim(); - const forwardedHost = request.get("x-forwarded-host")?.split(",")[0]?.trim(); - const proto = forwardedProto || request.protocol; - const host = forwardedHost || request.get("host") || `127.0.0.1:${PORT}`; + if (PUBLIC_URL) return PUBLIC_URL; + // Express compiles "trust proxy" into a per-peer predicate; only honor X-Forwarded-Host when the + // immediate peer is a trusted proxy, exactly as request.protocol does for X-Forwarded-Proto. + const trustProxy = request.app.get("trust proxy fn") as ((address: string | undefined, hop: number) => boolean) | undefined; + const peerTrusted = Boolean(trustProxy?.(request.socket.remoteAddress, 0)); + const proto = request.protocol === "https" ? "https" : "http"; + const forwardedHost = peerTrusted ? request.get("x-forwarded-host")?.split(",")[0]?.trim() : undefined; + const host = forwardedHost || request.get("host") || ""; + if (!HOST_HEADER_PATTERN.test(host)) return `http://127.0.0.1:${PORT}`; return `${proto}://${host}`; } @@ -493,11 +589,50 @@ function releaseTarget(settings: NetworkSettings | NetworkSettingsSnapshot) { }, deployment: { repoUrl: settings.deploymentRepo, - ref: settings.deploymentRef + ref: settings.deploymentRef, + commit: settings.deploymentCommit || undefined } }; } +/** + * Policy violations in an already-published node plan. Published plans must carry both commit + * pins; legacy plans that predate pinning are withheld from nodes until a new release is published. + */ +function nodePlanPolicyErrors(nodePlan: PublishedArtifacts["nodePlan"]): string[] { + if (!nodePlan) return []; + return releasePolicyErrors( + { + goZenonRepo: nodePlan.goZenon.repoUrl, + goZenonRef: nodePlan.goZenon.ref, + goZenonCommit: nodePlan.goZenon.commit, + deploymentRepo: nodePlan.deployment.repoUrl, + deploymentRef: nodePlan.deployment.ref, + deploymentCommit: nodePlan.deployment.commit + }, + REPO_POLICY, + { requirePins: true } + ); +} + +class PublishError extends Error { + constructor( + public readonly status: number, + message: string + ) { + super(message); + } +} + +function redactedNodePlan(nodePlan: T): T { + if (!nodePlan) return nodePlan; + return { + ...nodePlan, + goZenon: { ...nodePlan.goZenon, repoUrl: redactUrl(nodePlan.goZenon.repoUrl) }, + deployment: { ...nodePlan.deployment, repoUrl: redactUrl(nodePlan.deployment.repoUrl) } + }; +} + function buildPublishedNodePlan(settings: NetworkSettingsSnapshot, publishedAt: string, finalizedAt?: string) { return { schemaVersion: 1, @@ -587,376 +722,19 @@ async function withBootstrapNode( response.status(401).json({ error: "Invalid bootstrap token" }); } -function bootstrapInstallScript(origin: string): string { - return `#!/usr/bin/env bash -set -euo pipefail - -if [[ "$EUID" -ne 0 ]]; then - echo "Run this script as root, usually via sudo." >&2 - exit 1 -fi - -: "\${ZNN_BOOTSTRAP_TOKEN:?Set ZNN_BOOTSTRAP_TOKEN to the node bootstrap token from the testnet builder.}" - -BASE_URL="\${ZNN_TESTNET_URL:-${origin}}" -ZNN_DIR="\${ZNN_DIR:-/root/.znn}" -DEPLOYMENT_DIR="\${ZNN_DEPLOYMENT_DIR:-/opt/zenon-deployment}" -DEPLOYMENT_MIN_CPU_CORES="\${ZNN_DEPLOYMENT_MIN_CPU_CORES:-2}" -SERVICE_NAME="\${ZNN_SERVICE_NAME:-go-zenon}" -RPC_URL="\${ZNN_RPC_URL:-http://127.0.0.1:35997}" -BOOTSTRAP_TRACE="\${ZNN_BOOTSTRAP_TRACE:-0}" - -if ! [[ "$DEPLOYMENT_MIN_CPU_CORES" =~ ^[0-9]+$ ]] || (( DEPLOYMENT_MIN_CPU_CORES < 1 )); then - DEPLOYMENT_MIN_CPU_CORES=2 -fi - -if command -v apt-get >/dev/null 2>&1; then - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl git jq util-linux -fi - -cat > /usr/local/bin/znn-testnet-agent <<'AGENT' -#!/usr/bin/env bash -set -euo pipefail - -ENV_FILE="\${ZNN_AGENT_ENV_FILE:-/etc/cron.d/znn-testnet-agent}" -if [[ -z "\${ZNN_BOOTSTRAP_TOKEN:-}" && -r "$ENV_FILE" ]]; then - while IFS='=' read -r key value; do - case "$key" in - ZNN_BOOTSTRAP_TOKEN|ZNN_TESTNET_URL|ZNN_DIR|ZNN_DEPLOYMENT_DIR|ZNN_DEPLOYMENT_MIN_CPU_CORES|ZNN_RPC_URL|ZNN_SERVICE_NAME|ZNN_AGENT_STATE_DIR|ZNN_BOOTSTRAP_TRACE) - [[ -n "$value" ]] && export "$key=$value" - ;; - esac - done < <(grep -E '^(ZNN_BOOTSTRAP_TOKEN|ZNN_TESTNET_URL|ZNN_DIR|ZNN_DEPLOYMENT_DIR|ZNN_DEPLOYMENT_MIN_CPU_CORES|ZNN_RPC_URL|ZNN_SERVICE_NAME|ZNN_AGENT_STATE_DIR|ZNN_BOOTSTRAP_TRACE)=' "$ENV_FILE" || true) -fi - -: "\${ZNN_BOOTSTRAP_TOKEN:?Missing ZNN_BOOTSTRAP_TOKEN.}" - -BASE_URL="\${ZNN_TESTNET_URL:-${origin}}" -ZNN_DIR="\${ZNN_DIR:-/root/.znn}" -DEPLOYMENT_DIR="\${ZNN_DEPLOYMENT_DIR:-/opt/zenon-deployment}" -DEPLOYMENT_MIN_CPU_CORES="\${ZNN_DEPLOYMENT_MIN_CPU_CORES:-2}" -RPC_URL="\${ZNN_RPC_URL:-http://127.0.0.1:35997}" -SERVICE_NAME="\${ZNN_SERVICE_NAME:-go-zenon}" -STATE_DIR="\${ZNN_AGENT_STATE_DIR:-/var/lib/znn-testnet-agent}" -BOOTSTRAP_TRACE="\${ZNN_BOOTSTRAP_TRACE:-0}" -INSTALL_STATE_FILE="$STATE_DIR/install-state.json" -STATUS_FILE="$STATE_DIR/status.json" - -mkdir -p "$STATE_DIR" - -if ! [[ "$DEPLOYMENT_MIN_CPU_CORES" =~ ^[0-9]+$ ]] || (( DEPLOYMENT_MIN_CPU_CORES < 1 )); then - DEPLOYMENT_MIN_CPU_CORES=2 -fi - -auth_get() { - curl -fsSL -H "Authorization: Bearer $ZNN_BOOTSTRAP_TOKEN" "$1" -} - -try_auth_get() { - local tmp code - tmp="$(mktemp)" - code="$(curl -sS -H "Authorization: Bearer $ZNN_BOOTSTRAP_TOKEN" -w "%{http_code}" -o "$tmp" "$1" || true)" - if [[ "$code" == "200" ]]; then - cat "$tmp" - rm -f "$tmp" - return 0 - fi - rm -f "$tmp" - return 1 -} - -rpc() { - curl -fs --max-time 5 -H "Content-Type: application/json" \\ - -d "{\\"jsonrpc\\":\\"2.0\\",\\"id\\":1,\\"method\\":\\"$1\\",\\"params\\":[]}" \\ - "$RPC_URL" 2>/dev/null | jq -c '.result // {}' -} - -wipe_data_dir() { - local item base - mkdir -p "$ZNN_DIR" - shopt -s dotglob nullglob - for item in "$ZNN_DIR"/*; do - base="$(basename "$item")" - case "$base" in - wallet|genesis.json|config.json|network-private-key) - continue - ;; - esac - rm -rf -- "$item" - done - shopt -u dotglob nullglob -} - -patch_deployment_preflight() { - local preflight_file="$DEPLOYMENT_DIR/lib/preflight.sh" - [[ -f "$preflight_file" ]] || return 0 - - sed -i -E "s/cores < [0-9]+/cores < $DEPLOYMENT_MIN_CPU_CORES/" "$preflight_file" - sed -i -E "s/Minimum [0-9]+ required\\./Minimum $DEPLOYMENT_MIN_CPU_CORES required./" "$preflight_file" - sed -i -E '/mem_total_gb < [0-9]+/,/fi/ s/error_log "Total RAM \\$\\{mem_total_gb\\}GiB detected\\. Minimum [0-9]+GiB required\\."/warn_log "Total RAM \\\${mem_total_gb}GiB detected. 4GiB recommended for go-zenon builds."/' "$preflight_file" - sed -i -E '/mem_total_gb < [0-9]+/,/fi/ s/^[[:space:]]*return 1[[:space:]]*$/:/' "$preflight_file" - echo "Deployment pre-flight patch:" - echo " CPU minimum: $DEPLOYMENT_MIN_CPU_CORES core(s)" - echo " RAM check: warning only; 4GiB remains recommended" - if [[ "$BOOTSTRAP_TRACE" == "1" || "$BOOTSTRAP_TRACE" == "true" ]]; then - echo "Deployment pre-flight patch trace:" - grep -E 'cores <|Minimum [0-9]+ required|mem_total_gb <|Total RAM|4GiB recommended' "$preflight_file" | sed 's/^/ /' || true - fi -} - -install_release() { - local manifest="$1" - local event_id node_type go_repo go_ref go_commit deployment_repo deployment_ref genesis_url config_url producer_url producer_password_url network_private_key_url wipe_data apply_at desired_key installed_key binary_key installed_binary_key binary_missing artifacts_ready - - event_id="$(printf '%s' "$manifest" | jq -r '.eventId')" - node_type="$(printf '%s' "$manifest" | jq -r '.nodeType // "pillar"')" - go_repo="$(printf '%s' "$manifest" | jq -r '.goZenon.repoUrl')" - go_ref="$(printf '%s' "$manifest" | jq -r '.goZenon.ref')" - go_commit="$(printf '%s' "$manifest" | jq -r '.goZenon.commit // empty')" - deployment_repo="$(printf '%s' "$manifest" | jq -r '.deployment.repoUrl')" - deployment_ref="$(printf '%s' "$manifest" | jq -r '.deployment.ref')" - wipe_data="$(printf '%s' "$manifest" | jq -r '.actions.wipeData // false')" - apply_at="$(printf '%s' "$manifest" | jq -r '.actions.applyAt // empty')" - genesis_url="$(printf '%s' "$manifest" | jq -r '.genesisUrl')" - config_url="$(printf '%s' "$manifest" | jq -r '.configUrl')" - producer_url="$(printf '%s' "$manifest" | jq -r '.producerKeyFileUrl // empty')" - producer_password_url="$(printf '%s' "$manifest" | jq -r '.producerPasswordUrl // empty')" - network_private_key_url="$(printf '%s' "$manifest" | jq -r '.networkPrivateKeyUrl // empty')" - desired_key="$(printf '%s' "$manifest" | jq -r '[.eventId, (.nodeType // "pillar"), .goZenon.repoUrl, .goZenon.ref, (.goZenon.commit // ""), .deployment.repoUrl, .deployment.ref, (.actions.wipeData // false), (.actions.applyAt // "")] | @tsv')" - binary_key="$(printf '%s' "$manifest" | jq -r '[.goZenon.repoUrl, .goZenon.ref, (.goZenon.commit // ""), .deployment.repoUrl, .deployment.ref] | @tsv')" - installed_key="$(jq -r '.desiredKey // empty' "$INSTALL_STATE_FILE" 2>/dev/null || true)" - installed_binary_key="$(jq -r '.binaryKey // empty' "$INSTALL_STATE_FILE" 2>/dev/null || true)" - binary_missing=false - if ! command -v znnd >/dev/null 2>&1; then - binary_missing=true - fi - - artifacts_ready=false - if [[ -s "$ZNN_DIR/genesis.json" && -s "$ZNN_DIR/config.json" ]]; then - if [[ "$node_type" == "seed" && -s "$ZNN_DIR/network-private-key" ]]; then - artifacts_ready=true - elif [[ "$node_type" != "seed" && -s "$ZNN_DIR/wallet/producer.json" && -s "$ZNN_DIR/wallet/producer-password.txt" ]]; then - artifacts_ready=true - fi - fi - - if [[ "$desired_key" == "$installed_key" && "$artifacts_ready" == "true" ]]; then - return 0 - fi - - if command -v systemctl >/dev/null 2>&1; then - systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true - fi - - if [[ "$binary_key" != "$installed_binary_key" || "$binary_missing" == "true" ]]; then - rm -rf "$DEPLOYMENT_DIR" - git clone --depth 1 --branch "$deployment_ref" "$deployment_repo" "$DEPLOYMENT_DIR" - chmod +x "$DEPLOYMENT_DIR/zenon.sh" - patch_deployment_preflight - - cd "$DEPLOYMENT_DIR" - if ! ./zenon.sh --deploy zenon "$go_repo" "$go_ref"; then - echo "zenon.sh deployment failed. Last deployment log lines:" >&2 - tail -120 "$DEPLOYMENT_DIR/.znnsh.log" >&2 2>/dev/null || true - return 1 - fi - - if command -v systemctl >/dev/null 2>&1; then - systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true - fi - fi - - if [[ "$wipe_data" == "true" ]]; then - wipe_data_dir - fi - - mkdir -p "$ZNN_DIR/wallet" - auth_get "$genesis_url" > "$ZNN_DIR/genesis.json" - auth_get "$config_url" > "$ZNN_DIR/config.json" - if [[ -n "$producer_url" ]]; then - auth_get "$producer_url" > "$ZNN_DIR/wallet/producer.json" - fi - if [[ -n "$producer_password_url" ]]; then - auth_get "$producer_password_url" > "$ZNN_DIR/wallet/producer-password.txt" - fi - if [[ -n "$network_private_key_url" ]]; then - auth_get "$network_private_key_url" > "$ZNN_DIR/network-private-key" - fi - - chmod 700 "$ZNN_DIR" "$ZNN_DIR/wallet" - chmod 600 "$ZNN_DIR/genesis.json" "$ZNN_DIR/config.json" - [[ -f "$ZNN_DIR/wallet/producer.json" ]] && chmod 600 "$ZNN_DIR/wallet/producer.json" - [[ -f "$ZNN_DIR/wallet/producer-password.txt" ]] && chmod 600 "$ZNN_DIR/wallet/producer-password.txt" - [[ -f "$ZNN_DIR/network-private-key" ]] && chmod 600 "$ZNN_DIR/network-private-key" - - if command -v systemctl >/dev/null 2>&1; then - systemctl restart "$SERVICE_NAME" - fi - - jq -n \\ - --arg desiredKey "$desired_key" \\ - --arg binaryKey "$binary_key" \\ - --arg eventId "$event_id" \\ - --arg installedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \\ - --arg goRepo "$go_repo" \\ - --arg goRef "$go_ref" \\ - --arg goCommit "$go_commit" \\ - --arg deploymentRepo "$deployment_repo" \\ - --arg deploymentRef "$deployment_ref" \\ - --arg nodeType "$node_type" \\ - --arg applyAt "$apply_at" \\ - --argjson wipeData "$wipe_data" \\ - '{ - desiredKey: $desiredKey, - binaryKey: $binaryKey, - eventId: $eventId, - installedAt: $installedAt, - nodeType: $nodeType, - goZenon: { repoUrl: $goRepo, ref: $goRef, commit: $goCommit }, - deployment: { repoUrl: $deploymentRepo, ref: $deploymentRef }, - actions: ({ wipeData: $wipeData } + (if $applyAt == "" then {} else { applyAt: $applyAt } end)) - }' > "$INSTALL_STATE_FILE" -} - -report_status() { - local manifest="\${1:-}" - local waiting="\${2:-false}" - local event_id go_repo go_ref go_commit sync_json network_json process_json service_active logs error_count warn_count recent_json payload - - if [[ -n "$manifest" ]]; then - event_id="$(printf '%s' "$manifest" | jq -r '.eventId')" - go_repo="$(printf '%s' "$manifest" | jq -r '.goZenon.repoUrl')" - go_ref="$(printf '%s' "$manifest" | jq -r '.goZenon.ref')" - go_commit="$(printf '%s' "$manifest" | jq -r '.goZenon.commit // empty')" - else - event_id="waiting-for-release" - go_repo="" - go_ref="" - go_commit="" - fi - - sync_json="$(rpc stats.syncInfo || echo '{}')" - network_json="$(rpc stats.networkInfo || echo '{}')" - process_json="$(rpc stats.processInfo || echo '{}')" - service_active=false - if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet "$SERVICE_NAME"; then - service_active=true - fi - - logs="$(journalctl -u "$SERVICE_NAME" --since '1 minute ago' --no-pager 2>/dev/null | grep -Eai 'error|warn|panic|fatal|failed|exception' | tail -20 || true)" - error_count="$(printf '%s\\n' "$logs" | grep -Eai 'error|panic|fatal|failed|exception' | grep -c . || true)" - warn_count="$(printf '%s\\n' "$logs" | grep -Eai 'warn' | grep -c . || true)" - recent_json="$(printf '%s\\n' "$logs" | jq -R . | jq -s .)" - - payload="$(jq -n \\ - --arg eventId "$event_id" \\ - --arg reportedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \\ - --arg hostname "$(hostname)" \\ - --arg goRepo "$go_repo" \\ - --arg goRef "$go_ref" \\ - --arg goCommit "$go_commit" \\ - --argjson serviceActive "$service_active" \\ - --argjson waiting "$waiting" \\ - --argjson sync "$sync_json" \\ - --argjson network "$network_json" \\ - --argjson process "$process_json" \\ - --argjson errors "$error_count" \\ - --argjson warnings "$warn_count" \\ - --argjson recent "$recent_json" \\ - '{ - eventId: $eventId, - reportedAt: $reportedAt, - node: { - hostname: $hostname, - serviceActive: $serviceActive, - waitingForRelease: $waiting, - installedRepo: $goRepo, - installedRef: $goRef, - installedCommit: $goCommit - }, - sync: ({ - } + (if ($sync.state // null) == null then {} else { state: $sync.state } end) - + (if ($sync.currentHeight // null) == null then {} else { currentHeight: $sync.currentHeight } end) - + (if ($sync.targetHeight // null) == null then {} else { targetHeight: $sync.targetHeight } end)), - network: ({ - peerCount: (($network.peers // []) | length) - } + (if ($network.self.publicKey // null) == null then {} else { selfPublicKey: $network.self.publicKey } end) - + (if ($network.self.ip // null) == null then {} else { selfIp: $network.self.ip } end) - + { - peers: (($network.peers // []) | map( - {} - + (if (.publicKey // null) == null then {} else { publicKey: .publicKey } end) - + (if (.ip // null) == null then {} else { ip: .ip } end) - + (if (.name // null) == null then {} else { name: .name } end) - + (if (.version // null) == null then {} else { version: .version } end) - ) | .[0:20]) - }), - process: ({ - } + (if ($process.version // null) == null then {} else { version: $process.version } end) - + (if ($process.commit // null) == null then {} else { commit: $process.commit } end)), - logs: { - errorCountLastMinute: $errors, - warningCountLastMinute: $warnings, - recent: $recent - } - }')" - - curl -fsS -X POST "$BASE_URL/api/bootstrap/status" \\ - -H "Authorization: Bearer $ZNN_BOOTSTRAP_TOKEN" \\ - -H "Content-Type: application/json" \\ - -d "$payload" >/dev/null || true - - printf '%s\\n' "$payload" > "$STATUS_FILE" -} - -manifest="$(try_auth_get "$BASE_URL/api/bootstrap/manifest" || true)" -if [[ -z "$manifest" ]]; then - report_status "" true - echo "No published release is available yet. Waiting for Publish Release." - exit 0 -fi - -apply_at="$(printf '%s' "$manifest" | jq -r '.actions.applyAt // empty')" -if [[ -n "$apply_at" ]]; then - apply_at_epoch="$(date -u -d "$apply_at" +%s 2>/dev/null || echo 0)" - now_epoch="$(date -u +%s)" - if [[ "$apply_at_epoch" =~ ^[0-9]+$ ]] && (( apply_at_epoch > now_epoch )); then - report_status "$manifest" true - echo "Published release applies at $apply_at. Waiting." - exit 0 - fi -fi - -if ! install_release "$manifest"; then - report_status "$manifest" false - exit 1 -fi -report_status "$manifest" false -AGENT - -chmod 700 /usr/local/bin/znn-testnet-agent - -cat > /etc/cron.d/znn-testnet-agent <= NODE_STATUS_HISTORY_MIN_INTERVAL_MS; + const history = (appendSample ? [...previousHistory, historySample(latest)] : previousHistory).slice(-NODE_STATUS_HISTORY_LIMIT); target.nodeStatus = { latest, history @@ -1013,12 +794,38 @@ async function receiveNodeStatus(request: express.Request, response: express.Res } } +async function warnAboutStoredSettings(): Promise { + const state = await readState(); + for (const reason of releasePolicyErrors(state.settings, REPO_POLICY)) { + console.warn(`Stored release settings violate the repository policy (${reason}). Publishing is blocked until they are fixed.`); + } + if (state.publishedArtifacts?.nodePlan && nodePlanPolicyErrors(state.publishedArtifacts.nodePlan).length) { + console.warn("The currently published release violates the repository policy; nodes will not receive it until a compliant release is published."); + } + if (!PUBLIC_URL && process.env.NODE_ENV === "production") { + console.warn("PUBLIC_URL is not set; generated bootstrap scripts will derive their origin from request headers. Set PUBLIC_URL to the public https origin."); + } +} + async function main() { await ensureSporkWallet(); await ensurePillarStatusTokens(); + await warnAboutStoredSettings(); const app = express(); app.disable("x-powered-by"); + app.set("trust proxy", TRUST_PROXY); + app.use((_request, response, next) => { + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("X-Frame-Options", "DENY"); + response.setHeader("Referrer-Policy", "same-origin"); + next(); + }); + app.use("/api", (_request, response, next) => { + // API responses include session data, wallet secrets, and tokens; never let them be cached. + response.setHeader("Cache-Control", "private, no-store"); + next(); + }); app.use(cookieParser()); app.use(express.json({ limit: "1mb" })); @@ -1034,14 +841,21 @@ async function main() { return Boolean(receivedAt) && now - new Date(receivedAt as string).getTime() < 5 * 60_000; }; const nodes = [...state.pillars, ...state.seedNodes]; + // Public figures describe the published network; draft settings are only a fallback before + // the first publish. + const published = state.publishedArtifacts?.settings; const stats: PublicStats = { - chainIdentifier: state.settings.chainIdentifier, - genesisTimestampSec: state.settings.genesisTimestampSec, - goZenonRepo: state.settings.goZenonRepo, - goZenonRef: state.settings.goZenonRef, - goZenonCommit: state.settings.goZenonCommit, + chainIdentifier: published?.chainIdentifier ?? state.settings.chainIdentifier, + genesisTimestampSec: published?.genesisTimestampSec ?? state.settings.genesisTimestampSec, + // Report what nodes actually run: the published release, falling back to the draft settings. + goZenonRepo: redactUrl( + state.publishedArtifacts?.nodePlan?.goZenon.repoUrl ?? state.publishedArtifacts?.settings?.goZenonRepo ?? state.settings.goZenonRepo + ), + goZenonRef: state.publishedArtifacts?.nodePlan?.goZenon.ref ?? state.publishedArtifacts?.settings?.goZenonRef ?? state.settings.goZenonRef, + goZenonCommit: + state.publishedArtifacts?.nodePlan?.goZenon.commit ?? state.publishedArtifacts?.settings?.goZenonCommit ?? state.settings.goZenonCommit, pillarCount: state.pillars.length, - expectedPillars: state.settings.expectedPillars, + expectedPillars: published?.expectedPillars ?? state.settings.expectedPillars, seedNodeCount: state.seedNodes.length, activeNodes: nodes.filter((node) => isActive(node.nodeStatus)).length, totalNodes: nodes.length, @@ -1075,7 +889,7 @@ async function main() { response.status(404).json({ error: "node-plan.json has not been published" }); return; } - sendJsonFile(response, state.publishedArtifacts.nodePlan); + sendJsonFile(response, redactedNodePlan(state.publishedArtifacts.nodePlan)); }); app.post("/api/bootstrap/status", receiveNodeStatus); @@ -1093,6 +907,10 @@ async function main() { response.status(404).json({ error: "No published release is available yet" }); return; } + if (nodePlanPolicyErrors(state.publishedArtifacts.nodePlan).length) { + response.status(409).json({ error: "The published release violates the repository policy; wait for a compliant release" }); + return; + } response.json(bootstrapManifest(request, state.publishedArtifacts, node)); }); }); @@ -1166,12 +984,34 @@ async function main() { return; } - const result = await login(parsed.data.username, parsed.data.password); + const addressKey = request.ip ?? "unknown"; + const accountKey = `${parsed.data.username.trim().toLowerCase()}|${addressKey}`; + // Count the attempt before verifying so concurrent requests cannot exceed the limit together. + const retryAfterMs = Math.max(loginLimiterByAddress.admit(addressKey), loginLimiterByAccountAndAddress.admit(accountKey)); + if (retryAfterMs > 0) { + response.setHeader("Retry-After", String(Math.ceil(retryAfterMs / 1000))); + response.status(429).json({ error: "Too many login attempts. Try again later." }); + return; + } + if (loginsInFlight >= MAX_CONCURRENT_LOGINS) { + response.setHeader("Retry-After", "2"); + response.status(429).json({ error: "Too many logins in progress. Try again shortly." }); + return; + } + + loginsInFlight += 1; + let result: Awaited>; + try { + result = await login(parsed.data.username, parsed.data.password); + } finally { + loginsInFlight -= 1; + } if (!result) { response.status(401).json({ error: "Invalid username or password" }); return; } + loginLimiterByAccountAndAddress.reset(accountKey); setSessionCookie(response, result.token); response.json({ user: result.user }); }); @@ -1256,6 +1096,7 @@ async function main() { response.json({ user, settings: publicSettings(state.settings), + repoPolicy: REPO_POLICY, users: managedUsers(state), pillars: state.pillars.map(toPublicPillar), seedNodes: state.seedNodes.map(publicSeedNode), @@ -1284,6 +1125,7 @@ async function main() { ...parsed.data, minPillars: Math.min(parsed.data.minPillars, parsed.data.expectedPillars), goZenonCommit: parsed.data.goZenonCommit || undefined, + deploymentCommit: parsed.data.deploymentCommit || undefined, bootstrapPeers, genesisFunds: parsed.data.genesisFunds ?? state.settings.genesisFunds }; @@ -1457,18 +1299,60 @@ async function main() { }); app.post("/api/admin/publish", requireAuth("admin"), async (_request, response) => { - const result = await updateState((state) => { - const genesis = state.finalizedGenesis?.genesis ?? buildGenesis(state.settings, state.pillars); - const now = new Date().toISOString(); - if (!state.finalizedGenesis) { - state.finalizedGenesis = { - finalizedAt: now, - genesis - }; - } + const current = await readState(); + const policyErrors = releasePolicyErrors(current.settings, REPO_POLICY); + if (policyErrors.length) { + response.status(400).json({ error: `Release settings violate the repository policy: ${policyErrors.join("; ")}` }); + return; + } + + // Every published release is immutable: a pin the admin left empty is resolved to the current + // commit of the ref now, and the published plan carries both pins. + const release = { + goZenonRepo: current.settings.goZenonRepo, + goZenonRef: current.settings.goZenonRef, + goZenonCommit: current.settings.goZenonCommit, + deploymentRepo: current.settings.deploymentRepo, + deploymentRef: current.settings.deploymentRef, + deploymentCommit: current.settings.deploymentCommit + }; + try { + if (!release.goZenonCommit) release.goZenonCommit = await resolveGitRef(release.goZenonRepo, release.goZenonRef); + if (!release.deploymentCommit) release.deploymentCommit = await resolveGitRef(release.deploymentRepo, release.deploymentRef); + } catch (error: unknown) { + response.status(502).json({ + error: `Could not resolve the commit for the release refs (${(error as Error).message}). Set the commit pins explicitly and try again.` + }); + return; + } - const settings = settingsSnapshot(state.settings); - state.publishedArtifacts = { + let result: PublishedArtifacts; + try { + result = await updateState((state) => { + // Validate against the settings actually being published, inside the serialized update. + const errors = releasePolicyErrors(state.settings, REPO_POLICY); + if (errors.length) throw new PublishError(400, `Release settings violate the repository policy: ${errors.join("; ")}`); + // Every published input (settings, the pillar set, the finalized genesis) must be exactly + // what the admin saw when they clicked publish; ref resolution above took real time. + if (publishInputsKey(state) !== publishInputsKey(current)) { + throw new PublishError(409, "Settings or registrations changed while publishing; review them and publish again"); + } + + const genesis = state.finalizedGenesis?.genesis ?? buildGenesis(state.settings, state.pillars); + const now = new Date().toISOString(); + if (!state.finalizedGenesis) { + state.finalizedGenesis = { + finalizedAt: now, + genesis + }; + } + + const settings: NetworkSettingsSnapshot = { + ...settingsSnapshot(state.settings), + goZenonCommit: release.goZenonCommit, + deploymentCommit: release.deploymentCommit + }; + state.publishedArtifacts = { publishedAt: now, genesis, config: buildNodeConfig(settings), @@ -1478,10 +1362,17 @@ async function main() { seeders: [...settings.seeders], bootstrapPeers: [...(settings.bootstrapPeers ?? [])] }; - state.settings.wipeDataOnPublish = false; - state.settings.releaseApplyAtSec = undefined; - return state.publishedArtifacts; - }); + state.settings.wipeDataOnPublish = false; + state.settings.releaseApplyAtSec = undefined; + return state.publishedArtifacts; + }); + } catch (error: unknown) { + if (error instanceof PublishError) { + response.status(error.status).json({ error: error.message }); + return; + } + throw error; + } response.json({ published: publishedInfo(result) }); }); diff --git a/src/server/rate-limit.test.ts b/src/server/rate-limit.test.ts new file mode 100644 index 0000000..db11f12 --- /dev/null +++ b/src/server/rate-limit.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { AttemptLimiter } from "./rate-limit.js"; + +describe("AttemptLimiter", () => { + it("admits up to the limit and then blocks until the window ends", () => { + const limiter = new AttemptLimiter({ maxAttempts: 3, windowMs: 1000 }); + assert.equal(limiter.admit("a", 0), 0); + assert.equal(limiter.admit("a", 1), 0); + assert.equal(limiter.admit("a", 2), 0); + assert.equal(limiter.admit("a", 3), 997); + assert.equal(limiter.retryAfterMs("a", 500), 500); + assert.equal(limiter.admit("a", 1000), 0); + }); + + it("counts at admission so a burst cannot exceed the limit", () => { + const limiter = new AttemptLimiter({ maxAttempts: 10, windowMs: 1000 }); + const admitted = Array.from({ length: 30 }, () => limiter.admit("burst", 0)).filter((delay) => delay === 0).length; + assert.equal(admitted, 10); + }); + + it("keys are independent and reset clears a key", () => { + const limiter = new AttemptLimiter({ maxAttempts: 1, windowMs: 1000 }); + assert.equal(limiter.admit("a", 0), 0); + assert.equal(limiter.admit("b", 0), 0); + assert.ok(limiter.admit("a", 1) > 0); + limiter.reset("a"); + assert.equal(limiter.admit("a", 2), 0); + }); + + it("bounds the number of tracked keys", () => { + const limiter = new AttemptLimiter({ maxAttempts: 5, windowMs: 60_000, maxKeys: 100 }); + for (let index = 0; index < 1000; index += 1) limiter.admit(`user-${index}`, 0); + assert.ok(limiter.size <= 100); + }); + + it("prunes expired keys", () => { + const limiter = new AttemptLimiter({ maxAttempts: 5, windowMs: 1000, maxKeys: 1000 }); + for (let index = 0; index < 50; index += 1) limiter.admit(`user-${index}`, 0); + limiter.admit("late", 60_000); + assert.equal(limiter.size, 1); + }); +}); diff --git a/src/server/rate-limit.ts b/src/server/rate-limit.ts new file mode 100644 index 0000000..d53a796 --- /dev/null +++ b/src/server/rate-limit.ts @@ -0,0 +1,81 @@ +/** + * Small in-memory attempt counter used to slow down credential guessing on the login route. + * Attempts are counted at admission (before the expensive password check) so that a burst of + * concurrent requests cannot slip past the limit while the first check is still running. + * State is per process; that is sufficient because the app runs as a single replica and the + * goal is to make online guessing impractical, not to provide a distributed quota. + */ +interface Bucket { + attempts: number; + windowEndsAt: number; +} + +export interface AttemptLimiterOptions { + maxAttempts: number; + windowMs: number; + /** Hard cap on tracked keys; the oldest keys are evicted beyond it so memory stays bounded. */ + maxKeys?: number; +} + +const PRUNE_INTERVAL_MS = 30_000; + +export class AttemptLimiter { + private readonly buckets = new Map(); + private readonly maxKeys: number; + private lastPruneAt = 0; + + constructor(private readonly options: AttemptLimiterOptions) { + this.maxKeys = options.maxKeys ?? 10_000; + } + + /** Milliseconds until the key is allowed again, or 0 if it is not currently blocked. */ + retryAfterMs(key: string, now = Date.now()): number { + const bucket = this.buckets.get(key); + if (!bucket) return 0; + if (bucket.windowEndsAt <= now) { + this.buckets.delete(key); + return 0; + } + return bucket.attempts >= this.options.maxAttempts ? bucket.windowEndsAt - now : 0; + } + + /** + * Counts one attempt and returns the retry delay if the key is now over its limit. + * Returns 0 when the attempt is admitted. + */ + admit(key: string, now = Date.now()): number { + this.prune(now); + let bucket = this.buckets.get(key); + if (!bucket || bucket.windowEndsAt <= now) { + bucket = { attempts: 0, windowEndsAt: now + this.options.windowMs }; + this.buckets.delete(key); + this.buckets.set(key, bucket); + } + if (bucket.attempts >= this.options.maxAttempts) return bucket.windowEndsAt - now; + bucket.attempts += 1; + return 0; + } + + reset(key: string): void { + this.buckets.delete(key); + } + + get size(): number { + return this.buckets.size; + } + + private prune(now: number): void { + if (now - this.lastPruneAt >= PRUNE_INTERVAL_MS) { + this.lastPruneAt = now; + for (const [key, bucket] of this.buckets) { + if (bucket.windowEndsAt <= now) this.buckets.delete(key); + } + } + // Map iteration order is insertion order, so the first keys are the oldest. + while (this.buckets.size >= this.maxKeys) { + const oldest = this.buckets.keys().next().value; + if (oldest === undefined) break; + this.buckets.delete(oldest); + } + } +} diff --git a/src/server/repo-policy.test.ts b/src/server/repo-policy.test.ts new file mode 100644 index 0000000..d7e6622 --- /dev/null +++ b/src/server/repo-policy.test.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { describe, it } from "node:test"; +import { checkCommit, checkGitRef, checkRepoUrl, loadRepoPolicy, normalizeRepoUrl, redactUrl, releasePolicyErrors } from "./repo-policy.js"; + +const defaults = ["https://github.com/zenon-network/go-zenon.git", "https://github.com/hypercore-one/deployment.git"]; +const policy = loadRepoPolicy({}, defaults); + +describe("repository policy", () => { + it("accepts the default repositories with equivalent spellings", () => { + for (const url of [ + "https://github.com/zenon-network/go-zenon.git", + "https://github.com/zenon-network/go-zenon", + "https://GITHUB.com/zenon-network/go-zenon/" + ]) { + assert.equal(checkRepoUrl(url, policy).ok, true, url); + } + }); + + it("keeps repository path case", () => { + assert.equal(checkRepoUrl("https://github.com/Zenon-Network/go-zenon", policy).ok, false); + assert.equal(normalizeRepoUrl("https://GitHub.com/Foo/Bar.git"), "https://github.com/Foo/Bar"); + }); + + it("rejects credentials, http, query strings, other hosts, and other repositories", () => { + const rejected = [ + "https://user:token@github.com/zenon-network/go-zenon.git", + "https://user@github.com/zenon-network/go-zenon.git", + "http://github.com/zenon-network/go-zenon.git", + "https://github.com/zenon-network/go-zenon.git?x=1", + "https://github.com/zenon-network/go-zenon.git#frag", + "https://gitlab.com/zenon-network/go-zenon.git", + "https://github.com/evil/go-zenon.git", + "https://github.com/zenon-network/../evil.git", + "https://:bad", + "javascript:alert(1)", + "ssh://git@github.com/zenon-network/go-zenon.git" + ]; + for (const url of rejected) assert.equal(checkRepoUrl(url, policy).ok, false, url); + }); + + it("allows any repository on an allowed host when ALLOWED_REPOS=*", () => { + const open = loadRepoPolicy({ ALLOWED_REPOS: "*", ALLOWED_REPO_HOSTS: "github.com, example.org" }, defaults); + assert.equal(checkRepoUrl("https://github.com/someone/fork.git", open).ok, true); + assert.equal(checkRepoUrl("https://Example.org/x/y", open).ok, true); + assert.equal(checkRepoUrl("https://gitlab.com/someone/fork.git", open).ok, false); + }); + + it("uses explicit ALLOWED_REPOS when set", () => { + const custom = loadRepoPolicy({ ALLOWED_REPOS: "https://github.com/someone/fork.git" }, defaults); + assert.equal(checkRepoUrl("https://github.com/someone/fork", custom).ok, true); + assert.equal(checkRepoUrl(defaults[0], custom).ok, false); + }); + + it("redacts credentials", () => { + assert.equal(redactUrl("https://user:token@github.com/a/b.git"), "https://github.com/a/b.git"); + assert.equal(redactUrl("https://github.com/a/b.git"), "https://github.com/a/b.git"); + assert.equal(redactUrl("not a url://user:pw@host/x"), ""); + assert.equal(redactUrl("token@github.com:org/repo.git"), ""); + }); + + it("requires full commit hashes", () => { + assert.equal(checkCommit("9cde165877a1e4ff47d0df6cf8b8a65b121d550c").ok, true); + assert.equal(checkCommit("9CDE165877A1E4FF47D0DF6CF8B8A65B121D550C").ok, true); + assert.equal(checkCommit("9cde1658").ok, false); + assert.equal(checkCommit("9cde165877a1e4ff47d0df6cf8b8a65b121d550c0").ok, false); + assert.equal(checkCommit("zcde165877a1e4ff47d0df6cf8b8a65b121d550c").ok, false); + }); + + it("requires pins when asked", () => { + const base = { goZenonRepo: defaults[0], goZenonRef: "master", deploymentRepo: defaults[1], deploymentRef: "main" }; + assert.equal(releasePolicyErrors(base, policy).length, 0); + assert.equal(releasePolicyErrors(base, policy, { requirePins: true }).length, 2); + assert.equal(releasePolicyErrors({ ...base, goZenonCommit: "a".repeat(40), deploymentCommit: "b".repeat(40) }, policy, { requirePins: true }).length, 0); + }); + + it("reports every release setting problem", () => { + const errors = releasePolicyErrors( + { + goZenonRepo: "http://github.com/zenon-network/go-zenon.git", + goZenonRef: "-x", + goZenonCommit: "abc", + deploymentRepo: "https://github.com/hypercore-one/deployment.git", + deploymentRef: "main", + deploymentCommit: undefined + }, + policy + ); + assert.equal(errors.length, 3); + }); +}); + +describe("git ref validation", () => { + const valid = ["main", "master", "release/v0.0.9", "v1.2.3", "release+candidate", "feature/a-b_c.d", "a/b/c", "x.y", "1.0", "v1.0-rc1"]; + const invalid = [ + "", "-x", "--upload-pack=x", "HEAD", "@", "a..b", "a@{b}", "main//x", "main/.hidden", "main.", "foo.lock/bar", "foo.lock", + "a b", "a~b", "a^b", "a:b", "a?b", "a*b", "a[b", "a\\b", "a\u0001b", "a\u007fb", "ünicode", "/main", "main/", ".hidden" + ]; + + it("accepts valid refs", () => { + for (const ref of valid) assert.equal(checkGitRef(ref).ok, true, ref); + }); + + it("rejects invalid refs", () => { + for (const ref of invalid) assert.equal(checkGitRef(ref).ok, false, JSON.stringify(ref)); + }); + + it("agrees with git check-ref-format --branch on the corpus", (t) => { + const probe = spawnSync("git", ["--version"]); + if (probe.error || probe.status !== 0) { + t.skip("git not available"); + return; + } + for (const ref of [...valid, ...invalid]) { + if (ref === "" || ref.startsWith("-")) continue; // git treats these as options / usage errors + const git = spawnSync("git", ["check-ref-format", "--branch", ref]); + const gitOk = git.status === 0; + // Our validator may be stricter than git (non-ASCII and a bare "@" are rejected) but never looser. + if (gitOk) { + if (!checkGitRef(ref).ok) assert.ok(ref === "@" || /[^\x21-\x7e]/.test(ref), `rejected a ref git accepts: ${JSON.stringify(ref)}`); + } else { + assert.equal(checkGitRef(ref).ok, false, `accepted a ref git rejects: ${JSON.stringify(ref)}`); + } + } + }); +}); diff --git a/src/server/repo-policy.ts b/src/server/repo-policy.ts new file mode 100644 index 0000000..94ecc0d --- /dev/null +++ b/src/server/repo-policy.ts @@ -0,0 +1,168 @@ +/** + * Release repository policy. Operator nodes clone and execute whatever repositories the admin + * publishes, as root, so the set of acceptable repositories is pinned by deployment configuration + * rather than left to the admin session alone. + * + * ALLOWED_REPO_HOSTS comma-separated hostnames (default: github.com) + * ALLOWED_REPOS comma-separated repository URLs (default: the configured default go-zenon + * and deployment repositories); "*" disables the repository list and leaves + * only the host check + */ +export interface RepoPolicy { + allowedHosts: string[]; + /** Normalized repository URLs, or undefined when any repository on an allowed host is accepted. */ + allowedRepos?: string[]; +} + +function splitList(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +/** + * Canonical form for comparing repository URLs: lower-cased host, trailing slashes and ".git" + * removed. The path keeps its case because Git hosts may treat paths case-sensitively. + */ +export function normalizeRepoUrl(value: string): string | undefined { + let url: URL; + try { + url = new URL(value.trim()); + } catch { + return undefined; + } + const pathname = url.pathname.replace(/\/+$/, "").replace(/\.git$/, ""); + return `${url.protocol}//${url.hostname.toLowerCase()}${url.port ? `:${url.port}` : ""}${pathname}`; +} + +/** + * Strips embedded credentials from a URL so it can be logged or published safely. A value that is + * not a parseable URL is withheld entirely: it cannot be redacted reliably, so it is never echoed. + */ +export function redactUrl(value: string): string { + try { + const url = new URL(value); + if (!url.username && !url.password) return value; + url.username = ""; + url.password = ""; + return url.toString(); + } catch { + return ""; + } +} + +export function loadRepoPolicy(env: NodeJS.ProcessEnv, defaultRepos: string[]): RepoPolicy { + const allowedHosts = splitList(env.ALLOWED_REPO_HOSTS).map((host) => host.toLowerCase()); + const repoSetting = env.ALLOWED_REPOS?.trim(); + const repoList = repoSetting === "*" ? undefined : splitList(repoSetting).length ? splitList(repoSetting) : defaultRepos; + const allowedRepos = repoList?.map((repo) => { + const normalized = normalizeRepoUrl(repo); + if (!normalized) throw new Error(`ALLOWED_REPOS contains an invalid URL: ${redactUrl(repo)}`); + return normalized; + }); + return { + allowedHosts: allowedHosts.length ? allowedHosts : ["github.com"], + allowedRepos + }; +} + +export interface RepoCheck { + ok: boolean; + reason?: string; +} + +/** + * Validates a repository URL against the policy. Requires https, rejects embedded credentials + * (they would be published in public manifests), and enforces the host and repository lists. + */ +export function checkRepoUrl(value: string, policy: RepoPolicy): RepoCheck { + let url: URL; + try { + url = new URL(value.trim()); + } catch { + return { ok: false, reason: "Repository must be a valid URL" }; + } + if (url.protocol !== "https:") return { ok: false, reason: "Repository must use https://" }; + if (url.username || url.password) return { ok: false, reason: "Repository URL must not contain credentials" }; + if (!url.hostname) return { ok: false, reason: "Repository URL must include a host" }; + if (url.search || url.hash) return { ok: false, reason: "Repository URL must not contain a query string or fragment" }; + if (!/^\/[A-Za-z0-9._~%/-]+$/.test(url.pathname) || url.pathname.includes("..")) { + return { ok: false, reason: "Repository path may only contain letters, numbers, dots, underscores, hyphens, and slashes" }; + } + if (!policy.allowedHosts.includes(url.hostname.toLowerCase())) { + return { ok: false, reason: `Repository host must be one of: ${policy.allowedHosts.join(", ")}` }; + } + if (policy.allowedRepos) { + const normalized = normalizeRepoUrl(value); + if (!normalized || !policy.allowedRepos.includes(normalized)) { + return { ok: false, reason: `Repository must be one of: ${policy.allowedRepos.join(", ")}` }; + } + } + return { ok: true }; +} + +// Characters git forbids anywhere in a ref name, plus anything outside printable ASCII so the +// value is safe to interpolate into generated shell (it is always quoted there as well). +const REF_FORBIDDEN_CHARS = /[^\x21-\x7e]|[~^:?*[\\]/; + +/** + * Mirrors `git check-ref-format --branch` for a ref passed to `git clone --branch`, so a published + * ref cannot be one that every node would fail to clone, and can never start with "-". + */ +export function checkGitRef(value: string): RepoCheck { + const ref = value.trim(); + if (!ref) return { ok: false, reason: "Git ref is required" }; + if (ref.length > 160) return { ok: false, reason: "Git ref is too long" }; + if (ref.startsWith("-")) return { ok: false, reason: "Git ref must not start with '-'" }; + if (ref === "@" || ref === "HEAD") return { ok: false, reason: `'${ref}' is not a valid branch or tag name` }; + if (REF_FORBIDDEN_CHARS.test(ref)) { + return { ok: false, reason: "Git ref contains a character git does not allow (space, control, ~ ^ : ? * [ \\ or non-ASCII)" }; + } + if (ref.includes("..") || ref.includes("@{")) return { ok: false, reason: "Git ref must not contain '..' or '@{'" }; + if (ref.endsWith(".") || ref.endsWith("/")) return { ok: false, reason: "Git ref must not end with '.' or '/'" }; + for (const component of ref.split("/")) { + if (!component) return { ok: false, reason: "Git ref must not contain empty path components" }; + if (component.startsWith(".")) return { ok: false, reason: "Git ref components must not start with '.'" }; + if (component.endsWith(".lock")) return { ok: false, reason: "Git ref components must not end with '.lock'" }; + } + return { ok: true }; +} + +/** A commit pin must be a full 40-character SHA-1 so the node can compare it exactly. */ +export function checkCommit(value: string): RepoCheck { + return /^[0-9a-f]{40}$/.test(value.trim().toLowerCase()) + ? { ok: true } + : { ok: false, reason: "Commit pin must be a full 40-character hex commit hash" }; +} + +export interface ReleaseSettingsLike { + goZenonRepo: string; + goZenonRef: string; + goZenonCommit?: string; + deploymentRepo: string; + deploymentRef: string; + deploymentCommit?: string; +} + +/** + * Every reason the release settings would be refused by the policy; empty when they pass. + * With `requirePins`, both commit pins must be present (published plans always carry them). + */ +export function releasePolicyErrors(settings: ReleaseSettingsLike, policy: RepoPolicy, options: { requirePins?: boolean } = {}): string[] { + const errors: string[] = []; + const note = (label: string, check: RepoCheck) => { + if (!check.ok) errors.push(`${label}: ${check.reason}`); + }; + const pin = (label: string, value: string | undefined) => { + if (value) note(label, checkCommit(value)); + else if (options.requirePins) errors.push(`${label}: a commit pin is required`); + }; + note("go-zenon repository", checkRepoUrl(settings.goZenonRepo, policy)); + note("go-zenon ref", checkGitRef(settings.goZenonRef)); + pin("go-zenon commit", settings.goZenonCommit); + note("deployment repository", checkRepoUrl(settings.deploymentRepo, policy)); + note("deployment ref", checkGitRef(settings.deploymentRef)); + pin("deployment commit", settings.deploymentCommit); + return errors; +} diff --git a/src/server/seeders.test.ts b/src/server/seeders.test.ts new file mode 100644 index 0000000..9433ca7 --- /dev/null +++ b/src/server/seeders.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { isPublicIp } from "./seeders.js"; + +describe("isPublicIp", () => { + it("accepts globally routable addresses", () => { + for (const ip of ["8.8.8.8", "1.1.1.1", "203.0.114.1", "2606:4700::1111", "2a00:1450:4001::1", "2002:0808:0808::"]) { + assert.equal(isPublicIp(ip), true, ip); + } + }); + + it("rejects loopback, private, link-local, special-purpose, and non-global IPv4", () => { + const rejected = [ + "0.0.0.0", "0.1.2.3", "10.0.0.1", "100.64.0.1", "100.127.255.255", "127.0.0.1", "127.255.255.255", + "169.254.169.254", "172.16.0.1", "172.31.255.255", "192.0.0.1", "192.0.2.1", "192.88.99.1", + "192.168.1.1", "198.18.0.1", "198.19.255.255", "198.51.100.1", "203.0.113.9", "224.0.0.1", + "240.0.0.1", "255.255.255.255", "not-an-ip", "1.2.3", "1.2.3.4.5" + ]; + for (const ip of rejected) assert.equal(isPublicIp(ip), false, ip); + }); + + it("rejects non-global IPv6 including translation and tunnel prefixes", () => { + const rejected = [ + "::", "::1", "::ffff:127.0.0.1", "::ffff:10.0.0.1", "::ffff:0:127.0.0.1", "::127.0.0.1", + "64:ff9b::7f00:1", "64:ff9b::127.0.0.1", "64:ff9b:1:7f00:1::", "64:ff9b:1::1", + "fc00::1", "fd00::1", "fe80::1", "febf::1", "ff02::1", "100::1", + "2001::1", "2001:0:1::1", "2001:2::1", "2001:10::1", "2001:1f::1", "2001:20::1", "2001:2f::1", + "2001:db8::1", "2002:7f00:1::", "2002:0a00:1::", "3fff::1", "5f00::1", "4000::1" + ]; + for (const ip of rejected) assert.equal(isPublicIp(ip), false, ip); + }); +}); diff --git a/src/server/seeders.ts b/src/server/seeders.ts index 0ca8daf..dc2716f 100644 --- a/src/server/seeders.ts +++ b/src/server/seeders.ts @@ -46,11 +46,97 @@ export function validateSeedNodeIp(ip: string): boolean { return isIP(ip.trim()) !== 0; } +function parseIpv4(ip: string): number[] | undefined { + const parts = ip.split(".").map((part) => Number(part)); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return undefined; + return parts; +} + +function isPublicIpv4(ip: string): boolean { + const octets = parseIpv4(ip); + if (!octets) return false; + const [a, b] = octets; + if (a === 0) return false; // 0.0.0.0/8 "this" network + if (a === 10) return false; // 10.0.0.0/8 private + if (a === 100 && b >= 64 && b <= 127) return false; // 100.64.0.0/10 carrier-grade NAT + if (a === 127) return false; // loopback + if (a === 169 && b === 254) return false; // link-local, cloud metadata + if (a === 172 && b >= 16 && b <= 31) return false; // 172.16.0.0/12 private, Docker bridges + if (a === 192 && b === 0 && octets[2] === 0) return false; // 192.0.0.0/24 IETF protocol assignments + if (a === 192 && b === 0 && octets[2] === 2) return false; // 192.0.2.0/24 TEST-NET-1 + if (a === 192 && b === 88 && octets[2] === 99) return false; // 192.88.99.0/24 deprecated 6to4 relay anycast + if (a === 192 && b === 168) return false; // 192.168.0.0/16 private + if (a === 198 && (b === 18 || b === 19)) return false; // 198.18.0.0/15 benchmarking + if (a === 198 && b === 51 && octets[2] === 100) return false; // TEST-NET-2 + if (a === 203 && b === 0 && octets[2] === 113) return false; // TEST-NET-3 + if (a >= 224) return false; // multicast, reserved, broadcast + return true; +} + +function expandIpv6(ip: string): number[] | undefined { + // Returns the eight 16-bit groups of an IPv6 address, or undefined if it cannot be parsed. + let address = ip; + let embeddedIpv4: number[] | undefined; + const lastColon = address.lastIndexOf(":"); + if (address.includes(".") && lastColon >= 0) { + embeddedIpv4 = parseIpv4(address.slice(lastColon + 1)); + if (!embeddedIpv4) return undefined; + address = `${address.slice(0, lastColon + 1)}${((embeddedIpv4[0] << 8) | embeddedIpv4[1]).toString(16)}:${( + (embeddedIpv4[2] << 8) | + embeddedIpv4[3] + ).toString(16)}`; + } + const halves = address.split("::"); + if (halves.length > 2) return undefined; + const head = halves[0] ? halves[0].split(":") : []; + const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : []; + const missing = 8 - head.length - tail.length; + if (missing < 0 || (halves.length === 1 && missing !== 0)) return undefined; + const groups = [...head, ...Array(missing).fill("0"), ...tail].map((group) => Number.parseInt(group, 16)); + if (groups.length !== 8 || groups.some((group) => !Number.isInteger(group) || group < 0 || group > 0xffff)) return undefined; + return groups; +} + +function isPublicIpv6(ip: string): boolean { + const groups = expandIpv6(ip); + if (!groups) return false; + const [g0, g1, g2, , , , g6, g7] = groups; + const embeddedIpv4 = () => `${g6 >> 8}.${g6 & 0xff}.${g7 >> 8}.${g7 & 0xff}`; + // Fail closed: IANA allocates global unicast only from 2000::/3. Everything outside it + // (unspecified, loopback, IPv4-mapped/compatible/translated, NAT64 (well-known and local-use), + // unique-local, link-local, multicast, discard-only, and unallocated space) is rejected. + if ((g0 & 0xe000) !== 0x2000) return false; + if (g0 === 0x2001 && g1 === 0x0000) return false; // 2001::/32 Teredo (tunnels an obfuscated IPv4 address) + if (g0 === 0x2001 && g1 === 0x0002 && g2 === 0) return false; // 2001:2::/48 benchmarking + if (g0 === 0x2001 && (g1 & 0xfff0) === 0x0010) return false; // 2001:10::/28 ORCHID + if (g0 === 0x2001 && (g1 & 0xfff0) === 0x0020) return false; // 2001:20::/28 ORCHIDv2 + if (g0 === 0x2001 && g1 === 0x0db8) return false; // 2001:db8::/32 documentation + if (g0 === 0x2002) return isPublicIpv4(`${g1 >> 8}.${g1 & 0xff}.${g2 >> 8}.${g2 & 0xff}`); // 2002::/16 6to4 + if (g0 === 0x3fff) return false; // 3fff::/20 documentation + void embeddedIpv4; + return true; +} + +/** + * True only for globally routable unicast addresses. Used to keep server-side probes from + * reaching loopback, private, link-local, or cloud metadata addresses (SSRF). + */ +export function isPublicIp(ip: string): boolean { + const trimmed = ip.trim(); + const version = isIP(trimmed); + if (version === 4) return isPublicIpv4(trimmed); + if (version === 6) return isPublicIpv6(trimmed); + return false; +} + export async function probeSeedNode(input: SeedNodeProbeInput): Promise { const ip = input.ip.trim(); if (!validateSeedNodeIp(ip)) { throw new Error("Seed node must be an IP address"); } + if (!isPublicIp(ip)) { + throw new Error("Seed node must have a public IP address; loopback, private, and link-local addresses cannot be probed"); + } const rpcUrl = `http://${hostForUrl(ip)}:${input.rpcPort}`; const response = await fetch(rpcUrl, { @@ -64,6 +150,9 @@ export async function probeSeedNode(input: SeedNodeProbeInput): Promise { + it("excludes the spork wallet", () => { + assert.equal("sporkWallet" in settingsSnapshot(base), false); + assert.equal(publishSnapshotKey(base), publishSnapshotKey({ ...base, sporkWallet: { address: "z2", keyFile: {}, passwordCipher: "d" } })); + }); + + it("changes when any published field changes", () => { + const key = publishSnapshotKey(base); + for (const change of [ + { wipeDataOnPublish: true }, + { releaseApplyAtSec: 5 }, + { genesisTimestampSec: 101 }, + { seeders: ["enode://x"] }, + { goZenonCommit: "a".repeat(40) }, + { sporks: [{ id: "0".repeat(64), name: "n", description: "", activated: true, enforcementHeight: 0 }] } + ] as Partial[]) { + assert.notEqual(publishSnapshotKey({ ...base, ...change }), key, JSON.stringify(change)); + } + }); +}); + +describe("publish inputs key", () => { + const wallet = (address: string) => ({ address, keyFile: {}, passwordCipher: "c" }); + const pillar: PillarRecord = { + id: "p1", + userId: "u1", + pillarName: "one", + pillarWallet: wallet("z1a"), + rewardWallet: wallet("z1b"), + producerWallet: wallet("z1c"), + producerIndex: 0, + createdAt: "2026-01-01T00:00:00.000Z" + }; + const state = { settings: base, pillars: [pillar], finalizedGenesis: undefined }; + + it("changes when a pillar is registered or the genesis is finalized", () => { + const key = publishInputsKey(state); + assert.notEqual(publishInputsKey({ ...state, pillars: [] }), key); + assert.notEqual(publishInputsKey({ ...state, pillars: [pillar, { ...pillar, id: "p2", pillarName: "two" }] }), key); + assert.notEqual(publishInputsKey({ ...state, finalizedGenesis: { finalizedAt: "t", genesis: { x: 1 } } }), key); + assert.notEqual(publishInputsKey({ ...state, settings: { ...base, wipeDataOnPublish: true } }), key); + }); + + it("ignores node telemetry and download timestamps", () => { + const key = publishInputsKey(state); + const busy: PillarRecord = { + ...pillar, + packageDownloadedAt: "later", + nodeStatus: { latest: { receivedAt: "now" }, history: [] } + }; + assert.equal(publishInputsKey({ ...state, pillars: [busy] }), key); + }); +}); diff --git a/src/server/settings.ts b/src/server/settings.ts new file mode 100644 index 0000000..b2fbbcd --- /dev/null +++ b/src/server/settings.ts @@ -0,0 +1,47 @@ +import type { AppState, NetworkSettings, NetworkSettingsSnapshot, PillarRecord } from "../shared/types.js"; + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +/** The settings as published to nodes: everything except the spork wallet secret material. */ +export function settingsSnapshot(settings: NetworkSettings): NetworkSettingsSnapshot { + const { sporkWallet: _sporkWallet, ...snapshot } = settings; + return cloneJson(snapshot); +} + +/** + * Identity of everything a publish would snapshot. Publishing compares the key computed when the + * admin clicked publish with the key inside the serialized state update, so any concurrent edit to + * any published field (not only the repository coordinates) aborts the publish with a conflict. + */ +export function publishSnapshotKey(settings: NetworkSettings): string { + return JSON.stringify(settingsSnapshot(settings)); +} + +/** The parts of a pillar record that feed genesis and node configs (telemetry excluded). */ +function pillarGenesisInputs(pillar: PillarRecord) { + return { + id: pillar.id, + pillarName: pillar.pillarName, + createdAt: pillar.createdAt, + producerIndex: pillar.producerIndex, + pillarAddress: pillar.pillarWallet.address, + rewardAddress: pillar.rewardWallet.address, + producerAddress: pillar.producerWallet.address + }; +} + +/** + * Identity of every input a publish consumes: the settings snapshot, the pillar set that goes into + * genesis, and the finalized genesis if any. Node telemetry and download timestamps are excluded + * so status reports arriving during a publish do not cause spurious conflicts. + */ +export function publishInputsKey(state: Pick): string { + return JSON.stringify({ + settings: settingsSnapshot(state.settings), + pillars: state.pillars.map(pillarGenesisInputs), + finalizedAt: state.finalizedGenesis?.finalizedAt ?? null, + finalizedGenesis: state.finalizedGenesis?.genesis ?? null + }); +} diff --git a/src/server/storage.ts b/src/server/storage.ts index 82e318f..265595e 100644 --- a/src/server/storage.ts +++ b/src/server/storage.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { DEFAULT_GENESIS_FUNDS, DEFAULT_SPORKS, DEFAULT_SPORKS_VERSION } from "./constants.js"; import { multiaddrFromEnode, multiaddrFromPublicKey } from "./libp2p.js"; @@ -7,11 +7,12 @@ import type { AppState, NetworkSettings } from "../shared/types.js"; const DATA_DIR = process.env.DATA_DIR ?? path.join(process.cwd(), "data"); const STATE_FILE = path.join(DATA_DIR, "app-state.json"); -const DEFAULT_GO_ZENON_REPO = process.env.GO_ZENON_REPO ?? "https://github.com/zenon-network/go-zenon.git"; +export const DEFAULT_GO_ZENON_REPO = process.env.GO_ZENON_REPO ?? "https://github.com/zenon-network/go-zenon.git"; const DEFAULT_GO_ZENON_REF = process.env.GO_ZENON_REF ?? "master"; const DEFAULT_GO_ZENON_COMMIT = process.env.GO_ZENON_COMMIT; -const DEFAULT_DEPLOYMENT_REPO = process.env.DEPLOYMENT_REPO ?? "https://github.com/hypercore-one/deployment.git"; +export const DEFAULT_DEPLOYMENT_REPO = process.env.DEPLOYMENT_REPO ?? "https://github.com/hypercore-one/deployment.git"; const DEFAULT_DEPLOYMENT_REF = process.env.DEPLOYMENT_REF ?? "main"; +const DEFAULT_DEPLOYMENT_COMMIT = process.env.DEPLOYMENT_COMMIT; let stateUpdateQueue = Promise.resolve(); function defaultSettings(): NetworkSettings { @@ -26,6 +27,7 @@ function defaultSettings(): NetworkSettings { goZenonCommit: DEFAULT_GO_ZENON_COMMIT, deploymentRepo: DEFAULT_DEPLOYMENT_REPO, deploymentRef: DEFAULT_DEPLOYMENT_REF, + deploymentCommit: DEFAULT_DEPLOYMENT_COMMIT, releaseApplyAtSec: undefined, wipeDataOnPublish: false, sporkAddress: "", @@ -105,8 +107,25 @@ function normalizeState(state: Partial): AppState { }; } +// The state file holds password hashes, session hashes, encrypted wallet passwords, and node keys. +// Keep it and its directory readable by the service user only. +const DATA_DIR_MODE = 0o700; +const STATE_FILE_MODE = 0o600; + +let permissionsEnforced = false; + async function ensureDataDir(): Promise { - await mkdir(DATA_DIR, { recursive: true }); + await mkdir(DATA_DIR, { recursive: true, mode: DATA_DIR_MODE }); + if (permissionsEnforced) return; + // mkdir's mode only applies to directories it creates, so tighten anything that already existed + // (deployments created before these modes were enforced). This fails closed: if the modes cannot + // be enforced the error propagates and the caller (ultimately startup) fails, and the flag stays + // unset so the next call tries again. + await chmod(DATA_DIR, DATA_DIR_MODE); + await chmod(STATE_FILE, STATE_FILE_MODE).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + permissionsEnforced = true; } function findJsonValueEnd(content: string, start: number): number | undefined { @@ -194,7 +213,7 @@ function parseStateContent(content: string): { state: AppState; recovered: boole async function writeAtomic(filePath: string, content: string): Promise { await ensureDataDir(); const tempFile = path.join(DATA_DIR, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`); - await writeFile(tempFile, content, "utf8"); + await writeFile(tempFile, content, { encoding: "utf8", mode: STATE_FILE_MODE }); try { await rename(tempFile, filePath); } catch (error) { diff --git a/src/shared/types.ts b/src/shared/types.ts index 43f2ec1..d9e2ae7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -79,6 +79,7 @@ export interface NetworkSettings { goZenonCommit?: string; deploymentRepo: string; deploymentRef: string; + deploymentCommit?: string; releaseApplyAtSec?: number; wipeDataOnPublish: boolean; sporkAddress: string; @@ -163,6 +164,7 @@ export interface ReleaseTarget { deployment: { repoUrl: string; ref: string; + commit?: string; }; } @@ -230,6 +232,8 @@ export interface NodeStatusReport { installedCommit?: string; genesisSha256?: string; configSha256?: string; + /** Set by the bootstrap agent when the last release could not be applied, e.g. a commit mismatch. */ + lastError?: string; }; sync?: { state?: number; @@ -260,9 +264,15 @@ export interface PublicNodeStatus { historyCount: number; } +export interface RepoPolicyInfo { + allowedHosts: string[]; + allowedRepos?: string[]; +} + export interface AdminOverview { user: AuthUser; settings: PublicNetworkSettings; + repoPolicy: RepoPolicyInfo; users: ManagedUser[]; pillars: PublicPillar[]; seedNodes: PublicSeedNode[]; diff --git a/src/web/App.tsx b/src/web/App.tsx index 3ec42a3..6af792e 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -21,6 +21,7 @@ import { import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import type { AdminOverview, + RepoPolicyInfo, AuthUser, GenesisFundRecord, ManagedUser, @@ -118,6 +119,7 @@ function settingsKey(settings: PublicNetworkSettings): string { goZenonCommit: settings.goZenonCommit || "", deploymentRepo: settings.deploymentRepo, deploymentRef: settings.deploymentRef, + deploymentCommit: settings.deploymentCommit || "", wipeDataOnPublish: settings.wipeDataOnPublish, seeders: settings.seeders.filter(Boolean), bootstrapPeers: settings.bootstrapPeers.filter(Boolean), @@ -245,6 +247,15 @@ const RPC_ENDPOINTS = [ { label: "HTTPS", url: "https://rpc.testnet.zenon.info" } ]; +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + function repoShortName(repoUrl: string): string { return repoUrl.replace(/^https?:\/\/(www\.)?github\.com\//, "").replace(/\.git$/, "") || repoUrl; } @@ -363,9 +374,13 @@ function Landing({ onLogin }: { onLogin: (session: Session) => void }) { label="Node software" value={ stats ? ( - - {repoShortName(stats.goZenonRepo)} - + isHttpUrl(stats.goZenonRepo) ? ( + + {repoShortName(stats.goZenonRepo)} + + ) : ( + repoShortName(stats.goZenonRepo) + ) ) : ( "—" ) @@ -1014,6 +1029,12 @@ function shortCommit(value?: string): string { return value.length > 12 ? `${value.slice(0, 12)}...` : value; } +function repoPolicyHint(policy: RepoPolicyInfo): string { + return policy.allowedRepos + ? `Allowed: ${policy.allowedRepos.join(", ")} (set ALLOWED_REPOS to change)` + : `Any https repository on: ${policy.allowedHosts.join(", ")}`; +} + function nodeHealth(node: TelemetryNode): { label: string; tone: "ok" | "warn" | "bad" | "muted" } { const latest = node.nodeStatus?.latest; if (!latest) return { label: "No report", tone: "muted" }; @@ -1023,6 +1044,7 @@ function nodeHealth(node: TelemetryNode): { label: string; tone: "ok" | "warn" | const skew = clockSkewSeconds(node); if (skew !== undefined && Math.abs(skew) > 5 * 60) return { label: "Clock skew", tone: "bad" }; if (skew !== undefined && Math.abs(skew) > 60) return { label: "Clock skew", tone: "warn" }; + if (latest.node?.lastError) return { label: "Install failed", tone: "bad" }; if (latest.node?.waitingForRelease) return { label: "Waiting", tone: "warn" }; if (latest.node?.serviceActive === false) return { label: "Service down", tone: "bad" }; if ((latest.logs?.errorCountLastMinute ?? 0) > 0) return { label: "Errors", tone: "bad" }; @@ -1073,7 +1095,9 @@ function NodeStatusPanel({ nodes, refresh, refreshState }: { nodes: TelemetryNod {node.name} {node.nodeType} - {health.label} + + {health.label} + {formatAge(latest?.receivedAt)} {formatClockSkew(node)} @@ -1090,6 +1114,11 @@ function NodeStatusPanel({ nodes, refresh, refreshState }: { nodes: TelemetryNod E:{latest?.logs?.errorCountLastMinute ?? 0} W:{latest?.logs?.warningCountLastMinute ?? 0} + {latest?.node?.lastError ? ( +
+ {latest.node.lastError} +
+ ) : null} {recentLogs ? (
{recentLogs} @@ -1122,11 +1151,13 @@ interface CreateSeedNodeInput { function SettingsForm({ draft, setDraft, + repoPolicy, onSave, onProbeSeed }: { draft: PublicNetworkSettings; setDraft: React.Dispatch>; + repoPolicy: RepoPolicyInfo; onSave: (settings: PublicNetworkSettings) => Promise; onProbeSeed: (seed: ProbeSeedInput) => Promise<{ seed: SeedNodeProbeResult; settings: PublicNetworkSettings }>; }) { @@ -1237,6 +1268,7 @@ function SettingsForm({ value={draft.goZenonRepo} onChange={(event) => setDraft({ ...draft, goZenonRepo: event.target.value })} /> + {repoPolicyHint(repoPolicy)} +
- +
diff --git a/tsconfig.server.json b/tsconfig.server.json index 4336951..83e36f1 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -3,8 +3,16 @@ "compilerOptions": { "outDir": "dist/server", "rootDir": "src", - "types": ["node"], + "types": [ + "node" + ], "noEmit": false }, - "include": ["src/server/**/*.ts", "src/shared/**/*.ts"] + "include": [ + "src/server/**/*.ts", + "src/shared/**/*.ts" + ], + "exclude": [ + "src/**/*.test.ts" + ] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..a33e888 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.server.json", + "compilerOptions": { + "noEmit": true + }, + "include": [ + "src/server/**/*.ts", + "src/shared/**/*.ts" + ], + "exclude": [] +} From 213dbcf0cc1ca983c90348c12a2d48b6224250d0 Mon Sep 17 00:00:00 2001 From: 0x3639 <0x3639@protonmail.com> Date: Sat, 12 Sep 2026 21:06:14 -0500 Subject: [PATCH 2/2] Address CodeRabbit review on PR #4 - Rate limiter: evict only when inserting a new key and never the key being admitted, so a blocked key keeps its history when the map is full. Regression test added. - Node status history: drop node.lastError from retained samples; it stays on the latest report. - record_install_failure: keep replacing the install state (a --retry must rebuild and re-verify rather than take the fast path), make the intent explicit, and write the file atomically. Test asserts the install identity is cleared. - Bootstrap tests: run git with signing and hooks disabled and a timeout, so a developer's global gpgsign config cannot stall them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RjTkfrj9Pkdo1XMD2gkJac --- src/server/bootstrap-script.test.ts | 13 ++++++++++++- src/server/bootstrap-script.ts | 6 +++++- src/server/index.ts | 6 ++++-- src/server/rate-limit.test.ts | 15 +++++++++++++++ src/server/rate-limit.ts | 23 ++++++++++++++--------- 5 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/server/bootstrap-script.test.ts b/src/server/bootstrap-script.test.ts index cf60ad3..eed4630 100644 --- a/src/server/bootstrap-script.test.ts +++ b/src/server/bootstrap-script.test.ts @@ -157,11 +157,16 @@ describe("generated bootstrap script", { skip: !hasBash && "bash not available" it("record_install_failure and quarantine_binary stop the service and move the binary aside", () => { fakeSystemctl("x"); writeFileSync(path.join(bin, "znnd"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + // A previously successful install must be forgotten so the next attempt rebuilds and re-verifies. + writeFileSync(path.join(state, "install-state.json"), JSON.stringify({ desiredKey: "key1", binaryKey: "b", verifiedCommit: GOOD })); const result = runHelpers('record_install_failure "key1" "evt1" "boom"; quarantine_binary'); assert.equal(result.status, 0); const installState = JSON.parse(readFileSync(path.join(state, "install-state.json"), "utf8")); assert.equal(installState.failedKey, "key1"); assert.equal(installState.lastError, "boom"); + assert.equal(installState.desiredKey, undefined); + assert.equal(installState.verifiedCommit, undefined); + assert.equal(existsSync(path.join(state, "install-state.json.tmp")), false); assert.equal(existsSync(path.join(bin, "znnd")), false); assert.equal(existsSync(path.join(bin, "znnd.unverified")), true); assert.match(readFileSync(path.join(root, "systemctl.log"), "utf8"), /systemctl stop go-zenon/); @@ -216,7 +221,13 @@ describe("generated bootstrap script", { skip: !hasBash && "bash not available" describe("checkout_pinned", { skip: !hasGit && "git not available" }, () => { function git(cwd: string, ...args: string[]): string { - const result = spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "advice.detachedHead=false", ...args], { cwd, encoding: "utf8" }); + // Disable signing and hooks so a developer's global git config (e.g. gpg-signed commits + // prompting for a passphrase) cannot stall the test. + const result = spawnSync( + "git", + ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "advice.detachedHead=false", "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", "-c", "core.hooksPath=/dev/null", ...args], + { cwd, encoding: "utf8", timeout: 60_000 } + ); assert.equal(result.status, 0, result.stderr); return result.stdout.trim(); } diff --git a/src/server/bootstrap-script.ts b/src/server/bootstrap-script.ts index 5f0ded0..c66467f 100644 --- a/src/server/bootstrap-script.ts +++ b/src/server/bootstrap-script.ts @@ -317,6 +317,9 @@ quarantine_binary() { } record_install_failure() { + # Deliberately replaces the whole install state rather than merging into it: after a failure the + # node must not remember a desiredKey/verifiedCommit, or a later --retry could take the fast + # path and report success without rebuilding and re-verifying the (possibly quarantined) binary. local failed_key="$1" event_id="$2" message="$3" echo "$message" >&2 jq -n \\ @@ -324,7 +327,8 @@ record_install_failure() { --arg eventId "$event_id" \\ --arg lastError "$message" \\ --arg failedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \\ - '{ failedKey: $failedKey, eventId: $eventId, lastError: $lastError, failedAt: $failedAt }' > "$INSTALL_STATE_FILE" + '{ failedKey: $failedKey, eventId: $eventId, lastError: $lastError, failedAt: $failedAt }' > "$INSTALL_STATE_FILE.tmp" || return 1 + mv -f "$INSTALL_STATE_FILE.tmp" "$INSTALL_STATE_FILE" } patch_deployment_preflight() { diff --git a/src/server/index.ts b/src/server/index.ts index 13fcfd2..4140036 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -724,10 +724,12 @@ async function withBootstrapNode( function historySample(report: NodeStatusReport): NodeStatusReport { - // History only needs the numeric time series; drop per-peer detail and log lines so the state - // file stays small regardless of what a node reports. + // History only needs the numeric time series; drop per-peer detail, log lines, and the last + // error text so the state file stays small regardless of what a node reports. + const { lastError: _lastError, ...node } = report.node ?? {}; return { ...report, + node: report.node ? node : undefined, network: report.network ? { peerCount: report.network.peerCount, diff --git a/src/server/rate-limit.test.ts b/src/server/rate-limit.test.ts index db11f12..e1bf864 100644 --- a/src/server/rate-limit.test.ts +++ b/src/server/rate-limit.test.ts @@ -34,6 +34,21 @@ describe("AttemptLimiter", () => { assert.ok(limiter.size <= 100); }); + it("never evicts the key being admitted, so a blocked key stays blocked when the map is full", () => { + const limiter = new AttemptLimiter({ maxAttempts: 2, windowMs: 60_000, maxKeys: 3 }); + assert.equal(limiter.admit("victim", 0), 0); + assert.equal(limiter.admit("victim", 1), 0); + assert.ok(limiter.admit("victim", 2) > 0, "victim is blocked"); + limiter.admit("filler-1", 3); + limiter.admit("filler-2", 4); + assert.equal(limiter.size, 3); + // The map is full and "victim" is the oldest key; re-admitting it must not reset its bucket. + assert.ok(limiter.admit("victim", 5) > 0, "victim must remain blocked"); + // Inserting a genuinely new key evicts the oldest entry instead. + assert.equal(limiter.admit("new", 6), 0); + assert.equal(limiter.size, 3); + }); + it("prunes expired keys", () => { const limiter = new AttemptLimiter({ maxAttempts: 5, windowMs: 1000, maxKeys: 1000 }); for (let index = 0; index < 50; index += 1) limiter.admit(`user-${index}`, 0); diff --git a/src/server/rate-limit.ts b/src/server/rate-limit.ts index d53a796..3cca593 100644 --- a/src/server/rate-limit.ts +++ b/src/server/rate-limit.ts @@ -44,11 +44,14 @@ export class AttemptLimiter { * Returns 0 when the attempt is admitted. */ admit(key: string, now = Date.now()): number { - this.prune(now); + this.pruneExpired(now); let bucket = this.buckets.get(key); if (!bucket || bucket.windowEndsAt <= now) { - bucket = { attempts: 0, windowEndsAt: now + this.options.windowMs }; + // Only make room when a new key is inserted, and never by evicting the key being admitted: + // an existing (possibly blocked) bucket must keep its history. this.buckets.delete(key); + this.evictOldest(); + bucket = { attempts: 0, windowEndsAt: now + this.options.windowMs }; this.buckets.set(key, bucket); } if (bucket.attempts >= this.options.maxAttempts) return bucket.windowEndsAt - now; @@ -64,14 +67,16 @@ export class AttemptLimiter { return this.buckets.size; } - private prune(now: number): void { - if (now - this.lastPruneAt >= PRUNE_INTERVAL_MS) { - this.lastPruneAt = now; - for (const [key, bucket] of this.buckets) { - if (bucket.windowEndsAt <= now) this.buckets.delete(key); - } + private pruneExpired(now: number): void { + if (now - this.lastPruneAt < PRUNE_INTERVAL_MS) return; + this.lastPruneAt = now; + for (const [key, bucket] of this.buckets) { + if (bucket.windowEndsAt <= now) this.buckets.delete(key); } - // Map iteration order is insertion order, so the first keys are the oldest. + } + + /** Frees one slot for a new key. Map iteration order is insertion order, so the first key is the oldest. */ + private evictOldest(): void { while (this.buckets.size >= this.maxKeys) { const oldest = this.buckets.keys().next().value; if (oldest === undefined) break;