Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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"
19 changes: 6 additions & 13 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
135 changes: 135 additions & 0 deletions crates/librtbit/src/http_api/handlers/healthz.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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 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() {
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");
}

/// 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();
}
}
2 changes: 2 additions & 0 deletions crates/librtbit/src/http_api/handlers/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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))
Expand Down
8 changes: 7 additions & 1 deletion crates/librtbit/src/http_api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
}

Expand Down Expand Up @@ -455,6 +460,7 @@ mod tests {
"/auth/logout",
"/auth/status",
"/auth/setup",
"/healthz",
] {
assert!(is_public_auth_path(path), "{path}");
}
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions docker-compose.indexarr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
29 changes: 0 additions & 29 deletions docker/Dockerfile

This file was deleted.

42 changes: 0 additions & 42 deletions docker/Dockerfile.xx

This file was deleted.

7 changes: 3 additions & 4 deletions docs/REPOSITORY-LAYOUT.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,9 @@ if a manifest drifts from it.

| Path | Purpose |
|------|---------|
| `Dockerfile` | Builds the published image from source, as a multi-stage build with the web UI compiled in. CI (`.github/workflows/container.yml`) builds this file. |
| `docker/Dockerfile` | A minimal `scratch` image that packages binaries built elsewhere, for example by `docker/Dockerfile.xx`. |
| `docker/Dockerfile.xx` | Cross-compiles `rtbit` for several platforms inside Docker, using `tonistiigi/xx`. |
| `docker/compose-examples/server.yaml` | Compose example for the `docker/` image. |
| `Dockerfile` | The only Dockerfile. Builds the image from source, as a multi-stage build with the web UI compiled in, on an Alpine base. CI (`.github/workflows/container.yml`) builds this file, and `make docker-build-amd64` and `make docker-build-armv7` build it per platform. Its `HEALTHCHECK` probes `/healthz`. |
| `docker/compose-examples/server.yaml` | Standalone compose example for the published image, using host networking for UPnP. |
| `systemd/` | Per-user systemd unit, socket and environment file for running `rtbit` without a container. See [systemd/README.md](../systemd/README.md). |
| `docker-compose.yml` | Builds the image from this checkout (`docker compose up --build`). Used for local development from source. |
| `docker-compose.example.yml` | Template for a self-hosted deployment. It pulls the published image and uses host paths for downloads, with PUID, PGID and TZ settings. Copy it and edit the paths. |
| `docker-compose.local.yml` | Runs the published image with named volumes and no host paths. Useful for a quick trial on one machine. |
Expand Down
Loading
Loading