From 57bb6298e9ed90cc1e201654ae522eb8db4f9910 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:12:59 +0000 Subject: [PATCH 1/3] feat(ops): /healthz handler, HEALTHCHECK, systemd hardening, compose defaults (WI-1729) - Add an unauthenticated /healthz liveness handler (new handler file, with a unit test). Route registration and the auth-public path allowlist are left to the HTTP API router owner (WP1). - Dockerfile: HEALTHCHECK probes /healthz on the port from RTBIT_HTTP_API_LISTEN_ADDR; pin the builder to rust:1.99-alpine. - systemd: absolute ExecStart, Restart=on-failure, TimeoutStopSec=30 and systemd hardening limited to the home directory; rtbit.conf lists the env vars it was missing. - Compose: TZ defaults to Etc/UTC; Indexarr Postgres password comes from POSTGRES_PASSWORD (required) instead of a hardcoded value. Co-Authored-By: Claude Haiku 5.5 --- Dockerfile | 8 +++- .../librtbit/src/http_api/handlers/healthz.rs | 44 +++++++++++++++++++ docker-compose.example.yml | 2 +- docker-compose.indexarr.yml | 4 +- docker/Dockerfile.xx | 2 +- systemd/rtbit.conf | 18 +++++++- systemd/rtbit.service | 19 +++++++- 7 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 crates/librtbit/src/http_api/handlers/healthz.rs diff --git a/Dockerfile b/Dockerfile index 5a9b5d1..308b155 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # Multi-stage build: compile rtbit with webui from source, output minimal scratch image. -FROM rust:alpine AS builder +FROM rust:1.99-alpine AS builder RUN apk update && apk add clang lld npm pkgconf musl-dev openssl-dev openssl-libs-static curl git @@ -49,3 +49,9 @@ VOLUME /home/rtbit/completed EXPOSE 3030 EXPOSE 4240 + +# Liveness probe against the unauthenticated /healthz route. The port is taken +# from RTBIT_HTTP_API_LISTEN_ADDR so an overridden listen address still probes +# the right socket. Needs the /healthz route registered in the HTTP API router. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -q -O /dev/null "http://127.0.0.1:${RTBIT_HTTP_API_LISTEN_ADDR##*:}/healthz" diff --git a/crates/librtbit/src/http_api/handlers/healthz.rs b/crates/librtbit/src/http_api/handlers/healthz.rs new file mode 100644 index 0000000..a36d04f --- /dev/null +++ b/crates/librtbit/src/http_api/handlers/healthz.rs @@ -0,0 +1,44 @@ +use axum::response::IntoResponse; +use http::{HeaderValue, StatusCode, header}; + +/// Unauthenticated liveness probe for `GET /healthz`. +/// +/// Answers 200 whenever the HTTP server is serving requests. It deliberately +/// does not touch the session or the torrent state, so a probe can never fail +/// because of a slow torrent operation. Route registration and the auth +/// allowlist live in the HTTP API router. +pub(crate) async fn h_healthz() -> impl IntoResponse { + ( + StatusCode::OK, + [ + (header::CACHE_CONTROL, HeaderValue::from_static("no-store")), + ( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ), + ], + "ok", + ) +} + +#[cfg(test)] +mod tests { + use axum::response::IntoResponse; + use http::{StatusCode, header}; + + use super::h_healthz; + + #[tokio::test] + async fn healthz_returns_ok_plain_text_uncacheable() { + let resp = h_healthz().await.into_response(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store"); + assert_eq!( + resp.headers()[header::CONTENT_TYPE], + "text/plain; charset=utf-8" + ); + + let body = axum::body::to_bytes(resp.into_body(), 16).await.unwrap(); + assert_eq!(&body[..], b"ok"); + } +} diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 0f2bb6d..86c7a21 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -10,7 +10,7 @@ services: environment: PUID: "1000" PGID: "1000" - TZ: "Australia/Sydney" + TZ: "${TZ:-Etc/UTC}" RTBIT_UPNP_SERVER_ENABLE: "false" RTBIT_FASTRESUME: "true" RTBIT_DOWNLOAD_FOLDER: "/home/rtbit/downloads" diff --git a/docker-compose.indexarr.yml b/docker-compose.indexarr.yml index fcf7f64..4f0b773 100644 --- a/docker-compose.indexarr.yml +++ b/docker-compose.indexarr.yml @@ -21,7 +21,7 @@ services: restart: unless-stopped environment: POSTGRES_USER: indexarr - POSTGRES_PASSWORD: indexarr + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env or the shell} POSTGRES_DB: indexarr volumes: - indexarr-pgdata:/var/lib/postgresql/data @@ -41,7 +41,7 @@ services: postgres: condition: service_healthy environment: - INDEXARR_DB_URL: postgresql+asyncpg://indexarr:indexarr@postgres:5432/indexarr + INDEXARR_DB_URL: postgresql+asyncpg://indexarr:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env or the shell}@postgres:5432/indexarr INDEXARR_DB_BACKEND: postgresql INDEXARR_HOST: "0.0.0.0" INDEXARR_PORT: "8080" diff --git a/docker/Dockerfile.xx b/docker/Dockerfile.xx index 7b774e4..ee68404 100644 --- a/docker/Dockerfile.xx +++ b/docker/Dockerfile.xx @@ -1,7 +1,7 @@ # Dockerfile for cross compiling rtbit binaries from scratch inside docker. FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx -FROM --platform=$BUILDPLATFORM rust:alpine AS builder +FROM --platform=$BUILDPLATFORM rust:1.99-alpine AS builder COPY --from=xx / / RUN apk update && apk add clang lld npm pkgconf diff --git a/systemd/rtbit.conf b/systemd/rtbit.conf index 56fda9c..f517602 100644 --- a/systemd/rtbit.conf +++ b/systemd/rtbit.conf @@ -3,26 +3,36 @@ #RTBIT_LOG_FILE_RUST_LOG= #RTBIT_HTTP_API_LISTEN_ADDR= #RTBIT_HTTP_API_ALLOW_CREATE= +#RTBIT_HTTP_BASIC_AUTH_USERPASS= #RTBIT_SINGLE_THREAD_RUNTIME= #RTBIT_DHT_DISABLE= #RTBIT_DHT_PERSISTENCE_DISABLE= #RTBIT_DHT_BOOTSTRAP= +#RTBIT_DHT_DUMP_INTERVAL= #RTBIT_RUNTIME_WORKER_THREADS= +#RTBIT_RUNTIME_MAX_BLOCKING_THREADS= #RTBIT_TCP_LISTEN_DISABLE= #RTBIT_TCP_CONNECT_DISABLE= #RTBIT_EXPERIMENTAL_UTP_LISTEN_ENABLE= +#RTBIT_IPV4_ONLY= #RTBIT_LISTEN_PORT= #RTBIT_LISTEN_IP= +#RTBIT_ANNOUNCE_PORT= #RTBIT_UPNP_PORT_FORWARD_DISABLE= #RTBIT_UPNP_SERVER_ENABLE= #RTBIT_UPNP_SERVER_FRIENDLY_NAME= #RTBIT_BIND_DEVICE= -#RTBIT_RUNTIME_MAX_BLOCKING_THREADS= #RTBIT_DEFER_WRITES_UP_TO= #RTBIT_SOCKS_PROXY_URL= #RTBIT_CONCURRENT_INIT_LIMIT= +#RTBIT_PEER_LIMIT= +#RTBIT_PEER_CONNECT_TIMEOUT= +#RTBIT_PEER_READ_WRITE_TIMEOUT= +#RTBIT_TRACKER_REFRESH_INTERVAL= +#RTBIT_SHUTDOWN_TIMEOUT= #RTBIT_UMASK= #RTBIT_DISABLE_UPLOAD= +#RTBIT_STORAGE_READ_ONLY= #RTBIT_RATELIMIT_DOWNLOAD= #RTBIT_RATELIMIT_UPLOAD= #RTBIT_BLOCKLIST_URL= @@ -33,4 +43,10 @@ #RTBIT_SESSION_PERSISTENCE_DISABLE= #RTBIT_SESSION_PERSISTENCE_LOCATION= #RTBIT_FASTRESUME= +#RTBIT_FASTRESUME_VALIDATION_DENOM= #RTBIT_WATCH_FOLDER= +#RTBIT_DOWNLOAD_FOLDER= +#RTBIT_COMPLETED_FOLDER= +#RTBIT_INDEXARR_ENABLED= +#RTBIT_INDEXARR_URL= +#RTBIT_INDEXARR_API_KEY= diff --git a/systemd/rtbit.service b/systemd/rtbit.service index 677ff43..02cf71a 100644 --- a/systemd/rtbit.service +++ b/systemd/rtbit.service @@ -1,11 +1,28 @@ [Unit] Description=rtbit Bittorrent client +Documentation=https://github.com/TheDancingDeveloper-org/rustTorrent After=rtbit.socket Requires=rtbit.socket [Service] +Type=simple EnvironmentFile=-%E/rtbit/rtbit.conf -ExecStart=rtbit server start %h/Downloads +ExecStart=/usr/local/bin/rtbit server start %h/Downloads +Restart=on-failure +RestartSec=5 +# Gives the shutdown path time to flush session persistence before SIGKILL. +TimeoutStopSec=30 + +# Hardening. Downloads and the rtbit data/cache dirs live under the user's +# home, so home is the only writable location besides the private /tmp. +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ReadWritePaths=%h +ProtectKernelTunables=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes [Install] Also=rtbit.socket From 4412fe9e556e3ece9c567cdc864cb68dc0ce93a3 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:43:13 +0000 Subject: [PATCH 2/3] fix(ops): route /healthz, drop docker/ Dockerfiles, document systemd install (WI-1729) - Register GET /healthz in the API router and exempt it from auth, so the probe works without credentials, including before first-run setup. A router-level test checks /healthz returns 200 while /torrents returns 401 on the same auth-enabled server. - Remove docker/Dockerfile and docker/Dockerfile.xx. The root Dockerfile is the only image definition; `make docker-build-amd64`/`armv7` now build it per platform (docker-build-xx-one-platform renamed docker-build-one-platform), and debug-linux-docker-x86_64 builds it from source. - systemd: drop the filesystem and namespace-dependent sandboxing from the per-user unit (ProtectSystem, ReadWritePaths, PrivateTmp, ProtectKernelTunables, ProtectControlGroups). Keep NoNewPrivileges, RestrictSUIDSGID and LockPersonality. - Document the install path /usr/local/bin/rtbit and the per-user install steps in systemd/README.md; update docs/REPOSITORY-LAYOUT.md. Co-Authored-By: Claude Haiku 5.5 --- Makefile | 19 ++-- .../librtbit/src/http_api/handlers/healthz.rs | 91 +++++++++++++++++++ crates/librtbit/src/http_api/handlers/mod.rs | 2 + crates/librtbit/src/http_api/mod.rs | 8 +- docker/Dockerfile | 29 ------ docker/Dockerfile.xx | 42 --------- docs/REPOSITORY-LAYOUT.md | 7 +- systemd/README.md | 58 ++++++++++++ systemd/rtbit.service | 10 +- 9 files changed, 170 insertions(+), 96 deletions(-) delete mode 100644 docker/Dockerfile delete mode 100644 docker/Dockerfile.xx create mode 100644 systemd/README.md diff --git a/Makefile b/Makefile index f6f57fd..e3d43aa 100644 --- a/Makefile +++ b/Makefile @@ -56,24 +56,20 @@ check-repo: testserver: ulimit -n unlimited && cargo run -p librtbit --features http-api,tracing-subscriber-utils,webui,prometheus --example simulate_traffic -@PHONY: docker-build-xx-one-platform -docker-build-xx-one-platform: - docker build -f docker/Dockerfile.xx \ - --platform $(PLATFORM) \ - --output type=local,dest=target/cross/$(PLATFORM) . && \ +@PHONY: docker-build-one-platform +docker-build-one-platform: docker build \ -t ikatson/rtbit:$(shell git describe --tags)-dev-$(shell echo $(PLATFORM) | tr '/' '-') \ --platform $(PLATFORM) \ - -f docker/Dockerfile \ - target/cross/ + -f Dockerfile . @PHONY: docker-build-amd64 docker-build-amd64: - PLATFORM=linux/amd64 $(MAKE) docker-build-xx-one-platform + PLATFORM=linux/amd64 $(MAKE) docker-build-one-platform @PHONY: docker-build-armv7 docker-build-armv7: - PLATFORM=linux/arm/v7 $(MAKE) docker-build-xx-one-platform + PLATFORM=linux/arm/v7 $(MAKE) docker-build-one-platform @PHONY: clean clean: @@ -91,10 +87,7 @@ release-linux-current-target: @PHONY: debug-linux-docker-x86_64 debug-linux-docker-x86_64: - CARGO_RELEASE_PROFILE=dev \ - $(MAKE) release-linux-x86_64 && \ - cp target/x86_64-unknown-linux-musl/debug/rtbit target/cross/linux/amd64/ && \ - docker build -t ikatson/rtbit:tmp-debug -f docker/Dockerfile --platform linux/amd64 target/cross && \ + docker build -t ikatson/rtbit:tmp-debug -f Dockerfile --platform linux/amd64 . && \ docker push ikatson/rtbit:tmp-debug @PHONY: release-linux-x86_64 diff --git a/crates/librtbit/src/http_api/handlers/healthz.rs b/crates/librtbit/src/http_api/handlers/healthz.rs index a36d04f..4cd7a70 100644 --- a/crates/librtbit/src/http_api/handlers/healthz.rs +++ b/crates/librtbit/src/http_api/handlers/healthz.rs @@ -23,10 +23,22 @@ pub(crate) async fn h_healthz() -> impl IntoResponse { #[cfg(test)] mod tests { + use std::{net::Ipv4Addr, sync::Arc, time::Duration}; + use axum::response::IntoResponse; use http::{StatusCode, header}; + use librqbit_dualstack_sockets::{BindOpts, TcpListener}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, + }; use super::h_healthz; + use crate::{ + Api, ListenerMode, Session, SessionOptions, + http_api::{HttpApi, HttpApiOptions, auth::CredentialStore}, + listen::ListenerOptions, + }; #[tokio::test] async fn healthz_returns_ok_plain_text_uncacheable() { @@ -41,4 +53,83 @@ mod tests { let body = axum::body::to_bytes(resp.into_body(), 16).await.unwrap(); assert_eq!(&body[..], b"ok"); } + + /// Sends a raw HTTP/1.1 GET and returns the status code. Raw TCP keeps the test free + /// of an extra HTTP client dependency. + async fn get_status(addr: std::net::SocketAddr, path: &str) -> u16 { + let mut stream = TcpStream::connect(addr).await.unwrap(); + let request = + format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + stream.write_all(request.as_bytes()).await.unwrap(); + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(10), stream.read_to_end(&mut response)) + .await + .expect("response timed out") + .unwrap(); + let text = String::from_utf8_lossy(&response); + let status_line = text.lines().next().unwrap_or_default(); + status_line + .split_whitespace() + .nth(1) + .and_then(|code| code.parse().ok()) + .unwrap_or_else(|| panic!("no status line in response: {status_line:?}")) + } + + #[tokio::test] + async fn healthz_is_reachable_without_credentials_when_auth_is_enabled() { + let config_dir = tempfile::TempDir::with_prefix("healthz_auth").unwrap(); + let credentials = CredentialStore::load(config_dir.path().to_owned()).unwrap(); + credentials + .set_credentials("admin", "not-the-probe") + .unwrap(); + + let output = tempfile::TempDir::with_prefix("healthz_session").unwrap(); + let session = Session::new_with_opts( + output.path().to_owned(), + SessionOptions { + disable_dht: true, + disable_local_service_discovery: true, + listen: Some(ListenerOptions { + mode: ListenerMode::TcpOnly, + listen_addr: (Ipv4Addr::LOCALHOST, 0).into(), + ..Default::default() + }), + ..Default::default() + }, + ) + .await + .unwrap(); + let api = Api::new( + session, + None, + #[cfg(feature = "tracing-subscriber-utils")] + None, + ); + let http_api = HttpApi::new( + api, + Some(HttpApiOptions { + credential_store: Some(Arc::new(credentials)), + ..Default::default() + }), + ); + + let listener = TcpListener::bind_tcp( + (Ipv4Addr::LOCALHOST, 0).into(), + BindOpts { + request_dualstack: false, + reuseport: false, + device: None, + }, + ) + .unwrap(); + let addr = listener.socket().local_addr().unwrap(); + let server = tokio::spawn(http_api.make_http_api_and_run(listener, None)); + + // The probe must answer with no credentials at all... + assert_eq!(get_status(addr, "/healthz").await, 200); + // ...while an ordinary API route on the same server still requires them. + assert_eq!(get_status(addr, "/torrents").await, 401); + + server.abort(); + } } diff --git a/crates/librtbit/src/http_api/handlers/mod.rs b/crates/librtbit/src/http_api/handlers/mod.rs index 9f15218..80bb9e8 100644 --- a/crates/librtbit/src/http_api/handlers/mod.rs +++ b/crates/librtbit/src/http_api/handlers/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod configure; pub(crate) mod dht; +pub(crate) mod healthz; pub(crate) mod indexarr; pub(crate) mod logging; pub(crate) mod other; @@ -87,6 +88,7 @@ async fn h_api_root(parts: Parts) -> impl IntoResponse { pub fn make_api_router(state: ApiState) -> Router { let mut api_router = Router::new() .route("/", get(h_api_root)) + .route("/healthz", get(healthz::h_healthz)) .route("/rust_log", post(logging::h_set_rust_log)) .route("/dht/stats", get(dht::h_dht_stats)) .route("/dht/table", get(dht::h_dht_table)) diff --git a/crates/librtbit/src/http_api/mod.rs b/crates/librtbit/src/http_api/mod.rs index cf18269..c70f9c7 100644 --- a/crates/librtbit/src/http_api/mod.rs +++ b/crates/librtbit/src/http_api/mod.rs @@ -131,7 +131,12 @@ async fn admit(role: auth::Role, mut request: axum::extract::Request, next: Next fn is_public_auth_path(path: &str) -> bool { matches!( path, - "/auth/login" | "/auth/refresh" | "/auth/logout" | "/auth/status" | "/auth/setup" + "/auth/login" + | "/auth/refresh" + | "/auth/logout" + | "/auth/status" + | "/auth/setup" + | "/healthz" ) } @@ -455,6 +460,7 @@ mod tests { "/auth/logout", "/auth/status", "/auth/setup", + "/healthz", ] { assert!(is_public_auth_path(path), "{path}"); } diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index f4b5227..0000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -# Depends on either outputs of Dockerfile.xx or the binaries compiled elsewhere. -FROM scratch - -ADD https://curl.se/ca/cacert.pem /etc/ssl/cacerts.pem - -ARG TARGETPLATFORM - -## Assumes you are running this from respective context folder. -COPY --chmod=755 ${TARGETPLATFORM}/rtbit /bin/rtbit - -WORKDIR /home/rtbit - -ENV HOME=/home/rtbit -ENV XDG_DATA_HOME=/home/rtbit/db -ENV XDG_CACHE_HOME=/home/rtbit/cache -ENV SSL_CERT_FILE=/etc/ssl/cacerts.pem - -ENV RTBIT_HTTP_API_LISTEN_ADDR=0.0.0.0:3030 -ENV RTBIT_LISTEN_PORT=4240 - -VOLUME /home/rtbit/db -VOLUME /home/rtbit/cache -VOLUME /home/rtbit/downloads -VOLUME /home/rtbit/completed - -EXPOSE 3030 -EXPOSE 4240 -CMD ["server", "start", "/home/rtbit/downloads"] -ENTRYPOINT ["/bin/rtbit"] diff --git a/docker/Dockerfile.xx b/docker/Dockerfile.xx deleted file mode 100644 index ee68404..0000000 --- a/docker/Dockerfile.xx +++ /dev/null @@ -1,42 +0,0 @@ -# Dockerfile for cross compiling rtbit binaries from scratch inside docker. -FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx - -FROM --platform=$BUILDPLATFORM rust:1.99-alpine AS builder -COPY --from=xx / / - -RUN apk update && apk add clang lld npm pkgconf - -ARG TARGETPLATFORM -RUN xx-apk add gcc musl-dev openssl-dev openssl-libs-static - -COPY / /src/ -WORKDIR /src/ - -ENV PROFILE_NAME=release-github -ENV PROFILE_FOLDER=release-github - -RUN --mount=type=cache,target=/usr/local/cargo/git/db \ - --mount=type=cache,target=/usr/local/cargo/registry/cache \ - --mount=type=cache,target=/usr/local/cargo/registry/index \ - --mount=type=cache,target=/src/target < Date: Sat, 17 Oct 2026 23:34:43 +0000 Subject: [PATCH 3/3] fix(ops): per-user unit runs rtbit by name so packaged installs start The deb/rpm packages (WI-1740) ship systemd/rtbit.service but install the binary at /usr/bin/rtbit; an absolute /usr/local/bin path would fail there. systemd resolves a bare name via its fixed search path, which covers both. Co-Authored-By: Claude Opus 5.5 --- systemd/README.md | 4 +++- systemd/rtbit.service | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/systemd/README.md b/systemd/README.md index 135649f..868233d 100644 --- a/systemd/README.md +++ b/systemd/README.md @@ -11,7 +11,9 @@ socket-activated on `127.0.0.1:3030`. They do not install a system-wide service. ## 1. Install the binary -The unit runs the binary at `/usr/local/bin/rtbit`. Build it and copy it there: +The unit runs `rtbit` by name, and systemd finds it in `/usr/local/bin` or `/usr/bin`. +The deb and rpm packages install it to `/usr/bin` and ship this unit. For a manual +install, build it and copy it to `/usr/local/bin`: ```sh cargo build --release -p rtbit diff --git a/systemd/rtbit.service b/systemd/rtbit.service index 6cefda0..4deccaa 100644 --- a/systemd/rtbit.service +++ b/systemd/rtbit.service @@ -7,7 +7,7 @@ Requires=rtbit.socket [Service] Type=simple EnvironmentFile=-%E/rtbit/rtbit.conf -ExecStart=/usr/local/bin/rtbit server start %h/Downloads +ExecStart=rtbit server start %h/Downloads Restart=on-failure RestartSec=5 # Gives the shutdown path time to flush session persistence before SIGKILL.