diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4456da30..b3dbfb99 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,20 +10,24 @@ on: jobs: docker: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} steps: - name: Checkout uses: actions/checkout@v7 - # Set up Docker Buildx for multi-arch support - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - # Build multi-arch image - - name: Build Docker image (multi-arch) - run: | - docker buildx build --platform linux/amd64,linux/arm64 . - # Docker development - - name: Docker development - run: docker buildx build -f Dockerfile.brain.dev --platform linux/amd64,linux/arm64 . + - name: Build production image + run: docker buildx build --platform linux/${{ matrix.arch }} . + - name: Build development image + run: docker buildx build -f Dockerfile.brain.dev --platform linux/${{ matrix.arch }} . build: runs-on: ubuntu-latest steps: @@ -33,7 +37,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 24.22.1 - name: Enable Corepack run: corepack enable diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9149dfce..cd91d20f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,13 +9,23 @@ on: jobs: push-to-ghcr: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} permissions: contents: read packages: write steps: - name: Checkout uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag }} - name: Login to GHCR uses: docker/login-action@v4 @@ -24,30 +34,64 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - # Set up Docker Buildx for multi-arch support - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - # Build and push multi-arch Docker image - - name: Build and push Staking brain docker monorepo + - name: Build and push native image uses: docker/build-push-action@v7 with: + context: . file: Dockerfile push: true - platforms: linux/amd64, linux/arm64 - tags: ghcr.io/${{ github.repository_owner }}/staking-brain:${{ github.event.inputs.tag }} + platforms: linux/${{ matrix.arch }} + tags: ghcr.io/${{ github.repository_owner }}/staking-brain:build-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.arch }} + + publish-manifest: + runs-on: ubuntu-24.04 + needs: push-to-ghcr + permissions: + contents: read + packages: write + steps: + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Publish multi-platform image + env: + IMAGE: ghcr.io/${{ github.repository_owner }}/staking-brain + VERSION: ${{ inputs.tag }} + BUILD: build-${{ github.run_id }}-${{ github.run_attempt }} + run: | + docker buildx imagetools create \ + --tag "$IMAGE:$VERSION" \ + "$IMAGE:$BUILD-amd64" \ + "$IMAGE:$BUILD-arm64" gh-release: runs-on: ubuntu-latest - needs: push-to-ghcr + needs: publish-manifest permissions: contents: write steps: - name: Checkout uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag }} - name: Create Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ inputs.tag }} - run: gh release create "$RELEASE_TAG" --title "Release v$RELEASE_TAG" --prerelease --generate-notes + run: | + case "$RELEASE_TAG" in + *-*) release_flag=--prerelease ;; + *) release_flag=--latest ;; + esac + gh release create "$RELEASE_TAG" --verify-tag --title "Release v$RELEASE_TAG" "$release_flag" --generate-notes diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..ce62013c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.22.1 diff --git a/Dockerfile b/Dockerfile index f520cc6c..07fb3425 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,10 @@ -ARG DOCKER_IMAGE=node:24.21.0-alpine +ARG DOCKER_IMAGE=node:24.22.1-alpine3.24 # Build FROM ${DOCKER_IMAGE} AS build-stage +RUN apk add --no-cache --upgrade ca-certificates ca-certificates-bundle && update-ca-certificates + WORKDIR /app COPY package.json yarn.lock tsconfig.json .yarnrc.yml ./ COPY packages packages @@ -19,6 +21,8 @@ RUN yarn install --immutable && \ # Production FROM ${DOCKER_IMAGE} ENV NODE_ENV=production +RUN apk add --no-cache --upgrade ca-certificates ca-certificates-bundle && update-ca-certificates + WORKDIR /app COPY ./packages/brain/tls ./tls diff --git a/Dockerfile.brain.dev b/Dockerfile.brain.dev index ebc21483..9dca83a3 100644 --- a/Dockerfile.brain.dev +++ b/Dockerfile.brain.dev @@ -1,6 +1,8 @@ -ARG DOCKER_IMAGE=node:24.21.0-alpine +ARG DOCKER_IMAGE=node:24.22.1-alpine3.24 FROM ${DOCKER_IMAGE} AS build-stage +RUN apk add --no-cache --upgrade ca-certificates ca-certificates-bundle && update-ca-certificates + ENV NODE_ENV=development WORKDIR /app diff --git a/package.json b/package.json index bf9ae995..49f6ea2d 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "type": "module", "private": true, "engines": { - "node": ">=24.11.0" + "node": ">=24.22.1" }, "workspaces": [ "packages/*" diff --git a/packages/brain/src/modules/apiServers/brain/startBrainApi.ts b/packages/brain/src/modules/apiServers/brain/startBrainApi.ts index 53094b6e..28e1059c 100644 --- a/packages/brain/src/modules/apiServers/brain/startBrainApi.ts +++ b/packages/brain/src/modules/apiServers/brain/startBrainApi.ts @@ -15,7 +15,7 @@ export function startBrainApi({ allowedOriginsFromEnv: string | string[] | null; }): http.Server { const app = express(); - app.use(express.json()); + app.use(express.json({ inflate: false })); app.use(cors({ origin: allowedOriginsFromEnv ?? allowedOrigins })); app.use(createBrainValidatorsRouter({ brainDb })); diff --git a/packages/brain/src/modules/apiServers/launchpad/startLaunchpadApi.ts b/packages/brain/src/modules/apiServers/launchpad/startLaunchpadApi.ts index 3bae4ca1..853ef314 100644 --- a/packages/brain/src/modules/apiServers/launchpad/startLaunchpadApi.ts +++ b/packages/brain/src/modules/apiServers/launchpad/startLaunchpadApi.ts @@ -32,7 +32,7 @@ export function startLaunchpadApi({ allowedOriginsFromEnv: string | string[] | null; }): http.Server { const app = express(); - app.use(express.json({ limit: RPC_MAX_PAYLOAD_BYTES })); + app.use(express.json({ limit: RPC_MAX_PAYLOAD_BYTES, inflate: false })); app.use(cors({ origin: allowedOriginsFromEnv ?? allowedOrigins })); app.use(createKeystoresRouter({ reloadValidatorsCronTask, brainDb, network, validatorApi, signerApi, signerUrl })); diff --git a/packages/brain/src/modules/apiServers/ui/startUiServer.ts b/packages/brain/src/modules/apiServers/ui/startUiServer.ts index 35a3b5d1..74d53f2b 100644 --- a/packages/brain/src/modules/apiServers/ui/startUiServer.ts +++ b/packages/brain/src/modules/apiServers/ui/startUiServer.ts @@ -86,7 +86,7 @@ export function startUiServer({ origin: allowedOriginsFromEnv ?? allowedOrigins(network) }) ); - app.use(express.json()); + app.use(express.json({ inflate: false })); app.use(express.static(uiBuildPath)); // path-to-regexp (used by express >=5.0.0) has a breaking change // where it does not allow to use wildcard among other characters diff --git a/packages/brain/test/unit/modules/jsonBodyEncoding.unit.test.ts b/packages/brain/test/unit/modules/jsonBodyEncoding.unit.test.ts new file mode 100644 index 00000000..c2e94600 --- /dev/null +++ b/packages/brain/test/unit/modules/jsonBodyEncoding.unit.test.ts @@ -0,0 +1,142 @@ +import { expect } from "chai"; +import sinon from "sinon"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { once } from "node:events"; +import { brotliCompressSync } from "node:zlib"; +import { ConsensusClient, ExecutionClient, Network } from "@stakingbrain/common"; +import { startBrainApi } from "../../../src/modules/apiServers/brain/startBrainApi.js"; +import { startLaunchpadApi } from "../../../src/modules/apiServers/launchpad/startLaunchpadApi.js"; +import { startUiServer } from "../../../src/modules/apiServers/ui/startUiServer.js"; +import { + BeaconchainApi, + BlockExplorerApi, + PostgresClient, + ValidatorApi, + Web3SignerApi +} from "../../../src/modules/apiClients/index.js"; +import { BrainDataBase } from "../../../src/modules/db/index.js"; +import { CronJob } from "../../../src/modules/cron/cron.js"; +import { params } from "../../../src/params.js"; + +function postJson(server: http.Server, body: Buffer, encoding?: string): Promise { + const address = server.address() as AddressInfo; + return new Promise((resolve, reject) => { + const request = http.request( + { + hostname: "127.0.0.1", + port: address.port, + path: "/", + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": body.length, + ...(encoding ? { "Content-Encoding": encoding } : {}) + } + }, + (response) => { + response.resume(); + response.on("end", () => resolve(response.statusCode!)); + response.on("error", reject); + } + ); + request.on("error", reject); + request.end(body); + }); +} + +describe("JSON request content encoding", () => { + const servers = new Map(); + let directory: string; + + before(async () => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "stakingbrain-json-encoding-")); + fs.writeFileSync(path.join(directory, "index.html"), ""); + for (const port of ["brainPort", "launchpadPort", "uiPort"] as const) sinon.stub(params, port).value(0); + const brainDb = sinon.createStubInstance(BrainDataBase); + const validatorApi = sinon.createStubInstance(ValidatorApi); + const signerApi = sinon.createStubInstance(Web3SignerApi); + const beaconchainApi = sinon.createStubInstance(BeaconchainApi); + const reloadValidatorsCronTask = sinon.createStubInstance(CronJob); + const signerUrl = "http://127.0.0.1:1"; + const allowedOriginsFromEnv = null; + servers.set("Brain", startBrainApi({ brainDb, allowedOriginsFromEnv })); + servers.set( + "Launchpad", + startLaunchpadApi({ + brainDb, + validatorApi, + signerApi, + beaconchainApi, + reloadValidatorsCronTask, + signerUrl, + network: Network.Hoodi, + allowedOriginsFromEnv + }) + ); + servers.set( + "UI", + startUiServer({ + brainDb, + validatorApi, + signerApi, + beaconchainApi, + reloadValidatorsCronTask, + blockExplorerApi: sinon.createStubInstance(BlockExplorerApi), + postgresClient: sinon.createStubInstance(PostgresClient), + uiBuildPath: directory, + allowedOriginsFromEnv, + brainConfig: { + chain: { + network: Network.Hoodi, + executionClient: ExecutionClient.Geth, + consensusClient: ConsensusClient.Teku, + isMevBoostSet: false, + minGenesisTime: 0, + secondsPerSlot: 12, + slotsPerEpoch: 32 + }, + apis: { + signerUrl, + validatorUrl: signerUrl, + beaconchainUrl: signerUrl, + executionClientUrl: signerUrl, + prometheusUrl: signerUrl, + dappmanagerUrl: signerUrl, + blockExplorerUrl: signerUrl, + postgresUrl: signerUrl, + token: "", + tlsCert: null, + host: "localhost", + cors: null + } + } + }) + ); + await Promise.all([...servers.values()].map((server) => once(server, "listening"))); + }); + + after(async () => { + await Promise.all( + [...servers.values()].map( + (server) => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + ) + ); + sinon.restore(); + fs.rmSync(directory, { recursive: true, force: true }); + }); + + for (const name of ["Brain", "Launchpad", "UI"]) { + it(`${name} rejects Brotli-compressed JSON with HTTP 415`, async () => { + const body = brotliCompressSync(Buffer.from('{"probe":true}')); + expect(await postJson(servers.get(name)!, body, "br")).to.equal(415); + }); + + it(`${name} lets identity JSON reach routing`, async () => { + expect(await postJson(servers.get(name)!, Buffer.from('{"probe":true}'), "identity")).to.equal(404); + }); + } +});