diff --git a/.gitea/workflows/build-distroless.yml b/.gitea/workflows/build-distroless.yml new file mode 100644 index 0000000..62e88d0 --- /dev/null +++ b/.gitea/workflows/build-distroless.yml @@ -0,0 +1,71 @@ +name: Build Distroless Flood + +on: + workflow_dispatch: + inputs: + flood_version: + description: 'jesec/flood release tag' + required: true + default: 'v4.16.2' + type: string + +jobs: + build: + runs-on: kube + if: github.ref == 'refs/heads/main' + env: + DOCKER_BUILD_RECORD_UPLOAD: 'false' + DOCKER_BUILD_SUMMARY: 'false' + steps: + - name: Checkout code + uses: https://github.com/actions/checkout@v4 + with: + persist-credentials: false + + - name: Fetch Flood release + env: + FLOOD_VERSION: ${{ github.event.inputs.flood_version }} + run: | + test -n "$FLOOD_VERSION" || { echo "flood_version is required" >&2; exit 1; } + git check-ref-format "refs/tags/$FLOOD_VERSION" + git init flood-repo + git -C flood-repo fetch --depth=1 https://github.com/jesec/flood.git "refs/tags/$FLOOD_VERSION" + git -C flood-repo checkout --detach FETCH_HEAD + cp Dockerfile.distroless flood-repo/Dockerfile.distroless + cp build/runtime-root.py flood-repo/.ci-runtime-root.py + cp tests/startup-distroless.cjs flood-repo/.ci-startup-distroless.cjs + printf '\n.git\n.github\n.env\n' >> flood-repo/.dockerignore + + - name: Install Docker CLI + run: | + apt-get update + apt-get install -y --no-install-recommends docker.io + rm -rf /var/lib/apt/lists/* + + - name: Log in to Docker Hub + uses: https://github.com/docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Set up temporary builder + uses: https://github.com/docker/setup-buildx-action@v3 + with: + driver: docker-container + cache-binary: false + keep-state: false + cleanup: true + + - name: Build and push to Docker Hub + uses: https://github.com/docker/build-push-action@v6 + with: + context: ./flood-repo + file: ./flood-repo/Dockerfile.distroless + platforms: linux/amd64 + pull: true + push: true + load: false + tags: docker.io/k2patel/floodui:distroless + labels: | + org.opencontainers.image.source=https://git.k2patel.in/k2patel/docker-flood-client + org.opencontainers.image.revision=${{ github.sha }} diff --git a/Dockerfile.distroless b/Dockerfile.distroless new file mode 100644 index 0000000..43817f6 --- /dev/null +++ b/Dockerfile.distroless @@ -0,0 +1,27 @@ +# Alpine-built binaries and musl stay together; no shell/package manager in final image. +ARG NODE_IMAGE=docker.io/node:24.18.1-alpine3.24 +FROM ${NODE_IMAGE} AS nodebuild +WORKDIR /usr/src/app +COPY . ./ +RUN npm install -g corepack && corepack enable && corepack install \ + && pnpm install --frozen-lockfile +RUN npm run build + +FROM ${NODE_IMAGE} AS runtime +RUN apk add --no-cache mediainfo coreutils ca-certificates tzdata \ + lddtreepax python3 +COPY .ci-runtime-root.py /build/runtime-root.py +RUN python3 /build/runtime-root.py /runtime /usr/local/bin/node \ + /usr/bin/mediainfo "$(command -v df)" +COPY --from=nodebuild /usr/src/app/dist /runtime/usr/src/app/dist +COPY --from=nodebuild /usr/src/app/package.json /runtime/usr/src/app/package.json +COPY --from=nodebuild /usr/src/app/LICENSE /runtime/usr/src/app/LICENSE + +FROM scratch +COPY --from=runtime /runtime/ / +ENV NODE_ENV=production HOME=/root PATH=/usr/local/bin:/usr/bin:/bin +WORKDIR /usr/src/app +RUN --mount=type=bind,from=nodebuild,source=/usr/src/app/.ci-startup-distroless.cjs,target=/ci-startup.cjs ["/usr/local/bin/node", "/ci-startup.cjs"] +VOLUME ["/server/db"] +EXPOSE 3000 +ENTRYPOINT ["/usr/local/bin/node", "--enable-source-maps", "--use_strict", "dist/index.js", "--host=0.0.0.0", "--rundir=/server/"] diff --git a/README.md b/README.md index 5adb43f..2f8f86d 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,23 @@ publishing; it does not start a torrent daemon. The entrypoint uses `npm run start`, matching upstream v4.16.2, while retaining `--host=0.0.0.0` and `--rundir=/server/`. The upstream combined rTorrent stage is deliberately excluded. + +## Distroless variant + +The manual **Build Distroless Flood** workflow publishes `k2patel/floodui:distroless` +for the selected Flood release (default `v4.16.2`). It does not change `latest`. + +The Alpine 3.24-derived runtime contains Node 24.18.1, the bundled Flood server/UI, +mediainfo, GNU df, their musl libraries, CA certificates and timezone data. The +final image is built from scratch, with no shell, npm/pnpm, package manager, +node_modules, source tree or build tools. It starts Node directly and remains +separate from rTorrent. Root UID, port 3000, and `/server/db` stay compatible. + +Before publication, CI starts Flood inside the exact stripped filesystem and +requires an HTTP 200 response; it also checks mediainfo and df can execute and +that shell/package-manager binaries are absent. All builds run remotely, and +the temporary builder/cache is removed afterward. + +Change only the Compose image tag to `k2patel/floodui:distroless`; keep existing +mounts and connection settings. Revert to `latest` to roll back. A matching +rTorrent Compose overlay is provided in the docker-rtorrent repository. diff --git a/build/runtime-root.py b/build/runtime-root.py new file mode 100644 index 0000000..c4e4208 --- /dev/null +++ b/build/runtime-root.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Assemble an Alpine-derived, shell-free runtime from an explicit ELF allowlist.""" +import os +from pathlib import Path +import shutil +import subprocess +import sys + +root = Path(sys.argv[1]) +root.mkdir(parents=True, exist_ok=True) + +copied = set() + +def copy(source): + source = Path(os.path.normpath(source)) + if source in copied: + return + copied.add(source) + target = root / str(source).lstrip('/') + target.parent.mkdir(parents=True, exist_ok=True) + if source.is_symlink(): + link = os.readlink(source) + if not target.is_symlink(): + target.symlink_to(link) + # Preserve every hop, not just the final file in a multi-link chain. + copy(link if os.path.isabs(link) else source.parent / link) + elif source.is_dir(): + target.mkdir(exist_ok=True) + for child in source.iterdir(): + copy(child) + else: + shutil.copy2(source, target) + +for executable in sys.argv[2:]: + result = subprocess.run(['lddtreepax', '-l', executable], check=True, text=True, capture_output=True) + paths = result.stdout.splitlines() + if not paths or any(not p.startswith('/') or not Path(p).exists() for p in paths): + raise SystemExit(f'Unresolved runtime dependency for {executable}: {result.stdout}') + copy(executable) + for path in paths: + copy(path) + +# musl uses this file for nonstandard library directories (for example Lua). +for search_path in Path('/etc').glob('ld-musl-*.path'): + copy(search_path) + +for data in ['/etc/ssl/certs/ca-certificates.crt', '/etc/ssl/cert.pem', + '/etc/ssl/openssl.cnf', '/usr/lib/ossl-modules', '/usr/share/zoneinfo', + '/etc/terminfo', '/usr/share/terminfo', '/etc/services', '/etc/protocols', + '/etc/alpine-release', '/etc/os-release']: + if Path(data).exists(): + copy(data) +for directory in ['tmp', 'root', 'myconfig', 'home/nfs_download/rsession', 'server/db', 'config', 'etc']: + (root / directory).mkdir(parents=True, exist_ok=True) +os.chmod(root / 'tmp', 0o1777) +(root / 'root/.rtorrent.rc').touch() +(root / 'etc/passwd').write_text('root:x:0:0:root:/root:/sbin/nologin\n') +(root / 'etc/group').write_text('root:x:0:\n') +for forbidden in ['bin/sh', 'bin/bash', 'bin/busybox', 'sbin/apk', 'usr/bin/apk', + 'usr/bin/npm', 'usr/local/bin/npm', 'usr/bin/python3']: + assert not (root / forbidden).exists(), f'Unexpected runtime tool: {forbidden}' diff --git a/tests/startup-distroless.cjs b/tests/startup-distroless.cjs new file mode 100644 index 0000000..8b340d3 --- /dev/null +++ b/tests/startup-distroless.cjs @@ -0,0 +1,57 @@ +const {spawn, execFileSync} = require('node:child_process'); +const http = require('node:http'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +for (const file of ['/bin/sh', '/bin/bash', '/bin/busybox', '/sbin/apk', '/usr/local/bin/npm']) { + if (fs.existsSync(file)) throw new Error(`Unexpected runtime tool: ${file}`); +} +execFileSync('mediainfo', ['--Version'], {stdio: 'inherit'}); +execFileSync('df', ['--version'], {stdio: 'inherit'}); +const runDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flood-smoke-')); +const child = spawn(process.execPath, ['--enable-source-maps', '--use_strict', 'dist/index.js', + '--host=127.0.0.1', '--port=3000', `--rundir=${runDir}`], + {cwd: '/usr/src/app', stdio: 'inherit', detached: true}); +let startupError; +child.on('error', error => { startupError = error; }); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +const check = () => new Promise(resolve => { + const req = http.get('http://127.0.0.1:3000/', response => { + let bytes = 0; + response.on('data', chunk => { bytes += chunk.length; }); + response.on('end', () => resolve(response.statusCode === 200 && bytes > 0)); + }); + req.setTimeout(1000, () => req.destroy()); + req.on('error', () => resolve(false)); +}); +(async () => { + try { + const deadline = Date.now() + 60000; + while (Date.now() < deadline) { + if (startupError) throw startupError; + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error(`Flood exited before serving HTTP: ${child.exitCode ?? child.signalCode}`); + } + if (await check()) { + console.log('Flood startup smoke test passed: HTTP 200 with response body'); + return; + } + await sleep(500); + } + throw new Error('Flood did not serve HTTP within 60 seconds'); + } catch (error) { + console.error(error); + process.exitCode = 1; + } finally { + if (child.pid) { + try { process.kill(-child.pid, 'SIGTERM'); } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + await sleep(500); + try { process.kill(-child.pid, 'SIGKILL'); } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } + fs.rmSync(runDir, {recursive: true, force: true}); + } +})();