Add tested Alpine-derived distroless rTorrent variant
Build and Push Docker Image / build (push) Successful in 38s

This commit is contained in:
Ketan Patel committed 2026-09-30 21:51:46 -04:00
1 parent 4ac29238fe
commit 8b88089cb4
7 files changed
+307

No files matched your search

+51
View File
@@ -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 }}
+21
View File
@@ -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"]
+24
View File
@@ -23,3 +23,27 @@ are uploaded to Gitea, and no local container builds are required.
# Latest 0.16 version caveat # 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. - 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`.
+61
View File
@@ -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}'
+12
View File
@@ -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
+31
View File
@@ -0,0 +1,31 @@
#include <arpa/inet.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
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;
}
+107
View File
@@ -0,0 +1,107 @@
#define _POSIX_C_SOURCE 200809L
#include <arpa/inet.h>
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
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 = "<?xml version=\"1.0\"?><methodCall><methodName>system.client_version</methodName><params/></methodCall>";
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, "<methodResponse>") || strstr(response, "<fault>")) {
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;
}