From b8110c52eb321dead2189edfb8277dc76d1ffd83 Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 23 Oct 2026 23:34:24 +0800 Subject: [PATCH] fix: isolate Alpine Node runtime and bound URL probes (#3104) Pin Alpine amd64 to the verified upstream Node runtime without retaining temporary binaries in image layers. Bound fresh-process URL probes and verify serialized amd64/ARMv6 layers in CI. Keep existing Sequelize, SQLite and Debian runtime behavior unchanged. --- .github/workflows/release-readiness.yml | 54 +++++++++++++++++---- docker/Dockerfile | 17 ++++++- docker/Dockerfile.310 | 17 ++++++- scripts/verify-image-node-layers.py | 44 ++++++++++++++++++ scripts/verify-panel-package.cjs | 12 ++++- shell/node-runtime-probe.cjs | 62 +++++++++++++++++++++++++ test/back/node-runtime-probe.test.cjs | 47 +++++++++++++++++++ 7 files changed, 239 insertions(+), 14 deletions(-) create mode 100644 scripts/verify-image-node-layers.py create mode 100644 shell/node-runtime-probe.cjs create mode 100644 test/back/node-runtime-probe.test.cjs diff --git a/.github/workflows/release-readiness.yml b/.github/workflows/release-readiness.yml index 46e77022..1a282830 100644 --- a/.github/workflows/release-readiness.yml +++ b/.github/workflows/release-readiness.yml @@ -6,6 +6,11 @@ on: - '.github/workflows/release-readiness.yml' - 'package.json' - 'pnpm-lock.yaml' + - 'docker/Dockerfile*' + - 'shell/node-runtime-probe.cjs' + - 'scripts/verify-panel-package.cjs' + - 'scripts/verify-image-node-layers.py' + - 'test/back/node-runtime-probe.test.cjs' workflow_dispatch: permissions: @@ -41,6 +46,13 @@ jobs: path: static/ include-hidden-files: true if-no-files-found: error + - name: Pack the verified npm archive for isolated runtime validation + run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel" + - uses: actions/upload-artifact@v6 + with: + name: release-readiness-npm-panel + path: ${{ runner.temp }}/npm-panel/ + if-no-files-found: error images: needs: [regression, static] @@ -121,16 +133,38 @@ jobs: docker run --rm --network none --entrypoint node \ qinglong-readiness:${{ matrix.image }} \ -e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.4.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")' - - name: Export native Debian image for isolated dev validation - if: matrix.image == 'debian' - run: | - docker save qinglong-readiness:debian | gzip -1 > release-readiness-debian-image.tar.gz - sha256sum release-readiness-debian-image.tar.gz > release-readiness-debian-image.sha256 - docker image inspect qinglong-readiness:debian > release-readiness-debian-image.json - - uses: actions/upload-artifact@v6 - if: matrix.image == 'debian' + - name: Export ARMv6 image layers for Node regression checks + if: startsWith(matrix.image, 'alpine') + uses: docker/build-push-action@v7 with: - name: release-readiness-debian-image - path: release-readiness-debian-image.* + context: . + file: ${{ matrix.file }} + platforms: linux/arm/v6 + build-args: | + QL_URL=${{ steps.source.outputs.url }} + QL_BRANCH=${{ steps.source.outputs.branch }} + SOURCE_COMMIT=${{ steps.source.outputs.sha }} + outputs: type=docker,dest=${{ runner.temp }}/node-layers-arm.tar + push: false + provenance: false + sbom: false + - name: Check Node binaries in every serialized image layer + if: startsWith(matrix.image, 'alpine') + run: | + docker save qinglong-readiness:${{ matrix.image }} -o "$RUNNER_TEMP/node-layers-amd64.tar" + python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-amd64.tar" amd64 + python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-arm.tar" arm + rm "$RUNNER_TEMP/node-layers-amd64.tar" "$RUNNER_TEMP/node-layers-arm.tar" + - name: Export native image for isolated dev validation + if: matrix.image == 'debian' || matrix.image == 'alpine' + run: | + docker save qinglong-readiness:${{ matrix.image }} | gzip -1 > release-readiness-${{ matrix.image }}-image.tar.gz + sha256sum release-readiness-${{ matrix.image }}-image.tar.gz > release-readiness-${{ matrix.image }}-image.sha256 + docker image inspect qinglong-readiness:${{ matrix.image }} > release-readiness-${{ matrix.image }}-image.json + - uses: actions/upload-artifact@v6 + if: matrix.image == 'debian' || matrix.image == 'alpine' + with: + name: release-readiness-${{ matrix.image }}-image + path: release-readiness-${{ matrix.image }}-image.* compression-level: 0 if-no-files-found: error diff --git a/docker/Dockerfile b/docker/Dockerfile index 7b75859e..b95baf6e 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,8 +22,14 @@ RUN set -x \ npm_config_target_libc=musl \ pnpm install --prod --frozen-lockfile +# Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node. +FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64 + FROM python:3.11-alpine +ARG TARGETARCH +ARG TARGETVARIANT + ARG QL_MAINTAINER="whyour" LABEL maintainer="${QL_MAINTAINER}" ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git @@ -44,7 +50,8 @@ EXPOSE 5700 COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/ COPY --from=builder /opt/node-global/bin/. /usr/local/bin/ -RUN set -x \ +RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \ + set -x \ && apk update -f \ && apk upgrade \ && apk --no-cache add -f bash \ @@ -62,6 +69,10 @@ RUN set -x \ netcat-openbsd \ unzip \ npm \ + && if [ "$TARGETARCH" = amd64 ]; then \ + cp /tmp/node-runtime-amd64 /usr/local/bin/node; \ + ln -sf /usr/local/bin/node /usr/bin/node; \ + fi \ && rm -rf /var/cache/apk/* \ && apk update \ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ @@ -86,6 +97,10 @@ COPY static/ /ql/static/ COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/ COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml +RUN if [ "$TARGETARCH" = arm ] && [ "${TARGETVARIANT:-}" = v6 ]; then \ + QEMU_CPU=arm1176 node --jitless ${QL_DIR}/shell/node-runtime-probe.cjs; \ + else node ${QL_DIR}/shell/node-runtime-probe.cjs; fi + ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ diff --git a/docker/Dockerfile.310 b/docker/Dockerfile.310 index 7b414513..eea74383 100644 --- a/docker/Dockerfile.310 +++ b/docker/Dockerfile.310 @@ -22,8 +22,14 @@ RUN set -x \ npm_config_target_libc=musl \ pnpm install --prod --frozen-lockfile +# Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node. +FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64 + FROM python:3.10-alpine +ARG TARGETARCH +ARG TARGETVARIANT + ARG QL_MAINTAINER="whyour" LABEL maintainer="${QL_MAINTAINER}" ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git @@ -44,7 +50,8 @@ EXPOSE 5700 COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/ COPY --from=builder /opt/node-global/bin/. /usr/local/bin/ -RUN set -x \ +RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \ + set -x \ && apk update -f \ && apk upgrade \ && apk --no-cache add -f bash \ @@ -62,6 +69,10 @@ RUN set -x \ netcat-openbsd \ unzip \ npm \ + && if [ "$TARGETARCH" = amd64 ]; then \ + cp /tmp/node-runtime-amd64 /usr/local/bin/node; \ + ln -sf /usr/local/bin/node /usr/bin/node; \ + fi \ && rm -rf /var/cache/apk/* \ && apk update \ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ @@ -86,6 +97,10 @@ COPY static/ /ql/static/ COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/ COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml +RUN if [ "$TARGETARCH" = arm ] && [ "${TARGETVARIANT:-}" = v6 ]; then \ + QEMU_CPU=arm1176 node --jitless ${QL_DIR}/shell/node-runtime-probe.cjs; \ + else node ${QL_DIR}/shell/node-runtime-probe.cjs; fi + ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ diff --git a/scripts/verify-image-node-layers.py b/scripts/verify-image-node-layers.py new file mode 100644 index 00000000..cc9838b5 --- /dev/null +++ b/scripts/verify-image-node-layers.py @@ -0,0 +1,44 @@ +"""Check serialized Docker layers, including files hidden by later deletions.""" + +import hashlib +import json +import sys +import tarfile + +archive, architecture = sys.argv[1:] +assert architecture in {"amd64", "arm"} +nodes = [] +with tarfile.open(archive) as image: + manifests = json.load(image.extractfile("manifest.json")) + assert len(manifests) == 1, "Expected one platform image" + manifest = manifests[0] + config = json.load(image.extractfile(manifest["Config"])) + assert config["architecture"] == architecture + for name in manifest["Layers"]: + with tarfile.open(fileobj=image.extractfile(name), mode="r|*") as layer: + for member in layer: + filename = member.name.removeprefix("./") + assert not ( + filename == "tmp/node-runtime-amd64" and member.size > 0 + ), "Temporary amd64 Node remains in an immutable layer" + if filename not in {"usr/local/bin/node", "usr/bin/node"}: + continue + if not member.isfile(): + continue + with layer.extractfile(member) as binary: + header = binary.read(20) + assert header[:4] == b"\x7fELF", "Expected a native Node binary" + digest = hashlib.sha256(header) + while chunk := binary.read(1024 * 1024): + digest.update(chunk) + endian = "little" if header[5] == 1 else "big" + machine = int.from_bytes(header[18:20], endian) + assert machine == {"amd64": 62, "arm": 40}[architecture], ( + "Wrong Node architecture in an image layer" + ) + nodes.append({"path": filename, "size": member.size, "sha256": digest.hexdigest()}) +assert len(nodes) == 1, "Expected exactly one retained Node binary across all layers" +assert nodes[0]["path"] == ( + "usr/local/bin/node" if architecture == "amd64" else "usr/bin/node" +) +print(json.dumps({"pass": True, "architecture": architecture, "layers": len(manifest["Layers"]), "nodes": nodes})) diff --git a/scripts/verify-panel-package.cjs b/scripts/verify-panel-package.cjs index 05ce9d6d..c9e27dec 100644 --- a/scripts/verify-panel-package.cjs +++ b/scripts/verify-panel-package.cjs @@ -23,7 +23,7 @@ const run = (program, args, options = {}) => execute(program, args, { const requiredFiles = [ '.env.example', 'back/protos/api.proto', 'ecosystem.config.js', 'version.yaml', 'shell/upgrade.cjs', 'shell/install-bundle.cjs', 'shell/install-package.cjs', - 'shell/upgrade-lock.yaml', 'shell/start.sh', 'shell/start-simplify.sh', + 'shell/upgrade-lock.yaml', 'shell/node-runtime-probe.cjs', 'shell/start.sh', 'shell/start-simplify.sh', 'shell/task.sh', 'shell/preload/client.js', 'shell/preload/client.py', 'shell/preload/sitecustomize.js', 'shell/preload/sitecustomize.py', 'shell/preload/esm-loader.mjs', 'shell/preload/notificationAudit.js', @@ -191,6 +191,12 @@ async function verifyPanel(input, output) { const globalRoot = (await run('npm', ['root', '--global', '--prefix', prefix], { cwd: temp, env })).stdout.trim(); root = path.join(globalRoot, '@whyour/qinglong'); env.QL_DIR = root; + const runtimeProbes = []; + const checkRuntime = async () => { + const result = await run(process.execPath, [path.join(root, 'shell/node-runtime-probe.cjs')], { cwd: temp, env, timeout: 540000 }); + runtimeProbes.push(JSON.parse(result.stdout.trim())); + }; + await checkRuntime(); // Use a subprocess: cached candidate modules must not mask missing files // after old-package installation and the real npm overwrite below. await run(process.execPath, ['-e', `const root=process.argv[1],p=require('node:path');const m=require(root+'/package.json');for(const target of Object.values(m.bin))require('node:fs').accessSync(p.join(root,target));for(const name of ['platformBundle','upgradePrestart','panelUpgrade'])require(root+'/cli/src/internal/maintenance/'+name+'.cjs');require(root+'/shell/upgrade.cjs').verifyUpgrade(root,p.join(root,'static')).catch(e=>{console.error(e);process.exitCode=1});`, root], { env, cwd: temp }); @@ -287,6 +293,7 @@ async function verifyPanel(input, output) { const applying = await api('update/system', 'PUT'); await job(applying.id, 'completed'); const afterOnline = await healthy(); + await checkRuntime(); assert.notEqual(afterOnline.build.instanceId, original.build.instanceId); assert.equal(afterOnline.build.sourceCommit, fixture.sourceCommit); await fs.access(path.join(root, 'node_modules/.pnpm')); @@ -302,6 +309,7 @@ async function verifyPanel(input, output) { assert.equal((await healthy()).build.instanceId, afterOnline.build.instanceId); await run(path.join(prefix, 'bin/ql'), ['reload', 'system'], { cwd: temp, env, timeout: 240000 }); const afterOffline = await healthy(); + await checkRuntime(); assert.notEqual(afterOffline.build.instanceId, afterOnline.build.instanceId); assert.equal(afterOffline.build.sourceCommit, fixture.sourceCommit); await fs.access(path.join(root, 'node_modules/.pnpm')); @@ -325,7 +333,7 @@ async function verifyPanel(input, output) { customRootEnvironmentPreservedAutomatically: true, officialPlatformBundle: { baseImage: fixture.baseImage, sourceCommit: fixture.sourceCommit, sha256: fixture.sha256 }, migratedFromNpmToOfficialPnpmLayout: true, - existingNodePreserved: true, + existingNodePreserved: true, nativeUrlRuntimeProbes: runtimeProbes, frontendAndBackendStarted: true, checksumFailureKeptOldService: true, prestartBeforeReady: true, onlineActualRestart: true, offlinePreparationDidNotContactIndex: true, offlineBashActualRestart: true, diff --git a/shell/node-runtime-probe.cjs b/shell/node-runtime-probe.cjs new file mode 100644 index 00000000..6b58b49d --- /dev/null +++ b/shell/node-runtime-probe.cjs @@ -0,0 +1,62 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { spawnSync } = require('node:child_process'); + +// Run in new processes: a healthy parent can outlive a broken native runtime. +function probeNodeRuntime(executable = process.execPath) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-url-')); + const source = ` + const assert = require('node:assert/strict'); + const { pathToFileURL, fileURLToPath } = require('node:url'); + for (let i = 0; i < 1000; i++) { + assert.equal(new URL('/任务?q=' + i, 'https://example.com').hostname, 'example.com'); + assert.equal(new URL('https://例子.测试').hostname, 'xn--fsqu00a.xn--0zwm56d'); + assert.equal(new URL('http://[::1]:5700').port, '5700'); + const filename = '/tmp/中文 path/' + i + '.cjs'; + assert.equal(fileURLToPath(pathToFileURL(filename)), filename); + } + console.log('QINGLONG_NODE_URL_OK'); + `; + const file = path.join(directory, 'probe.cjs'); + fs.writeFileSync(file, source); + try { + for (let i = 0; i < 4; i++) { + const args = process.execArgv.includes('--jitless') ? ['--jitless'] : []; + args.push(...(i % 2 ? [file] : ['-e', source])); + const child = spawnSync(executable, args, { + encoding: 'utf8', + // Multi-architecture CI runs this same probe through QEMU. + timeout: 120000, + maxBuffer: 65536, + env: { ...process.env, NODE_OPTIONS: '' }, + }); + assert.equal( + child.status, + 0, + `Node URL runtime probe failed (${i % 2 ? 'file' : 'eval'}): signal=${ + child.signal + }; status=${child.status}; ${child.error?.message || child.stderr}`, + ); + assert.match(child.stdout, /QINGLONG_NODE_URL_OK/); + } + return { + ok: true, + node: process.version, + architecture: process.arch, + ada: process.versions.ada, + processes: 4, + urlIterationsPerProcess: 1000, + nodeSha256: createHash('sha256') + .update(fs.readFileSync(executable)) + .digest('hex'), + }; + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +module.exports = { probeNodeRuntime }; +if (require.main === module) console.log(JSON.stringify(probeNodeRuntime())); diff --git a/test/back/node-runtime-probe.test.cjs b/test/back/node-runtime-probe.test.cjs new file mode 100644 index 00000000..9b394ae3 --- /dev/null +++ b/test/back/node-runtime-probe.test.cjs @@ -0,0 +1,47 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { probeNodeRuntime } = require('../../shell/node-runtime-probe.cjs'); + +test('the retained Node handles URL, IDNA and file paths in fresh eval and file processes', () => { + const result = probeNodeRuntime(); + assert.equal(result.ok, true); + assert.equal(result.processes, 4); + assert.match(result.nodeSha256, /^[0-9a-f]{64}$/); +}); + +test('a native child crash fails the runtime probe instead of accepting its healthy parent', (t) => { + if (process.platform === 'win32') return t.skip('POSIX crash fixture'); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-crash-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const executable = path.join(directory, 'node'); + fs.writeFileSync(executable, '#!/bin/sh\nkill -SEGV $$\n', { mode: 0o700 }); + assert.throws( + () => probeNodeRuntime(executable), + /Node URL runtime probe failed.*SIGSEGV/, + ); +}); + +test('emulated Node probes have a bounded two-minute budget and reject a timeout', (t) => { + const childProcess = require('node:child_process'); + const modulePath = require.resolve('../../shell/node-runtime-probe.cjs'); + let directory; + const makeDirectory = fs.mkdtempSync; + t.mock.method(fs, 'mkdtempSync', (...args) => { + directory = makeDirectory(...args); + return directory; + }); + t.mock.method(childProcess, 'spawnSync', (_program, args, options) => { + assert.equal(options.timeout, 120000); + assert.equal(options.env.NODE_OPTIONS, ''); + return { status: null, signal: 'SIGTERM', error: Object.assign(new Error('probe timed out'), { code: 'ETIMEDOUT' }) }; + }); + delete require.cache[modulePath]; + t.after(() => { delete require.cache[modulePath]; }); + const probe = require(modulePath).probeNodeRuntime; + assert.throws(() => probe(), /Node URL runtime probe failed.*SIGTERM.*probe timed out/); + assert.ok(directory); + assert.equal(fs.existsSync(directory), false, 'Timed-out probes must remove their temporary source files'); +});