Add tested Alpine-derived distroless rTorrent variant
Build and Push Docker Image / build (push) Successful in 38s
Build and Push Docker Image / build (push) Successful in 38s
This commit is contained in:
1 parent
4ac29238fe
commit
8b88089cb4
7 files changed
+307
No files matched your search
@@ -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 }}
|
||||
@@ -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"]
|
||||
@@ -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`.
|
||||
@@ -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}'
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user