diff --git a/.gitea/workflows/build-distroless.yml b/.gitea/workflows/build-distroless.yml new file mode 100644 index 0000000..f0dcb0d --- /dev/null +++ b/.gitea/workflows/build-distroless.yml @@ -0,0 +1,51 @@ +name: Build Distroless rTorrent + +on: + workflow_dispatch: + +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: 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: . + file: Dockerfile.distroless + platforms: linux/amd64 + pull: true + push: true + load: false + tags: docker.io/k2patel/rtorrent:distroless + labels: | + org.opencontainers.image.source=https://git.k2patel.in/k2patel/docker-rtorrent + org.opencontainers.image.revision=${{ github.sha }} diff --git a/Dockerfile.distroless b/Dockerfile.distroless new file mode 100644 index 0000000..de7d803 --- /dev/null +++ b/Dockerfile.distroless @@ -0,0 +1,21 @@ +# Keep Alpine's musl ABI and rTorrent version; export only runtime dependencies. +FROM alpine:3.24 AS runtime +RUN apk add --no-cache rtorrent ca-certificates tzdata \ + build-base lddtreepax python3 +COPY install/rtorrent-entrypoint.c /build/rtorrent-entrypoint.c +RUN cc -Os -Wall -Wextra -Werror -o /usr/local/bin/rtorrent-entrypoint /build/rtorrent-entrypoint.c \ + && strip /usr/local/bin/rtorrent-entrypoint +COPY build/runtime-root.py /build/runtime-root.py +RUN python3 /build/runtime-root.py /runtime /usr/bin/rtorrent /usr/local/bin/rtorrent-entrypoint +COPY tests/rtorrent-smoke.c /build/rtorrent-smoke.c +RUN cc -static -Os -Wall -Wextra -Werror -o /build/rtorrent-smoke /build/rtorrent-smoke.c + +FROM scratch +COPY --from=runtime /runtime/ / +ENV HOME=/root PATH=/usr/local/bin:/usr/bin:/bin TERM=xterm +RUN --mount=type=bind,from=runtime,source=/build/rtorrent-smoke,target=/ci-smoke ["/ci-smoke"] +VOLUME ["/home/nfs_download"] +EXPOSE 5001/tcp +HEALTHCHECK --interval=10s --timeout=10s --start-period=90s --retries=5 \ + CMD ["/usr/local/bin/rtorrent-entrypoint", "--healthcheck"] +ENTRYPOINT ["/usr/local/bin/rtorrent-entrypoint"] diff --git a/README.md b/README.md index 3377bcc..7b35703 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,27 @@ are uploaded to Gitea, and no local container builds are required. # Latest 0.16 version caveat - since version 0.16 explicitly set `network.bind_address.set = 0.0.0.0`, when ipv6 is disabled on kernel. + +## Distroless variant + +`docker.io/k2patel/rtorrent:distroless` is built separately using the manual +**Build Distroless rTorrent** workflow. It preserves the Alpine 3.24 rTorrent +binary, musl libraries, CA certificates and timezone/terminal data in a `scratch` +final image. It contains no shell, package manager, BusyBox, or build tools. +A small native launcher removes the stale session lock and execs rTorrent, +so signals go directly to rTorrent. It also provides the shell-free TCP healthcheck. + +CI tests the assembled filesystem by starting rTorrent and making an SCGI XML-RPC +request before publishing. The working `latest` tag is independent. + +Your mounted `rtorrent.rc` must not depend on shell commands or utilities missing +from this image (including `execute` hooks). Review those hooks before switching. +Existing mount paths, SCGI port 5001, root UID and `HOME=/root` are preserved. +Use the Compose overlay to replace the shell-based healthcheck: + +```sh +docker compose -f docker-compose.yml -f docker-compose.distroless.yml up -d +``` + +The overlay also selects the separate Flood distroless image. Keep the same `.env` +values and mounted data. To roll back, use the base Compose file with `:latest`. 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/docker-compose.distroless.yml b/docker-compose.distroless.yml new file mode 100644 index 0000000..ddd3f09 --- /dev/null +++ b/docker-compose.distroless.yml @@ -0,0 +1,12 @@ +# Use alongside docker-compose.yml after checking rtorrent.rc for external commands. +services: + rtorrent: + image: docker.io/k2patel/rtorrent:distroless + environment: + - HOME=/root + volumes: + - "${TORRENTSESSION}:/home/nfs_download/rsession:rw" + healthcheck: + test: ["CMD", "/usr/local/bin/rtorrent-entrypoint", "--healthcheck"] + floodui: + image: docker.io/k2patel/floodui:distroless diff --git a/install/rtorrent-entrypoint.c b/install/rtorrent-entrypoint.c new file mode 100644 index 0000000..0f5807e --- /dev/null +++ b/install/rtorrent-entrypoint.c @@ -0,0 +1,31 @@ +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + if (argc == 2 && strcmp(argv[1], "--healthcheck") == 0) { + alarm(5); + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return 1; + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_port = htons(5001); + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + int result = connect(fd, (struct sockaddr *)&addr, sizeof(addr)); + close(fd); + return result == 0 ? 0 : 1; + } + const char *lock = "/home/nfs_download/rsession/rtorrent.lock"; + if (unlink(lock) < 0 && errno != ENOENT) { + perror("Cannot remove stale rtorrent lock"); + return 1; + } + argv[0] = "/usr/bin/rtorrent"; + execv(argv[0], argv); + perror("Cannot start rtorrent"); + return 127; +} diff --git a/tests/rtorrent-smoke.c b/tests/rtorrent-smoke.c new file mode 100644 index 0000000..ef6c660 --- /dev/null +++ b/tests/rtorrent-smoke.c @@ -0,0 +1,107 @@ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void pause_tick(void) { + struct timespec delay = {0, 100000000}; + nanosleep(&delay, NULL); +} + +int main(void) { + const char *forbidden[] = {"/bin/sh", "/bin/bash", "/bin/busybox", "/sbin/apk", NULL}; + for (int i = 0; forbidden[i]; ++i) { + if (access(forbidden[i], F_OK) == 0) { + fprintf(stderr, "Unexpected runtime tool: %s\n", forbidden[i]); + return 1; + } + } + const char *config = "/tmp/rtorrent-smoke.rc"; + const char *lock = "/home/nfs_download/rsession/rtorrent.lock"; + FILE *f = fopen(config, "w"); + if (!f) return 1; + fputs("system.daemon.set = true\nnetwork.bind_address.set = 0.0.0.0\n" + "network.scgi.open_port = 127.0.0.1:5001\n", f); + fclose(f); + f = fopen(lock, "w"); + if (!f) return 1; + fputs("stale smoke-test lock", f); + fclose(f); + pid_t child = fork(); + if (child < 0) return 1; + if (child == 0) { + execl("/usr/local/bin/rtorrent-entrypoint", "rtorrent-entrypoint", "-n", "-o", + "import=/tmp/rtorrent-smoke.rc", (char *)NULL); + _exit(127); + } + int result = 1, fd = -1, status; + for (int attempt = 0; attempt < 300; ++attempt) { + if (waitpid(child, &status, WNOHANG) == child) { + fprintf(stderr, "rTorrent exited before SCGI became ready\n"); + child = -1; + goto cleanup; + } + fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) goto cleanup; + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_port = htons(5001); + if (inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) != 1) goto cleanup; + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) break; + close(fd); + fd = -1; + pause_tick(); + } + if (fd < 0 || access(lock, F_OK) == 0) goto cleanup; + struct timeval timeout = {5, 0}; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + const char *body = "system.client_version"; + char headers[128], request[512], response[8192]; + int hlen = snprintf(headers, sizeof(headers), "CONTENT_LENGTH%c%zu%cSCGI%c1%c", 0, strlen(body), 0, 0, 0); + int prefix = snprintf(request, sizeof(request), "%d:", hlen); + memcpy(request + prefix, headers, (size_t)hlen); + size_t length = (size_t)prefix + (size_t)hlen; + request[length++] = ','; + memcpy(request + length, body, strlen(body)); + length += strlen(body); + if (send(fd, request, length, 0) != (ssize_t)length) goto cleanup; + size_t used = 0; + ssize_t n; + while (used < sizeof(response) - 1 && (n = recv(fd, response + used, sizeof(response) - 1 - used, 0)) > 0) + used += (size_t)n; + response[used] = '\0'; + if (!strstr(response, "") || strstr(response, "")) { + fprintf(stderr, "SCGI test failed: %s\n", response); + goto cleanup; + } + pid_t health = fork(); + if (health < 0) goto cleanup; + if (health == 0) { + execl("/usr/local/bin/rtorrent-entrypoint", "rtorrent-entrypoint", "--healthcheck", (char *)NULL); + _exit(127); + } + if (waitpid(health, &status, 0) < 0 || !WIFEXITED(status) || WEXITSTATUS(status) != 0) goto cleanup; + puts("Distroless rTorrent: startup, stale lock cleanup, SCGI and healthcheck passed"); + result = 0; +cleanup: + if (fd >= 0) close(fd); + if (child > 0) { + kill(child, SIGTERM); + for (int i = 0; i < 100; ++i) { + if (waitpid(child, &status, WNOHANG) == child) { child = -1; break; } + pause_tick(); + } + if (child > 0) { kill(child, SIGKILL); waitpid(child, &status, 0); } + } + unlink(config); + unlink(lock); + return result; +}