diff --git a/.dockerignore b/.containerignore similarity index 93% rename from .dockerignore rename to .containerignore index a084bf6b6..0b87ae817 100644 --- a/.dockerignore +++ b/.containerignore @@ -5,6 +5,7 @@ /.github /.gitignore /.vscode +/adr/ /bin/ /config-idx-back.local.toml /config-tracker.local.toml @@ -15,8 +16,9 @@ /data_v2.db* /data.db /data.db* +/docs/ /project-words.txt /README.md /rustfmt.toml /storage/ -/target/ \ No newline at end of file +/target/ diff --git a/.github/workflows/container.yaml b/.github/workflows/container.yaml index a777fed42..b77a9fcaa 100644 --- a/.github/workflows/container.yaml +++ b/.github/workflows/container.yaml @@ -15,8 +15,90 @@ env: CARGO_TERM_COLOR: always jobs: + lints: + name: Lints (Container infra) + runs-on: ubuntu-latest + + steps: + - id: checkout + name: Checkout Repository + uses: actions/checkout@v6 + + # Phase 9 §9.1.3 — guard against re-introducing the + # `mailcatcher` dev sidecar (or any SMTP/mail config) + # into the production-shaped baseline. The override + # file is *expected* to mention `mailcatcher` and is + # deliberately excluded from the audit. Comments are + # stripped before grepping so the explanatory header + # in `compose.yaml` (which legitimately references + # `mailcatcher` in prose) does not trip the audit; + # we are looking for live YAML config, not docs. + - id: compose-baseline-no-mailcatcher + name: compose.yaml has no mailcatcher / SMTP wiring + run: | + set -eu + # awk strips `# ...` comments while preserving line + # numbering 1:1 with the source file, so any error + # output points the reader at the real line. + if awk '{ sub(/#.*/, ""); print }' compose.yaml \ + | grep -nE 'mailcatcher|MAILER|SMTP|smtp_'; then + echo "::error file=compose.yaml::dev mail sidecar / SMTP config present in production-shaped baseline (ADR-T-009 §D1 / §8.1)" + exit 1 + fi + echo "compose.yaml clean." + + # Phase 9 / ADR-T-009 §D8 — vendored `su-exec.c` must not change + # without a fresh audit entry recording the new SHA-256 + # in contrib/dev-tools/su-exec/AUDIT.md. + - id: su-exec-audit + name: su-exec audit log matches vendored source + run: | + set -eu + audit=contrib/dev-tools/su-exec/AUDIT.md + test -s "$audit" + recorded=$(sed -n '/^## Audit Log/,$ { s/^SHA-256: \([0-9a-f]\{64\}\)$/\1/p; }' "$audit" | tail -1) + actual=$(sha256sum contrib/dev-tools/su-exec/su-exec.c | cut -d' ' -f1) + if [ -z "$recorded" ]; then + echo "::error file=$audit::no SHA-256 entry found in '## Audit Log' section (ADR-T-009 §D8)" + exit 1 + fi + if [ "$recorded" != "$actual" ]; then + echo "::error file=$audit::recorded SHA-256 ($recorded) does not match contrib/dev-tools/su-exec/su-exec.c ($actual). Append a new dated audit entry per ADR-T-009 §D8." + exit 1 + fi + echo "su-exec audit current ($actual)." + + # Phase 9 / ADR-T-009 Acceptance Criterion #7 — every env + # var listed in the entry script's manifest block must be + # documented in docs/containers.md. + - id: entry-env-docs + name: entry-script env vars documented + run: | + set -eu + script=share/container/entry_script_sh + vars=$(sed -n '/^# ENTRY_ENV_VARS:/,/^# END_ENTRY_ENV_VARS/p' "$script" \ + | grep -oE '[A-Z][A-Z0-9_]+' \ + | sort -u) + if [ -z "$vars" ]; then + echo "::error file=$script::ENTRY_ENV_VARS manifest block not found or empty (ADR-T-009 Acceptance Criterion #7)" + exit 1 + fi + missing=0 + for v in $vars; do + grep -q "$v" docs/containers.md || { + echo "::error file=docs/containers.md::env var '$v' is in the entry-script manifest but not documented" + missing=1 + } + done + grep -q 'compose\.override\.yaml' docs/containers.md || { + echo "::error file=docs/containers.md::two-file Compose split (compose.override.yaml) is not documented" + missing=1 + } + [ "$missing" -eq 0 ] + test: name: Test (Docker) + needs: lints runs-on: ubuntu-latest strategy: diff --git a/AGENTS.md b/AGENTS.md index d6e72e58b..5202363c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,10 @@ When working inside a package, prefer running only the `--package` tests, as the whole-project tests are slow to run. (Occasionally run the whole suite, for example when finishing up.) +## Commit Messages + +When writing a commit message, be sure to review the last few commit messages to compare the style. + ## Running Tests When running tests, tee to a temp file (`/tmp/...`) and then grep that @@ -44,6 +48,23 @@ API, perhaps using `#[doc(hidden)]` helpers when appropriate. Every test file (module) should maintain an index of the tests contained in the module-doc. The primary purpose is to make it easy to scan the test files to detect duplicates or overlapping coverage. Please opportunistically create if missing. +## POSIX Paths + +Treat paths as opaque byte sequences. POSIX permits any byte except +`\0` (NUL) and `/` (the path separator) in a file or directory name, +and there is no guarantee that the bytes are valid UTF-8. Concretely: + +- Prefer `OsStr` / `OsString` / `Path` / `PathBuf` (or `Utf8Path` + when UTF-8 really is a precondition you intend to enforce) over + ad-hoc `String` handling. +- Do not assume any particular character class — names may contain + spaces, newlines, control bytes, leading dashes, or arbitrary + non-UTF-8 bytes. +- NUL termination is only required when crossing a libc/FFI + boundary (e.g. `CString` for `open(2)`); interior NUL bytes are + invalid for those APIs and must be rejected, not silently + truncated. + ## Cross-Reference Conventions Eagerly corrected when spotted in **any** file! @@ -59,6 +80,13 @@ use their own `ADR--` form without the `§` prefix. | `M-` | Mudlark | `packages/mudlark/docs/idea.md` | | `R-` | render-text-as-image | `packages/render-text-as-image/` | +Helper crates (`index-health-check`, `index-auth-keypair`, +`index-config`, `index-config-probe`, `index-cli-common`, +`index-entry-script`) are internal implementation details of +the root crate and do not own separate ADRs or specification +docs. They share the `T-` prefix for any cross-references +that target them. + ### General Rules - Use `§§` for ranges: e.g. `§§IDEA M-12.2–12.5`. @@ -77,5 +105,6 @@ use their own `ADR--` form without the `§` prefix. To avoid partial or corrupted writes, always replace files atomically: 1. Read the file. -2. Using the CLI, `rm` the file. -3. Recreate the file. +2. Write the new content to a temporary file +3. Rename the temporary file to atomically overwrite the original file: + `mv file.tmp file` diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf12cb83..f2ec19590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,156 +7,401 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added +**Highlights:** container infrastructure refactor (ADR-T-009), +native role-based authorization replacing Casbin (ADR-T-008), +RSA-signed JWTs with revocation support (ADR-T-007), domain-scoped +error system (ADR-T-006), MSRV raised to 1.88. -- ADR-T-008: Document rationale for roles and permissions refactor. -- ADR-T-006: Document rationale for error system refactor. -- 188 crate-level tests for the domain error system (`src/tests/errors/`): - status-code mapping, display messages, `From` impl coverage, and - `ApiError` delegation (ADR-T-006 §1–§4). -- Native `PermissionMatrix` replacing Casbin: compile-time checked `Role` and - `Action` enums with an exhaustive default-deny policy table (ADR-T-008). -- `Permissions` trait abstraction for the authorization backend, consumed by - the `RequirePermission` extractor via `AppData.permissions`. -- `role: TEXT` column on `torrust_users` (migration for SQLite and MySQL); - existing `administrator = true` rows migrated to `role = 'admin'`, others - to `role = 'registered'`. -- `role: String` field on `TokenResponse`, `UserCompact`, `UserProfile`, and - `UserFull` API response models. -- `RequirePermission` Axum extractor enforcing role-based authorization at - the HTTP boundary before the handler runs (ADR-T-008 Phase 2). -- `ActionMarker` trait and `action_markers!` macro mapping zero-sized types to - `Action` enum variants for compile-time handler–permission binding. -- `Actor` struct yielded by `RequirePermission` carrying the resolved - `user_id` and `Role` for downstream handler use. -- `Actor::try_user_id()` non-panicking accessor returning `Option`, - safe for handlers that may serve guests (ADR-T-008). -- `Actor::is_authenticated()` convenience predicate (ADR-T-008). -- Compile-time `action_markers!` ↔ `Action::ALL` sync assertion: adding an - `Action` variant without a matching marker (or vice versa) is a compile - error (ADR-T-008). -- E2E tests for non-owner update and delete denial (`and_non_owners` module - in `tests/e2e/web/api/v1/contexts/torrent/contract.rs`) (ADR-T-008 Phase 4). -- ADR-T-007: Document rationale for JWT system refactor. -- Centralised JWT module (`src/jwt.rs`) consolidating all `jsonwebtoken` usage: - key loading, signing, verification, and algorithm configuration. -- `SessionClaims` with RFC 7519 registered claims (`sub`, `iss`, `aud`, `iat`, - `exp`) plus advisory `role`, `username`, and revocation `gen` fields. -- `VerifyClaims` with `aud: "email-verification"` for purpose separation. -- RSA key pair configuration: `auth.private_key_path` / `auth.public_key_path` - (or inline PEM via `auth.private_key_pem` / `auth.public_key_pem`). -- Ephemeral auto-generated RSA-2048 key pair when no keys are configured. - Sessions do not survive server restarts with ephemeral keys. Deployers who - want persistent sessions supply their own key pair via config. -- `torrust-generate-auth-keypair` CLI binary for generating RSA-2048 key pairs. - Outputs both PEM blocks to stdout; refuses to run if stdout is a terminal. -- Container auto-generation of persistent auth keys on first boot. The entry - script runs `torrust-generate-auth-keypair` and writes the PEM files to - `/etc/torrust/index/auth/` on the volume. Sessions survive restarts with no - manual setup. -- `kid` (Key ID) header in every JWT for future key rotation support. -- Configurable token lifetimes: `auth.session_token_lifetime_secs` (default: - 2 weeks) and `auth.email_verification_token_lifetime_secs` (default: ~10 years). -- `token_generation` column on `torrust_users` (migration for SQLite and MySQL). -- Token revocation: password changes, role changes (admin grant), and bans - increment `token_generation`; tokens with an older `gen` claim are rejected. -- Consolidated session validation: `JsonWebToken::validate_session` is the - sole entry point for verifying a session JWT, checking the token-generation - counter, and rejecting banned users. All callers delegate here. -- `BearerToken` extractor rejects missing/malformed `Authorization` headers at - the extraction boundary (`AuthError::TokenNotFound` / `AuthError::TokenInvalid`). -- `ExtractOptionalLoggedInUser` catches extraction rejection and returns `None` - for anonymous requests. -- `AuthError::TokenRevoked` variant for revoked-token responses. -- Crate tests for the JWT module (session + email-verification round-trips, - audience cross-contamination, tampered/garbage tokens). -- Crate tests for `parse_token` (valid extraction, whitespace trimming, - empty bearer, missing prefix, non-ASCII rejection). +### Breaking changes -### Changed +- MSRV raised from 1.85 to 1.88. +- `database.connect_url` and `tracker.token` are now mandatory + schema fields with no defaults. Supply them via env-var override + (`TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL`, + `..._TRACKER__TOKEN`) or a side-loaded `index.toml`. Missing values + fail at parse time with a precise `missing field` error + (ADR-T-009 §D2). +- TLS configuration renamed from `[net.tsl]` to `[net.tls]` in + operator TOMLs and from `"tsl"` to `"tls"` in the settings JSON + API. Clean break, no compatibility alias (ADR-T-009 §D3). +- `TORRUST_INDEX_DATABASE_DRIVER` is now a *first-boot TOML selector + only* (chooses which shipped default to seed). It no longer + dispatches the application's runtime driver — that is derived from + the URL scheme of `database.connect_url` (ADR-T-009 §D2). +- `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__*_PEM` and `..._PATH` are + mutually exclusive within a single key, both keys must use the + same delivery mechanism, and the pair must either both be set or + both be absent (ADR-T-009 §D3). +- Container `USER_ID` validation changed from `>= 1000` to + "non-negative integer, not `0`". The property the entry script + actually enforces is "do not run as root" (ADR-T-009 §D7). +- JWT signing changed from HMAC-HS256 to RS256. Existing tokens are + invalidated; users must re-login (ADR-T-007). +- JWT claims redesigned from `UserClaims { user, exp }` to + `SessionClaims { sub, iss, aud, iat, exp, role, username, gen }` + (ADR-T-007). +- Auth config keys `auth.user_claim_token_pepper`, + `auth.session_signing_key`, and `auth.email_verification_signing_key` + replaced by `auth.private_key_path` / `auth.public_key_path` (or + inline PEM). Deployers must generate an RSA key pair (ADR-T-007). +- `administrator: bool` replaced by `role: String` across the API + (`TokenResponse`, `UserCompact`, etc.); the legacy `admin: bool` + field is removed entirely (ADR-T-008). +- `administrator` column dropped from `torrust_users`; `role: TEXT` + is now sole authority (ADR-T-008). +- `ACTION` enum renamed to `Action` (variants unchanged) (ADR-T-008). +- `ServiceError` (41 variants) and `ServiceResult` replaced by + domain-scoped enums: `AuthError`, `UserError`, `TorrentError`, + `CategoryTagError`, with a thin `ApiError` wrapper (ADR-T-006). + +### ADR-T-009 — Container infrastructure refactor + +#### Added + +- ADR-T-009 itself. +- `torrust-index-config` workspace crate + (`packages/index-config/`) containing the parsing surface of the + configuration system. Leaf crate — no `tokio`, `reqwest`, `sqlx`, + `hyper`, `rustls`, `native-tls`, or `openssl` in its dep closure. +- Helper-binary crates split into leaves with no HTTP/TLS in their + dep closure: `torrust-index-cli-common` (shared P9 scaffolding — + `refuse_if_stdout_is_tty`, `init_json_tracing`, `emit`, `BaseArgs`), + `torrust-index-health-check` (stdlib-only, Happy Eyeballs IPv6/IPv4 + fallback), `torrust-index-auth-keypair` (RSA-2048 key generator), + `torrust-index-config-probe` (resolved-config JSON emitter), and + `torrust-index-entry-script` (test-only host-side driver). +- Sourced POSIX `sh` library at `share/container/entry_script_lib_sh` + containing the entry script's pure helpers (`inst`, + `key_configured`, `validate_auth_keys`, `seed_sqlite`). Shipped as + `0444 root:root` and sourced — not exec'd — by both the entry + script and the host-side test crate (§D3). +- Top-level `Makefile` with `make up-dev` (plain `docker compose up`) + and `make up-prod` (validates required credential env vars, runs + with the override excluded) (§D1). +- `compose.override.yaml` auto-loaded by Compose v2, re-introducing + the `mailcatcher` sidecar, `tty: true`, and permissive + `${VAR:-default}` substitutions on top of the production-shaped + baseline (§D1). +- `contrib/dev-tools/su-exec/AUDIT.md` recording provenance and a + SHA-256-anchored append-only audit log for the vendored + `su-exec.c`. CI fails the build when the file changes without a + matching audit entry (§D8). +- `jq_donor` Containerfile stage providing `jq` (and required shared + libs) from a pristine `rust:slim-trixie` base; both runtime images + copy `/usr/bin/jq` as `0500 root:root`. Used during the + pre-`su-exec` phase to parse the config probe's and keypair + helper's JSON output. An `ldd`-based allow-list catches future + transitive-dep changes at build time (§D5). +- Container runtime split into parallel `runtime_release` and + `runtime_debug` stages over a shared base-agnostic `runtime_assets` + bundle, with `busybox_donor`, `busybox_preflight`, `etc_seed`, + `adduser_preflight`, and a `preflight_gate` aggregator (§D4). +- Curated busybox applet subset in the release runtime: a single + root-only `/bin/busybox` (`0700 root:root`) plus symlinks for `sh`, + `adduser`, `addgroup`, `install`, `mkdir`, `dirname`, `chown`, + `chmod`, `tr`, `mktemp`, `cat`, `printf`, `rm`, `echo`, `grep`. + The unprivileged `torrust` user gets `EACCES` on busybox after + privilege drop (§D4). +- Container auto-generation of persistent auth keys on first boot: + the entry script runs `torrust-index-auth-keypair`, splits the + JSON output with `jq`, and writes PEMs to + `/etc/torrust/index/auth/` on the volume. +- `HEALTHCHECK` directive on the `debug` build target (was + previously omitted); debug `CMD` is now + `["/usr/bin/torrust-index"]` so debug is a drop-in replacement for + release (Phase 4). +- `Info::from_env` constructor on `torrust-index-config` — the + JSON-safe sibling of `Info::new` that skips diagnostic `println!`s + and filters empty-string env vars. +- `pub const DEFAULT_CONFIG_TOML_PATH` shared between the + application, helper binaries, and integration tests. +- `ApiToken::is_empty()` accessor so the config probe can reject + `tracker.token = ""` at the container boundary. +- `# ENTRY_ENV_VARS:` / `# END_ENTRY_ENV_VARS` canonical manifest + block in the entry script; CI verifies every name is documented in + `docs/containers.md`. +- `EXPOSE ${IMPORTER_API_PORT}/tcp` in Containerfile; port 3002 + mapped in compose. +- `restart: unless-stopped` on index and tracker compose services. +- `DEBUG=1` env-var gate for entry-script shell tracing (`set -x`). +- `#[doc(hidden)] pub mod test_helpers` in `torrust-index-config` + exposing `PLACEHOLDER_TOML` and `placeholder_settings()` — single + source of truth for the ~40 tests across both crates that + previously relied on the removed `Settings::default()` fixture + (Phase 5). +- `Configuration::for_tests` (test-only, `pub(crate)`) replacing the + deleted `impl Default for Configuration` (Phase 5). +- `clear_inherited_config_env()` test helper that strips + `TORRUST_INDEX_CONFIG_OVERRIDE_*` and + `TORRUST_INDEX_CONFIG_TOML[_PATH]` inside a `figment::Jail` so + default-configuration assertions stay deterministic (Phase 5). +- Inverted shipped-sample test suite asserting no shipped TOML + carries `connect_url`, `token`, `[mail.smtp]`, or auth key paths, + and that the schema rejects each sample with a missing-field error + (Phase 5, §D2). +- New loader tests `missing_database_connect_url_is_rejected` and + `missing_database_section_is_rejected` (Phase 5). +- Default `TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL` in + `compose.yaml`, alongside the existing `TRACKER__TOKEN` default; + matching exports in the mysql and sqlite e2e runner scripts. + +#### Changed + +- Configuration parsing surface moved from `src/config/` into the new + `torrust-index-config` workspace crate. `src/config/mod.rs` is now + a thin re-export shim plus the runtime `Configuration` wrapper; + existing `use crate::config::*;` call sites compile unchanged. +- Permission value types (`Role`, `Action`, `Effect`, + `PermissionOverride`, `RoleParseError`) moved to + `torrust_index_config::permissions` and re-exported from + `crate::services::authorization`. The `Permissions` trait and + `PermissionMatrix` runtime policy stay in the root crate. +- `load_settings` no longer ends with + `figment.join(Serialized::defaults(Settings::default()))`. Optional + sub-sections still default through their per-field `#[serde(default)]` + attributes; mandatory fields no longer have a silent fallback. +- `check_mandatory_options` no longer covers `tracker.token`; its + absence now surfaces through serde for a single consistent error + shape across all missing mandatory fields. +- `Info::new` routes its "loading extra configuration from …" + diagnostics through `tracing` (stderr) instead of `println!` + (stdout). +- Container `HEALTHCHECK` invokes `torrust-index-health-check` (was + `health_check`); the binary is rewritten in stdlib-only Rust. +- Entry script invokes `torrust-index-auth-keypair` (was + `torrust-generate-auth-keypair`) and consumes its JSON output via + `jq -r .private_key_pem` instead of `sed` PEM-block extraction. +- Entry script uses busybox short-option form for `adduser` so the + same invocation works on both runtime bases (distroless + `cc-debian13` ships `/etc/passwd` and `/etc/group` but not + `/etc/shadow`). +- Helper binaries (`torrust-index-health-check`, + `torrust-index-auth-keypair`) tightened from world-executable to + `0500 root:root`. The application binary keeps `0755`. +- `PATH` pinned in both runtime bases + (`/usr/local/bin:/bin:/usr/bin:/sbin` for release; + `/usr/local/bin:/busybox:/bin:/usr/bin:/sbin` for debug) so the + entry script's bare-name lookups resolve deterministically. +- Helper-binary TTY-refusal exit code unified on `2` (was `1` for + the keypair helper) via the shared `refuse_if_stdout_is_tty`. +- `.containerignore` now excludes `/adr/` and `/docs/` from the + build context. +- Container base images upgraded from Debian bookworm to trixie + (`rust:bookworm` → `rust:trixie`, `cc-debian12` → `cc-debian13`). +- `cargo-binstall` bootstrap pinned to tag `v1.18.1` (was `main`). +- MySQL compose image pinned to `8.0.45`; auth flag changed from + `--default-authentication-plugin` to `--authentication-policy`. +- Dev-only compose ports (tracker, MySQL, mailcatcher) bound to + `127.0.0.1`. +- `.dockerignore` renamed to `.containerignore` for Podman + compatibility. +- Removed redundant `--tests --benches --examples` from + Containerfile (covered by `--all-targets`). +- All shipped sample TOMLs under `share/default/config/` no longer + carry `connect_url`, `token`, `[mail.smtp]` values, or `[auth]` + key paths. Bare-metal developers who copy + `index.development.sqlite3.toml` verbatim must now supply + `connect_url` and `token` themselves (Phase 5, §D2). +- DEV-ONLY credential comments in `compose.yaml`. +- `docs/containers.md`: documented healthcheck behaviour, busybox + applet subset, Podman `--format docker` requirement for + `HEALTHCHECK`, entry-script debugging; fixed `USER_UID` → + `USER_ID` typo. + +#### Removed -- **BREAKING:** Raise MSRV from 1.85 to 1.88. -- **BREAKING:** `administrator: bool` replaced by `role: String` in API - responses (`TokenResponse`, `UserCompact`, etc.). The legacy `admin: bool` - field has been removed entirely (ADR-T-008). -- **BREAKING:** `administrator` column dropped from `torrust_users`; the - `role: TEXT` column is now the sole authority. Migration - `20260415000001_torrust_drop_administrator_column` handles both SQLite - (table-rebuild) and MySQL (`DROP COLUMN`). -- **BREAKING:** `ACTION` enum renamed to `Action`; variants unchanged. -- All HTTP handlers that require authorization now use +- Build-time `ARG API_PORT` / `ARG IMPORTER_API_PORT`. The runtime + `ENV` defaults are retained so the listener and `HEALTHCHECK` + resolve correctly; runtime `--env` overrides continue to work + (§D6). +- Monolithic `runtime` Containerfile stage and its ad-hoc `cp -sp` + busybox-applet copy, superseded by the curated symlink loop and + the release/debug split. +- `RUN env` and `CMD ["sh"]` from the previous debug target — debug + now ships the same `ENTRYPOINT` / `CMD` / `HEALTHCHECK` as + release; operators reach a shell with `docker run … sh`. +- `impl Default for Settings`, `impl Default for Tracker`, + `impl Default for Database`, the matching `#[serde(default = …)]` + attributes, and the now-dead `Tracker::default_token()` / + `Settings::default_tracker()` (Phase 5). +- `impl Default for Configuration` — superseded by + `Configuration::for_tests` (Phase 5). +- `contrib/dev-tools/container/build.sh` and `run.sh` — both stale, + passed wrong build-args and assumed a `Dockerfile` that no longer + exists. +- Stale `TORRUST_TRACKER_USER_UID` export from E2E container + scripts. + +### ADR-T-008 — Roles & permissions + +#### Added + +- ADR-T-008 itself. +- Native `PermissionMatrix` replacing Casbin: compile-time checked + `Role` and `Action` enums with an exhaustive default-deny policy + table. +- `Permissions` trait abstraction for the authorization backend, + consumed by the `RequirePermission` extractor via + `AppData.permissions`. +- `role: TEXT` column on `torrust_users` (migration for SQLite and + MySQL); existing `administrator = true` rows migrated to + `role = 'admin'`, others to `role = 'registered'`. +- `role: String` field on `TokenResponse`, `UserCompact`, + `UserProfile`, and `UserFull` API response models. +- `RequirePermission` Axum extractor enforcing role-based + authorization at the HTTP boundary before the handler runs. +- `ActionMarker` trait and `action_markers!` macro mapping + zero-sized types to `Action` variants for compile-time + handler–permission binding. A compile-time sync assertion catches + divergence between `action_markers!` and `Action::ALL`. +- `Actor` struct yielded by `RequirePermission` carrying the + resolved `user_id` and `Role`, with `try_user_id()` and + `is_authenticated()` helpers. +- E2E tests for non-owner update and delete denial + (`and_non_owners` module under torrent contract tests). + +#### Changed + +- All HTTP handlers requiring authorization use `RequirePermission` extractors instead of calling - `authorization::Service::authorize()` (ADR-T-008 Phase 2). -- Service methods no longer receive `maybe_user_id` for authorization - purposes — they receive an already-authorized `Actor` or are called - unconditionally. -- Unauthorized requests are rejected at the extractor boundary before - reaching the service layer (fail-fast). -- First-user auto-admin grant in `RegistrationService::register` now logs - a `warn!` on failure instead of silently discarding the `Result` via - `drop()`. -- **BREAKING:** JWT signing algorithm changed from HMAC-HS256 to RS256 - (RSA + SHA-256). Existing HS256 tokens are invalidated; users must re-login. -- **BREAKING:** JWT claims redesigned from `UserClaims { user, exp }` to - `SessionClaims { sub, iss, aud, iat, exp, role, username, gen }`. Existing - tokens without the new claims fail deserialization. -- **BREAKING:** Configuration keys changed — `auth.user_claim_token_pepper` / - `auth.session_signing_key` / `auth.email_verification_signing_key` replaced - by `auth.private_key_path` and `auth.public_key_path` (or inline PEM). - Deployers must generate an RSA key pair. -- **BREAKING:** Replace `ServiceError` (41 variants) and `ServiceResult` with - domain-scoped error enums: `AuthError`, `UserError`, `TorrentError`, - `CategoryTagError`, and a thin `ApiError` wrapper (ADR-T-006). -- `Authentication::get_user_id_from_bearer_token` now takes `BearerToken` - directly instead of `Option`. -- `parse_token` returns `Result` instead of panicking on malformed headers. -- JWT `exp` validation relies solely on the `jsonwebtoken` library; redundant - manual expiration check removed. -- Token signing uses `Result` propagation instead of `.unwrap()` / `.expect()`. -- `UserClaims` is now a type alias for `SessionClaims` (backward-compatible). -- `VerifyClaims` moved from `mailer` into the `jwt` module (re-exported for - backward compatibility). -- Service functions now return domain-specific `Result` instead - of `Result`. + `authorization::Service::authorize()`. +- Service methods no longer receive `maybe_user_id` for + authorization — they receive an already-authorized `Actor` or are + called unconditionally. Unauthorized requests are rejected at the + extractor boundary (fail-fast). +- First-user auto-admin grant in `RegistrationService::register` + now logs a `warn!` on failure instead of silently discarding the + `Result` via `drop()`. +- v1→v2 upgrade path: `insert_imported_user` writes the `role` + column instead of the removed `administrator` column. + +#### Removed + +- `admin: bool` from `TokenResponse`, `LoggedInUserData`, and + `TokenRenewalData` — superseded by `role: String`. +- `UserCompact::is_admin()` — no longer needed. +- `casbin` crate dependency and all Casbin-related code + (`CasbinConfiguration`, `CasbinEnforcer`, the SCREAMING_CASE + `ACTION` enum, `unstable.auth.casbin` config section). +- `authorization::Service` struct — replaced by + `RequirePermission` consulting `PermissionMatrix` directly. +- `ExtractLoggedInUser` and `ExtractOptionalLoggedInUser` + extractors — replaced by `RequirePermission`. +- Dead `Action` variants `GetSettings` and `GetCanonicalInfoHash`. + +### ADR-T-007 — JWT refactor + +#### Added + +- ADR-T-007 itself. +- Centralised JWT module (`src/jwt.rs`) consolidating all + `jsonwebtoken` usage: key loading, signing, verification, + algorithm configuration. +- `SessionClaims` with RFC 7519 registered claims (`sub`, `iss`, + `aud`, `iat`, `exp`) plus advisory `role`, `username`, and + revocation `gen` fields. +- `VerifyClaims` with `aud: "email-verification"` for purpose + separation. +- RSA key pair configuration: `auth.private_key_path` / + `auth.public_key_path` (or inline PEM via `..._pem`). +- Ephemeral auto-generated RSA-2048 key pair when no keys are + configured. Sessions do not survive server restarts with ephemeral + keys. +- `kid` (Key ID) header in every JWT for future key rotation. +- Configurable token lifetimes: + `auth.session_token_lifetime_secs` (default: 2 weeks) and + `auth.email_verification_token_lifetime_secs` (default: ~10 years). +- `token_generation` column on `torrust_users` (SQLite + MySQL + migrations). +- Token revocation: password changes, role changes (admin grant), + and bans increment `token_generation`; tokens with an older `gen` + claim are rejected. +- `JsonWebToken::validate_session` as the sole entry point for + verifying a session JWT, the token-generation counter, and the + banned-user check. All callers delegate here. +- `BearerToken` extractor rejects missing/malformed `Authorization` + headers at the extraction boundary + (`AuthError::TokenNotFound` / `AuthError::TokenInvalid`). +- `ExtractOptionalLoggedInUser` returns `None` for anonymous + requests instead of erroring. +- `AuthError::TokenRevoked` variant for revoked-token responses. +- Crate tests for the JWT module (session + + email-verification round-trips, audience cross-contamination, + tampered/garbage tokens) and for `parse_token`. + +#### Changed + +- `Authentication::get_user_id_from_bearer_token` takes + `BearerToken` directly instead of `Option`. +- `parse_token` returns `Result` instead of panicking on malformed + headers. +- JWT `exp` validation relies solely on the `jsonwebtoken` library; + the redundant manual expiration check is removed. +- Token signing uses `Result` propagation instead of `.unwrap()` / + `.expect()`. +- `UserClaims` is now a type alias for `SessionClaims`. +- `VerifyClaims` moved from `mailer` into the `jwt` module + (re-exported for backward compatibility). +- JWT session token `role` claim carries the database `role` + directly (`"registered"`, `"admin"`) instead of the previous + mapping (`"user"`, `"admin"`). + +#### Removed + +- `bearer_token::Extract` wrapper struct (replaced by `BearerToken` + directly). +- `get_optional_logged_in_user` free function (logic moved into + extractors). +- `get_claims_from_bearer_token` private method on `Authentication` + (inlined). +- `ClaimTokenPepper` / `JwtSigningSecret` / + `user_claim_token_pepper` config keys. + +### ADR-T-006 — Error system + +#### Added + +- ADR-T-006 itself. +- ~190 crate-level tests for the domain error system + (`src/tests/errors/`): status-code mapping, display messages, + `From` impl coverage, and `ApiError` delegation. + +#### Changed + +- Service functions return domain-specific + `Result` instead of `Result`. - Each domain error co-locates its HTTP status-code mapping via a `status_code()` method. - Error `From` impls use `tracing::error!` instead of `eprintln!`. -- JWT session token `role` claim now carries the database `role` value - directly (`"registered"`, `"admin"`) instead of the previous mapping - (`"user"`, `"admin"`). -- v1→v2 upgrade path: `insert_imported_user` now writes the `role` column - (`"admin"` / `"registered"`) instead of the removed `administrator` column. - Standardise all error derives on `thiserror`. -### Removed +#### Removed -- `admin: bool` field from `TokenResponse`, `LoggedInUserData`, and - `TokenRenewalData` — superseded by `role: String` (ADR-T-008). -- `UserCompact::is_admin()` convenience method — no longer needed after - `admin: bool` removal. -- `administrator` column from `torrust_users` schema (migration for both - SQLite and MySQL). -- `casbin` crate dependency and all Casbin-related code - (`CasbinConfiguration`, `CasbinEnforcer`, the `ACTION` enum in - SCREAMING_CASE) — replaced by the native `PermissionMatrix` (ADR-T-008). -- `unstable.auth.casbin` configuration section (`Unstable`, `Auth`, `Casbin` - config structs in `src/config/v2/unstable.rs`). -- `bearer_token::Extract` wrapper struct (replaced by `BearerToken` directly). -- `get_optional_logged_in_user` free function (logic moved into extractors). -- `get_claims_from_bearer_token` private method on `Authentication` (inlined). -- `ClaimTokenPepper` / `JwtSigningSecret` / `user_claim_token_pepper` config - keys (replaced by RSA key pair configuration). -- `ServiceError` enum and `ServiceResult` type alias from `src/errors.rs`. -- `http_status_code_for_service_error` and `map_database_error_to_service_error` - helper functions. -- `IntoResponse` impl for `database::Error` (now handled by domain errors). -- `authorization::Service` struct — replaced by `RequirePermission` - extractors consulting `PermissionMatrix` directly (ADR-T-008 Phase 2). -- `ExtractLoggedInUser` (`user_id.rs`) and `ExtractOptionalLoggedInUser` - (`optional_user_id.rs`) extractors — replaced by `RequirePermission` - (ADR-T-008 Phase 2). -- Dead `Action` variants `GetSettings` and `GetCanonicalInfoHash` (no - corresponding handlers existed). +- `ServiceError` enum and `ServiceResult` type alias from + `src/errors.rs`. +- `http_status_code_for_service_error` and + `map_database_error_to_service_error` helpers. +- `IntoResponse` impl for `database::Error` (now handled by domain + errors). + +### Security + +- Dev-only ports (MySQL 3306, tracker 6969/7070/1212, mailcatcher + 1025/1080) no longer bind to `0.0.0.0`; bound to `127.0.0.1`. +- Entry script `set -x` gated behind `DEBUG=1` to avoid leaking + env vars into logs. +- Compose credentials annotated as DEV-ONLY with TODO for Docker + secrets migration (ADR-T-009 §S1). + +### Fixed + +- MySQL compose healthcheck was referencing a non-existent Docker + secret (`/run/secrets/db-password`); now uses + `$$MYSQL_ROOT_PASSWORD`. +- Entry script `USER_ID` validation: + `-z "$USER_ID" && "$USER_ID" -lt 1000` always short-circuited to + an error when `USER_ID` was unset; corrected to `||`. +- Containerfile release `HEALTHCHECK` trailing whitespace removed. ## [4.0.0] - 2026-03-23 diff --git a/Cargo.lock b/Cargo.lock index 9f65bf7c2..da0c31c38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4258,7 +4258,6 @@ dependencies = [ "axum-server", "bittorrent-primitives", "bytes", - "camino", "chrono", "clap", "derive_more", @@ -4287,7 +4286,6 @@ dependencies = [ "serde_bytes", "serde_derive", "serde_json", - "serde_with", "sha-1", "sha2 0.11.0", "sqlx", @@ -4297,6 +4295,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "toml 1.1.2+spec-1.1.0", + "torrust-index-config", "torrust-index-render-text-as-image", "tower", "tower-http", @@ -4308,6 +4307,78 @@ dependencies = [ "which", ] +[[package]] +name = "torrust-index-auth-keypair" +version = "4.0.0-develop" +dependencies = [ + "clap", + "rsa", + "serde", + "serde_json", + "torrust-index-cli-common", + "tracing", +] + +[[package]] +name = "torrust-index-cli-common" +version = "4.0.0-develop" +dependencies = [ + "clap", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "torrust-index-config" +version = "4.0.0-develop" +dependencies = [ + "camino", + "derive_more", + "figment", + "lettre", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "tracing", + "url", +] + +[[package]] +name = "torrust-index-config-probe" +version = "4.0.0-develop" +dependencies = [ + "clap", + "percent-encoding", + "serde", + "serde_json", + "torrust-index-cli-common", + "torrust-index-config", + "tracing", + "url", +] + +[[package]] +name = "torrust-index-entry-script" +version = "4.0.0-develop" +dependencies = [ + "tempfile", +] + +[[package]] +name = "torrust-index-health-check" +version = "4.0.0-develop" +dependencies = [ + "clap", + "serde", + "serde_json", + "torrust-index-cli-common", + "tracing", +] + [[package]] name = "torrust-index-render-text-as-image" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index db21bfbfc..08d84fc9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,15 @@ [workspace] -members = [".", "packages/render-text-as-image", "packages/mudlark"] +members = [ + ".", + "packages/index-auth-keypair", + "packages/index-cli-common", + "packages/index-config", + "packages/index-config-probe", + "packages/index-entry-script", + "packages/index-health-check", + "packages/mudlark", + "packages/render-text-as-image", +] [package] default-run = "torrust-index" @@ -42,6 +52,7 @@ opt-level = 3 opt-level = 3 [dependencies] +torrust-index-config = { version = "4.0.0-develop", path = "packages/index-config" } torrust-index-render-text-as-image = { version = "0.1.0", path = "packages/render-text-as-image" } argon2 = "0" @@ -50,7 +61,6 @@ axum = { version = "0", features = ["multipart"] } axum-server = { version = "0", features = ["tls-rustls"] } bittorrent-primitives = "0.1.0" bytes = "1" -camino = { version = "1", features = ["serde"] } chrono = { version = "0", default-features = false, features = ["clock"] } clap = { version = "4", features = ["derive", "env"] } derive_more = { version = "2", features = ["display"] } @@ -86,7 +96,6 @@ serde_bencode = "0" serde_bytes = "0" serde_derive = "1" serde_json = "1" -serde_with = "3" sha-1 = "0" sha2 = "0" sqlx = { version = "0", features = ["migrate", "mysql", "runtime-tokio-native-tls", "sqlite", "time"] } @@ -103,10 +112,6 @@ url = { version = "2", features = ["serde"] } urlencoding = "2" uuid = { version = "1", features = ["v4"] } -[[bin]] -name = "torrust-generate-auth-keypair" -path = "src/bin/generate_auth_keypair.rs" - [dev-dependencies] tempfile = "3" which = "8" diff --git a/Containerfile b/Containerfile index cc0e79cf2..fd460f1f4 100644 --- a/Containerfile +++ b/Containerfile @@ -3,30 +3,74 @@ # Torrust Index ## Builder Image -FROM rust:bookworm AS chef +FROM rust:trixie AS chef WORKDIR /tmp -RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash +RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/v1.18.1/install-from-binstall-release.sh | bash RUN cargo binstall --no-confirm --locked cargo-chef cargo-nextest ## Tester Image -FROM rust:slim-bookworm AS tester +FROM rust:slim-trixie AS tester WORKDIR /tmp RUN apt-get update; apt-get install -y curl sqlite3; apt-get autoclean -RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash +RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/v1.18.1/install-from-binstall-release.sh | bash RUN cargo binstall --no-confirm --locked cargo-nextest imdl COPY ./share/ /app/share/torrust RUN mkdir -p /app/share/torrust/default/database/; \ sqlite3 /app/share/torrust/default/database/index.sqlite3.db "VACUUM;" +## jq donor (pristine base, no user code) +# +# Debian trixie's `jq` package is dynamically linked against +# libjq and libonig. The distroless runtime does not ship +# either, so we stage them here at deterministic paths +# alongside the binary, and the runtime stages copy all +# three artefacts (binary + two shared libs). +# +# The `*-linux-gnu` glob resolves to whichever multi-arch +# tuple the donor was built for; we re-stage under stable +# names (`/jq/...`) so the runtime COPY directives don't +# need to know the tuple. +FROM rust:slim-trixie AS jq_donor +RUN apt-get update && \ + apt-get install -y --no-install-recommends jq && \ + rm -rf /var/lib/apt/lists/* +# Pin jq's runtime shared-library set so a future donor-base +# upgrade that drags in a new transitive dep fails the build +# instead of producing a silently-broken runtime image. The +# allow-list mirrors what the runtime stages COPY across +# (libjq, libonig) plus libraries already present in the +# `cc-debian13` runtime base (libc, libm, ld-linux, vdso). +RUN set -eu; \ + expected='libc.so.6 libjq.so.1 libm.so.6 libonig.so.5 ld-linux-x86-64.so.2 linux-vdso.so.1'; \ + # ldd column 1 is sometimes a bare soname ("libc.so.6") + # and sometimes an absolute path ("/lib64/ld-linux-…"). + # Reduce to basenames so the allow-list check is uniform. + actual="$(ldd /usr/bin/jq | awk '{print $1}' | sed 's|.*/||' | sort -u | tr '\n' ' ')"; \ + for lib in $expected; do \ + case " $actual " in *" $lib "*) ;; *) echo "ERROR: jq lost expected library: $lib" >&2; exit 1 ;; esac; \ + done; \ + for lib in $actual; do \ + case " $expected " in *" $lib "*) ;; *) echo "ERROR: jq pulls unexpected library: $lib (allow-list: $expected)" >&2; exit 1 ;; esac; \ + done +# Stage jq + its two non-glibc shared libraries at +# deterministic paths under `/jq/`. The `*-linux-gnu` glob +# resolves to whichever multi-arch tuple the donor was +# built for; re-staging under stable names lets the runtime +# COPY directives stay tuple-agnostic. +RUN mkdir -p /jq && \ + cp /usr/bin/jq /jq/jq && \ + cp -L /usr/lib/*-linux-gnu/libjq.so.1 /jq/libjq.so.1 && \ + cp -L /usr/lib/*-linux-gnu/libonig.so.5 /jq/libonig.so.5 + ## Su Exe Compile -FROM docker.io/library/gcc:bookworm AS gcc +FROM docker.io/library/gcc:trixie AS gcc COPY ./contrib/dev-tools/su-exec/ /usr/local/src/su-exec/ RUN cc -Wall -Werror -g /usr/local/src/su-exec/su-exec.c -o /usr/local/bin/su-exec; chmod +x /usr/local/bin/su-exec -## Chef Prepare (look at project and see wat we need) +## Chef Prepare (look at project and see what we need) FROM chef AS recipe WORKDIR /build/src COPY . /build/src @@ -37,28 +81,28 @@ RUN cargo chef prepare --recipe-path /build/recipe.json FROM chef AS dependencies_debug WORKDIR /build/src COPY --from=recipe /build/recipe.json /build/recipe.json -RUN cargo chef cook --tests --benches --examples --workspace --all-targets --all-features --recipe-path /build/recipe.json -RUN cargo nextest archive --tests --benches --examples --workspace --all-targets --all-features --archive-file /build/temp.tar.zst ; rm -f /build/temp.tar.zst +RUN cargo chef cook --workspace --all-targets --all-features --recipe-path /build/recipe.json +RUN cargo nextest archive --workspace --all-targets --all-features --archive-file /build/temp.tar.zst ; rm -f /build/temp.tar.zst ## Cook (release) FROM chef AS dependencies WORKDIR /build/src COPY --from=recipe /build/recipe.json /build/recipe.json -RUN cargo chef cook --tests --benches --examples --workspace --all-targets --all-features --recipe-path /build/recipe.json --release -RUN cargo nextest archive --tests --benches --examples --workspace --all-targets --all-features --archive-file /build/temp.tar.zst --release ; rm -f /build/temp.tar.zst +RUN cargo chef cook --workspace --all-targets --all-features --recipe-path /build/recipe.json --release +RUN cargo nextest archive --workspace --all-targets --all-features --archive-file /build/temp.tar.zst --release ; rm -f /build/temp.tar.zst ## Build Archive (debug) FROM dependencies_debug AS build_debug WORKDIR /build/src COPY . /build/src -RUN cargo nextest archive --tests --benches --examples --workspace --all-targets --all-features --archive-file /build/torrust-index-debug.tar.zst +RUN cargo nextest archive --workspace --all-targets --all-features --archive-file /build/torrust-index-debug.tar.zst ## Build Archive (release) FROM dependencies AS build WORKDIR /build/src COPY . /build/src -RUN cargo nextest archive --tests --benches --examples --workspace --all-targets --all-features --archive-file /build/torrust-index.tar.zst --release +RUN cargo nextest archive --workspace --all-targets --all-features --archive-file /build/torrust-index.tar.zst --release # Extract and Test (debug) @@ -73,9 +117,26 @@ RUN cargo nextest run --workspace-remap /test/src/ --target-dir-remap /test/src/ RUN mkdir -p /app/bin/; \ cp -l /test/src/target/debug/torrust-index /app/bin/torrust-index; \ - cp -l /test/src/target/debug/torrust-generate-auth-keypair /app/bin/torrust-generate-auth-keypair -# RUN mkdir /app/lib/; cp -l $(realpath $(ldd /app/bin/torrust-index | grep "libz\.so\.1" | awk '{print $3}')) /app/lib/libz.so.1 -RUN chown -R root:root /app; chmod -R u=rw,go=r,a+X /app; chmod -R a+x /app/bin + cp -l /test/src/target/debug/torrust-index-health-check /app/bin/torrust-index-health-check; \ + cp -l /test/src/target/debug/torrust-index-auth-keypair /app/bin/torrust-index-auth-keypair; \ + cp -l /test/src/target/debug/torrust-index-config-probe /app/bin/torrust-index-config-probe +# Phase 4: per-binary modes. Application binary stays +# world-executable; root-phase-only helpers (health-check, +# auth-keypair, config-probe) tighten to root-only +# (0500 root:root) per ADR-T-009 §D4 / §D5 — same posture as +# busybox, su-exec, jq. The healthcheck binary is invoked +# from HEALTHCHECK, which runs as root (no --user in the +# directive), so 0500 is sufficient. The config-probe is +# invoked only from the entry script's pre-su-exec phase, +# so it is also root-only. +RUN chown -R root:root /app; chmod -R u=rw,go=r,a+X /app; \ + chmod 0755 /app/bin/torrust-index; \ + chown 0:0 /app/bin/torrust-index-health-check \ + /app/bin/torrust-index-auth-keypair \ + /app/bin/torrust-index-config-probe; \ + chmod 0500 /app/bin/torrust-index-health-check \ + /app/bin/torrust-index-auth-keypair \ + /app/bin/torrust-index-config-probe # Extract and Test (release) FROM tester AS test @@ -89,54 +150,202 @@ RUN cargo nextest run --workspace-remap /test/src/ --target-dir-remap /test/src/ RUN mkdir -p /app/bin/; \ cp -l /test/src/target/release/torrust-index /app/bin/torrust-index; \ - cp -l /test/src/target/release/health_check /app/bin/health_check; \ - cp -l /test/src/target/release/torrust-generate-auth-keypair /app/bin/torrust-generate-auth-keypair -# RUN mkdir -p /app/lib/; cp -l $(realpath $(ldd /app/bin/torrust-index | grep "libz\.so\.1" | awk '{print $3}')) /app/lib/libz.so.1 -RUN chown -R root:root /app; chmod -R u=rw,go=r,a+X /app; chmod -R a+x /app/bin + cp -l /test/src/target/release/torrust-index-health-check /app/bin/torrust-index-health-check; \ + cp -l /test/src/target/release/torrust-index-auth-keypair /app/bin/torrust-index-auth-keypair; \ + cp -l /test/src/target/release/torrust-index-config-probe /app/bin/torrust-index-config-probe +# Phase 4: per-binary modes (see test_debug above for rationale). +# The healthcheck binary is invoked from HEALTHCHECK, which +# runs as root (no --user in the directive), so 0500 is +# sufficient. The config-probe (ADR-T-009 §D3) is invoked +# only from the entry script's pre-su-exec phase, so it is +# also root-only. +RUN chown -R root:root /app; chmod -R u=rw,go=r,a+X /app; \ + chmod 0755 /app/bin/torrust-index; \ + chown 0:0 /app/bin/torrust-index-health-check \ + /app/bin/torrust-index-auth-keypair \ + /app/bin/torrust-index-config-probe; \ + chmod 0500 /app/bin/torrust-index-health-check \ + /app/bin/torrust-index-auth-keypair \ + /app/bin/torrust-index-config-probe -## Runtime -FROM gcr.io/distroless/cc-debian12:debug AS runtime -RUN ["/busybox/cp", "-sp", "/busybox/sh","/busybox/cat","/busybox/ls","/busybox/env", "/bin/"] -COPY --from=gcc --chmod=0555 /usr/local/bin/su-exec /bin/su-exec +## ── Runtime asset bundle (base-agnostic) ───────────────────── +# The lean release base does not ship busybox at all; the +# :debug variant does. Use the :debug image as a "donor" we +# extract a single root-only busybox binary from for the +# release base. +FROM gcr.io/distroless/cc-debian13:debug AS busybox_donor -ARG TORRUST_INDEX_CONFIG_TOML_PATH="/etc/torrust/index/index.toml" -ARG TORRUST_INDEX_DATABASE_DRIVER="sqlite3" -ARG USER_ID=1000 -ARG API_PORT=3001 -ARG IMPORTER_API_PORT=3002 +# Preflight: assert the donor still ships a real busybox ELF +# at the path we copy from, and that its `install` applet +# supports `-D` (the entry script's `inst()` helper depends on +# it). Cheap insurance against a future base reshuffle. +FROM busybox_donor AS busybox_preflight +RUN ["/busybox/sh", "-c", \ + "test -f /busybox/busybox \ + && /busybox/busybox --help >/dev/null \ + && /busybox/busybox install --help 2>&1 | grep -q -- '-D'"] -ENV TORRUST_INDEX_CONFIG_TOML_PATH=${TORRUST_INDEX_CONFIG_TOML_PATH} -ENV TORRUST_INDEX_DATABASE_DRIVER=${TORRUST_INDEX_DATABASE_DRIVER} -ENV USER_ID=${USER_ID} -ENV API_PORT=${API_PORT} -ENV IMPORTER_API_PORT=${IMPORTER_API_PORT} -ENV TZ=Etc/UTC +# Minimal /etc account files generated locally so adduser +# works regardless of base. The base image's own +# /etc/nsswitch.conf (which includes `hosts: files dns`) is +# deliberately left alone — the application makes outbound +# connections where host-name resolution must work. +FROM busybox_preflight AS etc_seed +# /etc/profile is seeded as an empty file so the debug image's +# `ENV ENV=/etc/profile` points at a real path. Operators who +# bind-mount a richer profile over it get the expected +# behaviour; absent that, busybox `sh` sources an empty file +# and continues silently rather than warning on every +# invocation. +RUN ["/busybox/sh", "-c", \ + "mkdir -p /seed/etc && \ + printf 'root:x:0:0:root:/:/bin/sh\\n' > /seed/etc/passwd && \ + printf 'root:x:0:\\n' > /seed/etc/group && \ + : > /seed/etc/profile"] -EXPOSE ${API_PORT}/tcp +# Preflight: assert `adduser -D` works without /etc/shadow. +# The entry script runs `adduser -D -s /bin/sh -u $USER_ID +# torrust` at first boot against the etc_seed layout (passwd +# + group, no shadow). Busybox adduser behaviour when shadow +# is absent varies by version; this stage catches regressions +# at build time rather than first boot. +FROM busybox_donor AS adduser_preflight +COPY --from=etc_seed /seed/etc/passwd /etc/passwd +COPY --from=etc_seed /seed/etc/group /etc/group +# Busybox `adduser` rejects UIDs outside 0..60000 (well below +# the conventional `nobody` value of 65534). Use a value in +# range that is still well above `USER_ID=1000` so the +# preflight cannot collide with a realistic runtime user. +RUN ["/busybox/sh", "-c", \ + "/busybox/adduser -D -s /bin/sh -u 59999 testuser \ + && /busybox/grep -q '^testuser:' /etc/passwd \ + && /busybox/test -d /home/testuser"] -RUN mkdir -p /var/lib/torrust/index /var/log/torrust/index /etc/torrust/index +FROM scratch AS runtime_assets +COPY --from=etc_seed --chmod=0644 --chown=0:0 /seed/etc/ /etc/ +# Single busybox binary, root-only. Copy the file directly +# (not via the /busybox/sh symlink) to avoid depending on the +# donor's symlink layout. +COPY --from=busybox_preflight --chmod=0700 --chown=0:0 \ + /busybox/busybox /bin/busybox +COPY --from=gcc --chmod=0700 --chown=0:0 \ + /usr/local/bin/su-exec /bin/su-exec +COPY --chmod=0555 --chown=0:0 \ + ./share/container/entry_script_sh /usr/local/bin/entry.sh +COPY --chmod=0444 --chown=0:0 \ + ./share/container/entry_script_lib_sh /usr/local/lib/torrust/entry_script_lib_sh -ENV ENV=/etc/profile -COPY --chmod=0555 ./share/container/entry_script_sh /usr/local/bin/entry.sh +## ── Preflight gate (aggregates all donor-validation stages) ── +# Both runtime bases COPY from this stage, creating an +# explicit BuildKit dependency edge that prevents any +# preflight from being pruned regardless of which image +# variant is built. +FROM scratch AS preflight_gate +COPY --from=busybox_preflight /etc/passwd /tmp/.busybox-ok +COPY --from=adduser_preflight /etc/passwd /tmp/.adduser-ok -VOLUME ["/var/lib/torrust/index","/var/log/torrust/index","/etc/torrust/index"] - -ENV RUNTIME="runtime" -ENTRYPOINT ["/usr/local/bin/entry.sh"] +## ── Runtime base: release (root-only curated subset) ───────── +FROM gcr.io/distroless/cc-debian13 AS runtime_release +# Note: distroless cc-debian13 is usrmerged — `/bin` is a +# symlink to `/usr/bin`, so a recursive `COPY / /` from a +# stage that ships `/bin/` as a real directory fails with +# "cannot copy to non-directory". Copy each curated path +# individually instead so BuildKit resolves the symlink. +COPY --from=runtime_assets /etc/passwd /etc/passwd +COPY --from=runtime_assets /etc/group /etc/group +COPY --from=runtime_assets /etc/profile /etc/profile +COPY --from=runtime_assets /bin/busybox /usr/bin/busybox +COPY --from=runtime_assets /bin/su-exec /usr/bin/su-exec +COPY --from=runtime_assets /usr/local/bin/entry.sh /usr/local/bin/entry.sh +COPY --from=preflight_gate /tmp/.adduser-ok /tmp/.preflight-sentinel +# Pin PATH so a future base-image change cannot silently break +# the entry script's bare-name lookups. +ENV PATH=/usr/local/bin:/bin:/usr/bin:/sbin +# Materialise the curated applet set as symlinks to the +# single root-only busybox binary. Symlinks are created in +# `/usr/bin/` (the canonical usrmerged location); `/bin/` +# resolves to the same path via the base's `/bin → /usr/bin` +# symlink. The applet list must match the curated applet +# reference in ADR-T-009 §D4 — update both in the same change. +RUN ["/usr/bin/busybox", "sh", "-c", \ + "for a in sh adduser addgroup install mkdir dirname chown chmod tr mktemp cat printf rm echo grep; do \ + /usr/bin/busybox ln -s busybox /usr/bin/$a; \ + done && rm -f /tmp/.preflight-sentinel"] +## ── Runtime base: debug (full busybox on PATH) ─────────────── +FROM gcr.io/distroless/cc-debian13:debug AS runtime_debug +COPY --from=etc_seed --chmod=0644 --chown=0:0 /seed/etc/ /etc/ +COPY --from=preflight_gate /tmp/.adduser-ok /tmp/.preflight-sentinel +# Pull su-exec from runtime_assets (which already copies it +# from gcc with the correct mode/ownership) so there is a +# single source for the compiled binary regardless of base. +COPY --from=runtime_assets /bin/su-exec /bin/su-exec +COPY --chmod=0555 --chown=0:0 \ + ./share/container/entry_script_sh /usr/local/bin/entry.sh +COPY --chmod=0444 --chown=0:0 \ + ./share/container/entry_script_lib_sh /usr/local/lib/torrust/entry_script_lib_sh +# Materialise /bin/sh → /busybox/sh so root's recorded login +# shell in /etc/passwd resolves correctly. The release base +# creates the same symlink as part of its curated-applet +# loop; the debug base needs an explicit one because +# /bin/busybox doesn't exist here. +RUN ["/busybox/sh", "-c", \ + "/busybox/ln -s /busybox/sh /bin/sh && rm -f /tmp/.preflight-sentinel"] +ENV PATH=/usr/local/bin:/busybox:/bin:/usr/bin:/sbin ## Torrust-Index (debug) -FROM runtime AS debug -ENV RUNTIME="debug" +FROM runtime_debug AS debug +ENV TORRUST_INDEX_CONFIG_TOML_PATH=/etc/torrust/index/index.toml \ + TORRUST_INDEX_DATABASE_DRIVER=sqlite3 \ + USER_ID=1000 \ + API_PORT=3001 \ + IMPORTER_API_PORT=3002 \ + TZ=Etc/UTC \ + ENV=/etc/profile \ + RUNTIME=debug +EXPOSE 3001/tcp 3002/tcp +VOLUME ["/var/lib/torrust/index","/var/log/torrust/index","/etc/torrust/index"] COPY --from=test_debug /app/ /usr/ -RUN env -CMD ["sh"] +# jq binary for entry-script JSON consumption (§2.2 step 4). +# Root-only (0500) — same posture as busybox and su-exec. +# The two shared libraries (libjq, libonig) are required at +# runtime; distroless cc-debian13's ld.so resolves them via +# the multiarch dir registered in /etc/ld.so.conf.d/. +COPY --from=jq_donor --chmod=0500 --chown=0:0 /jq/jq /usr/bin/jq +COPY --from=jq_donor --chmod=0444 --chown=0:0 /jq/libjq.so.1 /usr/lib/x86_64-linux-gnu/libjq.so.1 +COPY --from=jq_donor --chmod=0444 --chown=0:0 /jq/libonig.so.5 /usr/lib/x86_64-linux-gnu/libonig.so.5 +ENTRYPOINT ["/usr/local/bin/entry.sh"] +HEALTHCHECK --interval=5s --timeout=5s --start-period=3s --retries=3 \ + CMD /usr/bin/torrust-index-health-check "http://localhost:${API_PORT}/health_check" \ + && /usr/bin/torrust-index-health-check "http://localhost:${IMPORTER_API_PORT}/health_check" +# Default CMD matches release so the debug image is a drop-in +# replacement; operators can override with `sh` (or any other +# applet on PATH) at `docker run` / compose time. +CMD ["/usr/bin/torrust-index"] ## Torrust-Index (release) (default) -FROM runtime AS release -ENV RUNTIME="release" +FROM runtime_release AS release +ENV TORRUST_INDEX_CONFIG_TOML_PATH=/etc/torrust/index/index.toml \ + TORRUST_INDEX_DATABASE_DRIVER=sqlite3 \ + USER_ID=1000 \ + API_PORT=3001 \ + IMPORTER_API_PORT=3002 \ + TZ=Etc/UTC \ + RUNTIME=release +EXPOSE 3001/tcp 3002/tcp +VOLUME ["/var/lib/torrust/index","/var/log/torrust/index","/etc/torrust/index"] COPY --from=test /app/ /usr/ -HEALTHCHECK --interval=5s --timeout=5s --start-period=3s --retries=3 \ - CMD /usr/bin/health_check http://localhost:${API_PORT}/health_check && /usr/bin/health_check http://localhost:${IMPORTER_API_PORT}/health_check || exit 1 +# jq binary for entry-script JSON consumption (§2.2 step 4). +# Root-only (0500) — same posture as busybox and su-exec. +# The two shared libraries (libjq, libonig) are required at +# runtime; distroless cc-debian13's ld.so resolves them via +# the multiarch dir registered in /etc/ld.so.conf.d/. +COPY --from=jq_donor --chmod=0500 --chown=0:0 /jq/jq /usr/bin/jq +COPY --from=jq_donor --chmod=0444 --chown=0:0 /jq/libjq.so.1 /usr/lib/x86_64-linux-gnu/libjq.so.1 +COPY --from=jq_donor --chmod=0444 --chown=0:0 /jq/libonig.so.5 /usr/lib/x86_64-linux-gnu/libonig.so.5 +ENTRYPOINT ["/usr/local/bin/entry.sh"] +HEALTHCHECK --interval=5s --timeout=5s --start-period=3s --retries=3 \ + CMD /usr/bin/torrust-index-health-check "http://localhost:${API_PORT}/health_check" \ + && /usr/bin/torrust-index-health-check "http://localhost:${IMPORTER_API_PORT}/health_check" CMD ["/usr/bin/torrust-index"] diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..e6db96b88 --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +# Top-level developer / operator entry points for the +# Compose split introduced by ADR-T-009 §8.3. +# +# Targets: +# +# make up-dev Plain `docker compose up`. Auto-loads +# `compose.override.yaml`, applying the dev +# sandbox extras (mailcatcher, permissive +# credential defaults, tty allocation). No +# validation — operators get whatever +# defaults the override provides. +# +# make up-prod Production-shaped invocation. Validates +# required credentials are set in the +# environment before any container starts, +# then runs `docker compose --file +# $(COMPOSE_FILE) up -d --wait` with the +# override excluded. Make propagates the +# recipe's exit code, so a failed +# healthcheck (`--wait`) or a missing +# credential (`:?required`) surfaces as a +# non-zero `make` exit. +# +# Validation logic uses POSIX `sh` with `set -u` and +# explicit `: "$${VAR:?message}"` per required variable so +# the file is dependency-free and shellcheck-clean. +# +# COMPOSE_FILE is overridable so acceptance tests (and +# operators with a non-default layout) can point at an +# alternate baseline without filesystem manipulation: +# +# make up-prod COMPOSE_FILE=path/to/other.yaml +# +# Compose's own `COMPOSE_FILE` env var is ignored when +# `--file` is set on the command line, so the make-variable +# is the only reliable way to redirect the recipe. + +.PHONY: up-dev up-prod _validate-prod-env + +COMPOSE_FILE ?= compose.yaml + +up-dev: + docker compose up + +# NOTE: The MySQL check is name-coupled to the compose +# service (`mysql:`). This is acceptable because it is +# defence-in-depth only — the config probe (exit 3 on +# empty connect_url) and the MySQL entrypoint (rejects +# empty root password) are the authoritative gates. +_validate-prod-env: + @sh -uc '\ + : "$${USER_ID:?required (numeric host UID owning ./storage)}" && \ + : "$${TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN:?required}" && \ + : "$${TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL:?required}" && \ + : "$${TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN:?required}" && \ + if grep -q "^[[:space:]]*mysql:" $(COMPOSE_FILE); then \ + : "$${MYSQL_ROOT_PASSWORD:?required}"; \ + fi' + +up-prod: _validate-prod-env + docker compose --file $(COMPOSE_FILE) up -d --wait diff --git a/README.md b/README.md index 44532864b..0205d0235 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,21 @@ If you are using `Version 1` of `torrust-tracker-backend`, please view our [upgr ### Container Version -The Torrust Index is [deployed to DockerHub][dockerhub], you can run a demo immediately with the following commands: +The Torrust Index is [deployed to DockerHub][dockerhub], you can run a demo +immediately with the following commands. Per +[ADR-T-009 §D2](./adr/009-container-infrastructure-refactor.md), the image +no longer ships a default tracker token or `database.connect_url`, so two +overrides are mandatory at startup — a bare `docker run -it +torrust/index:develop` now fails with a `missing field` error rather than +booting against hidden defaults. #### Docker ```sh -docker run -it torrust/index:develop +docker run -it \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite:///var/lib/torrust/index/database/sqlite3.db?mode=rwc" \ + torrust/index:develop ``` > Please read our [container guide][containers.md] for more information. @@ -44,11 +53,34 @@ docker run -it torrust/index:develop #### Podman ```sh -podman run -it torrust/index:develop +podman run -it \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite:///var/lib/torrust/index/database/sqlite3.db?mode=rwc" \ + torrust/index:develop ``` > Please read our [container guide][containers.md] for more information. +#### Compose (development sandbox) + +For a complete local stack (index + tracker + MySQL + mailcatcher) the +repository ships a Compose split: a production-shaped +[`compose.yaml`](./compose.yaml) baseline plus an auto-loaded +[`compose.override.yaml`](./compose.override.yaml) that supplies dev +defaults. Two `Makefile` wrappers cover the documented invocation +paths: + +```sh +# Dev sandbox (auto-loads compose.override.yaml): +make up-dev + +# Production-shaped (validates required credentials first): +make up-prod +``` + +See [Compose Split](./docs/containers.md#compose-split) in the container +guide for the required env vars and the validation contract. + ### Development Version - Please assure you have the ___[latest stable (or nightly) version of Rust][Rust]___. @@ -93,7 +125,7 @@ _Optionally, you may choose to supply the entire configuration as an environment TORRUST_INDEX_CONFIG_TOML=$(cat "./storage/index/etc/index.toml") cargo run ``` -_For deployment, you __should__ override the `tracker_api_token`:_ +_For deployment, you __should__ override the `tracker.token` (per ADR-T-009 §D2 it is mandatory; the shipped TOMLs no longer carry a default):_ ```sh # Override secrets in configuration using environmental variables @@ -144,6 +176,7 @@ The following services are provided by the default configuration: - [ADR-T-006: Refactor the Error System](adr/006-error-system-refactor.md) — Replace the 41-variant `ServiceError` god enum with domain-scoped error enums (`AuthError`, `UserError`, `TorrentError`, `CategoryTagError`) and a thin `ApiError` wrapper. - [ADR-T-007: Refactor the JWT System](adr/007-jwt-system-refactor.md) — Centralise JWT handling into `src/jwt.rs`, redesign claims to RFC 7519, move to RS256 asymmetric signing, and consolidate session validation into a single code path. - [ADR-T-008: Refactor the Roles and Permissions System](adr/008-roles-and-permissions-refactor.md) — Replace Casbin with a native Rust permission system (`PermissionMatrix` + `RequirePermission` Axum extractors), migrate from `administrator: bool` to a `role` column, and add a `/me/permissions` discovery endpoint. +- [ADR-T-009: Container Infrastructure Refactor](adr/009-container-infrastructure-refactor.md) — Split the runtime image into `release` (distroless, root-only toolset) and `debug` bases; extract three helper binaries (`torrust-index-health-check`, `torrust-index-auth-keypair`, `torrust-index-config-probe`) into their own workspace crates with no HTTP/TLS/async-runtime deps; strip credentials from shipped TOMLs and make `database.connect_url` / `tracker.token` mandatory schema fields; split Compose into a production-shaped `compose.yaml` baseline plus an auto-loaded `compose.override.yaml` dev sandbox; and add an internal audit record for vendored `su-exec`. ## Contributing diff --git a/adr/007-jwt-system-refactor.md b/adr/007-jwt-system-refactor.md index d5d538c84..fc6c9eb30 100644 --- a/adr/007-jwt-system-refactor.md +++ b/adr/007-jwt-system-refactor.md @@ -213,15 +213,22 @@ direct dependency along with `rand`. PEM export uses `EncodePublicKey::to_public_key_pem` from transitive `pkcs8` and `spki`. -### Phase 6 — `generate-auth-keypair` CLI ✅ - -A binary `torrust-generate-auth-keypair` -(`src/bin/generate_auth_keypair.rs`) generates an RSA-2048 key -pair to stdout. Design: - -- Refuses to run if stdout is a terminal. -- Private key first, then public key — self-delimiting PEM. -- Diagnostics on stderr via `tracing`; `--debug` for verbose. +### Phase 6 — `auth-keypair` CLI ✅ + +A binary `torrust-index-auth-keypair` (initially shipped as +`torrust-generate-auth-keypair`) generates an RSA-2048 key +pair to stdout. As of ADR-T-009 Phase 2 the binary lives in +its own workspace crate at +[`packages/index-auth-keypair/`](../packages/index-auth-keypair/); +the earlier `src/bin/generate_auth_keypair.rs` location no +longer exists. Design: + +- Refuses to run if stdout is a terminal (exit code 2). +- Emits a single JSON object + `{"private_key_pem": "...", "public_key_pem": "..."}` + on stdout (P9 of ADR-T-009). The original raw-PEM + output was replaced in Phase 2. +- Diagnostics on stderr via `tracing` (NDJSON); `--debug` for verbose. - Uses `clap` for CLI. #### Container integration @@ -235,7 +242,10 @@ auto-generates persistent keys on first boot into - `[ ! -s … ]` (existence + non-empty) guards against zero-byte files from interrupted prior runs. - `trap … EXIT` ensures temp file cleanup. -- `sed` matches exact PEM markers (PKCS#8 / SPKI). +- `jq -r .private_key_pem` / `jq -r .public_key_pem` extract + the PEM blocks from the helper's JSON output (post + ADR-T-009 Phase 2; the original implementation used `sed` + against raw PEM markers). - Errors on stderr (visible in `docker logs`); non-zero exit on failure. - **TOCTOU note:** if two containers race against the same @@ -249,9 +259,11 @@ via the `/etc/torrust/index` volume. #### Containerfile -`torrust-generate-auth-keypair` is copied into `/usr/bin/` in +`torrust-index-auth-keypair` is copied into `/usr/bin/` in both the debug and release runtime images alongside -`torrust-index` and `health_check`. +`torrust-index` and (release only) `torrust-index-health-check`. +The debug image deliberately omits the health-check binary — +see [`docs/containers.md`](../docs/containers.md#debug-image-healthcheck). #### Host-supplied keys @@ -268,17 +280,17 @@ Two workflows: ```sh tmpfile=$(mktemp /tmp/auth_keys.XXXXXX) chmod 0600 "$tmpfile" -cargo run --bin torrust-generate-auth-keypair > "$tmpfile" -sed -n '/BEGIN PRIVATE KEY/,/END PRIVATE KEY/p' "$tmpfile" > private.pem -sed -n '/BEGIN PUBLIC KEY/,/END PUBLIC KEY/p' "$tmpfile" > public.pem +cargo run -p torrust-index-auth-keypair > "$tmpfile" +jq -r .private_key_pem "$tmpfile" > private.pem +jq -r .public_key_pem "$tmpfile" > public.pem rm -f "$tmpfile" ``` -> **Avoid** the Bash process-substitution form -> (`tee >(sed …) >(sed …)`). The `>(…)` sub-processes run -> asynchronously, so the `sed` writes may not have flushed -> when the pipeline exits — producing truncated PEM files. -> The POSIX version above is strictly correct. +The helper emits a single JSON object on stdout, so any +JSON-aware consumer (`jq`, `python -m json.tool`, a +`serde_json::from_reader::` in Rust) works. +The earlier `sed` PEM-marker recipe is no longer applicable +because newlines inside the PEM bodies are JSON-escaped. ### Phase 7 — Consolidate Session Validation ✅ @@ -436,7 +448,7 @@ pub async fn renew_token( Deployers upgrading across Phases 2–3 must: 1. Generate an RSA key pair — via - `torrust-generate-auth-keypair` (Phase 6) or `openssl`. + `torrust-index-auth-keypair` (Phase 6) or `openssl`. 2. Update config to reference key paths (or set env vars). 3. Accept session invalidation (users re-login once). diff --git a/adr/009-container-infrastructure-refactor.md b/adr/009-container-infrastructure-refactor.md new file mode 100644 index 000000000..da8790aa5 --- /dev/null +++ b/adr/009-container-infrastructure-refactor.md @@ -0,0 +1,1285 @@ +# ADR-T-009: Container Infrastructure Refactor + +**Status:** Implemented +**Date:** 2026-04-19 +**Supersedes:** Earlier `ADR-T-009` draft ("Container Infrastructure Hardening") whose tactical S-N items were merged without a written ADR file. Those items are summarised in [Prior Work](#prior-work) and are not re-litigated here. +**Relates to:** [ADR-T-007](007-jwt-system-refactor.md) (auth key generation performed by the entry script). + +--- + +## Context + +The container infrastructure consists of: + +- A multi-stage [`Containerfile`](../Containerfile) that builds, tests, and packages the index. +- A single [`compose.yaml`](../compose.yaml) that orchestrates the index together with `tracker`, `mysql`, and `mailcatcher`. +- A POSIX entry script ([`share/container/entry_script_sh`](../share/container/entry_script_sh)) that prepares the runtime, generates auth keys on first boot, and drops privileges via vendored `su-exec` ([`contrib/dev-tools/su-exec/su-exec.c`](../contrib/dev-tools/su-exec/su-exec.c)). +- Default configurations under [`share/default/config/`](../share/default/config/) shipped inside the image at `/usr/share/torrust/default/config/`. +- A small `health_check` binary ([`src/bin/health_check.rs`](../src/bin/health_check.rs)) invoked by the runtime `HEALTHCHECK`. +- E2E orchestration scripts under [`contrib/dev-tools/container/e2e/`](../contrib/dev-tools/container/e2e/) and operator documentation in [`docs/containers.md`](../docs/containers.md). + +The previous round of work (see [Prior Work](#prior-work)) brought the infrastructure to a defensible baseline by fixing a long list of concrete bugs. What remained was *structural*: several pieces of the design carried assumptions that no longer matched how the project is used, and continuing to layer fixes onto those assumptions kept producing the same shapes of bug. This ADR records the structural decisions and how they were implemented; the [Appendix](#appendix-diagnostic-detail) catalogues the diagnostic items (`R1`–`R10`) that motivated each one. + +### Prior Work + +The previous draft of ADR-T-009 ("Container Infrastructure Hardening") catalogued and resolved a set of tactical issues labelled S1 through S12 — entry-script tracing gated on `DEBUG=1`, MySQL healthcheck repair, dev-only port rebinding, restart policies, base-image upgrade `cc-debian12` → `cc-debian13`, and similar. Those changes are merged and are not re-litigated here. + +--- + +## Vision + +After this ADR the container subsystem is composed of three layers with deliberately separate concerns. + +**The image** is two parallel artifacts built from one Containerfile: a `release` image on a lean distroless base whose only privileged-user utilities are a curated, root-only busybox subset and `su-exec`; and a `debug` image on the same base's `:debug` variant that retains user-accessible developer affordances. In the release image, the unprivileged `torrust` user — under which the application actually runs — has no usable shell and no access to root utilities; the debug image retains user-accessible developer affordances by design. A separate workspace crate, with no transitive HTTP/TLS dependencies, supplies the health-check binary. + +**The configuration** is the operator's responsibility, not the image's. Shipped TOML defaults declare structure but no credentials, no `connect_url`, no environment-coupled hostnames, and no environment-coupled paths (notably auth-key paths, which the entry script owns and exports). The schema makes `database.connect_url` and `tracker.token` mandatory, so a missing value fails at parse time with a precise serde error rather than silently falling back to a hidden default. The entry script reads the same `TORRUST_INDEX_CONFIG_OVERRIDE_*` env vars the application reads, and is the single source of truth for any path it materialises (notably auth-key paths) — coordination with the application happens by the script *setting* the override before exec, not by two files agreeing on a constant. + +**The orchestration** is two compose files: a production-shaped baseline that references credentials via bare `${VAR}` and binds dev ports to localhost, and an auto-loaded `compose.override.yaml` that re-introduces the dev sandbox (mailcatcher, permissive defaults, tty). A `make up-prod` wrapper validates required env vars before invoking compose; plain `docker compose up` remains the zero-friction dev workflow. + +--- + +## Principles + +The decisions below follow from a small set of invariants the container subsystem commits to: + +- **P1.** Shipped defaults contain no credentials and no environment-coupled values. +- **P2.** Runtime configuration is runtime; build configuration is build. Neither leaks into the other. +- **P3.** In the release image, the unprivileged runtime user has no usable shell and no access to root utilities. (The debug image deliberately retains user-accessible affordances — see [D4](#d4--two-parallel-runtime-bases-root-only-utilities-in-release).) Privilege drop is irreversible from the application's side under the documented runtime configuration (no `CAP_SETUID`, GID set excludes 0). +- **P4.** The schema enforces required fields. Bootstrap does not re-validate what serde has already proven. +- **P5.** Where two components must agree on a value (path, port, credential), exactly one of them owns it and tells the other; they do not independently maintain a shared constant. +- **P6.** The compose baseline is production-shaped; dev affordances are an additive override layer, never a subtraction from the baseline. +- **P7.** Vendored security-sensitive code is treated as code we own, with a current internal audit record. +- **P8.** No machine-readable stdout to a TTY. Every helper binary that emits structured output (JSON, PEM) on stdout refuses to run when stdout is a terminal. The check is unconditional — it does not depend on whether the specific output is sensitive. Operators who want to see the output interactively pipe to `jq`, `less`, or `cat`. +- **P9.** Universal helper conventions. Every helper binary links the same baseline crates without exception or per-crate justification: `clap` (argv), `tracing` + `tracing-subscriber` with `json` feature (stderr diagnostics), `serde` + `serde_json` (stdout wire format). These are not enumerated in per-crate allowlists. On success (exit 0), stdout is one JSON object followed by one trailing newline. On failure (exit ≠ 0), stdout is empty — the exit code is the sole branch signal for callers, and the diagnostic goes to stderr via `tracing`. Stderr is always NDJSON `tracing` events regardless of exit code. A shared `torrust-index-cli-common` library crate provides the scaffolding (`refuse_if_stdout_is_tty`, `init_json_tracing`, `emit`, and a common `BaseArgs` with `--debug`). + +--- + +## Options Considered + +### Option A — Status quo plus targeted patches + +Patch the highest-severity items (R1, R2, R3) and leave the rest. Smallest diff. Leaves R4–R10 to keep producing tactical bugs that re-derive the same structural problems. + +### Option B — Focused refactor (this ADR) + +Treat the container infrastructure as a single subsystem and align it around the principles above. Each decision is locally small but the set is coherent, and each can land independently. Touches many files; requires coordinated CI and documentation updates. + +### Option C — Full containerisation reset + +Rebuild around a different base (Chainguard, Alpine, or from-scratch with statically linked binaries) and restructure the Containerfile from scratch. Maximum freedom; discards a large amount of working, tested infrastructure for marginal gain. Nothing in the identified problems requires changing the base image. + +--- + +## Decision + +**Adopt Option B.** + +Option A leaves known structural debt that has already proven willing to come back as new tactical bugs. Option C is disproportionate. Option B keeps the parts that work (multi-stage build, `cargo-chef` caching, in-build `nextest`, distroless runtime, vendored `su-exec`) and corrects the structural pieces that don't. + +The decisions that constitute Option B follow. + +--- + +## Decisions + +### D1 — Split compose into baseline + override + +**Follows from:** P6. +**Addresses:** [R1](#r1--composeyaml-conflates-dev-sandbox-and-deployment-template). + +`compose.yaml` is restructured as a production-shaped baseline (no `mailcatcher`, no `tty`, credentials referenced as bare `${VAR}`, dev-only ports on `127.0.0.1`). `compose.override.yaml` is auto-loaded by Compose v2 and carries the dev sandbox (mailcatcher service, permissive `${VAR:-default}` substitutions, tty). A `make up-prod` target validates required credential env vars and runs compose with `--file compose.yaml` (override excluded). `make up-dev` is plain `docker compose up`. + +The bare-`${VAR}` rule applies to credentials and environment-coupled hostnames, not to operator selectors that have a sensible cross-environment default (`TORRUST_INDEX_DATABASE_DRIVER` etc.) — those keep their `${VAR:-sqlite3}` defaults so plain `docker compose up` continues to work. + +#### Compose baseline restructure + +Changes to `compose.yaml`: + +- Remove `mailcatcher` (the service and the `index.depends_on: [..., mailcatcher, ...]` reference). +- Remove `tty: true` from `index` / `tracker`. +- Reference credentials via bare `${VAR}` (no default, no `:?required` assertion). +- Bind all ports to `127.0.0.1` except the index API. + +The bare-`${VAR}` rule applies to *credentials* (`..._TRACKER__TOKEN`, `..._DATABASE__CONNECT_URL`, MySQL root password, etc.) and to environment-coupled hostnames (`..._MAIL__SMTP__SERVER`). The tracker service's `TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN` follows the same rule: bare `${VAR}` in the prod baseline, with a `:-MyAccessToken` fallback added in `compose.override.yaml` for the dev sandbox. `make up-prod` validates it alongside the index-side credentials. + +Intra-compose service-name DNS (`tracker`, `mysql`, `mailcatcher`) is excluded: these are compose-network identifiers resolved by Docker's embedded DNS, not operator-supplied hostnames. + +**Why not `${VAR:?required}`.** Compose interpolates each file independently before merging; a `:?required` assertion in the base file fails *during base parse*, before the override's defaults can be considered. Validation is therefore deferred to the `make up-prod` wrapper. + +**Defence in depth against empty-string substitution.** Bare `${VAR}` with no fallback means a developer who runs `docker compose -f compose.yaml up` (explicitly bypassing the override) gets empty-string substitution rather than a compose-level error. Three layers catch this before it causes silent runtime misbehaviour: + +1. **Config probe ([D3](#d3--single-source-of-truth-for-auth-key-paths))** — the principled gate. Runs inside the container at startup regardless of how compose was invoked. An empty `connect_url` fails `url::Url::parse` (exit 3); an empty `tracker.token` is rejected explicitly (exit 4). This layer cannot be bypassed. +2. **MySQL entrypoint** — the official MySQL image refuses to start with an empty root password. +3. **`make up-prod` wrapper** — fail-fast convenience. Validates required env vars *before* container start so the operator gets a single clear error rather than waiting for each container to boot and fail individually. + +An audit of all mail/SMTP references across `compose.yaml` and `src/` was performed using two complementary greps (casual/legacy spellings and the override-prefix form `TORRUST_INDEX_CONFIG_OVERRIDE_MAIL__`), confirming no `index.environment:` block in the prod-shaped baseline still names `mailcatcher`. + +#### Override file + +`compose.override.yaml` is auto-loaded by Compose v2 and carries: + +- `mailcatcher` service, re-attached to `index.depends_on` using long-form (Compose v2 merges `depends_on` additively only in long-form; short-form silently replaces). +- `tty: true` on relevant services. +- Permissive credential defaults via `${VAR:-...}`. +- Optional dev-only port exposures. + +```yaml +services: + index: + depends_on: + mailcatcher: + condition: service_started + mailcatcher: + image: docker.io/dockage/mailcatcher:0.8.2 + # ... +``` + +#### Make targets + +A top-level `Makefile` (new; the only prior `Makefile` was the unrelated `contrib/dev-tools/su-exec/Makefile`) provides: + +- **`make up-dev`** — plain `docker compose up` (override auto-loaded, dev defaults apply). No validation. +- **`make up-prod`** — validates required env vars are set, then runs `docker compose --file compose.yaml up -d --wait` (override excluded). Produces a clear error on missing variables. + +```make +.PHONY: up-dev up-prod _validate-prod-env + +COMPOSE_FILE ?= compose.yaml + +up-dev: + docker compose up + +_validate-prod-env: + @sh -uc '\ + : "$${USER_ID:?required (numeric host UID owning ./storage)}" && \ + : "$${TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN:?required}" && \ + : "$${TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL:?required}" && \ + : "$${TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN:?required}" && \ + if grep -q "^[[:space:]]*mysql:" $(COMPOSE_FILE); then \ + : "$${MYSQL_ROOT_PASSWORD:?required}"; \ + fi' + +up-prod: _validate-prod-env + docker compose --file $(COMPOSE_FILE) up -d --wait +``` + +The MySQL check is name-coupled to the compose service (`mysql:`) — acceptable because it is defence-in-depth only. The `-d --wait` flags are deliberate: `up` without `-d` blocks indefinitely, and `--wait` makes Compose return non-zero if a service fails its healthcheck within the default timeout. The `COMPOSE_FILE` make-variable is overridable so acceptance tests (and operators with a non-default layout) can point at an alternate file. + +#### Bring-up issues found and fixed + +Phase 8 was validated end-to-end against `podman-compose 1.5.0` / `podman 5.8.2`. The bring-up exposed five latent bugs in earlier phases, all fixed inline: + +1. **Test binaries baking compile-time paths.** `env!("CARGO_BIN_EXE_…")` and `CARGO_MANIFEST_DIR` broke under cargo-nextest's archive → `--extract-to` → `--target-dir-remap` flow. **Fix:** drive contracts through the library API (probe) and `include_str!` + `tempfile` (entry-script tests). +2. **Missing `addgroup` in curated busybox applets.** Busybox `adduser -D` only writes `/etc/passwd`; `getgrnam("torrust")` then failed. **Fix:** add `addgroup` to the curated symlink loop, change the entry script to `addgroup -g "$USER_ID" torrust` followed by `adduser -D -s /bin/sh -u "$USER_ID" -G torrust torrust`, guard both with idempotent `grep`-of-`/etc/{passwd,group}` checks. +3. **`jq` shipped without shared libs.** The runtime stages copied only `/usr/bin/jq`; the binary then aborted with `libjq.so.1: cannot open shared object file`. **Fix:** copy `libjq.so.1` and `libonig.so.5` from the donor alongside the binary, with an `ldd`-based allow-list assertion so a future donor-base upgrade fails the build instead of producing a broken image. +4. **Empty-string env vars treated as configuration TOML.** `Info::from_env` returned `Some("")` for exported-but-empty variables. **Fix:** `Info::from_env` now filters empty strings; `compose.yaml` uses Compose's bare-name pass-through form (`- TORRUST_INDEX_CONFIG_TOML`, no `=`) for optional inline-TOML envs. +5. **Unqualified Docker Hub image names.** `mysql:8.0.45`, `dockage/mailcatcher:0.8.2`, and `torrust/tracker:develop` relied on short-name resolution. **Fix:** fully qualify all as `docker.io/...`. + +--- + +### D2 — Strip credentials from defaults; mandatory `connect_url` and `tracker.token` + +**Follows from:** P1, P4. +**Addresses:** [R2](#r2--credentials-embedded-in-shipped-default-configs). + +Every file under `share/default/config/` loses its literal `connect_url`, `token`, and `[mail.smtp]` values. The single rule "no credentials, no `connect_url`, no environment-coupled hostnames in shipped defaults" replaces the previous per-driver mix; SQLite `connect_url` values (no credentials, but environment-coupled) are stripped for consistency rather than carved out. + +`Database::connect_url` and `Tracker::token` become mandatory at the schema level. A missing value fails at deserialisation with a precise `missing field` error from serde, naming the section. No `check_mandatory_options` branch is added for these fields: the invariant lives in the type, not in a runtime check that readers must trust ran. + +The trade-off is acknowledged: a zero-config `docker run torrust-index` no longer produces a working SQLite instance. The simpler enforcement rule is worth the regression because the zero-config path mostly produced confusion when operators later tried to migrate to MySQL and discovered they had been running on an undocumented SQLite default. + +#### Credential and environment-coupled value strip + +All six files under `share/default/config/` were touched: + +| File | Values stripped | +|------|----------------| +| `index.container.toml` | `connect_url`, `token`, `[auth]` paths, `[mail.smtp]` | +| `index.development.sqlite3.toml` | `token` | +| `index.private.e2e.container.sqlite3.toml` | `connect_url`, `token`, `[auth]` paths, `[mail.smtp]` | +| `index.public.e2e.container.toml` | `connect_url`, `token`, `[auth]` paths, `[mail.smtp]` | +| `tracker.private.e2e.container.sqlite3.toml` | `token` | +| `tracker.public.e2e.container.sqlite3.toml` | `token` | + +The `[auth]` path entries (`private_key_path`, `public_key_path`) in the container-oriented files are environment-coupled values (they encode the container's volume layout). D2's rule strips them for the same reason it strips `connect_url`: the entry script owns these paths and exports them via `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__*` ([D3](#d3--single-source-of-truth-for-auth-key-paths)). + +The two `tracker.*` files ship from the index repo because they are consumed by the e2e compose flow; they use the tracker service's own config schema, not the index's `config::v2::tracker::Tracker` struct. The schema-level mandatory-token change applies only to the index's `Tracker` struct; the tracker TOMLs are touched here solely for the P1 credential-stripping rule. + +`index.development.sqlite3.toml` is not container-only — it is the starting-point template for `cargo run`-based development. After this change, a developer who copies it verbatim must supply `connect_url` and `token` via env var or add them to their local copy. + +#### Making `database.connect_url` mandatory + +Dropped `#[serde(default = "...")]` on `connect_url` in `packages/index-config/src/v2/database.rs` and removed `impl Default for Database`. Kept the field typed as `Url`. + +**Audit of `Database::default` consumers (verified).** A grep across `src/` and `tests/` found exactly one production call site: `packages/index-config/src/v2/mod.rs`, where `default_database()` served as the serde default for the enclosing `TorrustIndex.database` field. No test code called `config::v2::database::Database::default()`. Dropping `impl Default for Database` forced a single parallel change: remove the `#[serde(default = "default_database")]` attribute on `TorrustIndex.database` so an absent `[database]` block fails the same way as an absent `connect_url`. + +Sub-options considered and rejected: +- **(a)** Also change the field to `Option` and add a `check_mandatory_options` branch. Conflates "mandatory" with "type change" and forces every `&Url` consumer to handle the `Option`. +- **(b)** Keep the serde default and reject the known sentinel value. Brittle; the invariant lives far from the type. +- **(c)** Two-stage `RawDatabase` → `Database` validation. Adds a phantom type whose only job is to be unwrapped once. + +#### Making `tracker.token` mandatory + +Previously, `Tracker::default_token()` returned `ApiToken::new("MyAccessToken")` via `#[serde(default)]`, so stripping `token = "MyAccessToken"` from shipped TOMLs merely moved the credential from the TOML to Rust source. + +Dropped `#[serde(default = "Tracker::default_token")]` on `Tracker::token` and removed `Tracker::default_token()`, same pattern as `connect_url`. This keeps one rule for credentials ("no defaults") rather than two ("no defaults in TOML, but a sentinel default in Rust that a probe must know about"). The config probe's exit-4 gate for empty tokens remains as defence in depth — it covers a real gap because `ApiToken`'s `#[derive(Deserialize)]` constructs the inner `String` directly, bypassing the `assert!(!key.is_empty())` guard in `ApiToken::new`; `token = ""` in TOML would silently produce an empty token unless the probe rejects it. + +**Audit of `Tracker::default` consumers (verified).** Exactly one production call site: `packages/index-config/src/v2/mod.rs`, where `Settings::default_tracker()` → `Tracker::default()` served as the serde default. Dropping `impl Default for Tracker` forced removal of the `#[serde(default = "default_tracker")]` attribute on `Settings.tracker`. + +**Interaction with `check_mandatory_options`.** The existing `load_settings()` validated `"tracker.token"` as a mandatory option via `figment.find_value()`. Once the `#[serde(default)]` was removed, `figment.extract()` produces a serde `missing field` error for the same case, making the entry redundant. Removed `"tracker.token"` from the `mandatory_options` array. + +#### Test-fixture consolidation + +Removing `impl Default for Settings` (and the `Database` / `Tracker` `Default` impls under it) deleted the single ambient fixture that tests across both crates relied on. A `#[doc(hidden)] pub mod test_helpers` in `torrust-index-config` exposes: + +- `PLACEHOLDER_TOML` — the canonical "minimal but legal" TOML (every mandatory field present, nothing more). +- `placeholder_settings() -> Settings` — loads that TOML through `load_settings`, panicking on failure. + +The module is `#[doc(hidden)]` (so it does not appear in the public API surface) but `pub` (so integration test binaries in either crate can reach it). Consumers: + +- `packages/index-config/src/tests/mod.rs` re-exports `PLACEHOLDER_TOML` under its historical `MINIMUM_VALID_TOML` alias. +- The root crate's `Configuration::for_tests` loads from the same constant. +- `tests/environments/isolated.rs::ephemeral` calls `placeholder_settings()`. +- `tests/e2e/config.rs` intentionally does *not* use the helper: it tests the real shipped sample plus env-var overrides. + +--- + +### D3 — Single source of truth for auth-key paths + +**Follows from:** P5. +**Addresses:** [R3](#r3--entry-script-path-assumptions-conflict-with-config-overrides). + +The `Auth` config struct exposes both `*_PEM` and `*_PATH` fields per key, and both `Auth::default_private_key_path` and `default_public_key_path` return `None`. There is therefore no schema-level default path the entry script could be byte-equal to; previously the script wrote keys to its own hardcoded location while the application resolved to the in-memory ephemeral fallback, silently disagreeing. + +The fix makes the entry script the single source of truth: when no `*_PEM` and no `*_PATH` is configured for a given key, the script generates the key at its built-in location *and exports* `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH___PATH` to that same location before `exec`'ing the application. The two components agree by construction rather than by maintenance discipline. + +The script makes the per-key decision independently, enforces mutual exclusion within a single key (PEM + PATH for the same key is a configuration error), enforces pair-completeness (matching the application's existing invariant in `src/jwt.rs`), and enforces cross-pair source consistency (both keys must use the same delivery mechanism). + +The script does not poll env vars to discover the configuration. A small `torrust-index-config-probe` helper loads `Settings` through the parser extracted in [`D5`](#d5--helper-binaries-as-separate-workspace-crates) and prints the resolved auth-key sources. The script calls the helper once and dispatches on its output, so script and application share the parser by construction. + +#### Config probe helper (`torrust-index-config-probe`) + +A workspace crate `packages/index-config-probe/` (binary `torrust-index-config-probe`) loads the same `Settings` the application loads and emits the container-relevant resolved values as a JSON object on stdout. + +**Dependencies.** `torrust-index-config` (path dependency) and `torrust-index-cli-common` (P9 scaffolding). The helper inherits the parsing surface (`figment`, `toml`, `serde`, `serde_with`, `url`, `camino`, `derive_more`, `thiserror`, `tracing`) via `torrust-index-config`; it adds direct `url` and `percent-encoding` deps for sqlite-URL path-extraction logic. `figment` is declared with `default-features = false` and an explicit feature allowlist (`toml`, `env`) in `torrust-index-config`'s `Cargo.toml` so a future feature flip cannot smuggle `tokio` in transitively. + +**Contract.** + +```text +Usage: torrust-index-config-probe + +Loads the application's configuration through the same torrust-index-config +loader the application uses, honouring TORRUST_INDEX_CONFIG_TOML, +TORRUST_INDEX_CONFIG_TOML_PATH, and every TORRUST_INDEX_CONFIG_OVERRIDE_* +env var. No CLI flags override the config-file path — callers set +TORRUST_INDEX_CONFIG_TOML_PATH in the environment before invoking the +probe, the same mechanism the application uses. + +Refuses to run when stdout is a TTY (exit 2, per P8). + +On success (exit 0), emits one JSON object + trailing newline on stdout: + +{ + "schema": 1, + "database": { + "driver": "sqlite", + "path": "/var/lib/torrust/index/data.db" + }, + "auth": { + "private_key": { + "pem_set": false, + "path_set": true, + "source": "path", + "path": "/etc/torrust/index/auth/private.pem" + }, + "public_key": { + "pem_set": false, + "path_set": true, + "source": "path", + "path": "/etc/torrust/index/auth/public.pem" + } + } +} +``` + +**Field semantics:** + +| Field | Meaning | +|-------|---------| +| `schema` | Always 1. Incremented on breaking changes. | +| `database.driver` | URL scheme extracted from `connect_url`. One of `sqlite` or `mysql` — modelled internally as a `Driver` enum. Not the Containerfile's `TORRUST_INDEX_DATABASE_DRIVER` env var (which takes `sqlite3` / `mysql`). | +| `database.path` | For sqlite: the file path (absolute, relative, or `:memory:`). For non-sqlite: null. | +| `auth.*.pem_set` | Raw presence (non-empty after resolution) before PEM-overrides-PATH precedence. Both `None` and `Some("")` fold to `false` because a bare `${VAR}` in compose that substitutes to empty is indistinguishable from "unset" by the time the container starts. | +| `auth.*.path_set` | Same for the path field. | +| `auth.*.source` | Winner after precedence: `"pem"`, `"path"`, or `"none"`. | +| `auth.*.path` | Resolved path if source is `"path"`; null otherwise. | + +PEM material is *never* emitted, only its presence (`"pem_set": true`). The probe's stdout is safe to log. + +**Exit codes:** + +| Code | Meaning | +|------|---------| +| 0 | Recognised, well-formed configuration. | +| 1 | Unhandled panic or unexpected I/O on stdout. | +| 2 | Stdout is a TTY (P8), or clap argv-parse failure. | +| 3 | Config-load failure (missing field, parse error, IO error). The underlying error message is forwarded verbatim to stderr via tracing. | +| 4 | Security-critical field present but empty. Currently: `tracker.token`. | +| 5 | Unrecognised database scheme. | + +**URL resolution behaviour.** The helper does minimal decoding — just enough to dispatch on scheme and extract a path for the entry script's seeding decisions. `Settings::database.connect_url` is typed as `url::Url`, so `url::Url::parse` runs at deserialisation time. For `sqlite` URLs, the helper handles scheme-specific edge cases explicitly (e.g. `sqlite://data.db?mode=rwc` puts `data.db` in the host slot, not the path; `sqlite::memory:` is opaque). For non-sqlite schemes the helper emits `null` for `database.path`. + +| Spelling | `database.driver` | `database.path` | +|----------|--------------------|------------------| +| `sqlite://data.db?mode=rwc` | `"sqlite"` | `"data.db"` (relative) | +| `sqlite:///var/lib/torrust/index.db` | `"sqlite"` | `"/var/lib/torrust/index.db"` | +| `sqlite::memory:` | `"sqlite"` | `":memory:"` | +| `sqlite:///srv/My%20Data/x.db` | `"sqlite"` | `"/srv/My Data/x.db"` | +| `mysql://user:pass@host:3306/db` | `"mysql"` | `null` | +| `mariadb://...` | exit 5 | (stderr: "unsupported scheme: mariadb") | +| `postgres://...` | exit 5 | (stderr: "unsupported scheme: postgres") | +| (`connect_url` missing) | exit 3 | (stderr: serde "missing field" message) | + +The hierarchical-path branch percent-decodes via `decode_utf8_lossy`; a non-UTF-8 byte sequence would be replaced with `U+FFFD`. Container deployments overwhelmingly use UTF-8 paths; this is the v1-schema trade-off. + +**Default-config-path single source of truth.** The probe binary calls `Info::from_env(DEFAULT_CONFIG_TOML_PATH)` — the JSON-safe sibling of `Info::new` added to `torrust-index-config`. `Info::from_env` reads `TORRUST_INDEX_CONFIG_TOML[_PATH]` exactly like `Info::new` does but skips the diagnostic `println!`s that would corrupt the probe's stdout-only contract. The default path is the `pub const DEFAULT_CONFIG_TOML_PATH` re-exported from `torrust-index-config`. + +#### Entry-script integration + +The entry script runs under `set -eu`. A line-by-line audit was performed before introduction: + +- `inst()` is safe: an `if` with no `else` returns zero when the condition is false (POSIX §2.9.4.1). +- `chown -R` / `chmod -R` on volumes are preceded by `mkdir -p`, making failure unlikely; ordering was verified airtight. +- `$RUNTIME` / `$USER_ID` / `$TORRUST_INDEX_DATABASE_DRIVER` were guarded with `${VAR:-}` forms. + +The entry script `set -eu` line appears after the existing `DEBUG=1` → `set -x` line, and immediately after, the script sources the shell library: + +```sh +. /usr/local/lib/torrust/entry_script_lib_sh +``` + +**Temporal contract.** The probe runs exactly once, *before* the script exports any `TORRUST_INDEX_CONFIG_OVERRIDE_*` env vars of its own. The probe's output therefore reflects the operator's true configuration (TOML + operator-supplied env vars) with no script-injected values. + +**Runtime execution order:** + +1. Probe invocation. +2. `jq` field extraction. +3. Schema version gate. +4. Post-probe PEM/PATH mutual-exclusion check. +5. Pair-completeness check. +6. Cross-pair source consistency check. +7. Three-way auth-key dispatch. +8. Volumes-only directory guard. +9. Key materialisation. +10. Database seeding dispatch. + +The probe-consumption section: + +```sh +probe_json=$(/usr/bin/torrust-index-config-probe) || exit $? + +probe_schema=$(printf '%s' "$probe_json" | jq -r '.schema') +if [ "$probe_schema" != "1" ]; then + echo "ERROR: config probe emitted schema=$probe_schema" \ + "but this entry script expects schema=1" >&2 + exit 1 +fi + +database_driver=$(printf '%s' "$probe_json" | jq -r '.database.driver') +database_path=$(printf '%s' "$probe_json" | jq -r '.database.path // empty') + +auth_private_key_pem_set=$(printf '%s' "$probe_json" | jq -r '.auth.private_key.pem_set') +auth_private_key_path_set=$(printf '%s' "$probe_json" | jq -r '.auth.private_key.path_set') +auth_private_key_source=$(printf '%s' "$probe_json" | jq -r '.auth.private_key.source') +auth_private_key_path=$(printf '%s' "$probe_json" | jq -r '.auth.private_key.path // empty') + +auth_public_key_pem_set=$(printf '%s' "$probe_json" | jq -r '.auth.public_key.pem_set') +auth_public_key_path_set=$(printf '%s' "$probe_json" | jq -r '.auth.public_key.path_set') +auth_public_key_source=$(printf '%s' "$probe_json" | jq -r '.auth.public_key.source') +auth_public_key_path=$(printf '%s' "$probe_json" | jq -r '.auth.public_key.path // empty') +``` + +**Post-probe mutual-exclusion check:** + +```sh +for pair in private_key public_key; do + pem_var="auth_${pair}_pem_set" + path_var="auth_${pair}_path_set" + eval "pem_set=\"\$$pem_var\"" + eval "path_set=\"\$$path_var\"" + if [ "$pem_set" = true ] && [ "$path_set" = true ]; then + uc_pair=$(printf '%s' "$pair" | tr '[:lower:]' '[:upper:]') + echo "ERROR: both ${uc_pair}_PEM and ${uc_pair}_PATH are set;" \ + "these are mutually exclusive — pick one." >&2 + exit 1 + fi +done +``` + +**Pair-completeness and cross-pair source consistency:** + +```sh +key_configured() { + case $1 in + pem|path) return 0 ;; + *) return 1 ;; + esac +} +private_has=0; key_configured "$auth_private_key_source" && private_has=1 +public_has=0; key_configured "$auth_public_key_source" && public_has=1 +if [ "$private_has" -ne "$public_has" ]; then + echo "ERROR: auth keys must be configured as a complete pair;" \ + "one key is configured but the other is not." >&2 + exit 1 +fi + +if [ "$private_has" -eq 1 ] && [ "$public_has" -eq 1 ] \ + && [ "$auth_private_key_source" != "$auth_public_key_source" ]; then + echo "ERROR: private key source is '$auth_private_key_source'" \ + "but public key source is '$auth_public_key_source';" \ + "mixed PEM/PATH across the key pair is not supported." >&2 + exit 1 +fi +``` + +**Three-way auth-key dispatch per key (cases 1/2/3):** + +```sh +for pair in private_key public_key; do + src_var="auth_${pair}_source" + pth_var="auth_${pair}_path" + eval "src=\"\$$src_var\"" + eval "pth=\"\$$pth_var\"" + uc_pair=$(printf '%s' "$pair" | tr '[:lower:]' '[:upper:]') + + case $src in + pem) continue ;; + path) eval "${pair}_path=\"\$pth\"" ;; + none) + case $pair in + private_key) default=/etc/torrust/index/auth/private.pem ;; + public_key) default=/etc/torrust/index/auth/public.pem ;; + esac + eval "${pair}_path=\"\$default\"" + export "TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__${uc_pair}_PATH=$default" + ;; + esac +done +``` + +**Volumes-only directory guard** then **key materialisation:** + +```sh +for pair in private_key public_key; do + src_var="auth_${pair}_source" + eval "src=\"\$$src_var\"" + [ "$src" = pem ] && continue + + eval "keypath=\"\${${pair}_path}\"" + d=$(dirname "$keypath") + [ -d "$d" ] && continue + case "$d" in + /etc/torrust/index|/etc/torrust/index/*|\ + /var/lib/torrust/index|/var/lib/torrust/index/*|\ + /var/log/torrust/index|/var/log/torrust/index/*) + mkdir -p "$d"; chown torrust:torrust "$d"; chmod 0700 "$d" ;; + *) + echo "ERROR: auth key path $d is outside the volumes" \ + "the entry script manages." >&2 + exit 1 ;; + esac +done + +if [ -n "${private_key_path:-}" ] && [ -n "${public_key_path:-}" ]; then + if [ ! -s "$private_key_path" ] || [ ! -s "$public_key_path" ]; then + keypair_json=$(/usr/bin/torrust-index-auth-keypair) + printf '%s' "$keypair_json" | jq -r .private_key_pem > "$private_key_path" + printf '%s' "$keypair_json" | jq -r .public_key_pem > "$public_key_path" + chown torrust:torrust "$private_key_path" "$public_key_path" + chmod 0400 "$private_key_path" "$public_key_path" + fi +fi +``` + +**Database seeding dispatch:** + +```sh +case $database_driver in + sqlite) seed_sqlite "$database_path" ;; + mysql|mariadb) ;; # No file to seed. + *) + echo "ERROR: unexpected database.driver='$database_driver'" \ + "from config probe" >&2 + exit 1 ;; +esac +``` + +Where `seed_sqlite` handles the five outcomes: + +- Empty path → error (probe bug). +- `:memory:` → skip silently with info-level note. +- Relative path → warn and skip (no `WORKDIR` set, CWD is `/`). +- Absolute, non-empty file → leave alone. +- Absolute, zero-byte or missing → apply volumes-only auto-mkdir, delegate to `inst()`. + +**Note on `eval` usage.** The auth-key loops use a small number of scoped `eval`s to dereference computed variable names. Every right-hand side is double-quoted inside the `eval` string so paths containing spaces survive expansion. Every `eval`'d expansion references a variable populated by the preceding `jq` extraction. The alternative — duplicating each loop body once per key pair — is equivalent for two pairs. + +#### Sourced shell library and host-side tests + +The entry-script helper functions (`inst`, `key_configured`, `validate_auth_keys`, `seed_sqlite`) are extracted into a sourced library at `share/container/entry_script_lib_sh`. A test-only workspace crate `packages/index-entry-script/` (`torrust-index-entry-script`) drives the helpers via `sh` subprocess and asserts exit codes / stderr contents. The library has no top-level side effects (only function definitions), so sourcing is safe both inside the container and inside the host-side tests. + +The crate ships **no runtime code** of its own; it is a `[lib]` whose `tests/` exercise the shell helpers end-to-end. Test coverage: + +- `validate_auth_keys` — every branch of the three invariants (mutual exclusion, pair completeness, cross-pair source consistency). +- `seed_sqlite` — every outcome that does not require root or writing to a managed volume (`:memory:` skip, relative-path warn, absolute-non-empty untouched, outside-volumes error, empty-path error). + +Both runtime stages copy the library to `/usr/local/lib/torrust/entry_script_lib_sh` with mode `0444 root:root`. The entry script `. /usr/local/lib/torrust/entry_script_lib_sh` at the top, immediately after `set -eu`. + +#### `TORRUST_INDEX_DATABASE_DRIVER` scope narrowing + +`TORRUST_INDEX_DATABASE_DRIVER` is now a Containerfile-level selector for which default TOML to seed — it no longer drives runtime database decisions. The entry script's database dispatch uses the config probe's `database.driver` field (derived from `connect_url`'s URL scheme). Note the taxonomy difference: the env var uses `sqlite3` / `mysql`; the probe emits `sqlite` / `mysql` / `mariadb`. + +--- + +### D4 — Two parallel runtime bases; root-only utilities in release + +**Follows from:** P3. +**Addresses:** [R6](#r6--both-debug-and-release-inherit-the-debug-distroless-base). + +The previous Containerfile built both `release` and `debug` on the `:debug` distroless base, leaving the full busybox tree at `/busybox/` reachable by absolute path even from the unprivileged user — defeating the curated `/bin/` subset. + +After this change, `release` builds on the lean distroless `cc-debian13` and ships a single `/bin/busybox` (mode `0700 root:root`) with applet symlinks for the entry script's needs only; `debug` retains the `:debug` base with `/busybox/` on PATH so the unprivileged user has the full applet set. + +A completely shell-less release image is not viable: the entry script is POSIX shell and runs as PID 1 before privilege drop. The smaller-diff alternative — keeping `release` on `:debug` but `chmod 0700`'ing `/busybox/` — was rejected because the lean base has independent value (smaller image, fewer files for Trivy/Grype to scan, `/busybox/` directory absent entirely). A long-term alternative — reimplementing the entry-script first-boot work as a small Rust binary — is the right direction but out of scope; it is recorded in [Carry-Over](#carry-over-items). + +#### Containerfile restructure + +The shared runtime ingredients are factored into a base-agnostic `FROM scratch` stage, then layered onto two parallel runtime bases. + +```dockerfile +## ── Runtime asset bundle (base-agnostic) ───────────────────── +FROM gcr.io/distroless/cc-debian13:debug AS busybox_donor + +FROM busybox_donor AS busybox_preflight +RUN ["/busybox/sh", "-c", \ + "test -f /busybox/busybox \ + && /busybox/busybox --help >/dev/null \ + && /busybox/busybox install --help 2>&1 | grep -q -- '-D'"] + +FROM busybox_preflight AS etc_seed +RUN ["/busybox/sh", "-c", \ + "mkdir -p /seed/etc && \ + printf 'root:x:0:0:root:/:/bin/sh\\n' > /seed/etc/passwd && \ + printf 'root:x:0:\\n' > /seed/etc/group && \ + : > /seed/etc/profile"] + +FROM busybox_donor AS adduser_preflight +COPY --from=etc_seed /seed/etc/passwd /etc/passwd +COPY --from=etc_seed /seed/etc/group /etc/group +RUN ["/busybox/sh", "-c", \ + "/busybox/adduser -D -s /bin/sh -u 59999 testuser \ + && /busybox/grep -q '^testuser:' /etc/passwd \ + && /busybox/test -d /home/testuser"] + +FROM scratch AS runtime_assets +COPY --from=etc_seed --chmod=0644 --chown=0:0 /seed/etc/ /etc/ +COPY --from=busybox_preflight --chmod=0700 --chown=0:0 \ + /busybox/busybox /bin/busybox +COPY --from=gcc --chmod=0700 --chown=0:0 \ + /usr/local/bin/su-exec /bin/su-exec +COPY --chmod=0555 --chown=0:0 \ + ./share/container/entry_script_sh /usr/local/bin/entry.sh + +## ── Preflight gate ─────────────────────────────────────────── +FROM scratch AS preflight_gate +COPY --from=busybox_preflight /etc/passwd /tmp/.busybox-ok +COPY --from=adduser_preflight /etc/passwd /tmp/.adduser-ok + +## ── Runtime base: release ──────────────────────────────────── +FROM gcr.io/distroless/cc-debian13 AS runtime_release +# `gcr.io/distroless/cc-debian13` is usrmerged: `/bin` is a +# symlink to `/usr/bin`, so a recursive `COPY / /` from a +# scratch-built bundle whose layout uses `/bin/...` fails with +# *cannot copy to non-directory*. Copy each curated path +# individually into `/usr/bin/` and let the base's `/bin → +# /usr/bin` symlink forward bare-name lookups. +COPY --from=runtime_assets /etc/passwd /etc/passwd +COPY --from=runtime_assets /etc/group /etc/group +COPY --from=runtime_assets /etc/profile /etc/profile +COPY --from=runtime_assets /bin/busybox /usr/bin/busybox +COPY --from=runtime_assets /bin/su-exec /usr/bin/su-exec +COPY --from=runtime_assets /usr/local/bin/entry.sh /usr/local/bin/entry.sh +COPY --from=preflight_gate /tmp/.adduser-ok /tmp/.preflight-sentinel +ENV PATH=/usr/local/bin:/bin:/usr/bin:/sbin +RUN ["/usr/bin/busybox", "sh", "-c", \ + "for a in sh adduser addgroup install mkdir dirname chown chmod tr mktemp cat printf rm echo grep; do \ + /usr/bin/busybox ln -s busybox /usr/bin/$a; \ + done && rm -f /tmp/.preflight-sentinel"] + +## ── Runtime base: debug ────────────────────────────────────── +FROM gcr.io/distroless/cc-debian13:debug AS runtime_debug +COPY --from=etc_seed --chmod=0644 --chown=0:0 /seed/etc/ /etc/ +COPY --from=preflight_gate /tmp/.adduser-ok /tmp/.preflight-sentinel +COPY --from=runtime_assets /bin/su-exec /bin/su-exec +COPY --chmod=0555 --chown=0:0 \ + ./share/container/entry_script_sh /usr/local/bin/entry.sh +RUN ["/busybox/sh", "-c", \ + "/busybox/ln -s /busybox/sh /bin/sh && rm -f /tmp/.preflight-sentinel"] +ENV PATH=/usr/local/bin:/busybox:/bin:/usr/bin:/sbin +``` + +The existing `test` and `test_debug` stages are preserved with tightened permissions: the application binary (`torrust-index`) keeps `0755`; root-phase-only helper binaries (`torrust-index-health-check`, `torrust-index-auth-keypair`, `torrust-index-config-probe`) are tightened to `0500 root:root`. + +```dockerfile +RUN chmod 0755 /app/bin/torrust-index && \ + chown 0:0 /app/bin/torrust-index-health-check \ + /app/bin/torrust-index-auth-keypair \ + /app/bin/torrust-index-config-probe && \ + chmod 0500 /app/bin/torrust-index-health-check \ + /app/bin/torrust-index-auth-keypair \ + /app/bin/torrust-index-config-probe +``` + +The two final targets: + +```dockerfile +## ── Final: release ─────────────────────────────────────────── +FROM runtime_release AS release +ENV TORRUST_INDEX_CONFIG_TOML_PATH=/etc/torrust/index/index.toml \ + TORRUST_INDEX_DATABASE_DRIVER=sqlite3 \ + USER_ID=1000 API_PORT=3001 IMPORTER_API_PORT=3002 \ + TZ=Etc/UTC RUNTIME=release +EXPOSE 3001/tcp 3002/tcp +VOLUME ["/var/lib/torrust/index","/var/log/torrust/index","/etc/torrust/index"] +COPY --from=test /app/ /usr/ +COPY --from=jq_donor --chmod=0500 --chown=0:0 /jq/jq /usr/bin/jq +COPY --from=jq_donor --chmod=0444 --chown=0:0 /jq/libjq.so.1 /usr/lib/x86_64-linux-gnu/libjq.so.1 +COPY --from=jq_donor --chmod=0444 --chown=0:0 /jq/libonig.so.5 /usr/lib/x86_64-linux-gnu/libonig.so.5 +ENTRYPOINT ["/usr/local/bin/entry.sh"] +HEALTHCHECK --interval=5s --timeout=5s --start-period=3s --retries=3 \ + CMD /usr/bin/torrust-index-health-check "http://localhost:${API_PORT}/health_check" \ + && /usr/bin/torrust-index-health-check "http://localhost:${IMPORTER_API_PORT}/health_check" +CMD ["/usr/bin/torrust-index"] + +## ── Final: debug ───────────────────────────────────────────── +FROM runtime_debug AS debug +ENV TORRUST_INDEX_CONFIG_TOML_PATH=/etc/torrust/index/index.toml \ + TORRUST_INDEX_DATABASE_DRIVER=sqlite3 \ + USER_ID=1000 API_PORT=3001 IMPORTER_API_PORT=3002 \ + TZ=Etc/UTC ENV=/etc/profile RUNTIME=debug +EXPOSE 3001/tcp 3002/tcp +VOLUME ["/var/lib/torrust/index","/var/log/torrust/index","/etc/torrust/index"] +COPY --from=test_debug /app/ /usr/ +COPY --from=jq_donor --chmod=0500 --chown=0:0 /jq/jq /usr/bin/jq +COPY --from=jq_donor --chmod=0444 --chown=0:0 /jq/libjq.so.1 /usr/lib/x86_64-linux-gnu/libjq.so.1 +COPY --from=jq_donor --chmod=0444 --chown=0:0 /jq/libonig.so.5 /usr/lib/x86_64-linux-gnu/libonig.so.5 +ENTRYPOINT ["/usr/local/bin/entry.sh"] +HEALTHCHECK --interval=5s --timeout=5s --start-period=3s --retries=3 \ + CMD /usr/bin/torrust-index-health-check "http://localhost:${API_PORT}/health_check" \ + && /usr/bin/torrust-index-health-check "http://localhost:${IMPORTER_API_PORT}/health_check" +# Default CMD matches release so the debug image is a drop-in +# replacement for it. Operators reach an interactive shell with +# `docker run … sh` (or any other curated applet) at run time. +CMD ["/usr/bin/torrust-index"] +``` + +The `debug` image differs from `release` in two ways: (1) `runtime_debug` is layered on `gcr.io/distroless/cc-debian13:debug` and leaves the donor's full `/busybox/` tree in place and on PATH so the `torrust` user has user-accessible developer affordances; (2) `ENV=/etc/profile` is set so busybox `sh` sources it on interactive non-login invocations. `HEALTHCHECK`, the `torrust-index-health-check` binary, and the default `CMD` are identical to release — the debug image is a drop-in replacement that operators can swap in when they need an interactive break-glass shell, reachable with `docker run … sh` (or any other curated applet) at run time. The entry script runs unchanged on both. + +#### Curated applet reference + +The release-base symlink loop covers every applet the entry script invokes by bare name. Shell built-ins (`test`, `[`, `read`, `eval`, `case`, `cd`, `exec`, `set`, `trap`, `export`, `.`) do not need applet symlinks. + +| Applet | Used by | +|--------|---------| +| `sh` | Entry script interpreter | +| `adduser` | Create `torrust` user at first boot | +| `addgroup` | Create `torrust` group before `adduser` | +| `install` | `inst()` helper — seed config/database templates | +| `mkdir` | Create volume subdirectories, auth-key dirs | +| `dirname` | Resolve parent of auth-key and database paths | +| `chown` | Fix ownership on volumes, auth keys, seeded files | +| `chmod` | Fix permissions on volumes, auth keys | +| `tr` | Auth-key loops (`uc_pair` upper-case conversion) | +| `mktemp` | Temporary file for auth-key generation | +| `cat` | MOTD assembly, profile sourcing | +| `printf` | MOTD lines | +| `rm` | Clean up temp files | +| `echo` | Error messages, MOTD profile hook | +| `grep` | Idempotency guards in §D7's `addgroup` / `adduser` block; reserved for future entry-script extensions and ad-hoc operator break-glass debugging via `docker exec -u root`. | + +`su-exec` is a standalone binary at `/usr/bin/su-exec` (release) or `/bin/su-exec` (debug), not a busybox applet. + +**CI reconciliation.** A CI step extracts the applet list from the Containerfile's symlink-loop `for` statement and compares it against a grep of bare-name external commands in the entry script. The check is advisory (warns on mismatch, does not block the build) because the grep is necessarily heuristic. + +#### Design notes + +- **One binary, many names.** Busybox dispatches on `argv[0]`, so all symlinks to `/bin/busybox` invoke the corresponding applet. The asset bundle contains exactly one busybox binary (~1 MB). +- **Root-only permissions.** Every applet the entry script needs is reachable at `/bin/`; the unprivileged `torrust` user gets `EACCES` on `/bin/busybox` (and therefore every symlink to it) after `su-exec` drops privileges. +- **The `torrust` user has no usable login shell in release.** `adduser -s /bin/sh` records `/bin/sh` as the user's login shell, but `/bin/sh → /bin/busybox` is `0700 root:root`, so `su torrust` / `docker exec -u 1000 … sh` cannot spawn one. In the debug image, `/busybox/sh` is on PATH and user-accessible. +- **Security assumption.** The root-only mode relies on the unprivileged user's GID set not including 0 and on no `cap_dac_*` capabilities being granted. Both hold under the documented compose/run flow. +- **PATH is pinned in both bases.** In release, PATH does not include `/busybox/`; in debug, `/busybox/` appears on PATH before `/bin/`. +- **`docker exec -u root … sh` still works** for emergency production debugging on the release image. This is the documented break-glass procedure. +- **`runtime_assets` is `FROM scratch`**, base-agnostic, used only by the release base. +- **`preflight_gate`** aggregates all donor-validation stages behind a single stage. Both runtime bases COPY a sentinel from it, creating an explicit BuildKit dependency edge that prevents any preflight from being pruned. +- **Distroless `nonroot` UID is not preserved.** `runtime_assets` overwrites `/etc/passwd` with a root-only seed. Privilege drop uses `su-exec` to a runtime-created `torrust` user instead. +- **Base `nsswitch.conf` is preserved.** `etc_seed` seeds only `passwd`, `group`, and `profile`; `nsswitch.conf` is inherited from the base unchanged, preserving `hosts: files dns`. +- **No `/busybox/` directory in release.** The lean distroless base does not ship it; the R6 bypass concern is eliminated entirely. +- **Distroless ships per-architecture images**, so the busybox binary extracted from the `:debug` donor is native to the build platform; this survives a future `docker buildx` multi-platform rollout without changes. +- **`/etc/profile` is seeded as empty** so the debug image's `ENV ENV=/etc/profile` points at a real path. Operators who bind-mount a richer profile get the expected behaviour. + +--- + +### D5 — Helper binaries as separate workspace crates + +**Follows from:** P2, P8, P9. +**Addresses:** [R4](#r4--health_check-pulls-in-reqwest-for-a-localhost-get). + +Every helper binary is extracted into its own workspace crate under `packages/index-*/` and follows P9's universal conventions. A shared `packages/index-cli-common/` library crate (`torrust-index-cli-common`) provides the scaffolding so each binary's `main` is only domain logic. + +The crate boundary makes the "no HTTP/TLS deps" property a manifest-level invariant: a future contributor cannot accidentally re-introduce `reqwest` because the crate's `Cargo.toml` simply does not list it. `reqwest` remains in the workspace for the importer and tracker clients; the goal is to prune it from the *helper binaries'* dep closures, not from the workspace. + +#### Helper crate roster + +| Crate | Path | Domain deps (beyond P9 baseline) | +|---|---|---| +| `torrust-index-cli-common` | `packages/index-cli-common/` | *(library — no binary)* | +| `torrust-index-health-check` | `packages/index-health-check/` | *(none — stdlib networking)* | +| `torrust-index-auth-keypair` | `packages/index-auth-keypair/` | `rsa` (re-exports `pkcs8`) | +| `torrust-index-config-probe` | `packages/index-config-probe/` | `torrust-index-config` (path dep); plus direct `url` and `percent-encoding` deps for sqlite-URL path-extraction | +| `torrust-index-entry-script` | `packages/index-entry-script/` | *(test-only `[lib]` crate — no binary; ships host-side integration tests for the sourced shell library)* | + +The dep-closure exclusion check ([Acceptance Criterion #5](#5-helper-binary-dep-closures)) is one regex applied uniformly to all helper binaries — no per-crate allowlists. `torrust-index-entry-script` is excluded from that check by construction: it produces no binary. + +#### Shared CLI scaffolding (`index-cli-common`) + +**Public API:** + +```rust +/// Refuse to run if stdout is a terminal (P8). +/// Prints a diagnostic to stderr and exits with code 2. +pub fn refuse_if_stdout_is_tty(binary_name: &str); + +/// Initialise `tracing-subscriber` with JSON output on stderr. +pub fn init_json_tracing(level: tracing::Level); + +/// Serialise `value` as one JSON object + trailing newline to stdout. +pub fn emit(value: &T) -> std::io::Result<()>; + +/// Common `--debug` flag. Flatten into each binary's `Args` via `#[command(flatten)]`. +#[derive(clap::Args)] +pub struct BaseArgs { + #[arg(long)] + pub debug: bool, +} +``` + +**Dependencies.** The P9 baseline and nothing else: `clap`, `tracing`, `tracing-subscriber` (with `json` feature), `serde`, `serde_json`. + +Every binary's `main` reduces to: + +```rust +fn main() -> std::process::ExitCode { + let args = Args::parse(); + refuse_if_stdout_is_tty("torrust-index-"); + init_json_tracing(if args.base.debug { Level::DEBUG } else { Level::INFO }); + match run(&args) { + Ok(out) => { emit(&out).unwrap(); ExitCode::SUCCESS } + Err(e) => { error!(error = %e, "…"); ExitCode::from(e.exit_code()) } + } +} +``` + +#### Health-check rewrite + +Moved from `src/bin/health_check.rs` to `packages/index-health-check/`. Rewritten with `std::net::TcpStream` + minimal HTTP/1.1 GET (~30 lines), with `set_read_timeout` / `set_write_timeout` for a short connect/read window. No async runtime. + +JSON stdout on success: +```json +{"target": "http://localhost:3001/health_check", "status": 200, "elapsed_ms": 4} +``` + +On failure, stdout is empty; the exit code is the sole branch signal for callers (Docker, the entry script). Tests cover non-2xx response, connection refused, read timeout, and malformed status line using a `TcpListener` on an ephemeral port. + +#### Auth-keypair generator extraction + +Moved from `src/bin/generate_auth_keypair.rs` to `packages/index-auth-keypair/`. Domain dep is `rsa` (which re-exports `pkcs8`). + +JSON stdout: +```json +{"private_key_pem": "-----BEGIN PRIVATE KEY-----\n...", "public_key_pem": "-----BEGIN PUBLIC KEY-----\n..."} +``` + +This eliminated the `sed` post-processing in the previous documented usage. Consumers use `jq -r .private_key_pem` (shell) or `serde_json::from_reader::` (Rust). The existing TTY guard migrated to the shared `refuse_if_stdout_is_tty`, unifying on exit code 2 (was exit 1). + +The entry script's keygen invocation changed from `torrust-generate-auth-keypair` to `torrust-index-auth-keypair`, and the consumer migrated from `sed` PEM-block extraction to `jq` in the same change (`sed` cannot recover usable PEM from the new single-line JSON output). + +This required `jq` in the runtime image. A dedicated `jq_donor` stage in the Containerfile installs `jq` from a pristine `rust:slim-trixie` base: + +```dockerfile +FROM rust:slim-trixie AS jq_donor +RUN apt-get update && \ + apt-get install -y --no-install-recommends jq && \ + rm -rf /var/lib/apt/lists/* +``` + +Both runtime bases copy the binary (and its shared libs — `libjq.so.1`, `libonig.so.5`) from that stage as `0500 root:root`. `jq` is invoked only during the entry script's root-phase (before `su-exec` drops privileges). An `ldd`-based allow-list assertion in the `jq_donor` stage catches future transitive dep changes at build time. + +Tests verify the generated JSON output round-trips through `serde_json` and the PEM blocks are parseable by `rsa::RsaPrivateKey::from_pkcs8_pem` / `rsa::RsaPublicKey::from_public_key_pem`. + +#### Config crate extraction (`torrust-index-config`) + +The `torrust-index-config-probe` helper must call the same `figment` + `serde` parser the application uses, otherwise it reintroduces the disagreement the entry-script contract exists to eliminate. The application's parser was entangled with the root crate's runtime types; a probe binary depending on the root crate would inherit `tokio`/`reqwest`/TLS. + +The *parsing* surface of `src/config/` was extracted into `packages/index-config/` (crate `torrust-index-config`) whose non-stdlib deps are `serde`, `serde_json`, `serde_with`, `figment`, `toml`, `url`, `camino`, `derive_more`, `thiserror`, `tracing`, and `lettre` (with `default-features = false`, `builder` + `serde` features only — no async/TLS/transport machinery). + +**What moved:** + +- All of `src/config/v2/` (the schema modules). +- `src/config/validator.rs`. +- From `src/config/mod.rs`: `Settings` / `Info` / `Metadata` / `Version` / `Tls` / `Error` types, `load_settings`, `check_mandatory_options`, the `CONFIG_OVERRIDE_*` constants, and the `ENV_VAR_CONFIG_TOML*` constants. + +**What stayed in the root crate:** + +- The `Configuration` wrapper struct holding `tokio::sync::RwLock` and its `async` accessors. This is application runtime state, not parsing. + +**`Tsl` → `Tls` clean-break rename.** The original `Tsl` spelling was a typo. Renamed to `Tls` / `tls` as part of the extraction with no backwards-compatibility alias: + +- **Type:** `Tsl` → `Tls` (definition, all imports). +- **Field:** `Network::tsl` → `Network::tls` (definition and call sites). +- **Serde wire key:** TOML `[net.tsl]` → `[net.tls]`, JSON `"tsl"` → `"tls"`. +- **Local variables:** `opt_net_tsl` → `opt_net_tls`, `tsl_config` → `tls_config`, etc. +- **Shipped defaults:** commented-out `#[net.tsl]` → `#[net.tls]`. +- **Doc-comments:** "TSL" → "TLS". +- **Internal helpers:** `Network::default_tsl()` → `Network::default_tls()`. + +The grep-verified call-site count was around twenty Rust sites plus one TOML and one doc-comment JSON example. + +**Inward dependencies resolved:** + +1. **`DynError`** — defined a `pub type DynError = Arc` alias inside the new crate (one line) so the config crate has no dependency on the web layer. +2. **`PermissionOverride`, `Role`, `Action`, `Effect`** — value types (`#[derive(Deserialize)]` structs/enums) with no service-layer dependencies. Moved into the new crate under `permissions::`; re-exported from `src/services/authorization/` for backwards compatibility. +3. **`Tsl`** — sibling-module import after extraction; no cross-crate work needed. + +**Compatibility shim.** `src/config/mod.rs` became a thin re-export: `pub use torrust_index_config::*;` plus the `Configuration` wrapper. Every existing `use crate::config::Settings;` kept compiling. + +**Acceptance verified:** `cargo tree -p torrust-index-config -e normal --prefix none` excludes `tokio`, `reqwest`, `sqlx`, `hyper`, `rustls`, `native-tls`, `openssl`. `grep -rE 'Tsl|\.tsl' src/ share/` returns zero hits. + +--- + +### D6 — Drop build-time `ARG` for runtime concerns + +**Follows from:** P2. +**Addresses:** [R5](#r5--build-time-arg-for-runtime-concerns). + +`API_PORT` and `IMPORTER_API_PORT` lost their build-time `ARG` declarations and kept only their `ENV` defaults, which the listener and the healthcheck honour at runtime. `EXPOSE` continues to freeze the default port into image metadata at build time (a known limitation of `EXPOSE` itself, documented for operators); it does not affect actual port binding. + +--- + +### D7 — Refuse-if-root entry-script guard + +**Follows from:** P3. +**Addresses:** [R7](#r7--entry-script-user_id--1000-guard-encodes-the-wrong-property). + +The entry script's `USER_ID >= 1000` guard was replaced by an "is numeric" + `-eq 0` check. The old rule encoded the wrong property: it rejected valid configurations — rootless Podman with subuid remapping, low-UID CI runners, BSD-derived hosts — without stating its intent. + +```sh +case ${USER_ID:-} in + ''|*[!0-9]*) + echo "ERROR: USER_ID is unset or not numeric" >&2 + exit 1 + ;; +esac +if [ "$USER_ID" -eq 0 ]; then + echo "ERROR: USER_ID is 0 (root) — refusing to run as root" >&2 + exit 1 +fi +``` + +The `adduser` invocation was also changed to busybox short-option form: + +```sh +# before +adduser --disabled-password --shell "/bin/sh" --uid "$USER_ID" "torrust" +# after +addgroup -g "$USER_ID" torrust +adduser -D -s /bin/sh -u "$USER_ID" -G torrust torrust +``` + +This works uniformly against busybox `adduser` on both runtime bases. The `adduser_preflight` build stage exercises this exact invocation against the shadow-less `etc_seed` layout so a future busybox bump surfaces the failure at build time, not first boot. Both commands are guarded with idempotent `grep`-of-`/etc/{passwd,group}` checks so a container restart is a no-op. + +--- + +### D8 — Vendored `su-exec` gains an internal audit record + +**Follows from:** P7. +**Addresses:** [R10](#r10--vendored-su-exec-has-no-internal-audit-record). + +Upstream `su-exec` has not released since ~2017; framing the problem as "document a refresh procedure" is wrong-shaped. The vendored file is treated as code we own. `contrib/dev-tools/su-exec/AUDIT.md` records: + +- **Provenance.** Upstream URL, commit/tag, date vendored, SHA-256 of `su-exec.c`. +- **Choice rationale.** Why `su-exec` over `gosu` (Go runtime, larger binary) or `setpriv` (util-linux dependency, not on the lean distroless base). +- **Audit log.** Append-only table (oldest first): reviewer, date, repo commit, SHA-256 of `su-exec.c`, scope, conclusions. Each entry contains a structured `SHA-256: ` line (CI-parseable). +- **Re-audit triggers.** File-change trigger (CI-enforced: SHA-256 mismatch fails the build until a fresh audit entry is added). CVE trigger (manual review duty). + +**Why no calendar trigger.** The vendored file is ~105 lines of pure POSIX C (`setgroups` → `setgid` → `setuid` → `execvp`) with no networking, no crypto, and no dependencies beyond libc. Code that doesn't change can't become vulnerable through inaction. A 365-day hard CI gate that blocks every PR for an unchanged ~105-line file would be high cost for zero value. + +There is deliberately no "refresh procedure" section. If a re-vendor is ever needed, it will be a manual diff-and-review exercise. + +--- + +### D9 — Build hygiene and test-stage coupling + +**Follows from:** P2. +**Addresses:** [R8](#r8--containerignore-sends-excess-context-to-the-builder), [R9](#r9--test-stages-and-build-stages-are-entangled). + +`.containerignore` adds `adr/` and `docs/` to the existing exclusions. + +**Not excluded:** +- `packages/render-text-as-image/` — a workspace member and path dependency that must remain in the build context. (See [Carry-Over](#carry-over-items) for the long-term plan.) +- `tests/fixtures/` and `migrations//` — not excluded without a full CI matrix run confirming no test references them. + +The in-build test stages remain coupled to the image build (no "skip tests" build path is introduced). The trade-off is documented in `docs/containers.md`: the strong correctness guarantee ("no image without green tests") is real, and the alternative would inevitably be used in production. + +--- + +## Implementation Sequence + +The implementation landed in nine phases with the following dependencies: + +| Phase | Title | Decisions | +|-------|-------|-----------| +| 1 | Build hygiene | D6, D9 (build-context part) | +| 2 | Helper binaries | D5 (health-check, auth-keypair, cli-common) | +| 3 | Extract `index-config` crate | Foundation for D3/D5 | +| 4 | Runtime base split | D4, D7 | +| 5 | Schema & credential strip | D2 | +| 6 | Config probe | D3 (probe half) | +| 7 | Entry-script contract | D3 (script half) | +| 8 | Compose split | D1 | +| 9 | Documentation & audit | D8, D9 (docs part) | + +``` +Phase 1 (build hygiene) ──────────┐ +Phase 2 (helpers: D5) ────────────┤ [Phases 1, 2, 4 are +Phase 4 (runtime base split) ─────┤ mutually independent] + │ +Phase 3 (extract index-config) ───┐ + │ +Phase 5 (schema: D2) ─────────────┤ depends on: Phase 3 + │ (Phase 5 edits files + │ Phase 3 moves) + ▼ + Phase 6 (config probe) + depends on: Phase 3, Phase 5 + │ + ▼ + Phase 7 (entry-script contract) + depends on: Phase 2, Phase 4, Phase 5, Phase 6 + │ + ▼ + Phase 8 (compose split) + depends on: Phase 7 + │ + ▼ + Phase 9 (docs & audit) + depends on: all above +``` + +Phases 1, 2, and 4 were mutually independent and could land in any order. Phase 3 preceded Phase 5 (Phase 5 edits files Phase 3 moves). Phase 6 needed Phase 3 (to depend on the extracted config crate) and Phase 5 (so the mandatory-`connect_url` schema change was reflected). Phase 7 consumed outputs of Phases 2, 4, 5, and 6. + +--- + +## Consequences + +### Operational + +- `release` images ship a single root-only `/usr/bin/busybox` (reachable as `/bin/busybox` via the base's usrmerge symlink) with curated applet symlinks under `/usr/bin/`. The unprivileged `torrust` user cannot invoke any of them. Operators who need a user-accessible shell use the `debug` image or sidecar containers. +- The `debug` image is a drop-in replacement for `release`: same `HEALTHCHECK`, same default `CMD` (`/usr/bin/torrust-index`), same `torrust-index-health-check` binary on disk. It differs only in the runtime base (`gcr.io/distroless/cc-debian13:debug`) which retains a user-accessible `/busybox/` tree on PATH, giving the `torrust` user developer affordances. Operators reach an interactive break-glass shell with `docker run … sh` (or any other curated applet) instead of needing a separate orchestrator profile. +- `docker compose up` continues to work for dev (override auto-loaded). Production deployments use `make up-prod` or pass `--file compose.yaml` explicitly. +- The entry-script env-var contract widened; operators see more knobs documented in `docs/containers.md`. +- Bare-metal developers using `share/default/config/index.development.sqlite3.toml` as a starting template are also affected by the credential strip (D2): they must supply `connect_url` and `token` via env var or add them to their local copy. + +### Schema + +- `database.connect_url` is mandatory. Existing TOMLs that omit the field fail to load with a serde `missing field 'connect_url'` error. +- A config that omits the `[database]` section entirely also fails (the enclosing `serde(default)` was removed in the same change). +- The `[net.tsl]` config key and `"tsl"` JSON API key are renamed to `[net.tls]` / `"tls"` (clean break — the original spelling was a typo). Existing operator TOMLs and API consumers must update. +- `tracker.token` is mandatory at the schema level (same pattern as `database.connect_url`). +- Within the container, setting both `_PEM` and `_PATH` for the same auth key is a startup error (D3). Operators who previously configured both (relying on the application's silent PEM-overrides-PATH precedence) must remove one. +- Within the container, using different delivery mechanisms across the key pair (e.g. private via PEM, public via PATH) is a startup error (D3). + +### Security + +- `release` no longer contains `/busybox/`; the curated busybox subset and `su-exec` are `0700 root:root`, inaccessible to the application process after privilege drop. Combined with pruning HTTP/TLS deps from the helper binaries, this materially reduces the attack surface. +- Eliminating credentials from all shipped defaults closes the "forgot to override" footgun. +- No `USER` directive is set in the image. The entry script runs as root (it needs root for `adduser`, `chown`, and key generation) and drops to the `torrust` user via `su-exec` before `exec`'ing the application. A stray `docker run --entrypoint= release-image` therefore executes as root. This is a deliberate trade-off: the entry script's first-boot work requires root, and a `USER` directive would force every operator to `--user root` it away. The `0700 root:root` busybox/su-exec permissions limit what the unprivileged user can do *after* privilege drop. Operators who need defence against accidental root execution should enforce `runAsNonRoot` / `allowPrivilegeEscalation: false` at the orchestrator level. +- Auth-key PEM material passed via `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__*_PEM` is readable by any process that can read `/proc//environ`. Distroless removes most local-attack gadgets; operators who need stricter handling should mount keys as files (path overrides). Docker secrets is the long-term direction. + +### Maintenance + +- Vendored `su-exec` has an internal audit record with CI-enforced freshness checks. +- Two compose files instead of one, but each is simpler than the previous single file. + +--- + +## Acceptance Criteria + +### 1. Runtime base split (D4) + +`release`-tagged images contain no `/busybox/` directory; `/bin/busybox` and `/bin/su-exec` are mode `0700 root:root`; applet symlinks dereference to `/bin/busybox` and therefore return EACCES for `--user 1000` invocations. + +```sh +set -eu + +docker run --rm --entrypoint=/bin/sh release-image \ + -c '! test -e /busybox' + +docker run --rm --user 1000 --entrypoint=/bin/sh release-image -c 'echo pwned' +# Should fail: permission denied. + +docker run --rm --user 1000 --entrypoint=/bin/busybox release-image sh -c 'echo pwned' +# Should fail: permission denied. + +docker run --rm --user 1000 --entrypoint=/bin/su-exec release-image root sh +# Should fail: permission denied. +``` + +### 2. Credentials stripped (D2) + +`share/default/config/*.toml` contain no `connect_url`, `token`, or `mail` keys, and no literal dev credentials. + +```sh +set -eu + +! grep -rE '(secret_password|MyAccessToken)' share/default/config/ +! grep -rE '^[[:space:]]*(connect_url|token)[[:space:]]*=' share/default/config/ +! grep -rE 'mailcatcher' share/default/config/ +! grep -rE '^\[mail(\.|\])' share/default/config/ +``` + +### 3. Dev compose works (D1) + +`docker compose up` (or `make up-dev`) starts a working dev environment without operator intervention. + +```sh +set -eu +docker compose up -d --wait --wait-timeout 60 +curl -sf http://localhost:3001/health_check +docker compose down +``` + +### 4. Prod compose validates (D1) + +`make up-prod` fails with a clear error when required credential env vars are unset, before invoking compose. + +```sh +set -eu + +unset TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN \ + TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL \ + MYSQL_ROOT_PASSWORD 2>/dev/null + +output=$(make up-prod 2>&1) && { + echo "FAIL: make up-prod succeeded with no credentials set" >&2; exit 1 +} +echo "$output" | grep -qE 'TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN|TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL' + +# When validation passes, compose's own exit code must propagate. +export TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN=x +export TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL=sqlite::memory: +export TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN=x +export MYSQL_ROOT_PASSWORD=x +export USER_ID=1000 + +empty_compose=$(mktemp --suffix=.yaml) +trap 'rm -f "$empty_compose"' EXIT + +output=$(make up-prod COMPOSE_FILE="$empty_compose" 2>&1) && { + echo "FAIL: make up-prod succeeded with empty compose file" >&2; exit 1 +} +``` + +### 5. Helper-binary dep closures (D5) + +No helper binary crate's normal-edge dependency closure contains an HTTP client, async runtime, or TLS stack. One exclusion regex applied uniformly — no per-crate allowlists. + +```sh +set -eu + +forbidden='^(reqwest|tokio|tokio-[a-z0-9_-]+|hyper|hyper-[a-z0-9_-]+|rustls|rustls-[a-z0-9_-]+|native-tls|openssl|openssl-[a-z0-9_-]+)( |$)' + +for crate in torrust-index-health-check torrust-index-auth-keypair torrust-index-config-probe; do + cargo tree -p "$crate" -e normal --prefix none \ + | grep -Eq "$forbidden" && { + echo "FAIL: $crate pulls in a forbidden dependency" >&2; exit 1 + } +done +exit 0 +``` + +### 6. Helper JSON + TTY contract (P8, P9) + +Every helper binary, when invoked with stdout attached to a TTY, exits with code 2 before producing any output. When invoked with stdout piped, every helper emits exactly one JSON object followed by one trailing newline on stdout, and `tracing` NDJSON events on stderr. + +```sh +set -eu + +for bin in torrust-index-health-check \ + torrust-index-auth-keypair \ + torrust-index-config-probe; do + + # TTY refusal + tty_out="" + rc=0 + tty_out=$(docker run --rm -t --entrypoint="/usr/bin/$bin" release-image \ + 2>/dev/null) || rc=$? + [ "$rc" -eq 2 ] || { echo "FAIL: $bin did not exit 2 on TTY (got $rc)" >&2; exit 1; } + [ -z "$tty_out" ] || { echo "FAIL: $bin emitted output before TTY refusal" >&2; exit 1; } + + # JSON stdout + case $bin in + *health-check) + out=$(docker run --rm --entrypoint="/usr/bin/$bin" \ + release-image "http://localhost:1/nope" 2>/dev/null) || true + if [ -n "$out" ]; then + printf '%s' "$out" | jq empty || { + echo "FAIL: $bin stdout is not valid JSON" >&2; exit 1; } + fi ;; + *auth-keypair) + out=$(docker run --rm --entrypoint="/usr/bin/$bin" release-image 2>/dev/null) + printf '%s' "$out" | jq -e '.private_key_pem and .public_key_pem' >/dev/null || { + echo "FAIL: $bin did not emit the expected JSON shape" >&2; exit 1; } ;; + *config-probe) + out=$(docker run --rm --entrypoint="/usr/bin/$bin" \ + -e TORRUST_INDEX_CONFIG_TOML_PATH=/usr/share/torrust/default/config/index.container.toml \ + -e TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite::memory:" \ + -e TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="test" \ + release-image 2>/dev/null) + printf '%s' "$out" | jq -e '.schema and .database and .auth' >/dev/null || { + echo "FAIL: $bin did not emit the expected JSON shape" >&2; exit 1; } ;; + esac +done +``` + +### 7. Documentation complete + +`docs/containers.md` describes every env var the entry script reads and the relationship between `compose.yaml` and `compose.override.yaml`. + +The entry script maintains a canonical env-var manifest block (`ENTRY_ENV_VARS:` / `END_ENTRY_ENV_VARS`). A CI check extracts from the manifest and verifies every variable appears in `docs/containers.md`: + +```sh +set -eu + +vars=$(sed -n '/^# ENTRY_ENV_VARS:/,/^# END_ENTRY_ENV_VARS/p' \ + share/container/entry_script_sh \ + | grep -oE '[A-Z][A-Z0-9_]+' | sort -u) + +[ -n "$vars" ] || { echo "FAIL: ENTRY_ENV_VARS block not found" >&2; exit 1; } + +missing=0 +for v in $vars; do + grep -q "$v" docs/containers.md || { echo "MISSING: $v" >&2; missing=1; } +done + +grep -q 'compose\.override\.yaml' docs/containers.md || { + echo "MISSING: compose.override.yaml documentation" >&2; missing=1; } + +[ "$missing" -eq 0 ] +``` + +### 8. Audit record exists (D8) + +`contrib/dev-tools/su-exec/AUDIT.md` contains provenance, choice rationale, at least one dated full-file audit entry (with a structured `SHA-256: ` line), and CI-enforced re-audit triggers. + +```sh +set -eu + +audit=contrib/dev-tools/su-exec/AUDIT.md + +test -s "$audit" +grep -qi 'provenance' "$audit" +grep -qi 'rationale' "$audit" +grep -qi 'SHA-256' "$audit" +grep -qE '[0-9]{4}-[0-9]{2}-[0-9]{2}' "$audit" + +recorded=$(sed -n '/^## Audit Log/,$ { s/^SHA-256: \([0-9a-f]{64}\)$/\1/p; }' "$audit" \ + | tail -1) +actual=$(sha256sum contrib/dev-tools/su-exec/su-exec.c | cut -d' ' -f1) +[ "$recorded" = "$actual" ] +``` + +### 9. Refuse-if-root guard (D7) + +The entry script rejects `USER_ID=0` with a clear error and accepts valid low-UID values. + +```sh +set -eu + +output=$(docker run --rm -e USER_ID=0 release-image 2>&1) && { + echo "FAIL: accepted USER_ID=0" >&2; exit 1 +} +echo "$output" | grep -qi 'root' + +output=$(docker run --rm -e USER_ID=500 release-image 2>&1) || true +! echo "$output" | grep -qi 'refusing to run as root' +echo "$output" | grep -qiE 'adduser|torrust-index-config-probe|missing field|connect_url' +``` + +--- + +## Carry-Over Items + +Tracked for visibility; not part of this refactor: + +- Docker secrets integration for credential management. +- `docker buildx` multi-platform builds (`linux/arm64`). +- Image signing with `cosign`. +- Pin base images (`gcr.io/distroless/cc-debian13` and `:debug`) by digest rather than tag for reproducible builds and supply-chain integrity. +- Reimplement the entry script's first-boot work as a small Rust binary (`torrust-index-entry`), eliminating vendored `su-exec` (privilege drop via direct `setgroups`/`setgid`/`setuid` syscalls), the shell-based IFS/heredoc parsing of probe output, and most of the curated busybox applet set. The `torrust-index-config` extraction, the P9 universal helper conventions, and the `torrust-index-config-probe` helper are deliberate stepping stones: they pull the parsing surface out of the root crate, establish the stderr-tracing / stdout-JSON contract all helpers share, and prove the script-↔-Rust integration shape before committing to the full rewrite. The entry binary would depend on `torrust-index-config` and `torrust-index-auth-keypair` directly, eliminating the serialisation boundary entirely. +- Promote `packages/render-text-as-image/` to a published crate and drop the root crate's `path = "packages/..."` override; once that lands, the directory can safely be added to `.containerignore`. + +--- + +## Appendix: Diagnostic Detail + +The `R-N` items below are the structural problems that motivated each decision. They are kept here for traceability; each decision in the body cites the items it addresses. + +### R1 — `compose.yaml` conflates dev sandbox and deployment template + +The single file mixed dev-only (`mailcatcher`, `tty`, hardcoded dev credentials, dev-only ports) and prod-shaped (`restart: unless-stopped`, MySQL healthcheck wiring) concerns. Operators who treated it as a deployment template had to edit it in place; developers paid for prod-shaped semantics they didn't need. **Severity: High.** + +### R2 — Credentials embedded in shipped default configs + +Multiple TOMLs under `share/default/config/` carried literal passwords and dev-only tokens that got baked into the image. The `compose.yaml` credentials were annotated as dev-only; the TOML defaults — the ones embedded in the image artifact — were not. **Severity: High.** + +### R3 — Entry-script path assumptions conflict with config overrides + +The script hardcoded `/etc/torrust/index/auth/{private,public}.pem` for key generation and the SQLite default-database path. Operators were documented as being able to override these via `TORRUST_INDEX_CONFIG_OVERRIDE_*`; when they did, the script still wrote to the hardcoded location and the application silently used different (or no) keys. The `Auth` schema exposed four relevant fields (`*_PEM` and `*_PATH` per key) and fell back to an in-memory ephemeral key when no source was configured — a fallback that was *never* the intended container outcome. **Severity: Medium.** + +### R4 — `health_check` pulls in `reqwest` for a localhost GET + +`src/bin/health_check.rs` issued a single `GET /health_check` against localhost using `reqwest` + `tokio` + a TLS stack — its own comment said to "avoid third-party libraries because they ... introduce new attack vectors". A stdlib TCP + minimal HTTP/1.1 GET (~30 lines) eliminates hundreds of transitive deps from the binary's link graph. **Severity: Medium.** + +### R5 — Build-time `ARG` for runtime concerns + +`API_PORT` / `IMPORTER_API_PORT` were build-time `ARG`s; a consumer who wanted to change them had to rebuild the image. Ports are runtime configuration. Volume paths were hardcoded literals everywhere, so the parameterisation was also inconsistent. **Severity: Medium.** + +### R6 — Both `debug` and `release` inherit the `:debug` distroless base + +`release` built on `cc-debian13:debug`, inheriting the full busybox at `/busybox/`. The curated `/bin/` subset was bypassed by absolute-path invocation of any applet under `/busybox/`. The "minimal attack surface" property documented in `docs/containers.md` was weaker than it looked. **Severity: Medium.** + +### R7 — Entry-script `USER_ID >= 1000` guard encodes the wrong property + +The actual property was "do not run as root". The `< 1000` rule rejected valid configurations (rootless Podman with subuid remapping, low-UID CI runners, BSD-derived hosts) without stating its intent. **Severity: Low.** + +### R8 — `.containerignore` sends excess context to the builder + +`adr/` and `docs/` were in the build context and were not read by any stage; they slowed `cargo chef prepare`'s analysis and bloated the daemon's context tarball. **Severity: Low.** + +### R9 — Test stages and build stages are entangled + +The `test` and `test_debug` stages both gated the image build on test success *and* produced the binaries copied into `runtime`. Any flaky test blocked every image build until fixed. The decision was to keep the coupling and document it, since the alternative ("skip tests" path) would inevitably be used in production. **Severity: Low.** + +### R10 — Vendored `su-exec` has no internal audit record + +The vendored file had provenance metadata next to it but no record of which upstream commit it corresponded to, why `su-exec` was chosen over `gosu`/`setpriv`, or whether anyone had read the ~105 lines of C and concluded what. Upstream is effectively unmaintained, so the right framing was "code we own with a current audit", not "refresh procedure". **Severity: Low.** \ No newline at end of file diff --git a/compose.override.yaml b/compose.override.yaml new file mode 100644 index 000000000..47a53ae76 --- /dev/null +++ b/compose.override.yaml @@ -0,0 +1,53 @@ +# Dev sandbox extras for `compose.yaml`. Auto-loaded by +# Compose v2 when present (i.e. by `docker compose up` and +# `make up-dev`). Excluded by `make up-prod` via explicit +# `--file compose.yaml`. See ADR-T-009 §8.2. +# +# What this file adds: +# +# - The `mailcatcher` sidecar (re-attached to `index` via +# long-form `depends_on`, which Compose v2 merges +# additively — short-form would silently replace the +# base's tracker/mysql dependencies). +# - `tty: true` on `index` and `tracker` so interactive +# `docker attach` sessions get a sensible terminal. +# - Permissive `${VAR:-default}` credential defaults so a +# plain `docker compose up` works without operator +# intervention. Production paths (`make up-prod`) bypass +# this file and rely on the validated bare-`${VAR}` form +# in the base. + +services: + + index: + tty: true + environment: + # DEV-ONLY defaults — change for any public or production deployment. + - TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN=${TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN:-MyAccessToken} + - TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL=${TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL:-sqlite:///var/lib/torrust/index/database/index.sqlite3.db?mode=rwc} + # Long-form merge — extends the base's `tracker` / `mysql` + # dependencies rather than replacing them. See ADR-T-009 §8.2. + depends_on: + mailcatcher: + condition: service_started + + tracker: + tty: true + environment: + # DEV-ONLY default — change for any public or production deployment. + - TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN=${TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN:-MyAccessToken} + + mysql: + environment: + # DEV-ONLY credentials — change for any public or production deployment. + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-root_secret_password} + - MYSQL_USER=${MYSQL_USER:-db_user} + - MYSQL_PASSWORD=${MYSQL_PASSWORD:-db_user_secret_password} + + mailcatcher: + image: docker.io/dockage/mailcatcher:0.8.2 + networks: + - server_side + ports: + - 127.0.0.1:1080:1080 + - 127.0.0.1:1025:1025 diff --git a/compose.yaml b/compose.yaml index 223e6a948..62c01996a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,4 +1,19 @@ name: torrust + +# Production-shaped baseline. Dev sandbox extras (mailcatcher, +# permissive credential defaults, tty allocation) live in +# `compose.override.yaml`, which Compose v2 auto-loads when +# present. Per ADR-T-009 §8.1, credentials and the +# environment-coupled mail SMTP server are referenced as +# bare `${VAR}` (no default, no `:?required` assertion); +# validation is deferred to `make up-prod` and the in-container +# config probe (defence in depth, see ADR-T-009 §8.1). +# +# Operator selectors with a sensible cross-environment default +# (e.g. `TORRUST_INDEX_DATABASE_DRIVER`) keep their +# `${VAR:-default}` form so the documented `docker compose up` +# flow keeps working out of the box. + services: index: @@ -6,78 +21,93 @@ services: context: . dockerfile: ./Containerfile target: release - tty: true + restart: unless-stopped # Adjust to 'always' or 'no' per deployment needs. environment: - USER_ID=${USER_ID} - - TORRUST_INDEX_CONFIG_TOML=${TORRUST_INDEX_CONFIG_TOML} - - TORRUST_INDEX_DATABASE=${TORRUST_INDEX_DATABASE:-e2e_testing_sqlite3} + # Bare name (no `=`) is compose's pass-through form: the + # env var is forwarded only when set on the host, otherwise + # omitted entirely. Avoids propagating an empty string, + # which the config loader would treat as a zero-byte TOML. + - TORRUST_INDEX_CONFIG_TOML + - TORRUST_INDEX_DATABASE=${TORRUST_INDEX_DATABASE:-torrust_index} - TORRUST_INDEX_DATABASE_DRIVER=${TORRUST_INDEX_DATABASE_DRIVER:-sqlite3} - - TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN=${TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN:-MyAccessToken} + # Credential — bare ${VAR}, no default. See ADR-T-009 §8.1. + # Dev sandbox defaults live in `compose.override.yaml`. + - TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN=${TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN} + # Mandatory at the schema level (ADR-T-009 §D2): the shipped + # default TOMLs no longer carry a `database.connect_url`, so the + # operator must inject one. Bare ${VAR} keeps the production + # baseline credential-clean; the dev sandbox supplies a SQLite + # default in `compose.override.yaml`. + - TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL=${TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL} networks: - server_side ports: - 3001:3001 + - 127.0.0.1:3002:3002 volumes: - ./storage/index/lib:/var/lib/torrust/index:Z - ./storage/index/log:/var/log/torrust/index:Z - ./storage/index/etc:/etc/torrust/index:Z + # Long-form `depends_on` so `compose.override.yaml` can + # additively re-attach `mailcatcher` (ADR-T-009 §8.2). + # Short-form here would cause the override to silently + # replace this list rather than extend it. depends_on: - - tracker - - mailcatcher - - mysql + tracker: + condition: service_started + mysql: + condition: service_healthy tracker: - image: torrust/tracker:develop - tty: true + image: docker.io/torrust/tracker:develop + restart: unless-stopped # Adjust to 'always' or 'no' per deployment needs. environment: - USER_ID=${USER_ID} - - TORRUST_TRACKER_CONFIG_TOML=${TORRUST_TRACKER_CONFIG_TOML} - - TORRUST_TRACKER_DATABASE=${TORRUST_TRACKER_DATABASE:-e2e_testing_sqlite3} + # See the index service for the bare-name pass-through rationale. + - TORRUST_TRACKER_CONFIG_TOML + - TORRUST_TRACKER_DATABASE=${TORRUST_TRACKER_DATABASE:-torrust_tracker} - TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER=${TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER:-Sqlite3} - - TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN=${TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN:-MyAccessToken} + # Credentials — bare ${VAR}, no default. See ADR-T-009 §8.1. + - TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN=${TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN} networks: - server_side ports: - - 6969:6969/udp - - 7070:7070 - - 1212:1212 + - 127.0.0.1:6969:6969/udp + - 127.0.0.1:7070:7070 + - 127.0.0.1:1212:1212 volumes: - ./storage/tracker/lib:/var/lib/torrust/tracker:Z - ./storage/tracker/log:/var/log/torrust/tracker:Z - ./storage/tracker/etc:/etc/torrust/tracker:Z depends_on: - - mysql - - mailcatcher: - image: dockage/mailcatcher:0.8.2 - networks: - - server_side - ports: - - 1080:1080 - - 1025:1025 + mysql: + condition: service_healthy mysql: - image: mysql:8.0 - command: '--default-authentication-plugin=mysql_native_password' + image: docker.io/library/mysql:8.0.45 + command: '--authentication-policy=mysql_native_password' healthcheck: test: [ 'CMD-SHELL', - 'mysqladmin ping -h 127.0.0.1 --password="$$(cat /run/secrets/db-password)" --silent' + 'mysqladmin ping -h 127.0.0.1 --password="$$MYSQL_ROOT_PASSWORD" --silent' ] interval: 3s retries: 5 start_period: 30s environment: - MYSQL_ROOT_HOST=% - - MYSQL_ROOT_PASSWORD=root_secret_password - - MYSQL_DATABASE=${TORRUST_INDEX_MYSQL_DATABASE:-torrust_index_e2e_testing} - - MYSQL_USER=db_user - - MYSQL_PASSWORD=db_user_secret_password + # Credentials — bare ${VAR}, no default. See ADR-T-009 §8.1. + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + # Database name is a selector, not a credential — keep a default. + - MYSQL_DATABASE=${TORRUST_INDEX_MYSQL_DATABASE:-torrust_index} networks: - server_side ports: - - 3306:3306 + - 127.0.0.1:3306:3306 volumes: - mysql_data:/var/lib/mysql diff --git a/contrib/dev-tools/container/build.sh b/contrib/dev-tools/container/build.sh deleted file mode 100755 index c6c28635b..000000000 --- a/contrib/dev-tools/container/build.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -USER_ID=${USER_ID:-1000} - -echo "Building docker image ..." -echo "USER_ID: $USER_ID" - -docker build \ - --build-arg UID="$USER_ID" \ - -t torrust-index . diff --git a/contrib/dev-tools/container/e2e/mysql/e2e-env-down.sh b/contrib/dev-tools/container/e2e/mysql/e2e-env-down.sh index 00a4728e3..ff62978b8 100755 --- a/contrib/dev-tools/container/e2e/mysql/e2e-env-down.sh +++ b/contrib/dev-tools/container/e2e/mysql/e2e-env-down.sh @@ -1,5 +1,5 @@ #!/bin/bash -TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.public.e2e.container.mysql.toml) \ +TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.public.e2e.container.toml) \ TORRUST_TRACKER_CONFIG_TOML=$(cat ./share/default/config/tracker.public.e2e.container.sqlite3.toml) \ docker compose down diff --git a/contrib/dev-tools/container/e2e/mysql/e2e-env-up.sh b/contrib/dev-tools/container/e2e/mysql/e2e-env-up.sh index 328ee71ad..dd8dcb116 100755 --- a/contrib/dev-tools/container/e2e/mysql/e2e-env-up.sh +++ b/contrib/dev-tools/container/e2e/mysql/e2e-env-up.sh @@ -1,13 +1,14 @@ #!/bin/bash -TORRUST_INDEX_CONFIG=$(cat ./share/default/config/index.public.e2e.container.mysql.toml) \ +TORRUST_INDEX_CONFIG=$(cat ./share/default/config/index.public.e2e.container.toml) \ docker compose build USER_ID=${USER_ID:-1000} \ - TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.public.e2e.container.mysql.toml) \ + TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.public.e2e.container.toml) \ TORRUST_INDEX_DATABASE="torrust_index_e2e_testing" \ TORRUST_INDEX_DATABASE_DRIVER="mysql" \ TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ + TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="mysql://root:root_secret_password@mysql:3306/torrust_index_e2e_testing" \ TORRUST_INDEX_MYSQL_DATABASE="torrust_index_e2e_testing" \ TORRUST_TRACKER_CONFIG_TOML=$(cat ./share/default/config/tracker.public.e2e.container.sqlite3.toml) \ TORRUST_TRACKER_DATABASE="e2e_testing_sqlite3" \ diff --git a/contrib/dev-tools/container/e2e/mysql/run-e2e-tests.sh b/contrib/dev-tools/container/e2e/mysql/run-e2e-tests.sh index 046862d5e..9cf352804 100755 --- a/contrib/dev-tools/container/e2e/mysql/run-e2e-tests.sh +++ b/contrib/dev-tools/container/e2e/mysql/run-e2e-tests.sh @@ -6,9 +6,7 @@ echo "User name: $CURRENT_USER_NAME" echo "User id: $CURRENT_USER_ID" USER_ID=$CURRENT_USER_ID -TORRUST_TRACKER_USER_UID=$CURRENT_USER_ID export USER_ID -export TORRUST_TRACKER_USER_UID export TORRUST_INDEX_DATABASE="torrust_index_e2e_testing" export TORRUST_TRACKER_DATABASE="e2e_testing_sqlite3" @@ -39,9 +37,16 @@ docker ps ./contrib/dev-tools/container/e2e/mysql/install.sh || exit 1 # Run E2E tests with shared app instance +# +# The e2e config TOML intentionally omits `tracker.token` and +# `database.connect_url` (operators are expected to supply them via env +# overrides; see ADR-T-009 §D2). Inject host-side overrides so the test +# process can load the same config file the container uses. TORRUST_INDEX_E2E_SHARED=true \ - TORRUST_INDEX_CONFIG_TOML_PATH="./share/default/config/index.public.e2e.container.mysql.toml" \ + TORRUST_INDEX_CONFIG_TOML_PATH="./share/default/config/index.public.e2e.container.toml" \ TORRUST_INDEX_E2E_DB_CONNECT_URL="mysql://root:root_secret_password@127.0.0.1:3306/torrust_index_e2e_testing" \ + TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ + TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="mysql://root:root_secret_password@127.0.0.1:3306/torrust_index_e2e_testing" \ cargo test || { ./contrib/dev-tools/container/e2e/mysql/e2e-env-down.sh diff --git a/contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-up.sh b/contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-up.sh index 7c01187ce..81c66157d 100755 --- a/contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-up.sh +++ b/contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-up.sh @@ -8,6 +8,7 @@ USER_ID=${USER_ID:-1000} \ TORRUST_INDEX_DATABASE="e2e_testing_sqlite3" \ TORRUST_INDEX_DATABASE_DRIVER="sqlite3" \ TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ + TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite:///var/lib/torrust/index/database/e2e_testing_sqlite3.db?mode=rwc" \ TORRUST_TRACKER_CONFIG_TOML=$(cat ./share/default/config/tracker.private.e2e.container.sqlite3.toml) \ TORRUST_TRACKER_DATABASE="e2e_testing_sqlite3" \ TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER="sqlite3" \ diff --git a/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-down.sh b/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-down.sh index ad55685fb..ff62978b8 100755 --- a/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-down.sh +++ b/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-down.sh @@ -1,5 +1,5 @@ #!/bin/bash -TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.public.e2e.container.sqlite3.toml) \ +TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.public.e2e.container.toml) \ TORRUST_TRACKER_CONFIG_TOML=$(cat ./share/default/config/tracker.public.e2e.container.sqlite3.toml) \ docker compose down diff --git a/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-up.sh b/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-up.sh index 3fa63c0ff..3b110a5c8 100755 --- a/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-up.sh +++ b/contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-up.sh @@ -1,13 +1,14 @@ #!/bin/bash -TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.public.e2e.container.sqlite3.toml) \ +TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.public.e2e.container.toml) \ docker compose build USER_ID=${USER_ID:-1000} \ - TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.public.e2e.container.sqlite3.toml) \ + TORRUST_INDEX_CONFIG_TOML=$(cat ./share/default/config/index.public.e2e.container.toml) \ TORRUST_INDEX_DATABASE="e2e_testing_sqlite3" \ TORRUST_INDEX_DATABASE_DRIVER="sqlite3" \ TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ + TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite:///var/lib/torrust/index/database/e2e_testing_sqlite3.db?mode=rwc" \ TORRUST_TRACKER_CONFIG_TOML=$(cat ./share/default/config/tracker.public.e2e.container.sqlite3.toml) \ TORRUST_TRACKER_DATABASE="e2e_testing_sqlite3" \ TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER="sqlite3" \ diff --git a/contrib/dev-tools/container/e2e/sqlite/run-e2e-tests.sh b/contrib/dev-tools/container/e2e/sqlite/run-e2e-tests.sh index 024e7ae3d..d60de24cd 100755 --- a/contrib/dev-tools/container/e2e/sqlite/run-e2e-tests.sh +++ b/contrib/dev-tools/container/e2e/sqlite/run-e2e-tests.sh @@ -6,9 +6,7 @@ echo "User name: $CURRENT_USER_NAME" echo "User id: $CURRENT_USER_ID" USER_ID=$CURRENT_USER_ID -TORRUST_TRACKER_USER_UID=$CURRENT_USER_ID export USER_ID -export TORRUST_TRACKER_USER_UID export TORRUST_INDEX_DATABASE="e2e_testing_sqlite3" export TORRUST_TRACKER_DATABASE="e2e_testing_sqlite3" @@ -40,9 +38,16 @@ echo "Running E2E tests with a public tracker ..." docker ps # Run E2E tests with shared app instance +# +# The e2e config TOML intentionally omits `tracker.token` and +# `database.connect_url` (operators are expected to supply them via env +# overrides; see ADR-T-009 §D2). Inject host-side overrides so the test +# process can load the same config file the container uses. TORRUST_INDEX_E2E_SHARED=true \ - TORRUST_INDEX_CONFIG_TOML_PATH="./share/default/config/index.public.e2e.container.sqlite3.toml" \ + TORRUST_INDEX_CONFIG_TOML_PATH="./share/default/config/index.public.e2e.container.toml" \ TORRUST_INDEX_E2E_DB_CONNECT_URL="sqlite://./storage/index/lib/database/e2e_testing_sqlite3.db?mode=rwc" \ + TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ + TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite://./storage/index/lib/database/e2e_testing_sqlite3.db?mode=rwc" \ cargo test || { ./contrib/dev-tools/container/e2e/sqlite/mode/public/e2e-env-down.sh @@ -67,9 +72,15 @@ echo "Running E2E tests with a private tracker ..." docker ps # Run E2E tests with shared app instance +# +# Same rationale as above — supply mandatory `tracker.token` and +# `database.connect_url` via env overrides for the host-side test +# process (ADR-T-009 §D2). TORRUST_INDEX_E2E_SHARED=true \ TORRUST_INDEX_CONFIG_TOML_PATH="./share/default/config/index.private.e2e.container.sqlite3.toml" \ TORRUST_INDEX_E2E_DB_CONNECT_URL="sqlite://./storage/index/lib/database/e2e_testing_sqlite3.db?mode=rwc" \ + TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ + TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite://./storage/index/lib/database/e2e_testing_sqlite3.db?mode=rwc" \ cargo test || { ./contrib/dev-tools/container/e2e/sqlite/mode/private/e2e-env-down.sh diff --git a/contrib/dev-tools/container/run.sh b/contrib/dev-tools/container/run.sh deleted file mode 100755 index 64f7c5434..000000000 --- a/contrib/dev-tools/container/run.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -USER_ID=${USER_ID:-1000} -TORRUST_INDEX_CONFIG_TOML=$(cat config.toml) - -docker run -it \ - --user="$USER_ID" \ - --publish 3001:3001/tcp \ - --env TORRUST_INDEX_CONFIG_TOML="$TORRUST_INDEX_CONFIG_TOML" \ - --volume "$(pwd)/storage":"/app/storage" \ - torrust-index diff --git a/contrib/dev-tools/su-exec/AUDIT.md b/contrib/dev-tools/su-exec/AUDIT.md new file mode 100644 index 000000000..f0cd70656 --- /dev/null +++ b/contrib/dev-tools/su-exec/AUDIT.md @@ -0,0 +1,125 @@ +# `su-exec` Vendoring Audit + +This document records the provenance, rationale, and review +history for the vendored copy of [`su-exec.c`](./su-exec.c). +It is consumed by ADR-T-009 §D8 / Acceptance Criterion #8 — +the file-change CI guard parses the most recent `SHA-256:` +line in the [Audit Log](#audit-log) section and fails the +build when it disagrees with `sha256sum su-exec.c`. + +## Provenance + +- **Upstream project:** [`ncopa/su-exec`](https://github.com/ncopa/su-exec) + (MIT, Copyright (c) 2015 Natanael Copa; unmaintained, + no tagged releases). +- **Upstream commit.** *Not recorded at initial vendor + time.* The copy was taken from the then-latest + upstream `master` during the repository commit below + and has not been modified since. Re-vendoring is the + only supported update path, so a precise upstream + commit will be recorded as part of that exercise + (together with a fresh audit-log entry). +- **Vendored on:** 2023-10-14, in repository commit + `1f5351db88dc8ea7d295c115c86feb3e70498aa0` + ("dev: upgrade containers"). The file has been unchanged + in this repository since. +- **Files vendored:** [`su-exec.c`](./su-exec.c) (1900 bytes, + ~75 LoC of C), [`Makefile`](./Makefile), + [`README.md`](./README.md), and [`LICENSE`](./LICENSE) + (MIT). +- **Initial SHA-256 of `su-exec.c`:** + `d6c40440609a23483f12eb6295b5191e94baf08298a856bab6e15b10c3b82891` + +## Choice Rationale + +The container entry script needs to drop privileges from +root to the runtime `torrust` user *and* `exec` into the +application without spawning a child shell — TTY signals +must reach the application directly, and the process tree +must remain shallow so `PID 1` semantics are preserved. + +Alternatives considered: + +- **`gosu`** — Go-based; functionally equivalent. Rejected + because the static binary is roughly 1.8 MB versus + `su-exec`'s ~10 KB. The size delta matters on the lean + distroless `release` base, which deliberately avoids + pulling in a full language runtime for a privilege-drop + shim. +- **`setpriv`** (util-linux) — pulls in util-linux as a + dependency. Not present on the lean distroless + `cc-debian13` base; adding it would expand the runtime + attack surface for no functional gain. +- **`su` / `runuser`** — both fork the target as a child + rather than `exec`-ing it. This breaks the PID-1 / signal + chain that the Compose `--init`-less workflow depends on. + +`su-exec` is ~75 lines of C with no transitive +dependencies beyond libc. The codebase is small enough to +audit in full and stable enough that "unmaintained upstream" +is a feature rather than a risk: there is no churn to track. + +## Re-Audit Triggers + +The audit is **not** on a calendar — see ADR-T-009 §D8 for +the full rationale. Static, frozen C code with a finite +review surface does not decay with time; a calendar trigger +would manufacture review work without producing review +signal. + +The two real triggers: + +- **File-change trigger (automated).** CI computes + `sha256sum contrib/dev-tools/su-exec/su-exec.c` and + compares it against the most recent `SHA-256:` line in + the [Audit Log](#audit-log) section below. Mismatch fails + the build until a new audit-log entry is appended that + records the new hash and the reviewer's findings. The + check lives in the Container CI workflow so it cannot be + bypassed by the normal PR flow. +- **CVE trigger (manual).** When a CVE is publicly + disclosed against `su-exec` or against a closely related + project (`gosu`, `setpriv`, BusyBox `su`/`runuser`) that + could plausibly apply to this code path, perform a fresh + review and append an entry. There is deliberately no + automated CVE feed wired in: the false-positive rate for + an unmaintained project of this size is not worth the + noise. + +There is deliberately **no refresh procedure** documented +here. Upstream has not released in years; if a re-vendor is +ever needed it will be a manual diff-and-review exercise +producing a new audit entry as a side effect. + +## Audit Log + +Append-only. Newest entry at the bottom. Each entry must +contain a `SHA-256: <64-hex>` line — the structured marker +the CI guard parses. + +### 2026-04-21 — Initial audit + +- **Reviewer:** ADR-T-009 Phase 9 implementation. +- **Repository commit at review time:** + `76f0fcde62d60b55837037549ffab32210cb81a9` (HEAD before + this commit lands). +- **Scope.** Full read-through of `su-exec.c` against the + upstream commit recorded in [Provenance](#provenance). + Verified that: + - The vendored file is byte-for-byte identical to + upstream (no local modifications). + - The code performs `getpwnam`/`getgrnam` lookups, + parses the `user[:group]` spec, calls + `setgroups`/`setgid`/`setuid` in the correct order, + and `execvp`s the target — no shell invocation, no + `system()`, no `popen()`. + - All return codes from the privilege-changing syscalls + are checked; failure paths exit non-zero with `err()` + rather than continuing with reduced privileges silently. + - There is no network code, no signal handling beyond + libc defaults, and no use of environment variables + other than what `execvp` itself consults via `PATH`. +- **Conclusion.** Suitable for the entry-script's + privilege-drop-and-exec role. No findings. + +SHA-256: d6c40440609a23483f12eb6295b5191e94baf08298a856bab6e15b10c3b82891 diff --git a/docs/containers.md b/docs/containers.md index b335eb8ce..c570aaad5 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -2,28 +2,40 @@ ## Demo environment -It is simple to setup the index with the default -configuration and run it using the pre-built public docker image: +The pre-built public image still runs with one command, but +after ADR-T-009 §D2 it requires two operator-supplied +secrets at startup: With Docker: ```sh -docker run -it torrust/index:latest +docker run -it \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite:///var/lib/torrust/index/database/sqlite3.db?mode=rwc" \ + torrust/index:latest ``` or with Podman: ```sh -podman run -it torrust/index:latest +podman run -it \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite:///var/lib/torrust/index/database/sqlite3.db?mode=rwc" \ + torrust/index:latest ``` +A bare `docker run -it torrust/index:latest` (the +zero-config invocation that worked in earlier releases) now +fails to start with a serde `missing field` error — this is +intentional, see [ADR-T-009 §D2](../adr/009-container-infrastructure-refactor.md). + ## Requirements - Tested with recent versions of Docker or Podman. ## Volumes -The [Containerfile](../Containerfile) (i.e. the Dockerfile) Defines Three Volumes: +The [Containerfile](../Containerfile) defines three volumes: ```Dockerfile VOLUME ["/var/lib/torrust/index","/var/log/torrust/index","/etc/torrust/index"] @@ -49,10 +61,18 @@ mkdir -p ./storage/index/lib/ ./storage/index/log/ ./storage/index/etc/ ### Matching Ownership ID's of Host Storage and Container Volumes -It is important that the `torrust` user has the same uid `$(id -u)` as the host mapped folders. In our [entry script](../share/container/entry_script_sh), installed to `/usr/local/bin/entry.sh` inside the container, switches to the `torrust` user created based upon the `USER_UID` environmental variable. +It is important that the `torrust` user has the same uid `$(id -u)` as the host mapped folders. In our [entry script](../share/container/entry_script_sh), installed to `/usr/local/bin/entry.sh` inside the container, switches to the `torrust` user created based upon the `USER_ID` environmental variable. When running the container, you may use the `--env USER_ID="$(id -u)"` argument that gets the current user-id and passes to the container. +`USER_ID` must be a non-negative integer and must not be `0` +(the entry script refuses to run as root). Any positive UID +is accepted — including low-UID values produced by rootless +Podman with subuid remapping, low-UID CI runners, or +BSD-derived hosts. The previous `USER_ID >= 1000` rule was +dropped because it rejected several of these legitimate +configurations without stating its intent. + ### Mapped Tree Structure Using the standard mapping defined above produces this following mapped tree: @@ -61,7 +81,7 @@ Using the standard mapping defined above produces this following mapped tree: storage/index/ ├── lib │ ├── database -│ │   └── sqlite3.db => /var/lib/torrust/index/database/sqlite3.db [auto populated] +│ │ └── index.sqlite3.db => /var/lib/torrust/index/database/index.sqlite3.db [auto populated, sqlite3 only] │ └── tls │ ├── localhost.crt => /var/lib/torrust/index/tls/localhost.crt [user supplied] │ └── localhost.key => /var/lib/torrust/index/tls/localhost.key [user supplied] @@ -79,10 +99,53 @@ storage/index/ > container entry script. Sessions persist across restarts as long as the > `/etc/torrust/index` volume is retained. To use your own keys, either > pre-populate the volume before first boot or overwrite the generated files and -> restart. +> restart. Per ADR-T-009 §D3 the script is the single source of truth for the +> auth-key paths: when neither `..._AUTH__*_PEM` nor `..._AUTH__*_PATH` is +> configured, the script generates the pair at the locations above and exports +> the corresponding `..._AUTH__*_PATH` overrides so the application sees the +> same paths. +> +> The SQLite database file is only auto-populated when the resolved +> `database.connect_url` (via `TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL` +> or the mounted `index.toml`) names an absolute SQLite path under one of the +> managed volumes. MySQL/MariaDB connections are not seeded — the application +> connects directly. See [Entry Script Contract](#entry-script-contract). ## Building the Container +### Test-Stage Gate + +The image build is **gated on the workspace test suite passing**. +The `Containerfile` defines intermediate `test` / `test_debug` +stages that compile the workspace's test archive and run it +with `cargo nextest run` before the runtime image stages +(`runtime_release` / `runtime_debug`) are assembled. A red +test run blocks image production until the underlying issue +is fixed. + +This is a deliberate trade-off: + +- **What you get.** A red `develop`/`main` test run cannot + silently ship as a published image. There is no + build-time `--skip-tests` escape hatch — and that omission + is intentional. Any such hatch would, in time, end up wired + into a release pipeline "just for this one urgent fix" and + would defeat the gate's whole purpose. +- **What it costs.** A flaky or environment-dependent test + blocks image production even when the application is + unaffected. Treat this as a forcing function, not as + collateral damage: stabilise or quarantine the test rather + than reaching for an opt-out. +- **Escalation path.** When a known-flaky test blocks an + urgent build, the supported response is to `#[ignore]` + the specific test on a tracked branch with a linked + issue, rebuild, then follow up by fixing the test and + removing the `#[ignore]`. There is no privileged rebuild + path that bypasses the gate. + +See [ADR-T-009 §D9](../adr/009-container-infrastructure-refactor.md) +for the design rationale. + ### Clone and Change into Repository ```sh @@ -110,38 +173,67 @@ docker build --target debug --tag torrust-index:debug --file Containerfile . ### (Podman) Build +Podman defaults to writing OCI-format manifests. The OCI image-spec +has no field for `HEALTHCHECK`, so building without `--format docker` +drops the directive (and prints a `WARN[…] HEALTHCHECK is not +supported for OCI image format` line). Pass `--format docker` so the +healthcheck survives in the manifest: + ```sh # Release Mode -podman build --target release --tag torrust-index:release --file Containerfile . +podman build --format docker --target release --tag torrust-index:release --file Containerfile . # Debug Mode -podman build --target debug --tag torrust-index:debug --file Containerfile . +podman build --format docker --target debug --tag torrust-index:debug --file Containerfile . ``` +`docker build` defaults to Docker-format manifests, so the flag is +only needed for Podman / Buildah. Running OCI-format images on Docker +or Podman works regardless of which format they were built with; +the format only matters for Docker-specific manifest extensions like +`HEALTHCHECK`. + ## Running the Container ### Basic Run -No arguments are needed for simply checking the container image works: +The minimum invocation supplies the two mandatory overrides +introduced by ADR-T-009 §D2 (`tracker.token` and +`database.connect_url`). Without them, the config probe +fails the schema check and the entry script aborts startup +before privilege drop. See [Entry Script Contract](#entry-script-contract) +for the full boot sequence. #### (Docker) Run Basic ```sh # Release Mode -docker run -it torrust-index:release +docker run -it \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MySecretToken" \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite:///var/lib/torrust/index/database/index.sqlite3.db?mode=rwc" \ + torrust-index:release # Debug Mode -docker run -it torrust-index:debug +docker run -it \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MySecretToken" \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite:///var/lib/torrust/index/database/index.sqlite3.db?mode=rwc" \ + torrust-index:debug ``` #### (Podman) Run Basic ```sh # Release Mode -podman run -it torrust-index:release +podman run -it \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MySecretToken" \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite:///var/lib/torrust/index/database/index.sqlite3.db?mode=rwc" \ + torrust-index:release # Debug Mode -podman run -it torrust-index:debug +podman run -it \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MySecretToken" \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite:///var/lib/torrust/index/database/index.sqlite3.db?mode=rwc" \ + torrust-index:debug ``` ### Arguments @@ -157,15 +249,28 @@ Environmental variables are loaded through the `--env`, in the format `--env VAR The following environmental variables can be set: - `TORRUST_INDEX_CONFIG_TOML_PATH` - The in-container path to the index configuration file, (default: `"/etc/torrust/index/index.toml"`). -- `TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN` - Override of the admin token. If set, this value overrides any value set in the config. +- `TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN` - **Required.** Tracker admin token. Per ADR-T-009 §D2 the shipped TOMLs no longer carry a default value for this field, so the operator must supply it via this env var (or pre-populate the in-volume `index.toml`). Startup fails with `missing field 'token'` otherwise. +- `TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL` - **Required.** Database connection URL (e.g. `sqlite:///var/lib/torrust/index/database/index.sqlite3.db?mode=rwc` or a `mysql://...` URL). Same rule as `TRACKER__TOKEN`: shipped TOMLs no longer carry a default, so absent both env var and operator-supplied TOML the application fails to start with `missing field 'connect_url'`. - `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PATH` - Path to an RSA private key PEM file for JWT signing. Optional: without this, ephemeral auto-generated keys are used (sessions will not survive restarts). - `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH` - Path to an RSA public key PEM file for JWT verification. Required when `PRIVATE_KEY_PATH` is set. - `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PEM` - Inline RSA private key PEM string (alternative to file path). Optional: for persistent sessions. - `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PEM` - Inline RSA public key PEM string (alternative to file path). Required when `PRIVATE_KEY_PEM` is set. -- `TORRUST_INDEX_DATABASE_DRIVER` - The database type used for the container, (options: `sqlite3`, `mysql`, default `sqlite3`). Please Note: This dose not override the database configuration within the `.toml` config file. +- `TORRUST_INDEX_DATABASE_DRIVER` - **First-boot TOML selector only** (options: `sqlite3`, `mysql`, default `sqlite3`). Per ADR-T-009 §7.4, this env var now selects which default `index.toml` is seeded into `/etc/torrust/index/` on first boot — it is read by the entry script at container start, not at image-build time. It no longer drives runtime database decisions: those are taken from the config probe's `database.driver` field, derived from `database.connect_url`'s URL scheme. Note the taxonomy difference — the env var uses `sqlite3` / `mysql`; the probe (and the application) emit `sqlite` / `mysql`. Operators who scripted around this env var expecting it to control runtime behaviour must update their scripts to supply `TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL` instead. - `TORRUST_INDEX_CONFIG_TOML` - Load config from this environmental variable instead from a file, (i.e: `TORRUST_INDEX_CONFIG_TOML=$(cat index-index.toml)`). -- `USER_ID` - The user id for the runtime crated `torrust` user. Please Note: This user id should match the ownership of the host-mapped volumes, (default `1000`). +- `USER_ID` - The user id for the runtime-created `torrust` user. Must be a non-negative integer and must not be `0`. Should match the ownership of the host-mapped volumes (default `1000`). - `API_PORT` - The port for the index API. This should match the port used in the configuration, (default `3001`). +- `IMPORTER_API_PORT` - The port for the importer API. This should match the port used in the configuration, (default `3002`). +- `TZ` - Container time zone passed through to the runtime (default `Etc/UTC`). Set in the Containerfile `ENV` block; override at `docker run` time if you need wall-clock log timestamps in a specific zone. + +> NOTE: `API_PORT` and `IMPORTER_API_PORT` are runtime `ENV` values, not +> build-time `ARG`s. Overriding them at `docker run` / `podman run` time +> with `--env API_PORT=…` correctly reaches the application listener and +> the in-container `HEALTHCHECK`, but the `EXPOSE` directive in the +> `Containerfile` is evaluated at build time and bakes the *defaults* +> (`3001`, `3002`) into image metadata. Tools that read that metadata +> (`docker inspect`, `docker port`) will continue to report the defaults +> regardless of any `--env` override. Use `--publish host:container` to +> map whichever container port the application is actually listening on. ### Sockets @@ -218,6 +323,7 @@ mkdir -p ./storage/index/lib/ ./storage/index/log/ ./storage/index/etc/ ## --env TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH="/var/lib/torrust/index/jwt/public.pem" \ docker run -it \ --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MySecretToken" \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite:///var/lib/torrust/index/database/index.sqlite3.db?mode=rwc" \ --env USER_ID="$(id -u)" \ --publish 0.0.0.0:3001:3001/tcp \ --volume ./storage/index/lib:/var/lib/torrust/index:Z \ @@ -230,7 +336,7 @@ docker run -it \ ```sh ## Build Container Image -podman build --target release --tag torrust-index:release --file Containerfile . +podman build --format docker --target release --tag torrust-index:release --file Containerfile . ## Setup Mapped Volumes mkdir -p ./storage/index/lib/ ./storage/index/log/ ./storage/index/etc/ @@ -238,6 +344,7 @@ mkdir -p ./storage/index/lib/ ./storage/index/log/ ./storage/index/etc/ ## Run Torrust Index Container Image podman run -it \ --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MySecretToken" \ + --env TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite:///var/lib/torrust/index/database/index.sqlite3.db?mode=rwc" \ --env USER_ID="$(id -u)" \ --publish 0.0.0.0:3001:3001/tcp \ --volume ./storage/index/lib:/var/lib/torrust/index:Z \ @@ -245,3 +352,243 @@ podman run -it \ --volume ./storage/index/etc:/etc/torrust/index:Z \ torrust-index:release ``` + +## Compose Split + +Per ADR-T-009 §8, the repository ships two Compose files +with a clear separation between *production-shaped baseline* +and *dev sandbox*: + +- [`compose.yaml`](../compose.yaml) — **deployment template.** + Production-shaped: no `mailcatcher` sidecar, no `tty`, + external ports bound to `127.0.0.1` (except the index API + on `:3001`), and credentials referenced as bare `${VAR}` + with no defaults. This is the file operators copy as a + starting point for real deployments. +- [`compose.override.yaml`](../compose.override.yaml) — + **for development.** Auto-loaded by Compose v2 (i.e. by a + plain `docker compose up` and by `make up-dev`). Adds the + `mailcatcher` sidecar, allocates TTYs on `index` / + `tracker`, and supplies permissive `${VAR:-default}` + defaults for the credentials the baseline leaves blank. + +Two top-level [`Makefile`](../Makefile) targets wrap the two +documented invocation paths: + +```sh +# Dev sandbox: auto-loads compose.override.yaml. +make up-dev + +# Production-shaped: validates required credentials, then +# runs `docker compose --file compose.yaml up -d --wait` +# (override excluded). Required env vars: +# +# USER_ID (numeric host UID owning ./storage) +# TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN +# TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL +# TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN +# MYSQL_ROOT_PASSWORD (only if the local mysql sidecar is in use) +make up-prod +``` + +`make up-prod` is fail-fast convenience — defence in depth, +not the only line. The container's config probe (ADR-T-009 +§6) is the authoritative gate: it rejects empty +`connect_url` (exit 3) and empty `tracker.token` (exit 4) +regardless of how Compose was invoked. + +A developer running `docker compose -f compose.yaml up` +(deliberately bypassing the override) gets bare `${VAR}` +substitution to empty strings; the config probe catches this +inside the container, so the worst case is a clear startup +failure rather than silent misbehaviour. + +## Runtime Image Notes + +### Healthcheck (both targets) + +Both `release` and `debug` ship the same two-probe `HEALTHCHECK` +block, invoking `torrust-index-health-check` against the index API +and the importer API in turn. The healthcheck binary is itself +`0500 root:root`; the `HEALTHCHECK` directive runs as root (no +`--user` flag in the directive), so the unprivileged `torrust` user +cannot invoke it directly. + +If you build with Podman without `--format docker`, the directive is +silently dropped at build time (see the build section above) and the +image will report no health status. `docker build` is unaffected. + +### Available Shell Commands (Busybox Subset) + +The two build targets ship deliberately different shell +footprints. + +**`release` target.** Built on the lean +`gcr.io/distroless/cc-debian13` base. Ships a single +`/bin/busybox` binary (mode `0700 root:root`) plus a curated +set of applet symlinks pointing at it. The unprivileged +`torrust` user that the application runs as gets `EACCES` +on `/bin/busybox` (and therefore on every applet symlink) +after privilege drop — the busybox tree is reachable only +by root. The curated symlink set covers exactly the applets +the entry script needs at first boot: + +- `sh`, `adduser`, `addgroup`, `install`, `mkdir`, `dirname`, + `chown`, `chmod`, `tr`, `mktemp`, `cat`, `printf`, `rm`, + `echo`, `grep` + +`su-exec` is a separate root-only binary at `/bin/su-exec`, +not a busybox applet. `jq` is a separate root-only binary at +`/usr/bin/jq` used by the entry script's auth-keypair +bootstrap. None of these are reachable by the unprivileged +`torrust` user. + +There is no `/busybox/` directory in the release image — the +full busybox applet tree from the upstream `:debug` +distroless image is deliberately not present, so absolute-path +invocations like `/busybox/ls` cannot bypass the curated +subset. + +For emergency operational debugging, `docker exec -u root … +sh` still works on the release image (the curated `/bin/sh` +resolves through PATH to `/bin/busybox`, and `0700 root:root` +permits root invocation). This is the documented break-glass +procedure. + +**`debug` target.** Built on `gcr.io/distroless/cc-debian13:debug`, +which ships the upstream full busybox tree at `/busybox/` +with default world-executable permissions. The debug image +leaves that tree in place and puts `/busybox/` on `PATH` so +the unprivileged user retains access to the complete applet +set (`id`, `whoami`, `ps`, `grep`, `wget`, …). This is the +debug image's purpose; use it whenever you need an +interactive shell as the application user. + +### Entry Script Debugging + +The container entry script does not produce verbose output by default. +To enable shell tracing (`set -x`) for startup troubleshooting, set the +`DEBUG` environment variable: + +```sh +--env DEBUG=1 +``` + +The entry script also runs under `set -eu` (POSIX `errexit` + +`nounset`): any unchecked command failure aborts startup +immediately, and references to unset variables are treated as +errors. This converts a class of silent-misconfiguration bugs +into loud, actionable startup failures. + +### Entry Script Contract + +Per ADR-T-009 §7, the entry script reads its configuration in +the following order. Each step depends on the values +resolved by previous steps. + +The complete list of environment variables the entry script +consults is maintained as a canonical manifest comment block +in [`share/container/entry_script_sh`](../share/container/entry_script_sh), +delimited by the `# ENTRY_ENV_VARS:` and +`# END_ENTRY_ENV_VARS` sentinel lines. CI verifies that +every variable named between those sentinels is documented +in this file (see ADR-T-009 Acceptance Criterion #7); +when adding or removing a variable from the entry script, +update both the manifest block and the env-var section +above. + +1. **`USER_ID`** — numeric, non-zero (refuses to run as + root). Default `1000`. Used to create the unprivileged + `torrust` user via `adduser` and to chown the volume + directories. +2. **`TORRUST_INDEX_DATABASE_DRIVER`** — selects which + default TOML is installed at `/etc/torrust/index/index.toml` + on first boot (see the env-var entry above for the + build-time-only scope). +3. **`RUNTIME`** — selects the message-of-the-day banner + (`runtime`, `debug`, or `release`). Set by the + Containerfile per image variant. +4. **Config probe (`/usr/bin/torrust-index-config-probe`)** + — invoked as root after the default TOML is in place. + The probe is the same loader the application uses, so it + sees the operator's full TOML + env-var stack. Its JSON + output is consumed by `jq` and drives the remaining + steps. The probe runs *before* the script exports any + `TORRUST_INDEX_CONFIG_OVERRIDE_*` of its own, so its + output reflects only operator-supplied values. +5. **`TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__{PRIVATE,PUBLIC}_KEY_{PEM,PATH}`** + — the probe reports raw presence and resolved source for + each key. The script enforces three invariants post-probe: + PEM and PATH are mutually exclusive within a single key; + both keys must be configured or neither (no half-pair); + and both keys must use the same delivery mechanism (no + mixed PEM/PATH across the pair). When neither key is + configured anywhere (probe `source=none`), the script + applies the container defaults + (`/etc/torrust/index/auth/private.pem` and + `.../public.pem`) and **exports the corresponding + `..._AUTH__*_PATH` override env var** so the application + sees the same path the script materialises. The script + is the single source of truth for these defaults; there + is no constant duplicated between two files to drift + apart. +6. **`database.connect_url`** (resolved by the probe) — + the probe's `database.driver` field selects the seeding + dispatch (`sqlite` seeds the default DB file; `mysql` + is a no-op since the application connects directly). + For SQLite the seed is materialised at the resolved path + only when the parent directory lives under one of the + managed volumes (`/etc/torrust/index/`, + `/var/lib/torrust/index/`, `/var/log/torrust/index/`); + paths outside those roots must be pre-created by the + operator. +7. **`exec /bin/su-exec torrust ...`** — drops privileges + and execs the application (or the supplied `CMD`). + +#### Required Overrides Between Phases + +The default TOML shipped at +`/usr/share/torrust/default/config/index.container.toml` +intentionally leaves `[tracker]` and `[database]` empty so +operators must supply real values. Per ADR-T-009 §D2 the +schema requires `tracker.token` and +`database.connect_url` — the config probe will exit non-zero +(codes 3/4) and the entry script will abort startup if +either is missing. Supply them via: + +```sh +--env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN=... +--env TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL=sqlite:///var/lib/torrust/index/database/index.sqlite3.db?mode=rwc +``` + +or by mounting a populated `index.toml` at +`/etc/torrust/index/index.toml`. The +[`compose.override.yaml`](../compose.override.yaml) shipped +in the repo wires both overrides for the local dev workflow +(`make up-dev`); see [Compose Split](#compose-split). + +#### Runtime `jq` Dependency + +Both runtime images ship a root-only `/usr/bin/jq` (mode +`0500 root:root`, sourced from a pristine `rust:slim-trixie` +`jq_donor` build stage in the Containerfile). It is invoked +only during the entry script's pre-`su-exec` phase to parse +the config probe's JSON output and the auth-keypair helper's +JSON output. The unprivileged `torrust` user has no access +to `/usr/bin/jq` after privilege drop. + +#### Sourced Shell Library + +The entry script's pure helper functions (`inst`, +`key_configured`, `validate_auth_keys`, `seed_sqlite`) live +in a separate POSIX `sh` library shipped at +`/usr/local/lib/torrust/entry_script_lib_sh` (mode +`0444 root:root`, sourced — not exec'd). Splitting them +out lets the workspace test crate +[`packages/index-entry-script/`](../packages/index-entry-script/) +drive each helper through a host `sh` subprocess and assert +the exit-code / stderr contracts of every branch of +ADR-T-009 §7.1's auth-key invariants and §7.2's seeding +outcomes. The library has no top-level side effects, so +sourcing it from either the entry script or a test harness +is safe. diff --git a/packages/index-auth-keypair/Cargo.toml b/packages/index-auth-keypair/Cargo.toml new file mode 100644 index 000000000..ee0581a69 --- /dev/null +++ b/packages/index-auth-keypair/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "torrust-index-auth-keypair" + +authors.workspace = true +description = "RSA-2048 key pair generator for Torrust Index JWT authentication" +edition.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +torrust-index-cli-common = { version = "4.0.0-develop", path = "../index-cli-common" } + +clap = { version = "4", features = ["derive"] } +rsa = { version = "0.9", default-features = false, features = ["std", "pem"] } +serde = { version = "1", features = ["derive"] } +tracing = "0" + +[dev-dependencies] +serde_json = "1" + +[lints] +workspace = true diff --git a/packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs b/packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs new file mode 100644 index 000000000..31b41ef57 --- /dev/null +++ b/packages/index-auth-keypair/src/bin/torrust-index-auth-keypair.rs @@ -0,0 +1,56 @@ +//! Generate an RSA-2048 key pair for JWT authentication. +//! +//! # Usage +//! +//! ```sh +//! torrust-index-auth-keypair | jq -r .private_key_pem > private.pem +//! torrust-index-auth-keypair | jq -r .public_key_pem > public.pem +//! ``` + +use std::process::ExitCode; + +use clap::Parser; +use torrust_index_auth_keypair::generate_keypair; +use torrust_index_cli_common::{BaseArgs, emit, init_json_tracing, refuse_if_stdout_is_tty}; +use tracing::{error, info}; + +#[derive(Parser)] +#[command( + name = "torrust-index-auth-keypair", + about = "Generate an RSA-2048 key pair for Torrust Index JWT authentication" +)] +struct Args { + #[command(flatten)] + base: BaseArgs, +} + +fn main() -> ExitCode { + let args = Args::parse(); + init_json_tracing(if args.base.debug { + tracing::Level::DEBUG + } else { + tracing::Level::INFO + }); + refuse_if_stdout_is_tty("torrust-index-auth-keypair"); + + info!("Generating RSA-2048 key pair..."); + + match generate_keypair() { + Ok(out) => { + info!("Key pair generated successfully."); + match emit(&out) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + // Writing to stdout failed (e.g. broken pipe). Honour + // the helper's exit-code contract instead of panicking. + error!(error = %e, "failed to write key pair to stdout"); + ExitCode::FAILURE + } + } + } + Err(e) => { + error!(error = %e, "keypair generation failed"); + ExitCode::FAILURE + } + } +} diff --git a/packages/index-auth-keypair/src/lib.rs b/packages/index-auth-keypair/src/lib.rs new file mode 100644 index 000000000..f5eda4d41 --- /dev/null +++ b/packages/index-auth-keypair/src/lib.rs @@ -0,0 +1,39 @@ +//! RSA-2048 key pair generator for Torrust Index JWT authentication. +//! +//! Outputs a JSON object with `private_key_pem` and `public_key_pem` +//! fields to stdout (P9). Diagnostics go to stderr via `tracing`. + +use rsa::RsaPrivateKey; +use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; +use serde::{Deserialize, Serialize}; + +#[cfg(test)] +mod tests; + +#[derive(Serialize, Deserialize)] +pub struct KeypairOutput { + pub private_key_pem: String, + pub public_key_pem: String, +} + +/// # Errors +/// +/// Returns an error string if RSA key generation or PEM export fails. +pub fn generate_keypair() -> Result { + let mut rng = rsa::rand_core::OsRng; + let private_key = RsaPrivateKey::new(&mut rng, 2048).map_err(|e| format!("RSA key generation failed: {e}"))?; + + let private_pem = private_key + .to_pkcs8_pem(LineEnding::LF) + .map_err(|e| format!("private key PEM export failed: {e}"))?; + + let public_pem = private_key + .to_public_key() + .to_public_key_pem(LineEnding::LF) + .map_err(|e| format!("public key PEM export failed: {e}"))?; + + Ok(KeypairOutput { + private_key_pem: private_pem.to_string(), + public_key_pem: public_pem, + }) +} diff --git a/packages/index-auth-keypair/src/tests/mod.rs b/packages/index-auth-keypair/src/tests/mod.rs new file mode 100644 index 000000000..95bf43d55 --- /dev/null +++ b/packages/index-auth-keypair/src/tests/mod.rs @@ -0,0 +1,30 @@ +//! # Auth-keypair tests +//! +//! | Test | What it covers | +//! |---------------------------------------|-----------------------------------------| +//! | `generated_json_round_trips` | JSON output deserialises back | +//! | `private_pem_parses` | Private key PEM is valid PKCS#8 | +//! | `public_pem_parses` | Public key PEM is valid SPKI | + +use rsa::pkcs8::{DecodePrivateKey, DecodePublicKey}; + +#[test] +fn generated_json_round_trips() { + let output = super::generate_keypair().unwrap(); + let json = serde_json::to_string(&output).unwrap(); + let parsed: super::KeypairOutput = serde_json::from_str(&json).unwrap(); + assert!(!parsed.private_key_pem.is_empty()); + assert!(!parsed.public_key_pem.is_empty()); +} + +#[test] +fn private_pem_parses() { + let output = super::generate_keypair().unwrap(); + rsa::RsaPrivateKey::from_pkcs8_pem(&output.private_key_pem).expect("private key PEM should parse"); +} + +#[test] +fn public_pem_parses() { + let output = super::generate_keypair().unwrap(); + rsa::RsaPublicKey::from_public_key_pem(&output.public_key_pem).expect("public key PEM should parse"); +} diff --git a/packages/index-auth-keypair/tests/keypair_generation.rs b/packages/index-auth-keypair/tests/keypair_generation.rs new file mode 100644 index 000000000..d1ddeaf4a --- /dev/null +++ b/packages/index-auth-keypair/tests/keypair_generation.rs @@ -0,0 +1,42 @@ +//! # Auth-keypair integration tests +//! +//! | Test | What it covers | +//! |-----------------------------------------|-------------------------------------------| +//! | `generated_keypair_round_trips_as_json` | JSON output deserialises back correctly | +//! | `output_contains_valid_pem_keys` | PEM keys parse as RSA PKCS#8 / SPKI | +//! | `successive_calls_produce_distinct_keys` | No hardcoded/cached key material | + +use rsa::pkcs8::{DecodePrivateKey, DecodePublicKey}; +use torrust_index_auth_keypair::{KeypairOutput, generate_keypair}; + +#[test] +fn generated_keypair_round_trips_as_json() { + let output = generate_keypair().unwrap(); + let json = serde_json::to_string(&output).unwrap(); + let parsed: KeypairOutput = serde_json::from_str(&json).unwrap(); + assert!(!parsed.private_key_pem.is_empty()); + assert!(!parsed.public_key_pem.is_empty()); +} + +#[test] +fn output_contains_valid_pem_keys() { + let output = generate_keypair().unwrap(); + + rsa::RsaPrivateKey::from_pkcs8_pem(&output.private_key_pem).expect("private_key_pem should be valid PKCS#8"); + rsa::RsaPublicKey::from_public_key_pem(&output.public_key_pem).expect("public_key_pem should be valid SPKI"); +} + +#[test] +fn successive_calls_produce_distinct_keys() { + let kp1 = generate_keypair().unwrap(); + let kp2 = generate_keypair().unwrap(); + + assert_ne!( + kp1.private_key_pem, kp2.private_key_pem, + "private keys should differ between calls" + ); + assert_ne!( + kp1.public_key_pem, kp2.public_key_pem, + "public keys should differ between calls" + ); +} diff --git a/packages/index-cli-common/Cargo.toml b/packages/index-cli-common/Cargo.toml new file mode 100644 index 000000000..ebe64ad59 --- /dev/null +++ b/packages/index-cli-common/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "torrust-index-cli-common" + +authors.workspace = true +description = "Shared CLI scaffolding for Torrust Index helper binaries" +edition.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0" +tracing-subscriber = { version = "0", features = ["json"] } + +[lints] +workspace = true diff --git a/packages/index-cli-common/src/lib.rs b/packages/index-cli-common/src/lib.rs new file mode 100644 index 000000000..298022c8e --- /dev/null +++ b/packages/index-cli-common/src/lib.rs @@ -0,0 +1,55 @@ +//! Shared CLI scaffolding for Torrust Index helper binaries (P9). +//! +//! Every helper binary uses this crate for: +//! - TTY refusal (P8) +//! - JSON tracing initialisation on stderr +//! - JSON output on stdout + +use std::io::{self, IsTerminal, Write}; + +/// Refuse to run if stdout is a terminal (P8). +/// +/// Emits a `tracing::error!` event (NDJSON on stderr, per P9) +/// and exits with code 2. Call this **after** [`init_json_tracing`] +/// so the diagnostic is structured rather than a bare `eprintln!`. +pub fn refuse_if_stdout_is_tty(binary_name: &str) { + if io::stdout().is_terminal() { + tracing::error!( + binary = binary_name, + "stdout is a terminal \u{2014} pipe to a file or another process" + ); + std::process::exit(2); + } +} + +/// Initialise `tracing-subscriber` with JSON output on stderr. +pub fn init_json_tracing(level: tracing::Level) { + tracing_subscriber::fmt() + .json() + .with_max_level(level) + .with_writer(io::stderr) + .init(); +} + +/// Serialise `value` as one JSON object + trailing newline to stdout. +/// +/// # Errors +/// +/// Returns an error if serialisation or writing to stdout fails. +pub fn emit(value: &T) -> io::Result<()> { + let json = serde_json::to_string(value)?; + let stdout = io::stdout(); + let mut out = stdout.lock(); + out.write_all(json.as_bytes())?; + out.write_all(b"\n")?; + out.flush() +} + +/// Common `--debug` flag for all helpers. Flatten into each +/// binary's `Args` struct via `#[command(flatten)]`. +#[derive(clap::Args)] +pub struct BaseArgs { + /// Enable debug-level logging on stderr. + #[arg(long)] + pub debug: bool, +} diff --git a/packages/index-config-probe/Cargo.toml b/packages/index-config-probe/Cargo.toml new file mode 100644 index 000000000..ffad51baa --- /dev/null +++ b/packages/index-config-probe/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "torrust-index-config-probe" + +authors.workspace = true +description = "Resolves the Torrust Index configuration and emits the container-relevant subset as JSON" +edition.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +torrust-index-cli-common = { version = "4.0.0-develop", path = "../index-cli-common" } +torrust-index-config = { version = "4.0.0-develop", path = "../index-config" } + +clap = { version = "4", features = ["derive"] } +percent-encoding = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0" +url = "2" + +[lints] +workspace = true diff --git a/packages/index-config-probe/src/bin/torrust-index-config-probe.rs b/packages/index-config-probe/src/bin/torrust-index-config-probe.rs new file mode 100644 index 000000000..522f3a731 --- /dev/null +++ b/packages/index-config-probe/src/bin/torrust-index-config-probe.rs @@ -0,0 +1,79 @@ +//! Resolve the Torrust Index configuration and print the +//! container-relevant subset as a single JSON object on stdout. +//! +//! See ADR-T-009 §D3. + +use std::process::ExitCode; + +use clap::Parser; +use torrust_index_cli_common::{BaseArgs, emit, init_json_tracing, refuse_if_stdout_is_tty}; +use torrust_index_config::{DEFAULT_CONFIG_TOML_PATH, Info, load_settings}; +use torrust_index_config_probe::{ProbeError, probe}; +use tracing::error; + +#[derive(Parser)] +#[command( + name = "torrust-index-config-probe", + about = "Emit the container-relevant subset of the resolved Torrust Index configuration as JSON" +)] +struct Args { + #[command(flatten)] + base: BaseArgs, +} + +fn main() -> ExitCode { + install_panic_hook(); + + let args = Args::parse(); + refuse_if_stdout_is_tty("torrust-index-config-probe"); + init_json_tracing(if args.base.debug { + tracing::Level::DEBUG + } else { + tracing::Level::INFO + }); + + // `Info::from_env` is the JSON-safe sibling of `Info::new`: + // it reads the same env vars but skips the diagnostic + // `println!`s that would corrupt our stdout-only contract. + let info = Info::from_env(DEFAULT_CONFIG_TOML_PATH); + + let settings = match load_settings(&info) { + Ok(s) => s, + Err(e) => { + error!(error = %e, "failed to load configuration"); + return ExitCode::from(3); + } + }; + + match probe(&settings) { + Ok(out) => { + if let Err(e) = emit(&out) { + error!(error = %e, "failed to write JSON to stdout"); + return ExitCode::from(1); + } + ExitCode::SUCCESS + } + Err(ProbeError::EmptyTrackerToken) => { + error!("tracker.token is empty — refusing to start"); + ExitCode::from(4) + } + Err(ProbeError::UnsupportedScheme(s)) => { + error!(scheme = %s, "unsupported scheme"); + ExitCode::from(5) + } + } +} + +/// Map any unhandled panic to exit code 1 so the contract in +/// ADR-T-009 §D3 ("1 — Internal error (unhandled +/// panic, unexpected I/O)") is honoured. Without this hook a +/// panic would exit with Rust's default 101. +fn install_panic_hook() { + let default = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + // Preserve the default formatted backtrace on stderr, + // then exit with the documented code. + default(info); + std::process::exit(1); + })); +} diff --git a/packages/index-config-probe/src/lib.rs b/packages/index-config-probe/src/lib.rs new file mode 100644 index 000000000..86069d936 --- /dev/null +++ b/packages/index-config-probe/src/lib.rs @@ -0,0 +1,223 @@ +//! Resolves the Torrust Index configuration and emits the +//! container-relevant subset as JSON. +//! +//! The probe is a small bridge between the entry script (POSIX +//! shell, can't parse TOML or env-var overrides) and the +//! application's actual configuration loader. The shell pipes +//! the probe's stdout into `jq`; the future Rust entry binary +//! will deserialise the same JSON via `serde_json`. +//! +//! ADR-T-009 §D3 (config probe helper). + +use percent_encoding::percent_decode_str; +use serde::{Deserialize, Serialize}; +use torrust_index_config::{Auth, Settings, Tracker}; +use url::Url; + +#[cfg(test)] +mod tests; + +/// Schema version of the JSON output. Incremented on breaking +/// changes (per ADR-T-009 §D3). +pub const SCHEMA: u32 = 1; + +/// Container-relevant resolved configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Probe { + pub schema: u32, + pub database: DatabaseProbe, + pub auth: AuthProbe, +} + +/// Resolved database settings. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DatabaseProbe { + /// URL scheme extracted from `connect_url`. Not the + /// Containerfile's `TORRUST_INDEX_DATABASE_DRIVER` env var + /// (which uses `sqlite3`/`mysql`). + pub driver: Driver, + /// File path for `sqlite` URLs (absolute, relative, or + /// `:memory:`); `null` for non-`sqlite` schemes. + pub path: Option, +} + +/// The set of database URL schemes the entry script knows. +/// +/// Mirrors the schemes recognised by the application's own +/// `databases::database::get_driver` (`sqlite`, `mysql`). +/// Anything outside this set produces +/// [`ProbeError::UnsupportedScheme`] before this enum is ever +/// constructed, so the dispatch table is enforced at the type +/// level rather than via stringly-typed `match`es. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Driver { + Sqlite, + Mysql, +} + +/// Resolved auth-key settings for both the private and public +/// halves of the JWT keypair. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthProbe { + pub private_key: AuthKeyProbe, + pub public_key: AuthKeyProbe, +} + +/// Resolved settings for a single auth key. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthKeyProbe { + /// Raw presence (non-empty after resolution) of the inline + /// PEM field, before PEM-overrides-PATH precedence. + pub pem_set: bool, + /// Raw presence of the path field. + pub path_set: bool, + /// Winner after precedence: `pem`, `path`, or `none`. + pub source: AuthKeySource, + /// Resolved path if `source` is `path`; `null` otherwise. + pub path: Option, +} + +/// Which delivery mechanism wins the per-key precedence. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum AuthKeySource { + Pem, + Path, + None, +} + +/// Errors raised by the probe after the application's loader has +/// already returned a `Settings` value. +#[derive(Debug)] +pub enum ProbeError { + /// `tracker.token` deserialised to an empty string. The + /// application's `ApiToken::new` panics on empty input but + /// `#[derive(Deserialize)]` bypasses that guard, so we reject + /// at the container boundary instead. + EmptyTrackerToken, + /// `database.connect_url` uses a scheme the entry script does + /// not know how to dispatch on (anything other than `sqlite` + /// or `mysql`). + UnsupportedScheme(String), +} + +impl std::fmt::Display for ProbeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyTrackerToken => f.write_str("tracker.token is empty"), + Self::UnsupportedScheme(s) => write!(f, "unsupported scheme: {s}"), + } + } +} + +impl std::error::Error for ProbeError {} + +/// Resolve the container-relevant subset of `settings` into the +/// JSON-shaped [`Probe`] value. +/// +/// # Errors +/// +/// Returns [`ProbeError::EmptyTrackerToken`] if `tracker.token` +/// is empty after deserialisation, or +/// [`ProbeError::UnsupportedScheme`] if `database.connect_url`'s +/// scheme is not one of `sqlite` or `mysql`. +pub fn probe(settings: &Settings) -> Result { + check_tracker_token(&settings.tracker)?; + + Ok(Probe { + schema: SCHEMA, + database: probe_database(&settings.database.connect_url)?, + auth: probe_auth(&settings.auth), + }) +} + +const fn check_tracker_token(tracker: &Tracker) -> Result<(), ProbeError> { + if tracker.token.is_empty() { + return Err(ProbeError::EmptyTrackerToken); + } + Ok(()) +} + +fn probe_database(connect_url: &Url) -> Result { + let scheme = connect_url.scheme().to_ascii_lowercase(); + + let (driver, path) = match scheme.as_str() { + "sqlite" => (Driver::Sqlite, Some(extract_sqlite_path(connect_url))), + "mysql" => (Driver::Mysql, None), + other => return Err(ProbeError::UnsupportedScheme(other.to_string())), + }; + + Ok(DatabaseProbe { driver, path }) +} + +/// Extract the file path from a `sqlite://` (or `sqlite::memory:`) +/// URL. The shapes are spelled out in ADR-T-009 §D3. +fn extract_sqlite_path(url: &Url) -> String { + // Opaque form (e.g. `sqlite::memory:`) — `cannot_be_a_base` + // returns true and `path()` is the opaque body. + if url.cannot_be_a_base() { + return url.path().to_string(); + } + + // Authority form: `sqlite://data.db?mode=rwc` puts the + // relative file in the host slot (`url`'s parser does not + // recognise `sqlite` as a special scheme, so it accepts this + // shape but exposes `data.db` as the host string). + if let Some(host) = url.host_str() + && !host.is_empty() + { + return host.to_string(); + } + + // Hierarchical form: `sqlite:///var/lib/...` — empty + // authority, real path. Percent-decode so callers see + // `My Data` and not `My%20Data`. + // + // POSIX paths are byte strings, not text. We assume sqlite + // file paths in container deployments are valid UTF-8 (the + // common case for declarative compose stacks); a non-UTF-8 + // byte sequence would be replaced with U+FFFD here. If a + // future deployment needs raw-bytes fidelity, switch the + // wire format to a base64-encoded byte string and surface a + // schema bump. + let raw_path = url.path(); + percent_decode_str(raw_path).decode_utf8_lossy().into_owned() +} + +fn probe_auth(auth: &Auth) -> AuthProbe { + AuthProbe { + private_key: probe_auth_key(auth.private_key_pem.as_deref(), auth.private_key_path.as_deref()), + public_key: probe_auth_key(auth.public_key_pem.as_deref(), auth.public_key_path.as_deref()), + } +} + +/// Resolve a single auth-key pair into the wire-format +/// [`AuthKeyProbe`]. +/// +/// **Empty-string-equals-absent semantics are defined here**, +/// not in the config crate's deserialiser: `Auth` stores the +/// fields as `Option` and accepts the empty string at +/// the type level. The probe collapses both `None` *and* +/// `Some("")` into `*_set = false` because a bare `${VAR}` in a +/// compose file that substitutes to an empty value is +/// indistinguishable from "unset" at the container boundary. +fn probe_auth_key(pem: Option<&str>, path: Option<&str>) -> AuthKeyProbe { + let pem_set = pem.is_some_and(|s| !s.is_empty()); + let path_set = path.is_some_and(|s| !s.is_empty()); + + let (source, resolved_path) = if pem_set { + (AuthKeySource::Pem, None) + } else if path_set { + (AuthKeySource::Path, path.map(str::to_string)) + } else { + (AuthKeySource::None, None) + }; + + AuthKeyProbe { + pem_set, + path_set, + source, + path: resolved_path, + } +} diff --git a/packages/index-config-probe/src/tests/mod.rs b/packages/index-config-probe/src/tests/mod.rs new file mode 100644 index 000000000..a47955024 --- /dev/null +++ b/packages/index-config-probe/src/tests/mod.rs @@ -0,0 +1,209 @@ +//! # Config-probe unit tests +//! +//! | Test | What it covers | +//! |---------------------------------------------------|--------------------------------------------------| +//! | `sqlite_relative_url_yields_relative_path` | `sqlite://data.db?mode=rwc` → `data.db` | +//! | `sqlite_absolute_url_yields_absolute_path` | `sqlite:///var/...` → `/var/...` | +//! | `sqlite_memory_url_yields_memory_marker` | `sqlite::memory:` → `:memory:` | +//! | `sqlite_percent_encoded_path_is_decoded` | `%20` → space | +//! | `mysql_url_yields_null_path` | mysql → `null` path | +//! | `mariadb_url_is_unsupported` | mariadb is not a supported backend (exit 5) | +//! | `postgres_url_is_unsupported` | exit-5 condition | +//! | `auth_pem_only_resolves_to_pem` | PEM beats absent path | +//! | `auth_path_only_resolves_to_path` | path-only branch | +//! | `auth_neither_resolves_to_none` | nothing configured | +//! | `auth_both_pem_and_path_pem_wins` | precedence per D3 raw-presence reporting | +//! | `empty_tracker_token_is_rejected` | exit-4 condition | +//! | `probe_round_trips_as_json` | JSON output deserialises back | +//! | `auth_key_source_serialises_lowercase` | enum wire format is stable | +//! | `driver_serialises_lowercase` | `Driver` enum wire format is stable | +//! | `placeholder_settings_yield_known_shape` | end-to-end shape via `settings_with_database` | + +use serde_json::json; +use torrust_index_config::test_helpers::placeholder_settings; +use torrust_index_config::{Info, load_settings}; +use url::Url; + +use crate::{AuthKeySource, AuthProbe, DatabaseProbe, Driver, Probe, ProbeError, SCHEMA, probe, probe_database}; + +fn settings_with_database(connect_url: &str) -> torrust_index_config::Settings { + let toml = format!( + r#" +[metadata] +schema_version = "2.0.0" + +[logging] +threshold = "info" + +[tracker] +token = "MyAccessToken" + +[database] +connect_url = "{connect_url}" +"# + ); + load_settings(&Info::from_toml(&toml)).expect("test TOML must load") +} + +#[test] +fn sqlite_relative_url_yields_relative_path() { + let url = Url::parse("sqlite://data.db?mode=rwc").unwrap(); + let out = probe_database(&url).unwrap(); + assert_eq!(out.driver, Driver::Sqlite); + assert_eq!(out.path.as_deref(), Some("data.db")); +} + +#[test] +fn sqlite_absolute_url_yields_absolute_path() { + let url = Url::parse("sqlite:///var/lib/torrust/index.db").unwrap(); + let out = probe_database(&url).unwrap(); + assert_eq!(out.driver, Driver::Sqlite); + assert_eq!(out.path.as_deref(), Some("/var/lib/torrust/index.db")); +} + +#[test] +fn sqlite_memory_url_yields_memory_marker() { + let url = Url::parse("sqlite::memory:").unwrap(); + let out = probe_database(&url).unwrap(); + assert_eq!(out.driver, Driver::Sqlite); + assert_eq!(out.path.as_deref(), Some(":memory:")); +} + +#[test] +fn sqlite_percent_encoded_path_is_decoded() { + let url = Url::parse("sqlite:///srv/My%20Data/x.db").unwrap(); + let out = probe_database(&url).unwrap(); + assert_eq!(out.path.as_deref(), Some("/srv/My Data/x.db")); +} + +#[test] +fn mysql_url_yields_null_path() { + let url = Url::parse("mysql://user:pass@host:3306/db").unwrap(); + let out = probe_database(&url).unwrap(); + assert_eq!(out.driver, Driver::Mysql); + assert_eq!(out.path, None); +} + +#[test] +fn mariadb_url_is_unsupported() { + // The application's own `databases::database::get_driver` + // does not recognise `mariadb` (only `sqlite` / `mysql`), + // so the probe must reject it at the container boundary + // rather than emit a driver value the entry script could + // not dispatch on. + let url = Url::parse("mariadb://user:pass@host:3306/db").unwrap(); + match probe_database(&url) { + Err(ProbeError::UnsupportedScheme(s)) => assert_eq!(s, "mariadb"), + other => panic!("expected UnsupportedScheme, got {other:?}"), + } +} + +#[test] +fn postgres_url_is_unsupported() { + let url = Url::parse("postgres://user@host/db").unwrap(); + match probe_database(&url) { + Err(ProbeError::UnsupportedScheme(s)) => assert_eq!(s, "postgres"), + other => panic!("expected UnsupportedScheme, got {other:?}"), + } +} + +#[test] +fn auth_pem_only_resolves_to_pem() { + let key = super::probe_auth_key(Some("-----BEGIN PRIVATE KEY-----\n..."), None); + assert!(key.pem_set); + assert!(!key.path_set); + assert_eq!(key.source, AuthKeySource::Pem); + assert_eq!(key.path, None); +} + +#[test] +fn auth_path_only_resolves_to_path() { + let key = super::probe_auth_key(None, Some("/etc/torrust/index/auth/private.pem")); + assert!(!key.pem_set); + assert!(key.path_set); + assert_eq!(key.source, AuthKeySource::Path); + assert_eq!(key.path.as_deref(), Some("/etc/torrust/index/auth/private.pem")); +} + +#[test] +fn auth_neither_resolves_to_none() { + let key = super::probe_auth_key(None, None); + assert!(!key.pem_set); + assert!(!key.path_set); + assert_eq!(key.source, AuthKeySource::None); + assert_eq!(key.path, None); +} + +#[test] +fn auth_both_pem_and_path_pem_wins() { + let key = super::probe_auth_key(Some("PEM"), Some("/some/path")); + assert!(key.pem_set); + assert!(key.path_set); + assert_eq!(key.source, AuthKeySource::Pem); + assert_eq!(key.path, None, "PEM-wins precedence yields null path"); +} + +#[test] +fn empty_tracker_token_is_rejected() { + // Use a TOML that bypasses ApiToken::new (which would assert). + let toml = r#" +[metadata] +schema_version = "2.0.0" + +[logging] +threshold = "info" + +[tracker] +token = "" + +[database] +connect_url = "sqlite://data.db?mode=rwc" +"#; + let settings = load_settings(&Info::from_toml(toml)).expect("empty token must still parse"); + match probe(&settings) { + Err(ProbeError::EmptyTrackerToken) => {} + other => panic!("expected EmptyTrackerToken, got {other:?}"), + } +} + +#[test] +fn probe_round_trips_as_json() { + let settings = placeholder_settings(); + let out = probe(&settings).unwrap(); + let json = serde_json::to_string(&out).unwrap(); + let parsed: Probe = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.schema, SCHEMA); + assert_eq!(parsed, out); +} + +#[test] +fn auth_key_source_serialises_lowercase() { + let auth = AuthProbe { + private_key: super::probe_auth_key(Some("PEM"), None), + public_key: super::probe_auth_key(None, Some("/p")), + }; + let value = serde_json::to_value(&auth).unwrap(); + assert_eq!(value["private_key"]["source"], json!("pem")); + assert_eq!(value["public_key"]["source"], json!("path")); +} + +#[test] +fn driver_serialises_lowercase() { + assert_eq!(serde_json::to_value(Driver::Sqlite).unwrap(), json!("sqlite")); + assert_eq!(serde_json::to_value(Driver::Mysql).unwrap(), json!("mysql")); +} + +#[test] +fn placeholder_settings_yield_known_shape() { + let settings = settings_with_database("sqlite://data.db?mode=rwc"); + let out = probe(&settings).unwrap(); + assert_eq!( + out.database, + DatabaseProbe { + driver: Driver::Sqlite, + path: Some("data.db".into()), + } + ); + assert_eq!(out.auth.private_key.source, AuthKeySource::None); + assert_eq!(out.auth.public_key.source, AuthKeySource::None); +} diff --git a/packages/index-config-probe/tests/binary.rs b/packages/index-config-probe/tests/binary.rs new file mode 100644 index 000000000..ffcf60430 --- /dev/null +++ b/packages/index-config-probe/tests/binary.rs @@ -0,0 +1,149 @@ +//! # Config-probe binary contract tests +//! +//! These tests pin the *binary's* exit-code and stdout-shape +//! contract from §6.1 by driving the underlying library +//! functions directly — the same pattern as +//! `packages/index-config/tests/shipped_samples.rs`. +//! +//! Why not spawn the binary? `CARGO_BIN_EXE_*` is resolved at +//! compile time and bakes the build-time path into the test +//! binary. Under cargo-nextest's archive → `--extract-to` → +//! `--target-dir-remap` workflow used by the container `test` +//! stage, that baked path no longer exists at run time, so a +//! subprocess test fails with `ENOENT` even though the +//! contract under test is intact. Driving the same code paths +//! through the library makes the tests independent of the +//! binary's on-disk location and equivalent in coverage: the +//! exit-code mapping and the stdout JSON shape are both pure +//! functions of the loader / probe / serialiser results. +//! +//! ## Index +//! +//! | Test | What it covers | +//! |---------------------------------------------|---------------------------------------------| +//! | `valid_toml_yields_emittable_probe` | Happy path: probe → JSON round-trips | +//! | `missing_connect_url_maps_to_exit_3` | Loader-failure path (binary returns 3) | +//! | `empty_tracker_token_maps_to_exit_4` | `ProbeError::EmptyTrackerToken` → exit 4 | +//! | `unsupported_scheme_maps_to_exit_5` | `ProbeError::UnsupportedScheme` → exit 5 | +//! | `unknown_flag_maps_to_clap_exit_2` | Pins clap's argv-parse exit code (§6.1) | + +use clap::Parser; +use torrust_index_cli_common::BaseArgs; +use torrust_index_config::{Info, load_settings}; +use torrust_index_config_probe::{Driver, Probe, ProbeError, probe}; + +const VALID_TOML: &str = r#" +[metadata] +schema_version = "2.0.0" + +[logging] +threshold = "info" + +[tracker] +token = "MyAccessToken" + +[database] +connect_url = "sqlite://data.db?mode=rwc" +"#; + +const MISSING_CONNECT_URL_TOML: &str = r#" +[metadata] +schema_version = "2.0.0" + +[logging] +threshold = "info" + +[tracker] +token = "MyAccessToken" +"#; + +const EMPTY_TRACKER_TOKEN_TOML: &str = r#" +[metadata] +schema_version = "2.0.0" + +[logging] +threshold = "info" + +[tracker] +token = "" + +[database] +connect_url = "sqlite://data.db?mode=rwc" +"#; + +const POSTGRES_SCHEME_TOML: &str = r#" +[metadata] +schema_version = "2.0.0" + +[logging] +threshold = "info" + +[tracker] +token = "MyAccessToken" + +[database] +connect_url = "postgres://user@host/db" +"#; + +#[test] +fn valid_toml_yields_emittable_probe() { + let settings = load_settings(&Info::from_toml(VALID_TOML)).expect("valid TOML must load"); + let out = probe(&settings).expect("probe must succeed"); + + // Mirror the binary's `emit` step: serialise to a single + // JSON object terminated by a newline, then round-trip. + let mut json = serde_json::to_string(&out).expect("probe must serialise"); + json.push('\n'); + assert!(json.ends_with('\n'), "emitted JSON must end with a newline"); + + let parsed: Probe = serde_json::from_str(json.trim_end()).expect("emitted JSON must round-trip"); + assert_eq!(parsed.schema, torrust_index_config_probe::SCHEMA); + assert_eq!(parsed.database.driver, Driver::Sqlite); +} + +#[test] +fn missing_connect_url_maps_to_exit_3() { + // Binary maps any `load_settings` failure to exit 3. + let result = load_settings(&Info::from_toml(MISSING_CONNECT_URL_TOML)); + assert!(result.is_err(), "missing `database.connect_url` must fail the loader"); +} + +#[test] +fn empty_tracker_token_maps_to_exit_4() { + // Binary maps `ProbeError::EmptyTrackerToken` to exit 4. + let settings = + load_settings(&Info::from_toml(EMPTY_TRACKER_TOKEN_TOML)).expect("loader accepts empty token; probe is the gate"); + match probe(&settings) { + Err(ProbeError::EmptyTrackerToken) => {} + other => panic!("expected EmptyTrackerToken, got {other:?}"), + } +} + +#[test] +fn unsupported_scheme_maps_to_exit_5() { + // Binary maps `ProbeError::UnsupportedScheme` to exit 5. + let settings = load_settings(&Info::from_toml(POSTGRES_SCHEME_TOML)).expect("loader accepts any URL; probe is the gate"); + match probe(&settings) { + Err(ProbeError::UnsupportedScheme(scheme)) => assert_eq!(scheme, "postgres"), + other => panic!("expected UnsupportedScheme(\"postgres\"), got {other:?}"), + } +} + +#[test] +fn unknown_flag_maps_to_clap_exit_2() { + // §6.1 lists exit code 2 for "clap argv-parse failure". + // Mirror the binary's `Args` so this test pins clap's + // contract without spawning a subprocess. + #[derive(Parser)] + #[command(name = "torrust-index-config-probe")] + struct Args { + #[command(flatten)] + #[allow(dead_code)] + base: BaseArgs, + } + + let Err(err) = Args::try_parse_from(["torrust-index-config-probe", "--this-flag-does-not-exist"]) else { + panic!("unknown flag must be rejected"); + }; + assert_eq!(err.exit_code(), 2, "clap's argv-parse failure exit code is 2"); +} diff --git a/packages/index-config/Cargo.toml b/packages/index-config/Cargo.toml new file mode 100644 index 000000000..fda23a874 --- /dev/null +++ b/packages/index-config/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "torrust-index-config" + +authors.workspace = true +description = "Configuration schema and loader for the Torrust Index" +edition.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +camino = { version = "1", features = ["serde"] } +derive_more = { version = "2", features = ["display"] } +figment = { version = "0", default-features = false, features = ["env", "toml"] } +lettre = { version = "0", default-features = false, features = ["builder", "serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_with = "3" +thiserror = "2" +toml = "1" +tracing = "0" +url = { version = "2", features = ["serde"] } + +[lints] +workspace = true diff --git a/packages/index-config/src/lib.rs b/packages/index-config/src/lib.rs new file mode 100644 index 000000000..ae7fbc49d --- /dev/null +++ b/packages/index-config/src/lib.rs @@ -0,0 +1,386 @@ +//! Configuration schema and loader for the Torrust Index. +//! +//! This crate owns the *parsing* surface of the index's +//! configuration. The runtime `Configuration` wrapper that holds +//! settings under a `tokio::sync::RwLock` lives in the root crate +//! alongside the application. +//! +//! Extracted from `src/config/` per ADR-T-009 phase 3. +pub mod permissions; +pub mod v2; +pub mod validator; + +#[doc(hidden)] +pub mod test_helpers; + +#[cfg(test)] +mod tests; + +use std::env; +use std::sync::Arc; + +use camino::Utf8PathBuf; +use derive_more::Display; +use figment::Figment; +use figment::providers::{Env, Format, Toml}; +use serde::{Deserialize, Serialize}; +use serde_with::{NoneAsEmptyString, serde_as}; +use thiserror::Error; + +/// Type-erased boxed error used by the configuration loader's +/// error variants. +/// +/// Re-exported by the root crate as `crate::web::api::server::DynError` +/// for backwards compatibility. +pub type DynError = Arc; + +pub type Settings = v2::Settings; + +pub type Api = v2::api::Api; + +pub type Registration = v2::registration::Registration; +pub type Email = v2::registration::Email; + +pub type Auth = v2::auth::Auth; +pub type PasswordConstraints = v2::auth::PasswordConstraints; + +pub type Database = v2::database::Database; + +pub type ImageCache = v2::image_cache::ImageCache; + +pub type Mail = v2::mail::Mail; +pub type Smtp = v2::mail::Smtp; +pub type Credentials = v2::mail::Credentials; + +pub type Network = v2::net::Network; + +pub type TrackerStatisticsImporter = v2::tracker_statistics_importer::TrackerStatisticsImporter; + +pub type Tracker = v2::tracker::Tracker; +pub type ApiToken = v2::tracker::ApiToken; + +pub type Logging = v2::logging::Logging; +pub type Threshold = v2::logging::Threshold; + +pub type Website = v2::website::Website; +pub type Demo = v2::website::Demo; +pub type Terms = v2::website::Terms; +pub type TermsPage = v2::website::TermsPage; +pub type TermsUpload = v2::website::TermsUpload; +pub type Markdown = v2::website::Markdown; + +/// Prefix for env vars that overwrite configuration options. +const CONFIG_OVERRIDE_PREFIX: &str = "TORRUST_INDEX_CONFIG_OVERRIDE_"; + +/// Path separator in env var names for nested values in configuration. +const CONFIG_OVERRIDE_SEPARATOR: &str = "__"; + +/// The whole `index.toml` file content. It has priority over the config file. +/// Even if the file is not on the default path. +pub const ENV_VAR_CONFIG_TOML: &str = "TORRUST_INDEX_CONFIG_TOML"; + +/// The `index.toml` file location. +pub const ENV_VAR_CONFIG_TOML_PATH: &str = "TORRUST_INDEX_CONFIG_TOML_PATH"; + +/// Default path for the configuration TOML when neither +/// [`ENV_VAR_CONFIG_TOML`] nor [`ENV_VAR_CONFIG_TOML_PATH`] is set. +/// +/// Both the application bootstrap and helper binaries (e.g. +/// `torrust-index-config-probe`) refer to this constant so the +/// default cannot drift between call sites. +pub const DEFAULT_CONFIG_TOML_PATH: &str = "./share/default/config/index.development.sqlite3.toml"; + +/// The latest (and currently only) supported configuration schema version. +/// +/// `load_settings` rejects any parsed `Settings` whose +/// `metadata.schema_version` is not equal to this constant. +pub const LATEST_VERSION: &str = "2.0.0"; + +/// Info about the configuration specification. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)] +#[display("Metadata(app: {app}, purpose: {purpose}, schema_version: {schema_version})")] +pub struct Metadata { + /// The application this configuration is valid for. + #[serde(default = "Metadata::default_app")] + app: App, + + /// The purpose of this parsed file. + #[serde(default = "Metadata::default_purpose")] + purpose: Purpose, + + /// The schema version for the configuration. + #[serde(default = "Metadata::default_schema_version")] + #[serde(flatten)] + schema_version: Version, +} + +impl Default for Metadata { + fn default() -> Self { + Self { + app: Self::default_app(), + purpose: Self::default_purpose(), + schema_version: Self::default_schema_version(), + } + } +} + +impl Metadata { + const fn default_app() -> App { + App::TorrustIndex + } + + const fn default_purpose() -> Purpose { + Purpose::Configuration + } + + fn default_schema_version() -> Version { + Version::latest() + } +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)] +#[serde(rename_all = "kebab-case")] +pub enum App { + TorrustIndex, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)] +#[serde(rename_all = "lowercase")] +pub enum Purpose { + Configuration, +} + +/// The configuration version. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)] +#[serde(rename_all = "lowercase")] +pub struct Version { + #[serde(default = "Version::default_semver")] + schema_version: String, +} + +impl Default for Version { + fn default() -> Self { + Self { + schema_version: Self::default_semver(), + } + } +} + +impl Version { + fn new(semver: &str) -> Self { + Self { + schema_version: semver.to_owned(), + } + } + + fn latest() -> Self { + Self { + schema_version: LATEST_VERSION.to_string(), + } + } + + fn default_semver() -> String { + LATEST_VERSION.to_string() + } +} + +/// Information required for loading config +#[derive(Debug, Default, Clone)] +pub struct Info { + pub config_toml: Option, + pub config_toml_path: String, +} + +impl Info { + /// Build configuration Info. + /// + /// # Errors + /// + /// Will return `Err` if unable to obtain a configuration. + /// + #[allow(clippy::needless_pass_by_value)] + pub fn new(default_config_toml_path: String) -> Result { + let info = Self::from_env(&default_config_toml_path); + + if info.config_toml.is_some() { + // The TOML body may contain secrets (DB connect URLs, API + // tokens, SMTP passwords, …) so log only the env-var name + // — never its value — and route through `tracing` (stderr) + // so we don't pollute the JSON-only stdout contract used + // by helper binaries (P9). + tracing::info!( + env_var = ENV_VAR_CONFIG_TOML, + "loading extra configuration from environment variable" + ); + } + + if env::var(ENV_VAR_CONFIG_TOML_PATH).ok().is_some_and(|s| !s.is_empty()) { + tracing::info!(path = %info.config_toml_path, "loading extra configuration from file"); + } else { + tracing::info!( + path = %info.config_toml_path, + "loading extra configuration from default configuration file" + ); + } + + Ok(info) + } + + /// Build [`Info`] from the same env vars [`Self::new`] reads, + /// without the diagnostic `println!`s. + /// + /// Helper binaries that own a JSON-only stdout contract (P9) + /// must use this constructor instead of [`Self::new`] to avoid + /// corrupting their output stream. + #[must_use] + pub fn from_env(default_config_toml_path: &str) -> Self { + // Treat an empty value as unset so callers (e.g. `docker compose`/`podman-compose`) + // can safely forward `KEY=${HOST_VAR}` without clobbering the file-based config + // when `HOST_VAR` is not exported. + let config_toml = env::var(ENV_VAR_CONFIG_TOML).ok().filter(|s| !s.is_empty()); + let config_toml_path = env::var(ENV_VAR_CONFIG_TOML_PATH) + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| default_config_toml_path.to_string()); + + Self { + config_toml, + config_toml_path, + } + } + + #[must_use] + pub fn from_toml(config_toml: &str) -> Self { + Self { + config_toml: Some(config_toml.to_owned()), + config_toml_path: String::new(), + } + } +} + +/// Errors that can occur when loading the configuration. +#[derive(Error, Debug)] +pub enum Error { + /// Unable to load the configuration from the environment variable. + /// This error only occurs if there is no configuration file and the + /// `TORRUST_INDEX_CONFIG_TOML` environment variable is not set. + #[error("Unable to load from Environmental Variable: {source}")] + UnableToLoadFromEnvironmentVariable { source: DynError }, + + #[error("Unable to load from Config File: {source}")] + UnableToLoadFromConfigFile { source: DynError }, + + /// Unable to load the configuration from the configuration file. + #[error("Failed processing the configuration: {source}")] + ConfigError { source: DynError }, + + #[error("The error for errors that can never happen.")] + Infallible, + + #[error("Unsupported configuration version: {version}")] + UnsupportedVersion { version: Version }, + + #[error("Missing mandatory configuration option. Option path: {path}")] + MissingMandatoryOption { path: String }, +} + +impl From for Error { + fn from(err: figment::Error) -> Self { + tracing::error!(%err, "Failed processing the configuration"); + Self::ConfigError { source: Arc::new(err) } + } +} + +/// Port number representing that the OS will choose one randomly from the available ports. +/// +/// It's the port number `0` +pub const FREE_PORT: u16 = 0; + +/// TLS configuration for the HTTPS endpoint. +/// +/// Both fields default to `None` (treated as "not configured") when +/// absent **or** when given as an empty string. An incomplete +/// `[net.tls]` table — for example one missing `ssl_key_path` — therefore +/// behaves identically to `[net.tls]` being absent altogether, rather +/// than being silently coerced into a `Some("")` that would later fail +/// at TLS load time with a misleading "no such file" error. +#[serde_as] +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default)] +pub struct Tls { + /// Path to the SSL certificate file. + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub ssl_cert_path: Option, + /// Path to the SSL key file. + #[serde_as(as = "NoneAsEmptyString")] + #[serde(default)] + pub ssl_key_path: Option, +} + +/// Loads the settings from the [`Info`] struct. +/// +/// The whole configuration in TOML format is included in +/// `info.config_toml` (and overrides the file path when set). +/// Configuration provided via env var takes priority over the +/// configuration file path. +/// +/// # Errors +/// +/// Will return `Err` if a mandatory option is missing, the schema +/// version is not supported, or the underlying figment extraction +/// fails. +pub fn load_settings(info: &Info) -> Result { + // Load configuration provided by the user, prioritizing env vars + let figment = info.config_toml.as_ref().map_or_else( + || { + Figment::from(Toml::file(&info.config_toml_path)) + .merge(Env::prefixed(CONFIG_OVERRIDE_PREFIX).split(CONFIG_OVERRIDE_SEPARATOR)) + }, + |config_toml| { + // Config in env var has priority over config file path + Figment::from(Toml::string(config_toml)).merge(Env::prefixed(CONFIG_OVERRIDE_PREFIX).split(CONFIG_OVERRIDE_SEPARATOR)) + }, + ); + + // Make sure user has provided the mandatory options. + check_mandatory_options(&figment)?; + + // Build final configuration. Per-field `#[serde(default = "...")]` + // attributes fill in defaults for absent optional sections; the + // mandatory `tracker.token` and `database.connect_url` (and the + // enclosing `[tracker]` / `[database]` sections themselves) carry + // no schema-level default, so an absent value fails here with a + // precise serde `missing field` error (ADR-T-009 §D2). + let settings: Settings = figment.extract()?; + + if settings.metadata.schema_version != Version::new(LATEST_VERSION) { + return Err(Error::UnsupportedVersion { + version: settings.metadata.schema_version, + }); + } + + Ok(settings) +} + +/// Some configuration options are mandatory. The application will +/// refuse to start if any is only obtained via the in-code default. +/// +/// # Errors +/// +/// Will return an error if a mandatory configuration option is only +/// obtained by default value, meaning the user hasn't overridden it. +fn check_mandatory_options(figment: &Figment) -> Result<(), Error> { + let mandatory_options = ["logging.threshold", "metadata.schema_version"]; + + for mandatory_option in mandatory_options { + let found = figment.find_value(mandatory_option).is_ok(); + + if !found { + return Err(Error::MissingMandatoryOption { + path: mandatory_option.to_owned(), + }); + } + } + + Ok(()) +} diff --git a/packages/index-config/src/permissions.rs b/packages/index-config/src/permissions.rs new file mode 100644 index 000000000..384504e67 --- /dev/null +++ b/packages/index-config/src/permissions.rs @@ -0,0 +1,162 @@ +//! Permission-related value types shared between the +//! configuration schema and the authorization service. +//! +//! Per ADR-T-009 phase 3 these *value* types live in the config +//! crate so that both `[[permissions.overrides]]` deserialisation +//! and the runtime authorization matrix can reference them +//! without the config crate depending on the service layer. +//! +//! The `PermissionMatrix` and `Permissions` *trait* (the runtime +//! policy) remain in the root crate's `services::authorization` +//! module, which re-exports the types defined here for backwards +//! compatibility with existing call sites. +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +// ── Role ───────────────────────────────────────────────────────────── + +/// User privilege level. +/// +/// Stored as a lowercase string in the `torrust_users.role` column. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + Guest, + Registered, + Moderator, + Admin, +} + +impl Role { + /// All variants (compile-time safe — see tests). + pub const ALL: &[Self] = &[Self::Guest, Self::Registered, Self::Moderator, Self::Admin]; +} + +impl fmt::Display for Role { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Guest => "guest", + Self::Registered => "registered", + Self::Moderator => "moderator", + Self::Admin => "admin", + }; + write!(f, "{s}") + } +} + +impl FromStr for Role { + type Err = RoleParseError; + + fn from_str(s: &str) -> Result { + match s { + "guest" => Ok(Self::Guest), + "registered" => Ok(Self::Registered), + "moderator" => Ok(Self::Moderator), + "admin" => Ok(Self::Admin), + _ => Err(RoleParseError(s.to_owned())), + } + } +} + +/// Error returned when a string cannot be parsed into a [`Role`]. +#[derive(Debug, Clone)] +pub struct RoleParseError(pub String); + +impl fmt::Display for RoleParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "unknown role: {:?}", self.0) + } +} + +impl std::error::Error for RoleParseError {} + +// ── Action ─────────────────────────────────────────────────────────── + +/// An operation that may be authorized. +/// +/// Adding a variant without updating the root-crate `PermissionMatrix`'s +/// `default_grant` method is a compile error (the exhaustive `match` has +/// no wildcard). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Action { + GetAboutPage, + GetLicensePage, + AddCategory, + DeleteCategory, + GetCategories, + GetImageByUrl, + GetSettingsSecret, + GetPublicSettings, + GetSiteName, + AddTag, + DeleteTag, + GetTags, + AddTorrent, + GetTorrent, + DeleteTorrent, + GetTorrentInfo, + GenerateTorrentInfoListing, + ChangePassword, + BanUser, + /// Render a user profile as a PNG image (admin-only). + GenerateUserProfileSpecification, + UpdateTorrent, + GetMyPermissions, +} + +impl Action { + /// Every variant in declaration order. + pub const ALL: &[Self] = &[ + Self::GetAboutPage, + Self::GetLicensePage, + Self::AddCategory, + Self::DeleteCategory, + Self::GetCategories, + Self::GetImageByUrl, + Self::GetSettingsSecret, + Self::GetPublicSettings, + Self::GetSiteName, + Self::AddTag, + Self::DeleteTag, + Self::GetTags, + Self::AddTorrent, + Self::GetTorrent, + Self::DeleteTorrent, + Self::GetTorrentInfo, + Self::GenerateTorrentInfoListing, + Self::ChangePassword, + Self::BanUser, + Self::GenerateUserProfileSpecification, + Self::UpdateTorrent, + Self::GetMyPermissions, + ]; +} + +impl fmt::Display for Action { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(self, f) + } +} + +// ── PermissionOverride ─────────────────────────────────────────────── + +/// Effect of a permission override. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Effect { + Allow, + Deny, +} + +/// A single operator-supplied permission override. +/// +/// Loaded from the `[[permissions.overrides]]` TOML array and applied +/// on top of the default matrix at startup. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PermissionOverride { + pub role: Role, + pub action: Action, + pub effect: Effect, +} diff --git a/packages/index-config/src/test_helpers.rs b/packages/index-config/src/test_helpers.rs new file mode 100644 index 000000000..c00ee02a4 --- /dev/null +++ b/packages/index-config/src/test_helpers.rs @@ -0,0 +1,42 @@ +//! Shared test fixtures for `Settings`. +//! +//! After ADR-T-009 §D2, `Settings` carries no `impl Default`: +//! `tracker.token` and `database.connect_url` are mandatory at the +//! schema level. This module owns the single canonical placeholder +//! TOML used wherever a "minimal but legal" baseline is needed — +//! by this crate's own tests, by the root crate's `#[cfg(test)]` +//! constructor, and by integration tests in either crate. +//! +//! The module is `#[doc(hidden)] pub` so it is reachable from +//! integration test binaries without appearing in the public docs. + +use crate::{Info, Settings, load_settings}; + +/// Minimum legal TOML — every mandatory option present, nothing +/// more. Mirrors the operator-supplied surface after ADR-T-009 §D2. +pub const PLACEHOLDER_TOML: &str = r#" +[metadata] +schema_version = "2.0.0" + +[logging] +threshold = "info" + +[tracker] +token = "MyAccessToken" + +[database] +connect_url = "sqlite://data.db?mode=rwc" +"#; + +/// A fully-populated `Settings` loaded from [`PLACEHOLDER_TOML`]. +/// +/// Replaces the previous `Settings::default()` test fixture. +/// +/// # Panics +/// +/// Panics if [`PLACEHOLDER_TOML`] ever stops loading — that would +/// be a bug in the loader and must surface loudly. +#[must_use] +pub fn placeholder_settings() -> Settings { + load_settings(&Info::from_toml(PLACEHOLDER_TOML)).expect("PLACEHOLDER_TOML must load") +} diff --git a/packages/index-config/src/tests/loader.rs b/packages/index-config/src/tests/loader.rs new file mode 100644 index 000000000..5bfe6d126 --- /dev/null +++ b/packages/index-config/src/tests/loader.rs @@ -0,0 +1,234 @@ +//! Tests for [`crate::load_settings`]. +//! +//! ## Index +//! +//! | Test | What it proves | +//! |---------------------------------------------------|----------------------------------------------------------------------| +//! | `minimum_valid_toml_loads_with_defaults` | The shortest legal config produces a fully-populated `Settings`. | +//! | `missing_logging_threshold_is_rejected` | `MissingMandatoryOption("logging.threshold")` fires before defaults. | +//! | `missing_metadata_schema_version_is_rejected` | `MissingMandatoryOption("metadata.schema_version")` fires. | +//! | `missing_tracker_token_is_rejected` | Absent `tracker.token` surfaces as a serde missing-field error. | +//! | `missing_database_connect_url_is_rejected` | Absent `database.connect_url` surfaces as a serde missing-field err. | +//! | `missing_database_section_is_rejected` | Absent `[database]` section surfaces as a serde missing-field err. | +//! | `unsupported_schema_version_is_rejected` | A `1.0.0` schema is refused via `UnsupportedVersion`. | +//! | `wrong_field_type_surfaces_as_config_error` | A type mismatch in an optional field surfaces as `ConfigError`. | +//! | `malformed_toml_collapses_to_missing_mandatory` | Documents the quirk: unparseable TOML reads as "all keys missing". | +//! | `info_from_toml_round_trips_through_load` | `Info::from_toml` is the canonical hermetic entry point. | +//! | `from_figment_error_into_config_error_preserves` | The `From` impl wires the source through. | + +use crate::tests::{MINIMUM_VALID_TOML, info_from, placeholder_settings}; +use crate::{Error, Version, load_settings}; + +#[test] +fn minimum_valid_toml_loads_with_defaults() { + let settings = load_settings(&info_from(MINIMUM_VALID_TOML)).expect("minimum TOML must load"); + + // Defaults filled in: + assert_eq!(settings.api.default_torrent_page_size, 10); + assert_eq!(settings.image_cache.capacity, 128_000_000); + assert_eq!(settings.tracker_statistics_importer.port, 3002); + // Mandatory options preserved: + assert_eq!(settings.tracker.token.to_string(), "MyAccessToken"); + assert_eq!(settings.database.connect_url.as_str(), "sqlite://data.db?mode=rwc"); + assert_eq!(settings.metadata.schema_version, Version::default()); +} + +#[test] +fn missing_logging_threshold_is_rejected() { + let toml = r#" +[metadata] +schema_version = "2.0.0" + +[tracker] +token = "MyAccessToken" + +[database] +connect_url = "sqlite://data.db?mode=rwc" +"#; + match load_settings(&info_from(toml)) { + Err(Error::MissingMandatoryOption { path }) => assert_eq!(path, "logging.threshold"), + other => panic!("expected MissingMandatoryOption(logging.threshold), got {other:?}"), + } +} + +#[test] +fn missing_metadata_schema_version_is_rejected() { + let toml = r#" +[logging] +threshold = "info" + +[tracker] +token = "MyAccessToken" + +[database] +connect_url = "sqlite://data.db?mode=rwc" +"#; + match load_settings(&info_from(toml)) { + Err(Error::MissingMandatoryOption { path }) => assert_eq!(path, "metadata.schema_version"), + other => panic!("expected MissingMandatoryOption(metadata.schema_version), got {other:?}"), + } +} + +#[test] +fn missing_tracker_token_is_rejected() { + // Per ADR-T-009 §D2, `tracker.token` carries no schema-level + // default — the absence surfaces as a serde missing-field error + // (wrapped as `ConfigError`), not via `check_mandatory_options`. + let toml = r#" +[metadata] +schema_version = "2.0.0" + +[logging] +threshold = "info" + +[tracker] + +[database] +connect_url = "sqlite://data.db?mode=rwc" +"#; + match load_settings(&info_from(toml)) { + Err(Error::ConfigError { source }) => { + let msg = source.to_string(); + assert!(msg.contains("token"), "expected 'token' in error, got: {msg}"); + } + other => panic!("expected ConfigError(missing field 'token'), got {other:?}"), + } +} + +#[test] +fn missing_database_connect_url_is_rejected() { + // Per ADR-T-009 §D2, `database.connect_url` carries no + // schema-level default — the absence surfaces as a serde + // missing-field error (wrapped as `ConfigError`). + let toml = r#" +[metadata] +schema_version = "2.0.0" + +[logging] +threshold = "info" + +[tracker] +token = "MyAccessToken" + +[database] +"#; + match load_settings(&info_from(toml)) { + Err(Error::ConfigError { source }) => { + let msg = source.to_string(); + assert!(msg.contains("connect_url"), "expected 'connect_url' in error, got: {msg}"); + } + other => panic!("expected ConfigError(missing field 'connect_url'), got {other:?}"), + } +} + +#[test] +fn missing_database_section_is_rejected() { + // The `[database]` section itself carries no `#[serde(default)]`, + // so omitting it entirely fails the same way as omitting an + // inner mandatory key (ADR-T-009 §D2, "explicit failure forces + // an explicit choice"). + let toml = r#" +[metadata] +schema_version = "2.0.0" + +[logging] +threshold = "info" + +[tracker] +token = "MyAccessToken" +"#; + match load_settings(&info_from(toml)) { + Err(Error::ConfigError { source }) => { + let msg = source.to_string(); + assert!(msg.contains("database"), "expected 'database' in error, got: {msg}"); + } + other => panic!("expected ConfigError(missing field 'database'), got {other:?}"), + } +} + +#[test] +fn unsupported_schema_version_is_rejected() { + let toml = r#" +[metadata] +schema_version = "1.0.0" + +[logging] +threshold = "info" + +[tracker] +token = "MyAccessToken" + +[database] +connect_url = "sqlite://data.db?mode=rwc" +"#; + match load_settings(&info_from(toml)) { + Err(Error::UnsupportedVersion { version }) => { + // `Version` round-trips through Display. + let rendered = format!("{version:?}"); + assert!(rendered.contains("1.0.0"), "unexpected: {rendered}"); + } + other => panic!("expected UnsupportedVersion, got {other:?}"), + } +} + +#[test] +fn wrong_field_type_surfaces_as_config_error() { + // All mandatory options are present, so check_mandatory_options + // succeeds — but `api.default_torrent_page_size` is declared as `u8`, + // and a string is not assignable. Figment's `extract::` then + // fails with the wrapped error. + let toml = r#" +[metadata] +schema_version = "2.0.0" + +[logging] +threshold = "info" + +[tracker] +token = "MyAccessToken" + +[database] +connect_url = "sqlite://data.db?mode=rwc" + +[api] +default_torrent_page_size = "not-a-number" +"#; + match load_settings(&info_from(toml)) { + Err(Error::ConfigError { source }) => { + assert!(!source.to_string().is_empty(), "ConfigError must carry a source"); + } + other => panic!("expected ConfigError, got {other:?}"), + } +} + +#[test] +fn malformed_toml_collapses_to_missing_mandatory() { + // Quirk worth documenting: when the TOML source itself fails to + // parse, Figment returns the parse error from `find_value`, which + // our loader treats indistinguishably from "key absent". The first + // mandatory key checked (`logging.threshold`) wins. + let toml = "this is = \"not closed"; + match load_settings(&info_from(toml)) { + Err(Error::MissingMandatoryOption { path }) => assert_eq!(path, "logging.threshold"), + other => panic!("expected MissingMandatoryOption, got {other:?}"), + } +} + +#[test] +fn info_from_toml_round_trips_through_load() { + // `placeholder_settings()` -> TOML -> back to Settings. + let original = placeholder_settings(); + let serialised = original.to_toml(); + let reloaded = load_settings(&info_from(&serialised)).expect("placeholder Settings must round-trip via TOML"); + assert_eq!(reloaded, original); +} + +#[test] +fn from_figment_error_into_config_error_preserves_source() { + let figment_err: figment::Error = figment::Error::from("boom"); + let err: Error = figment_err.into(); + match err { + Error::ConfigError { source } => assert!(source.to_string().contains("boom")), + other => panic!("expected ConfigError, got {other:?}"), + } +} diff --git a/packages/index-config/src/tests/metadata.rs b/packages/index-config/src/tests/metadata.rs new file mode 100644 index 000000000..3e780e485 --- /dev/null +++ b/packages/index-config/src/tests/metadata.rs @@ -0,0 +1,50 @@ +//! Tests for [`crate::Metadata`], [`crate::Version`], [`crate::App`], +//! [`crate::Purpose`]. +//! +//! ## Index +//! +//! | Test | What it proves | +//! |--------------------------------------------|-------------------------------------------------------------| +//! | `default_metadata_uses_latest_version` | `Metadata::default()` matches the `LATEST_VERSION` constant.| +//! | `metadata_display_format_is_stable` | The `Display` impl quotes app/purpose/schema_version. | +//! | `app_serialises_as_kebab_case` | `App::TorrustIndex` ↔ `"torrust-index"`. | +//! | `purpose_serialises_as_lowercase` | `Purpose::Configuration` ↔ `"configuration"`. | +//! | `version_new_and_default_match_latest` | `Version::new(LATEST)` == `Version::default()`. | + +use crate::{App, LATEST_VERSION, Metadata, Purpose, Version}; + +#[test] +fn default_metadata_uses_latest_version() { + let m = Metadata::default(); + assert!(format!("{m}").contains(LATEST_VERSION)); +} + +#[test] +fn metadata_display_format_is_stable() { + let m = Metadata::default(); + let s = format!("{m}"); + assert!(s.starts_with("Metadata(app: ")); + assert!(s.contains("purpose: ")); + assert!(s.contains("schema_version: ")); +} + +#[test] +fn app_serialises_as_kebab_case() { + let json = serde_json::to_string(&App::TorrustIndex).unwrap(); + assert_eq!(json, "\"torrust-index\""); + let parsed: App = serde_json::from_str("\"torrust-index\"").unwrap(); + assert_eq!(parsed, App::TorrustIndex); +} + +#[test] +fn purpose_serialises_as_lowercase() { + let json = serde_json::to_string(&Purpose::Configuration).unwrap(); + assert_eq!(json, "\"configuration\""); +} + +#[test] +fn version_new_and_default_match_latest() { + let a = Version::default(); + let b: Version = serde_json::from_value(serde_json::json!({ "schema_version": LATEST_VERSION })).unwrap(); + assert_eq!(a, b); +} diff --git a/packages/index-config/src/tests/mod.rs b/packages/index-config/src/tests/mod.rs new file mode 100644 index 000000000..46e5928fa --- /dev/null +++ b/packages/index-config/src/tests/mod.rs @@ -0,0 +1,32 @@ +//! Crate-level test suite for `torrust-index-config`. +//! +//! These tests have `pub(crate)` visibility into the configuration +//! crate (per AGENTS.md "Test Locations"), and they exercise the +//! parsing surface — `load_settings`, defaults, mandatory-option +//! enforcement, schema-version handshake, secret redaction — without +//! relying on global process state (env vars). +//! +//! ## Index of submodules +//! +//! | Module | Focus | +//! |---------------|------------------------------------------------------| +//! | `loader` | `load_settings` happy path + every `Error` variant. | +//! | `metadata` | `Metadata` / `Version` / `App` / `Purpose` shape. | +//! | `redaction` | `Settings::remove_secrets` covers every secret slot. | +//! | `quirks` | Unicode, IPv6, NoneAsEmptyString, validator quirks. | +//! | `permissions` | `Role` / `Action` / `Effect` round-trip & overrides. | + +mod loader; +mod metadata; +mod permissions; +mod quirks; +mod redaction; + +use crate::Info; +pub use crate::test_helpers::{PLACEHOLDER_TOML as MINIMUM_VALID_TOML, placeholder_settings}; + +/// Convenience: build an [`Info`] that bypasses the filesystem and +/// the environment so tests stay hermetic and parallel-safe. +pub fn info_from(toml: &str) -> Info { + Info::from_toml(toml) +} diff --git a/packages/index-config/src/tests/permissions.rs b/packages/index-config/src/tests/permissions.rs new file mode 100644 index 000000000..651ed03af --- /dev/null +++ b/packages/index-config/src/tests/permissions.rs @@ -0,0 +1,65 @@ +//! Tests for [`crate::permissions`]. +//! +//! ## Index +//! +//! | Test | What it proves | +//! |--------------------------------------------|-------------------------------------------------------------| +//! | `every_role_round_trips_via_string` | `Role::ALL` ↔ `Display`/`FromStr` agree on every variant. | +//! | `unknown_role_is_a_typed_error` | `RoleParseError` carries the offending input. | +//! | `effect_serialises_as_lowercase` | `Effect::Allow` ↔ `"allow"`, `Effect::Deny` ↔ `"deny"`. | +//! | `permission_override_round_trips_via_toml` | Single override survives a TOML serialise/parse cycle. | +//! | `action_all_is_complete_and_unique` | `Action::ALL` has no duplicates and matches variant count. | + +use std::collections::HashSet; +use std::str::FromStr; + +use crate::permissions::{Action, Effect, PermissionOverride, Role}; + +/// Wrapper used by the round-trip test so toml-rs has a table to +/// hang the override fields off of. +#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)] +struct Wrap { + item: PermissionOverride, +} + +#[test] +fn every_role_round_trips_via_string() { + for role in Role::ALL { + let s = role.to_string(); + let parsed = Role::from_str(&s).expect("Display output must be FromStr-parseable"); + assert_eq!(*role, parsed); + } +} + +#[test] +fn unknown_role_is_a_typed_error() { + let err = Role::from_str("supreme-overlord").expect_err("must reject unknown roles"); + assert!(format!("{err}").contains("supreme-overlord")); +} + +#[test] +fn effect_serialises_as_lowercase() { + assert_eq!(serde_json::to_string(&Effect::Allow).unwrap(), "\"allow\""); + assert_eq!(serde_json::to_string(&Effect::Deny).unwrap(), "\"deny\""); +} + +#[test] +fn permission_override_round_trips_via_toml() { + let original = PermissionOverride { + role: Role::Registered, + action: Action::DeleteTorrent, + effect: Effect::Allow, + }; + let encoded = toml::to_string(&Wrap { item: original.clone() }).unwrap(); + let decoded: Wrap = toml::from_str(&encoded).unwrap(); + assert_eq!(decoded.item, original); +} + +#[test] +fn action_all_is_complete_and_unique() { + let unique: HashSet<_> = Action::ALL.iter().collect(); + assert_eq!(unique.len(), Action::ALL.len(), "Action::ALL must not contain duplicates"); + // Sanity: at least the publicly documented variants are present. + assert!(Action::ALL.contains(&Action::AddTorrent)); + assert!(Action::ALL.contains(&Action::GetMyPermissions)); +} diff --git a/packages/index-config/src/tests/quirks.rs b/packages/index-config/src/tests/quirks.rs new file mode 100644 index 000000000..ba929aeee --- /dev/null +++ b/packages/index-config/src/tests/quirks.rs @@ -0,0 +1,146 @@ +//! Quirky / edge-case tests that don't fit anywhere else. +//! +//! These exist to catch the sort of breakage that "normal" tests +//! never quite reach: empty-string TLS paths, IPv6 bind addresses, +//! emoji tracker tokens, the stubbornly-unsupported UDP-private +//! tracker, and the lone `Threshold` enum that pretends to be a +//! `LevelFilter`. +//! +//! ## Index +//! +//! | Test | What it proves | +//! |------------------------------------------------------|----------------------------------------------------------------------| +//! | `tls_empty_string_paths_deserialise_to_none` | The `NoneAsEmptyString` adapter empties out → `None`. | +//! | `tls_section_with_no_fields_deserialises_to_none` | An empty `[net.tls]` table leaves both paths as `None`. | +//! | `ipv6_bind_address_round_trips` | `[::1]:7000` survives parse → serialise → parse. | +//! | `tracker_token_accepts_unicode_grapheme_clusters` | A 🦀-laden token loads and pretty-prints intact. | +//! | `tracker_validator_rejects_udp_private` | The classic "you can't have a private UDP tracker" guard fires. | +//! | `tracker_validator_accepts_https_private` | …but HTTPS private trackers are fine. | +//! | `threshold_display_is_lowercase` | All six `Threshold` variants render in lowercase. | +//! | `threshold_levelfilter_conversion_is_total` | Every `Threshold` maps to a distinct `LevelFilter`. | +//! | `empty_api_token_panics` | `ApiToken::new("")` is a contract violation. | +//! | `to_json_is_pretty_printed` | `Settings::to_json` emits indented JSON (not minified). | + +use std::collections::HashSet; +use std::net::{IpAddr, Ipv6Addr, SocketAddr}; + +use tracing::level_filters::LevelFilter; +use url::Url; + +use crate::tests::{MINIMUM_VALID_TOML, info_from, placeholder_settings}; +use crate::v2::tracker::{ApiToken, Tracker}; +use crate::validator::{ValidationError, Validator}; +use crate::{Threshold, load_settings}; + +#[test] +fn tls_empty_string_paths_deserialise_to_none() { + // Inject a `[net.tls]` section with empty paths on top of the minimum config. + let toml = format!("{MINIMUM_VALID_TOML}\n[net.tls]\nssl_cert_path = \"\"\nssl_key_path = \"\"\n"); + let settings = load_settings(&info_from(&toml)).expect("TOML must load"); + let tls = settings.net.tls.expect("[net.tls] table must materialise as Some"); + assert!(tls.ssl_cert_path.is_none(), "empty string must collapse to None"); + assert!(tls.ssl_key_path.is_none(), "empty string must collapse to None"); +} + +#[test] +fn tls_section_with_no_fields_deserialises_to_none() { + // A `[net.tls]` table with no fields at all must not synthesise empty + // paths — otherwise `make_rust_tls` would later try to load a cert + // from `""` and emit a misleading "no such file" error at startup. + let toml = format!("{MINIMUM_VALID_TOML}\n[net.tls]\n"); + let settings = load_settings(&info_from(&toml)).expect("TOML must load"); + let tls = settings.net.tls.expect("[net.tls] table must materialise as Some"); + assert!(tls.ssl_cert_path.is_none(), "missing field must default to None"); + assert!(tls.ssl_key_path.is_none(), "missing field must default to None"); +} + +#[test] +fn ipv6_bind_address_round_trips() { + let mut s = placeholder_settings(); + s.net.bind_address = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 7000); + let toml = s.to_toml(); + let reloaded = load_settings(&info_from(&toml)).expect("IPv6 bind address must round-trip"); + assert_eq!(reloaded.net.bind_address, s.net.bind_address); +} + +#[test] +fn tracker_token_accepts_unicode_grapheme_clusters() { + let token = ApiToken::new("🦀-token-📦-ñoño"); + assert_eq!(token.to_string(), "🦀-token-📦-ñoño"); + // Bytes match the UTF-8 of the input. + assert_eq!(token.as_bytes(), "🦀-token-📦-ñoño".as_bytes()); +} + +#[test] +fn tracker_validator_rejects_udp_private() { + let t = Tracker { + api_url: Url::parse("http://localhost:1212/").unwrap(), + listed: false, + private: true, + token: ApiToken::new("MyAccessToken"), + token_valid_seconds: 7_257_600, + url: Url::parse("udp://localhost:6969").unwrap(), + }; + match t.validate() { + Err(ValidationError::UdpTrackersInPrivateModeNotSupported) => {} + other => panic!("expected UdpTrackersInPrivateModeNotSupported, got {other:?}"), + } +} + +#[test] +fn tracker_validator_accepts_https_private() { + let t = Tracker { + api_url: Url::parse("http://localhost:1212/").unwrap(), + listed: false, + private: true, + token: ApiToken::new("MyAccessToken"), + token_valid_seconds: 7_257_600, + url: Url::parse("https://tracker.example.com/announce").unwrap(), + }; + t.validate().expect("HTTPS private trackers are allowed"); +} + +#[test] +fn threshold_display_is_lowercase() { + let pairs = [ + (Threshold::Off, "off"), + (Threshold::Error, "error"), + (Threshold::Warn, "warn"), + (Threshold::Info, "info"), + (Threshold::Debug, "debug"), + (Threshold::Trace, "trace"), + ]; + for (t, expected) in pairs { + assert_eq!(t.to_string(), expected); + } +} + +#[test] +fn threshold_levelfilter_conversion_is_total() { + let mapped: HashSet = [ + Threshold::Off, + Threshold::Error, + Threshold::Warn, + Threshold::Info, + Threshold::Debug, + Threshold::Trace, + ] + .into_iter() + .map(LevelFilter::from) + .collect(); + assert_eq!(mapped.len(), 6, "every Threshold must map to a unique LevelFilter"); +} + +#[test] +#[should_panic(expected = "tracker API token cannot be empty")] +fn empty_api_token_panics() { + drop(ApiToken::new("")); +} + +#[test] +fn to_json_is_pretty_printed() { + let json = placeholder_settings().to_json(); + assert!(json.contains('\n'), "to_json should be pretty-printed (multi-line)"); + assert!(json.starts_with('{')); + assert!(json.trim_end().ends_with('}')); +} diff --git a/packages/index-config/src/tests/redaction.rs b/packages/index-config/src/tests/redaction.rs new file mode 100644 index 000000000..310737250 --- /dev/null +++ b/packages/index-config/src/tests/redaction.rs @@ -0,0 +1,83 @@ +//! Tests for [`crate::Settings::remove_secrets`]. +//! +//! Every secret-bearing slot listed in the source must end up +//! redacted; non-secret fields must be untouched. +//! +//! ## Index +//! +//! | Test | What it proves | +//! |-----------------------------------------------|---------------------------------------------------------| +//! | `tracker_token_is_replaced_with_stars` | Tracker API token becomes `***`. | +//! | `database_password_is_replaced_with_stars` | The password component of the connect URL is masked. | +//! | `database_url_without_password_is_unchanged` | URLs that have nothing to redact survive verbatim. | +//! | `smtp_password_is_replaced_with_stars` | SMTP credentials password becomes `***`. | +//! | `auth_keys_are_redacted_when_set` | Inline PEMs and key paths are individually redacted. | +//! | `non_secret_fields_are_preserved` | Logging threshold, network bind address, etc. survive. | + +use std::net::SocketAddr; + +use url::Url; + +use crate::tests::placeholder_settings; + +#[test] +fn tracker_token_is_replaced_with_stars() { + let mut s = placeholder_settings(); + let mut redacted = s.clone(); + redacted.remove_secrets(); + assert_ne!(s.tracker.token.to_string(), redacted.tracker.token.to_string()); + assert_eq!(redacted.tracker.token.to_string(), "***"); + let _ = &mut s; // silence unused-mut suggestion if any +} + +#[test] +fn database_password_is_replaced_with_stars() { + let mut s = placeholder_settings(); + s.database.connect_url = Url::parse("mysql://root:hunter2@db:3306/idx").unwrap(); + s.remove_secrets(); + assert_eq!(s.database.connect_url.password(), Some("***")); + assert_eq!(s.database.connect_url.username(), "root"); +} + +#[test] +fn database_url_without_password_is_unchanged() { + let mut s = placeholder_settings(); + s.database.connect_url = Url::parse("sqlite://data.db?mode=rwc").unwrap(); + let before = s.database.connect_url.clone(); + s.remove_secrets(); + assert_eq!(s.database.connect_url, before); +} + +#[test] +fn smtp_password_is_replaced_with_stars() { + let mut s = placeholder_settings(); + s.mail.smtp.credentials.password = "super-secret".to_owned(); + s.remove_secrets(); + assert_eq!(s.mail.smtp.credentials.password, "***"); +} + +#[test] +fn auth_keys_are_redacted_when_set() { + let mut s = placeholder_settings(); + s.auth.private_key_pem = Some("-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----".into()); + s.auth.public_key_pem = Some("-----BEGIN PUBLIC KEY-----\nBBBB\n-----END PUBLIC KEY-----".into()); + s.auth.private_key_path = Some("/etc/torrust/jwt.key".into()); + s.auth.public_key_path = Some("/etc/torrust/jwt.pub".into()); + + s.remove_secrets(); + + assert_eq!(s.auth.private_key_pem.as_deref(), Some("***-redacted-private-key-pem***")); + assert_eq!(s.auth.public_key_pem.as_deref(), Some("***-redacted-public-key-pem***")); + assert_eq!(s.auth.private_key_path.as_deref(), Some("***-redacted***")); + assert_eq!(s.auth.public_key_path.as_deref(), Some("***-redacted***")); +} + +#[test] +fn non_secret_fields_are_preserved() { + let mut s = placeholder_settings(); + let original_bind: SocketAddr = s.net.bind_address; + let original_threshold = s.logging.threshold.clone(); + s.remove_secrets(); + assert_eq!(s.net.bind_address, original_bind); + assert_eq!(s.logging.threshold, original_threshold); +} diff --git a/src/config/v2/api.rs b/packages/index-config/src/v2/api.rs similarity index 100% rename from src/config/v2/api.rs rename to packages/index-config/src/v2/api.rs diff --git a/src/config/v2/auth.rs b/packages/index-config/src/v2/auth.rs similarity index 100% rename from src/config/v2/auth.rs rename to packages/index-config/src/v2/auth.rs diff --git a/src/config/v2/database.rs b/packages/index-config/src/v2/database.rs similarity index 55% rename from src/config/v2/database.rs rename to packages/index-config/src/v2/database.rs index 2575ba22b..d605794d6 100644 --- a/src/config/v2/database.rs +++ b/packages/index-config/src/v2/database.rs @@ -2,26 +2,16 @@ use serde::{Deserialize, Serialize}; use url::Url; /// Database configuration. +/// +/// `connect_url` is mandatory: there is no schema-level default and no +/// `impl Default for Database`. A missing value fails at +/// deserialisation with a precise serde `missing field 'connect_url'` +/// error (ADR-T-009 §D2). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Database { /// The connection URL for the database. For example: /// /// Sqlite: `sqlite://data.db?mode=rwc`. /// Mysql: `mysql://root:root_secret_password@mysql:3306/torrust_index_e2e_testing`. - #[serde(default = "Database::default_connect_url")] pub connect_url: Url, } - -impl Default for Database { - fn default() -> Self { - Self { - connect_url: Self::default_connect_url(), - } - } -} - -impl Database { - fn default_connect_url() -> Url { - Url::parse("sqlite://data.db?mode=rwc").unwrap() - } -} diff --git a/src/config/v2/image_cache.rs b/packages/index-config/src/v2/image_cache.rs similarity index 100% rename from src/config/v2/image_cache.rs rename to packages/index-config/src/v2/image_cache.rs diff --git a/src/config/v2/logging.rs b/packages/index-config/src/v2/logging.rs similarity index 100% rename from src/config/v2/logging.rs rename to packages/index-config/src/v2/logging.rs diff --git a/src/config/v2/mail.rs b/packages/index-config/src/v2/mail.rs similarity index 100% rename from src/config/v2/mail.rs rename to packages/index-config/src/v2/mail.rs diff --git a/src/config/v2/mod.rs b/packages/index-config/src/v2/mod.rs similarity index 81% rename from src/config/v2/mod.rs rename to packages/index-config/src/v2/mod.rs index 8e5c57dba..85eef63a6 100644 --- a/src/config/v2/mod.rs +++ b/packages/index-config/src/v2/mod.rs @@ -25,10 +25,15 @@ use self::permissions::Permissions; use self::tracker::{ApiToken, Tracker}; use self::tracker_statistics_importer::TrackerStatisticsImporter; use self::website::Website; -use super::Metadata; -use super::validator::{ValidationError, Validator}; +use crate::Metadata; +use crate::validator::{ValidationError, Validator}; /// The whole configuration for the index. +/// +/// `tracker` and `database` carry no schema-level defaults — they +/// must appear in the parsed input. Their absence (or the absence +/// of their mandatory inner fields) fails deserialisation with a +/// precise serde `missing field` error (ADR-T-009 §D2). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Settings { /// Configuration metadata. @@ -44,7 +49,6 @@ pub struct Settings { pub website: Website, /// The tracker configuration. - #[serde(default = "Settings::default_tracker")] pub tracker: Tracker, /// The network configuration. @@ -56,7 +60,6 @@ pub struct Settings { pub auth: Auth, /// The database configuration. - #[serde(default = "Settings::default_database")] pub database: Database, /// The SMTP configuration. @@ -84,26 +87,6 @@ pub struct Settings { pub tracker_statistics_importer: TrackerStatisticsImporter, } -impl Default for Settings { - fn default() -> Self { - Self { - metadata: Self::default_metadata(), - logging: Self::default_logging(), - website: Self::default_website(), - tracker: Self::default_tracker(), - net: Self::default_network(), - auth: Self::default_auth(), - database: Self::default_database(), - mail: Self::default_mail(), - image_cache: Self::default_image_cache(), - api: Self::default_api(), - registration: Self::default_registration(), - permissions: Self::default_permissions(), - tracker_statistics_importer: Self::default_tracker_statistics_importer(), - } - } -} - impl Settings { pub fn remove_secrets(&mut self) { self.tracker.token = ApiToken::new("***"); @@ -157,10 +140,6 @@ impl Settings { Website::default() } - fn default_tracker() -> Tracker { - Tracker::default() - } - fn default_network() -> Network { Network::default() } @@ -169,10 +148,6 @@ impl Settings { Auth::default() } - fn default_database() -> Database { - Database::default() - } - fn default_mail() -> Mail { Mail::default() } diff --git a/src/config/v2/net.rs b/packages/index-config/src/v2/net.rs similarity index 88% rename from src/config/v2/net.rs rename to packages/index-config/src/v2/net.rs index 4c50e940c..d55c92e81 100644 --- a/src/config/v2/net.rs +++ b/packages/index-config/src/v2/net.rs @@ -3,7 +3,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use serde::{Deserialize, Serialize}; use url::Url; -use crate::config::Tsl; +use crate::Tls; /// The the base URL for the API. /// @@ -25,9 +25,9 @@ pub struct Network { #[serde(default = "Network::default_bind_address")] pub bind_address: SocketAddr, - /// TSL configuration. - #[serde(default = "Network::default_tsl")] - pub tsl: Option, + /// TLS configuration. + #[serde(default = "Network::default_tls")] + pub tls: Option, } impl Default for Network { @@ -35,7 +35,7 @@ impl Default for Network { Self { bind_address: Self::default_bind_address(), base_url: Self::default_base_url(), - tsl: Self::default_tsl(), + tls: Self::default_tls(), } } } @@ -57,7 +57,7 @@ impl Network { None } - const fn default_tsl() -> Option { + const fn default_tls() -> Option { None } } diff --git a/src/config/v2/permissions.rs b/packages/index-config/src/v2/permissions.rs similarity index 92% rename from src/config/v2/permissions.rs rename to packages/index-config/src/v2/permissions.rs index 2ec92dcf6..ecb11d5fa 100644 --- a/src/config/v2/permissions.rs +++ b/packages/index-config/src/v2/permissions.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::services::authorization::PermissionOverride; +use crate::permissions::PermissionOverride; /// Operator-supplied permission overrides. /// diff --git a/src/config/v2/registration.rs b/packages/index-config/src/v2/registration.rs similarity index 100% rename from src/config/v2/registration.rs rename to packages/index-config/src/v2/registration.rs diff --git a/src/config/v2/tracker.rs b/packages/index-config/src/v2/tracker.rs similarity index 77% rename from src/config/v2/tracker.rs rename to packages/index-config/src/v2/tracker.rs index 8c884e7a3..0738965cc 100644 --- a/src/config/v2/tracker.rs +++ b/packages/index-config/src/v2/tracker.rs @@ -3,9 +3,14 @@ use std::fmt; use serde::{Deserialize, Serialize}; use url::Url; -use super::{ValidationError, Validator}; +use crate::validator::{ValidationError, Validator}; /// Configuration for the associated tracker. +/// +/// `token` is mandatory: there is no schema-level default and no +/// `impl Default for Tracker`. A missing value fails at +/// deserialisation with a precise serde `missing field 'token'` +/// error (ADR-T-009 §D2). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Tracker { /// The url of the tracker API. For example: `http://localhost:1212/`. @@ -21,7 +26,6 @@ pub struct Tracker { pub private: bool, /// The token used to authenticate with the tracker API. - #[serde(default = "Tracker::default_token")] pub token: ApiToken, /// The amount of seconds the tracker API token is valid. @@ -43,19 +47,6 @@ impl Validator for Tracker { } } -impl Default for Tracker { - fn default() -> Self { - Self { - url: Self::default_url(), - listed: Self::default_listed(), - private: Self::default_private(), - api_url: Self::default_api_url(), - token: Self::default_token(), - token_valid_seconds: Self::default_token_valid_seconds(), - } - } -} - impl Tracker { pub fn override_tracker_api_token(&mut self, tracker_api_token: &ApiToken) { self.token = tracker_api_token.clone(); @@ -77,10 +68,6 @@ impl Tracker { Url::parse("http://localhost:1212/").unwrap() } - fn default_token() -> ApiToken { - ApiToken::new("MyAccessToken") - } - const fn default_token_valid_seconds() -> u64 { 7_257_600 } @@ -104,6 +91,18 @@ impl ApiToken { pub const fn as_bytes(&self) -> &[u8] { self.0.as_bytes() } + + /// Returns `true` if the underlying token string is empty. + /// + /// `ApiToken::new` rejects empty input via `assert!`, but + /// `#[derive(Deserialize)]` bypasses that guard. Container + /// boundaries (e.g. `torrust-index-config-probe`) call this + /// to refuse a `tracker.token = ""` deserialised through a + /// bare `${VAR}` substitution. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.0.is_empty() + } } impl fmt::Display for ApiToken { diff --git a/src/config/v2/tracker_statistics_importer.rs b/packages/index-config/src/v2/tracker_statistics_importer.rs similarity index 100% rename from src/config/v2/tracker_statistics_importer.rs rename to packages/index-config/src/v2/tracker_statistics_importer.rs diff --git a/src/config/v2/website.rs b/packages/index-config/src/v2/website.rs similarity index 100% rename from src/config/v2/website.rs rename to packages/index-config/src/v2/website.rs diff --git a/src/config/validator.rs b/packages/index-config/src/validator.rs similarity index 100% rename from src/config/validator.rs rename to packages/index-config/src/validator.rs diff --git a/packages/index-config/tests/permission_overrides.rs b/packages/index-config/tests/permission_overrides.rs new file mode 100644 index 000000000..9a425ecec --- /dev/null +++ b/packages/index-config/tests/permission_overrides.rs @@ -0,0 +1,76 @@ +//! Integration test: `[[permissions.overrides]]` is parsed and +//! preserved through the loader. +//! +//! ## Index +//! +//! | Test | What it proves | +//! |-----------------------------------------------|-----------------------------------------------------------| +//! | `multiple_overrides_load_in_declared_order` | Order is stable; each tuple decodes correctly. | +//! | `unknown_action_is_a_config_error` | A typo in `action` surfaces as `Error::ConfigError`. | +//! | `effect_must_be_lowercase` | `"Allow"` (capitalised) fails to deserialise. | + +use torrust_index_config::permissions::{Action, Effect, Role}; +use torrust_index_config::test_helpers::PLACEHOLDER_TOML as BASE; +use torrust_index_config::{Error, Info, load_settings}; + +#[test] +fn multiple_overrides_load_in_declared_order() { + let toml = format!( + "{BASE}\n\ + [[permissions.overrides]]\n\ + role = \"registered\"\n\ + action = \"DeleteTorrent\"\n\ + effect = \"allow\"\n\ + \n\ + [[permissions.overrides]]\n\ + role = \"moderator\"\n\ + action = \"BanUser\"\n\ + effect = \"deny\"\n" + ); + + let settings = load_settings(&Info::from_toml(&toml)).expect("permissions overrides must load"); + let overrides = &settings.permissions.overrides; + assert_eq!(overrides.len(), 2); + assert_eq!(overrides[0].role, Role::Registered); + assert_eq!(overrides[0].action, Action::DeleteTorrent); + assert_eq!(overrides[0].effect, Effect::Allow); + assert_eq!(overrides[1].role, Role::Moderator); + assert_eq!(overrides[1].action, Action::BanUser); + assert_eq!(overrides[1].effect, Effect::Deny); +} + +#[test] +fn unknown_action_is_a_config_error() { + let toml = format!( + "{BASE}\n\ + [[permissions.overrides]]\n\ + role = \"registered\"\n\ + action = \"OverthrowTheState\"\n\ + effect = \"allow\"\n" + ); + match load_settings(&Info::from_toml(&toml)) { + Err(Error::ConfigError { source }) => { + let msg = source.to_string(); + assert!( + msg.contains("OverthrowTheState") || msg.contains("variant"), + "unexpected: {msg}" + ); + } + other => panic!("expected ConfigError, got {other:?}"), + } +} + +#[test] +fn effect_must_be_lowercase() { + let toml = format!( + "{BASE}\n\ + [[permissions.overrides]]\n\ + role = \"registered\"\n\ + action = \"DeleteTorrent\"\n\ + effect = \"Allow\"\n" + ); + assert!(matches!( + load_settings(&Info::from_toml(&toml)), + Err(Error::ConfigError { .. }) + )); +} diff --git a/packages/index-config/tests/round_trip.rs b/packages/index-config/tests/round_trip.rs new file mode 100644 index 000000000..3fae9b6dc --- /dev/null +++ b/packages/index-config/tests/round_trip.rs @@ -0,0 +1,63 @@ +//! Integration tests: full TOML / JSON round-trips through the +//! crate's *public* API only. Nothing here may import private items. +//! +//! ## Index +//! +//! | Test | What it proves | +//! |----------------------------------------------------|-----------------------------------------------------------------| +//! | `placeholder_round_trips_through_toml` | `placeholder() -> to_toml -> load_settings` is the identity. | +//! | `placeholder_round_trips_through_json_then_toml` | JSON survives a re-serialise via TOML (proves shape symmetry). | +//! | `two_pass_toml_is_a_fixed_point` | After one TOML round-trip, the next round-trip is byte-stable. | +//! | `customised_settings_survive_round_trip` | A non-default `Settings` (custom token, URLs, page sizes) too. | + +use torrust_index_config::test_helpers::placeholder_settings as placeholder; +use torrust_index_config::{Info, Settings, load_settings}; + +#[test] +fn placeholder_round_trips_through_toml() { + let original = placeholder(); + let toml = original.to_toml(); + let reloaded = load_settings(&Info::from_toml(&toml)).expect("placeholder Settings must round-trip"); + assert_eq!(reloaded, original); +} + +#[test] +fn placeholder_round_trips_through_json_then_toml() { + let original = placeholder(); + let json = original.to_json(); + let from_json: Settings = serde_json::from_str(&json).expect("JSON must deserialise back to Settings"); + assert_eq!(from_json, original); + + let toml = from_json.to_toml(); + let reloaded = load_settings(&Info::from_toml(&toml)).expect("JSON->Settings->TOML must reload"); + assert_eq!(reloaded, original); +} + +#[test] +fn two_pass_toml_is_a_fixed_point() { + let original = placeholder(); + let pass_1 = original.to_toml(); + let after_1: Settings = load_settings(&Info::from_toml(&pass_1)).unwrap(); + let pass_2 = after_1.to_toml(); + assert_eq!(pass_1, pass_2, "to_toml must be a fixed point after one load cycle"); +} + +#[test] +fn customised_settings_survive_round_trip() { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + + use url::Url; + + let mut original = placeholder(); + original.tracker.token = torrust_index_config::ApiToken::new("a-very-different-token"); + original.tracker.api_url = Url::parse("https://tracker.example.org:1212/").unwrap(); + original.tracker.url = Url::parse("https://tracker.example.org/announce").unwrap(); + original.net.bind_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4242); + original.api.default_torrent_page_size = 25; + original.api.max_torrent_page_size = 250; + original.website.name = "My Curated Index".to_owned(); + + let toml = original.to_toml(); + let reloaded = load_settings(&Info::from_toml(&toml)).expect("customised Settings must round-trip"); + assert_eq!(reloaded, original); +} diff --git a/packages/index-config/tests/shipped_samples.rs b/packages/index-config/tests/shipped_samples.rs new file mode 100644 index 000000000..0bd0c058e --- /dev/null +++ b/packages/index-config/tests/shipped_samples.rs @@ -0,0 +1,113 @@ +//! Integration test: every checked-in `index.*.toml` from the +//! repository's `share/default/config/` directory is *structurally +//! valid* but intentionally incomplete. +//! +//! Per ADR-T-009 §D2, shipped defaults carry no credentials and no +//! environment-coupled values: `tracker.token`, `database.connect_url`, +//! `[mail.smtp]`, and `[auth]` paths must come from the operator at +//! runtime (env vars or a side-loaded TOML). A shipped sample +//! therefore parses as TOML but fails the schema's mandatory-field +//! check until the operator supplies those values. +//! +//! The samples are embedded at compile time with `include_str!` so +//! the test binary is self-contained and does not depend on the +//! repository layout at runtime. +//! +//! ## Index +//! +//! | Test | What it proves | +//! |---------------------------------------------------|----------------------------------------------------------------------| +//! | `every_shipped_index_toml_is_valid_toml` | Every embedded `index.*.toml` is syntactically valid TOML. | +//! | `every_shipped_index_toml_omits_credentials` | No shipped sample carries `connect_url` / `token` / `[mail.smtp]`. | +//! | `every_shipped_index_toml_demands_runtime_secrets`| The schema rejects each sample for a missing mandatory secret field. | +//! | `development_sqlite3_uses_info_threshold` | The dev sample still sets `logging.threshold = "info"`. | + +use torrust_index_config::{Error, Info, Threshold, load_settings}; + +/// Embedded `index.*.toml` samples shipped in `share/default/config/`. +/// +/// Keep in sync with the directory contents. Non-`index.*` files +/// (e.g. tracker samples) are intentionally excluded. +const SHIPPED_INDEX_SAMPLES: &[(&str, &str)] = &[ + ( + "index.container.toml", + include_str!("../../../share/default/config/index.container.toml"), + ), + ( + "index.development.sqlite3.toml", + include_str!("../../../share/default/config/index.development.sqlite3.toml"), + ), + ( + "index.private.e2e.container.sqlite3.toml", + include_str!("../../../share/default/config/index.private.e2e.container.sqlite3.toml"), + ), + ( + "index.public.e2e.container.toml", + include_str!("../../../share/default/config/index.public.e2e.container.toml"), + ), +]; + +const DEVELOPMENT_SQLITE3_TOML: &str = include_str!("../../../share/default/config/index.development.sqlite3.toml"); + +#[test] +fn every_shipped_index_toml_is_valid_toml() { + assert!( + !SHIPPED_INDEX_SAMPLES.is_empty(), + "expected to find shipped index.*.toml samples" + ); + + for (name, toml_str) in SHIPPED_INDEX_SAMPLES { + let parsed: Result = toml::from_str(toml_str); + assert!(parsed.is_ok(), "shipped sample {name} is not valid TOML: {:?}", parsed.err()); + } +} + +#[test] +fn every_shipped_index_toml_omits_credentials() { + // ADR-T-009 §D2: no credentials, no `connect_url`, no SMTP + // hostnames, no auth-key paths in shipped defaults. + for (name, toml) in SHIPPED_INDEX_SAMPLES { + for forbidden in ["connect_url", "token =", "[mail.smtp]", "private_key_path", "public_key_path"] { + assert!( + !toml.contains(forbidden), + "shipped sample {name} must not contain `{forbidden}` (ADR-T-009 §D2)" + ); + } + } +} + +#[test] +fn every_shipped_index_toml_demands_runtime_secrets() { + // The sample on its own must fail the loader: the operator is + // required to supply `tracker.token` and `database.connect_url` + // (via env var or a side-loaded TOML) at runtime. The exact + // missing-field name varies per sample (whichever is checked + // first wins), so the test only asserts that the load *fails* + // with an error mentioning `token` or `connect_url`. + for (name, toml) in SHIPPED_INDEX_SAMPLES { + match load_settings(&Info::from_toml(toml)) { + Err(Error::ConfigError { source }) => { + let msg = source.to_string(); + assert!( + msg.contains("token") || msg.contains("connect_url"), + "shipped sample {name} failed for unexpected reason: {msg}" + ); + } + Ok(_) => panic!("shipped sample {name} loaded without operator-supplied secrets"), + Err(other) => panic!("shipped sample {name} failed with unexpected error variant: {other:?}"), + } + } +} + +#[test] +fn development_sqlite3_uses_info_threshold() { + // The dev sample is operator-incomplete too, but its `logging` + // section is still inspectable via raw TOML parsing. + let table: toml::Table = toml::from_str(DEVELOPMENT_SQLITE3_TOML).expect("dev sample must be valid TOML"); + let threshold = table + .get("logging") + .and_then(|l| l.get("threshold")) + .and_then(toml::Value::as_str) + .expect("dev sample must declare logging.threshold"); + assert_eq!(threshold, Threshold::Info.to_string()); +} diff --git a/packages/index-entry-script/Cargo.toml b/packages/index-entry-script/Cargo.toml new file mode 100644 index 000000000..1a1649eaf --- /dev/null +++ b/packages/index-entry-script/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "torrust-index-entry-script" + +authors.workspace = true +description = "Integration tests for the Torrust Index container entry script's pure shell helpers" +edition.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/packages/index-entry-script/src/lib.rs b/packages/index-entry-script/src/lib.rs new file mode 100644 index 000000000..35752f194 --- /dev/null +++ b/packages/index-entry-script/src/lib.rs @@ -0,0 +1,157 @@ +//! # `torrust-index-entry-script` +//! +//! Integration-test harness for the container entry script's +//! pure shell helpers in +//! [`share/container/entry_script_lib_sh`](../../../share/container/entry_script_lib_sh). +//! +//! The crate ships **no runtime code** of its own — it exists +//! purely as a home for `tests/` that invoke `sh` as a +//! subprocess against the shell library and assert exit +//! codes / stderr contents. This keeps the tests inside +//! `cargo test --workspace` (so CI runs them automatically) +//! while the helpers themselves remain POSIX `sh`, since they +//! must run inside the distroless busybox runtime where Rust +//! is absent. +//! +//! ## What is covered here +//! +//! Anything in `entry_script_lib_sh` that can be exercised +//! host-side without root and without writing to the +//! container's managed volumes +//! (`/etc/torrust/index/`, `/var/lib/torrust/index/`, +//! `/var/log/torrust/index/`): +//! +//! * `validate_auth_keys` — every branch of ADR-T-009 §7.1's +//! three invariants (mutual exclusion, pair completeness, +//! cross-pair source consistency). +//! * `seed_sqlite` — every branch of ADR-T-009 §7.2's five +//! seeding outcomes that does not require chowning into a +//! managed volume. +//! +//! ## What is **not** covered here +//! +//! * The "missing-under-volume seeded" outcome of +//! `seed_sqlite` (writes to `/var/lib/torrust/index/…`). +//! * The end-to-end §7.1 "case-3 export" of +//! `TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__*_PATH` plus key +//! materialisation against the real generator. +//! +//! Both belong in the container e2e suite (Phase 8 / 9). + +use std::path::PathBuf; +use std::process::{Command, Output, Stdio}; +use std::sync::OnceLock; + +/// Contents of the shell library, embedded at compile time. +/// +/// Embedding sidesteps the cargo-nextest archive → +/// `--extract-to` → `--target-dir-remap` workflow used by the +/// container `test` stage: a path computed from +/// `CARGO_MANIFEST_DIR` is baked at compile time and no longer +/// exists at run time, so the library would fail to source. +const LIB_CONTENTS: &str = include_str!("../../../share/container/entry_script_lib_sh"); + +/// Path to a materialised copy of the shell library. +/// +/// The library is written once into the OS tempdir on first +/// use and reused for the lifetime of the test binary. Tests +/// only read it (via `sh -c '. "$lib"; …'`), so a single +/// writer is fine. +#[doc(hidden)] +#[must_use] +pub fn lib_path() -> PathBuf { + static PATH: OnceLock = OnceLock::new(); + PATH.get_or_init(|| { + // Use a per-process file name so concurrent test + // binaries (cargo nextest runs them in parallel) do + // not race on a shared path: one process's `fs::write` + // would truncate the file while another sourced it, + // producing spurious `command not found` failures. + let path = std::env::temp_dir().join(format!("torrust-index-entry-script-lib.{}.sh", std::process::id())); + std::fs::write(&path, LIB_CONTENTS).expect("failed to materialise entry_script_lib_sh into tempdir"); + path + }) + .clone() +} + +/// Run a snippet of POSIX shell with `entry_script_lib_sh` +/// already sourced. +/// +/// The snippet is passed to `sh -c`. The library is sourced +/// **before** the snippet so callers can invoke any function +/// it defines (`validate_auth_keys`, `seed_sqlite`, …) +/// directly. Stdin is closed so a runaway helper cannot block +/// the test. +/// +/// Returns the captured `Output`. The caller asserts on +/// `status` and `stderr` as appropriate. +#[doc(hidden)] +#[must_use] +pub fn run_sh(snippet: &str) -> Output { + let path = lib_path(); + let lib = path + .to_str() + .expect("entry_script_lib_sh tempfile path must be UTF-8 for sh -c"); + + // The leading `set -eu` mirrors the discipline the entry + // script itself runs under (see `share/container/entry_script_sh`, + // which executes its body under `set -eu`). Running the test + // snippet under the same flags ensures host-side coverage cannot + // mask failure modes — e.g. unbound variables or unchecked command + // failures — that the real entry script would surface. `. "$lib"` + // sources the helpers; the user-supplied snippet runs after. + let script = format!("set -eu\n. \"{lib}\"\n{snippet}"); + + Command::new("sh") + .arg("-c") + .arg(script) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("failed to spawn sh; is /bin/sh available?") +} + +/// Same as [`run_sh`], but forwards each argument to the snippet +/// as a positional parameter (`$1`, `$2`, …). +/// +/// This is the natural shape for testing functions that take +/// positional arguments (e.g. `validate_auth_keys`): +/// +/// ```ignore +/// run_sh_with_args( +/// "validate_auth_keys \"$@\"", +/// &["false", "false", "none", "false", "false", "none"], +/// ); +/// ``` +/// +/// Arguments are **not** spliced into the script text — they are +/// passed as additional `argv` entries to `sh -c`, after a `"sh"` +/// placeholder for `$0`. `sh` then exposes them to the snippet as +/// `$1`, `$2`, … (and through `"$@"`). Because nothing is +/// interpolated into the script string, no quoting or +/// metacharacter escaping is required: arbitrary byte sequences +/// (single quotes, backslashes, spaces, …) are forwarded verbatim +/// by the kernel's `execve` boundary. +#[doc(hidden)] +#[must_use] +pub fn run_sh_with_args(snippet: &str, args: &[&str]) -> Output { + let path = lib_path(); + let lib = path + .to_str() + .expect("entry_script_lib_sh tempfile path must be UTF-8 for sh -c"); + + let script = format!(". \"{lib}\"\n{snippet}"); + + let mut cmd = Command::new("sh"); + cmd.arg("-c") + .arg(script) + .arg("sh") // $0 placeholder + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for a in args { + cmd.arg(a); + } + cmd.output().expect("failed to spawn sh; is /bin/sh available?") +} diff --git a/packages/index-entry-script/tests/seed_sqlite.rs b/packages/index-entry-script/tests/seed_sqlite.rs new file mode 100644 index 000000000..b4806103f --- /dev/null +++ b/packages/index-entry-script/tests/seed_sqlite.rs @@ -0,0 +1,109 @@ +//! # `seed_sqlite` — host-side integration tests +//! +//! Drives the `seed_sqlite` shell helper in +//! `share/container/entry_script_lib_sh` via `sh` subprocess +//! and asserts every ADR-T-009 §7.2 outcome that does not +//! require root or writing to a managed container volume: +//! +//! | Test | Outcome | +//! |--------------------------------|----------------------------------| +//! | `empty_path_errors` | exit 1 with "database.path is empty" | +//! | `memory_path_skips` | exit 0 with INFO line | +//! | `relative_path_skips` | exit 0 with WARN line | +//! | `nonempty_absolute_untouched` | exit 0, file bytes unchanged | +//! | `outside_volumes_errors` | exit 1 with "outside the … volumes" | +//! +//! The "missing-under-volume seeded" outcome (mkdir + `inst()` +//! into `/var/lib/torrust/index/`) requires root and the +//! container's `torrust` user; it is exercised by the +//! container e2e suite (Phase 8/9). + +use std::fs; + +use tempfile::TempDir; +use torrust_index_entry_script::run_sh_with_args; + +const SNIPPET: &str = "seed_sqlite \"$1\""; + +#[test] +fn empty_path_errors() { + let out = run_sh_with_args(SNIPPET, &[""]); + assert_eq!(out.status.code(), Some(1), "expected exit 1"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("database.path is empty"), + "missing diagnostic; stderr={stderr}", + ); +} + +#[test] +fn memory_path_skips() { + let out = run_sh_with_args(SNIPPET, &[":memory:"]); + assert!( + out.status.success(), + "expected success; status={:?}; stderr={}", + out.status.code(), + String::from_utf8_lossy(&out.stderr), + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("INFO") && stderr.contains(":memory:"), + "missing INFO/:memory: line; stderr={stderr}", + ); +} + +#[test] +fn relative_path_skips() { + let out = run_sh_with_args(SNIPPET, &["data/index.db"]); + assert!( + out.status.success(), + "expected success; status={:?}; stderr={}", + out.status.code(), + String::from_utf8_lossy(&out.stderr), + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("WARN") && stderr.contains("relative SQLite path"), + "missing WARN/relative line; stderr={stderr}", + ); +} + +#[test] +fn nonempty_absolute_untouched() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("existing.db"); + let payload = b"preexisting-content"; + fs::write(&path, payload).expect("write existing"); + + let path_str = path.to_str().expect("tempdir path is utf-8"); + let out = run_sh_with_args(SNIPPET, &[path_str]); + + assert!( + out.status.success(), + "expected success; status={:?}; stderr={}", + out.status.code(), + String::from_utf8_lossy(&out.stderr), + ); + + let after = fs::read(&path).expect("read after"); + assert_eq!(after, payload, "non-empty existing file must not be touched"); +} + +#[test] +fn outside_volumes_errors() { + let dir = TempDir::new().expect("tempdir"); + // Force the parent-doesn't-exist branch so seed_sqlite + // hits the volumes-only guard rather than trying to + // install into an existing tempdir. + let path = dir.path().join("sub").join("missing.db"); + let path_str = path.to_str().expect("tempdir path is utf-8"); + + let out = run_sh_with_args(SNIPPET, &[path_str]); + + assert_eq!(out.status.code(), Some(1), "expected exit 1"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("outside the") && stderr.contains("volumes"), + "missing volumes-guard diagnostic; stderr={stderr}", + ); +} diff --git a/packages/index-entry-script/tests/validate_auth_keys.rs b/packages/index-entry-script/tests/validate_auth_keys.rs new file mode 100644 index 000000000..2632fee2f --- /dev/null +++ b/packages/index-entry-script/tests/validate_auth_keys.rs @@ -0,0 +1,101 @@ +//! # `validate_auth_keys` — host-side integration tests +//! +//! Drives the `validate_auth_keys` shell helper in +//! `share/container/entry_script_lib_sh` via `sh` subprocess +//! and asserts every branch of ADR-T-009 §7.1's invariants: +//! +//! | Test | Invariant exercised | +//! |-----------------------------------|----------------------------| +//! | `both_none_passes` | happy path — no config | +//! | `both_path_passes` | happy path — both PATH | +//! | `both_pem_passes` | happy path — both PEM | +//! | `private_pem_and_path_errors` | per-key mutual exclusion | +//! | `public_pem_and_path_errors` | per-key mutual exclusion | +//! | `private_only_errors` | pair completeness | +//! | `public_only_errors` | pair completeness | +//! | `mixed_pem_path_errors` | cross-pair source consistency | +//! +//! Argument order to `validate_auth_keys`: +//! +//! ```text +//! priv_pem_set priv_path_set priv_source +//! pub_pem_set pub_path_set pub_source +//! ``` + +use torrust_index_entry_script::run_sh_with_args; + +const SNIPPET: &str = "validate_auth_keys \"$@\""; + +fn assert_pass(args: &[&str]) { + let out = run_sh_with_args(SNIPPET, args); + assert!( + out.status.success(), + "expected success for args {:?}; status={:?}; stderr={}", + args, + out.status.code(), + String::from_utf8_lossy(&out.stderr), + ); + assert!( + out.stderr.is_empty(), + "expected silent success for args {:?}; stderr={}", + args, + String::from_utf8_lossy(&out.stderr), + ); +} + +fn assert_fail(args: &[&str], needle: &str) { + let out = run_sh_with_args(SNIPPET, args); + assert_eq!( + out.status.code(), + Some(1), + "expected exit 1 for args {:?}; got {:?}; stderr={}", + args, + out.status.code(), + String::from_utf8_lossy(&out.stderr), + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains(needle), + "expected diagnostic containing {needle:?} for args {args:?}; got: {stderr}", + ); +} + +#[test] +fn both_none_passes() { + assert_pass(&["false", "false", "none", "false", "false", "none"]); +} + +#[test] +fn both_path_passes() { + assert_pass(&["false", "true", "path", "false", "true", "path"]); +} + +#[test] +fn both_pem_passes() { + assert_pass(&["true", "false", "pem", "true", "false", "pem"]); +} + +#[test] +fn private_pem_and_path_errors() { + assert_fail(&["true", "true", "pem", "false", "false", "none"], "mutually exclusive"); +} + +#[test] +fn public_pem_and_path_errors() { + assert_fail(&["false", "false", "none", "true", "true", "pem"], "mutually exclusive"); +} + +#[test] +fn private_only_errors() { + assert_fail(&["false", "true", "path", "false", "false", "none"], "complete pair"); +} + +#[test] +fn public_only_errors() { + assert_fail(&["false", "false", "none", "false", "true", "path"], "complete pair"); +} + +#[test] +fn mixed_pem_path_errors() { + assert_fail(&["true", "false", "pem", "false", "true", "path"], "mixed PEM/PATH"); +} diff --git a/packages/index-health-check/Cargo.toml b/packages/index-health-check/Cargo.toml new file mode 100644 index 000000000..f3509ad90 --- /dev/null +++ b/packages/index-health-check/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "torrust-index-health-check" + +authors.workspace = true +description = "Minimal health-check binary for Torrust Index containers" +edition.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +torrust-index-cli-common = { version = "4.0.0-develop", path = "../index-cli-common" } + +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +tracing = "0" + +[dev-dependencies] +serde_json = "1" + +[lints] +workspace = true diff --git a/packages/index-health-check/src/bin/torrust-index-health-check.rs b/packages/index-health-check/src/bin/torrust-index-health-check.rs new file mode 100644 index 000000000..fbaba35d2 --- /dev/null +++ b/packages/index-health-check/src/bin/torrust-index-health-check.rs @@ -0,0 +1,52 @@ +//! Minimal health-check binary for Torrust Index containers. +//! +//! On success (exit 0), emits a JSON object to stdout per P9. +//! On failure (exit ≠ 0), stdout is empty; diagnostics go to stderr. + +use std::process::ExitCode; + +use clap::Parser; +use torrust_index_cli_common::{BaseArgs, emit, init_json_tracing, refuse_if_stdout_is_tty}; +use torrust_index_health_check::do_health_check; +use tracing::error; + +#[derive(Parser)] +#[command( + name = "torrust-index-health-check", + about = "Minimal health-check for Torrust Index containers" +)] +struct Args { + /// URL to check, e.g. `http://localhost:3001/health_check`. + url: String, + + #[command(flatten)] + base: BaseArgs, +} + +fn main() -> ExitCode { + let args = Args::parse(); + init_json_tracing(if args.base.debug { + tracing::Level::DEBUG + } else { + tracing::Level::INFO + }); + refuse_if_stdout_is_tty("torrust-index-health-check"); + + match do_health_check(&args.url) { + Ok(out) => match emit(&out) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + // Writing the JSON object to stdout failed (e.g. broken + // pipe when the consumer has already exited). Surface + // the failure through the documented exit-code contract + // rather than letting Rust's panic handler set its own. + error!(error = %e, "failed to write health-check output to stdout"); + ExitCode::FAILURE + } + }, + Err(e) => { + error!(error = %e, "health check failed"); + ExitCode::FAILURE + } + } +} diff --git a/packages/index-health-check/src/lib.rs b/packages/index-health-check/src/lib.rs new file mode 100644 index 000000000..d5d20b625 --- /dev/null +++ b/packages/index-health-check/src/lib.rs @@ -0,0 +1,215 @@ +//! Minimal health-check library for Torrust Index containers. +//! +//! Uses only `std::net::TcpStream` — no async runtime, no TLS crate. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use serde::Serialize; + +#[cfg(test)] +mod tests; + +#[derive(Serialize)] +pub struct HealthCheckOutput { + pub target: String, + pub status: u16, + pub elapsed_ms: u64, +} + +#[derive(Debug)] +pub struct HealthCheckError(pub String); + +impl std::fmt::Display for HealthCheckError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// # Errors +/// +/// Returns an error if the URL is malformed, the connection fails, +/// or the server responds with a non-2xx status. +pub fn do_health_check(url: &str) -> Result { + let start = Instant::now(); + + // Parse URL: expect http://host:port/path + let stripped = url + .strip_prefix("http://") + .ok_or_else(|| HealthCheckError(format!("unsupported URL scheme: {url}")))?; + + let (host_port, path) = stripped + .find('/') + .map_or((stripped, "/"), |i| (&stripped[..i], &stripped[i..])); + + let timeout = Duration::from_secs(5); + + // Resolve `host:port` ourselves so we can apply an explicit + // connect-timeout: `TcpStream::connect` falls back to the OS's + // SYN-retransmit schedule (often tens of seconds), which is far + // too long for a container orchestrator's health probe. + // + // `localhost` typically resolves to both `::1` and `127.0.0.1`. + // Glibc returns IPv6 first, but the index API binds to + // `0.0.0.0` (IPv4 only) and the tracker statistics importer to + // `127.0.0.1`, so a probe that only tries the first address + // gets `Connection refused` on `::1` and never reaches the + // listening IPv4 socket. We follow Happy Eyeballs (RFC 8305): + // prefer IPv6 first, but launch the IPv4 attempt after a + // small delay so a black-holed (no-RST) IPv6 address cannot + // burn the entire HEALTHCHECK budget. First success wins. + let addrs: Vec = host_port + .to_socket_addrs() + .map_err(|e| HealthCheckError(format!("resolve {host_port}: {e}")))? + .collect(); + if addrs.is_empty() { + return Err(HealthCheckError(format!("resolve {host_port}: no addresses returned"))); + } + + let mut stream = happy_eyeballs_connect(host_port, &addrs, timeout)?; + stream + .set_read_timeout(Some(timeout)) + .map_err(|e| HealthCheckError(format!("set read timeout: {e}")))?; + stream + .set_write_timeout(Some(timeout)) + .map_err(|e| HealthCheckError(format!("set write timeout: {e}")))?; + + let request = format!("GET {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n\r\n"); + stream + .write_all(request.as_bytes()) + .map_err(|e| HealthCheckError(format!("write request: {e}")))?; + stream.flush().map_err(|e| HealthCheckError(format!("flush request: {e}")))?; + + let mut reader = BufReader::new(&stream); + let mut status_line = String::new(); + reader + .read_line(&mut status_line) + .map_err(|e| HealthCheckError(format!("read status line: {e}")))?; + + // Parse "HTTP/1.x NNN ..." + let parts: Vec<&str> = status_line.splitn(3, ' ').collect(); + if parts.len() < 2 { + return Err(HealthCheckError(format!("malformed status line: {status_line:?}"))); + } + let status: u16 = parts[1] + .parse() + .map_err(|_| HealthCheckError(format!("invalid status code: {:?}", parts[1])))?; + + let elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); + + if !(200..300).contains(&status) { + return Err(HealthCheckError(format!("non-success status {status} from {url}"))); + } + + Ok(HealthCheckOutput { + target: url.to_string(), + status, + elapsed_ms, + }) +} + +/// Per-attempt launch stagger (RFC 8305 "Connection Attempt Delay"). +/// +/// 250 ms is the spec's default. Long enough that a successful +/// connect on the preferred family almost always finishes first; +/// short enough that a black-holed address can't burn the entire +/// HEALTHCHECK budget. +const CONNECT_ATTEMPT_DELAY: Duration = Duration::from_millis(250); + +/// Connect to the first address that responds, preferring IPv6 +/// (RFC 6724 / 8305 default). +/// +/// Algorithm (Happy Eyeballs v2, lite): +/// +/// 1. Split resolved addresses by family, preserving the +/// resolver's relative order within each family. +/// 2. Interleave them — `v6, v4, v6, v4, …` — so an IPv6 +/// address is always attempted first. +/// 3. Spawn one connect thread per address, staggered by +/// `CONNECT_ATTEMPT_DELAY`. Each attempt is bounded by the +/// remaining time of the overall `total_timeout` budget. +/// 4. The first successful `TcpStream` wins; remaining +/// in-flight attempts are abandoned (the channel send on +/// a closed receiver is silently dropped). +/// +/// Threads outlive this call only briefly — at worst until +/// their connect attempt completes against the bounded +/// per-attempt timeout. This is a CLI binary that exits +/// immediately after `do_health_check` returns, so any +/// background I/O is reaped by the OS. +fn happy_eyeballs_connect(host_port: &str, addrs: &[SocketAddr], total_timeout: Duration) -> Result { + // Single-address fast path: no scheduling overhead, no thread. + if let [only] = addrs { + return TcpStream::connect_timeout(only, total_timeout) + .map_err(|e| HealthCheckError(format!("connect to {host_port} ({only}): {e}"))); + } + + // Split by family, preserving resolver order within each. + let (v6, v4): (Vec, Vec) = addrs.iter().copied().partition(SocketAddr::is_ipv6); + + // Interleave: IPv6 first, then matching IPv4, etc. + let mut ordered = Vec::with_capacity(addrs.len()); + let mut v6 = v6.into_iter(); + let mut v4 = v4.into_iter(); + loop { + let a = v6.next(); + let b = v4.next(); + if a.is_none() && b.is_none() { + break; + } + if let Some(x) = a { + ordered.push(x); + } + if let Some(x) = b { + ordered.push(x); + } + } + + let deadline = Instant::now() + total_timeout; + let (tx, rx) = mpsc::channel::>(); + + for (i, addr) in ordered.iter().copied().enumerate() { + let tx = tx.clone(); + let host_port = host_port.to_string(); + // `i as u32` would clip silently for absurd address counts; + // saturate explicitly so the stagger stays well-defined. + let stagger = CONNECT_ATTEMPT_DELAY * u32::try_from(i).unwrap_or(u32::MAX); + thread::spawn(move || { + if !stagger.is_zero() { + thread::sleep(stagger); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + // Past the overall budget; don't even try. + drop(tx.send(Err(HealthCheckError(format!( + "connect to {host_port} ({addr}): deadline elapsed before attempt" + ))))); + return; + } + let result = TcpStream::connect_timeout(&addr, remaining) + .map_err(|e| HealthCheckError(format!("connect to {host_port} ({addr}): {e}"))); + // Receiver may already be gone (another address won). + // The send error is informational only. + drop(tx.send(result)); + }); + } + drop(tx); // close the original sender so `recv_timeout` exits + // cleanly once every spawned thread has finished. + + let mut last_err: Option = None; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + match rx.recv_timeout(remaining) { + Ok(Ok(stream)) => return Ok(stream), + Ok(Err(e)) => last_err = Some(e), + Err(_) => break, // overall deadline elapsed or all senders gone + } + } + Err(last_err.unwrap_or_else(|| HealthCheckError(format!("connect to {host_port}: no address could be reached")))) +} diff --git a/packages/index-health-check/src/tests/mod.rs b/packages/index-health-check/src/tests/mod.rs new file mode 100644 index 000000000..d64211aa8 --- /dev/null +++ b/packages/index-health-check/src/tests/mod.rs @@ -0,0 +1,193 @@ +//! # Health-check tests +//! +//! | Test | What it covers | +//! |------------------------------------|---------------------------------------| +//! | `success_on_200` | Happy path: 200 OK response | +//! | `failure_on_non_2xx` | Non-success HTTP status code | +//! | `failure_on_connection_refused` | Target not listening | +//! | `failure_on_read_timeout` | Server accepts but never responds | +//! | `failure_on_malformed_status_line` | Server sends garbage | +//! | `localhost_falls_back_to_ipv4` | `::1` refuses, `127.0.0.1` listens | +//! | `ipv6_literal_url_is_supported` | `http://[::1]:port/` parses + connects | +//! | `prefers_ipv6_when_both_listen` | Dual-stack: IPv6 attempted first | + +use std::io::{Read, Write}; +use std::net::{IpAddr, TcpListener, ToSocketAddrs}; + +/// Bind an ephemeral-port listener and return (listener, url). +fn ephemeral_server() -> (TcpListener, String) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("http://127.0.0.1:{}/health_check", addr.port()); + (listener, url) +} + +/// Accept one connection, read the request, respond with `response`. +fn serve_once(listener: &TcpListener, response: &[u8]) { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0u8; 1024]; + let _n = stream.read(&mut buf); + stream.write_all(response).unwrap(); + stream.flush().unwrap(); +} + +#[test] +fn success_on_200() { + let (listener, url) = ephemeral_server(); + let handle = std::thread::spawn(move || super::do_health_check(&url)); + serve_once(&listener, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + let result = handle.join().unwrap(); + assert!(result.is_ok()); + let output = result.unwrap(); + assert_eq!(output.status, 200); +} + +#[test] +fn failure_on_non_2xx() { + let (listener, url) = ephemeral_server(); + let handle = std::thread::spawn(move || super::do_health_check(&url)); + serve_once(&listener, b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n"); + let result = handle.join().unwrap(); + assert!(result.is_err()); +} + +#[test] +fn failure_on_connection_refused() { + // Use an ephemeral port that nothing is listening on. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); // Close it so connection is refused. + let url = format!("http://127.0.0.1:{port}/health_check"); + let result = super::do_health_check(&url); + assert!(result.is_err()); +} + +#[test] +fn failure_on_read_timeout() { + let (listener, url) = ephemeral_server(); + let handle = std::thread::spawn(move || super::do_health_check(&url)); + // Accept the connection but never send a response. + let (_stream, _) = listener.accept().unwrap(); + let result = handle.join().unwrap(); + assert!(result.is_err()); +} + +#[test] +fn failure_on_malformed_status_line() { + let (listener, url) = ephemeral_server(); + let handle = std::thread::spawn(move || super::do_health_check(&url)); + serve_once(&listener, b"GARBAGE\r\n\r\n"); + let result = handle.join().unwrap(); + assert!(result.is_err()); +} + +/// Regression: the in-container probe targets `localhost`, which +/// glibc resolves to `::1` first and `127.0.0.1` second. The index +/// API binds to `0.0.0.0` (IPv4) and the importer to `127.0.0.1`, +/// so a probe that only tried the first resolved address would +/// fail with `Connection refused` on `::1` and never reach the +/// listening IPv4 socket. Bind an IPv4-only listener on the same +/// port `::1` rejects, then probe via a name that resolves to +/// both — the call must succeed by falling back to IPv4. +#[test] +fn localhost_falls_back_to_ipv4() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + // Sanity-check that `localhost` actually resolves to both + // families on this host; if it doesn't (e.g. IPv6-disabled + // CI runner), the test isn't exercising the regression path + // and we skip rather than give a false pass. + let mut saw_v6 = false; + let mut saw_v4 = false; + for a in ("localhost", port).to_socket_addrs().unwrap() { + match a.ip() { + IpAddr::V6(_) => saw_v6 = true, + IpAddr::V4(_) => saw_v4 = true, + } + } + if !(saw_v6 && saw_v4) { + eprintln!("skipping: `localhost` does not resolve to both v6 and v4 here"); + return; + } + + let url = format!("http://localhost:{port}/health_check"); + let handle = std::thread::spawn(move || super::do_health_check(&url)); + serve_once(&listener, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + let result = handle.join().unwrap(); + assert!(result.is_ok(), "expected v4 fallback to succeed"); +} + +/// IPv6 literal in the URL must round-trip through both the +/// URL parser (which keeps the `[::1]:port` host token intact) +/// and `to_socket_addrs` (which understands bracketed literals). +#[test] +fn ipv6_literal_url_is_supported() { + let listener = match TcpListener::bind("[::1]:0") { + Ok(l) => l, + Err(e) => { + eprintln!("skipping: cannot bind [::1]: {e}"); + return; + } + }; + let port = listener.local_addr().unwrap().port(); + let url = format!("http://[::1]:{port}/health_check"); + let handle = std::thread::spawn(move || super::do_health_check(&url)); + serve_once(&listener, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + let result = handle.join().unwrap(); + assert!(result.is_ok(), "expected IPv6 literal probe to succeed"); +} + +/// Happy Eyeballs preference: when both families have a listener, +/// the IPv6 attempt fires first (no stagger) and should win the +/// race. Verified by binding only on `::1` and `127.0.0.1` to the +/// same ephemeral port and checking which one accepts. The IPv6 +/// listener accepts; the IPv4 one stays idle. +#[test] +fn prefers_ipv6_when_both_listen() { + use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; + + // Bind v6 first to discover an unused port; then bind the + // matching v4 on the same port. If the kernel happens to be + // configured with `bindv6only=0` and grabbed the v4 wildcard + // already, the second bind will fail — skip in that case. + let v6 = match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, 0))) { + Ok(l) => l, + Err(e) => { + eprintln!("skipping: cannot bind ::1: {e}"); + return; + } + }; + let port = v6.local_addr().unwrap().port(); + let v4 = match TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))) { + Ok(l) => l, + Err(e) => { + eprintln!("skipping: cannot bind 127.0.0.1:{port}: {e}"); + return; + } + }; + + // Sanity-check resolver order to make sure this host has both + // families available for `localhost`. + let mut saw_v6 = false; + let mut saw_v4 = false; + for a in ("localhost", port).to_socket_addrs().unwrap() { + match a.ip() { + IpAddr::V6(_) => saw_v6 = true, + IpAddr::V4(_) => saw_v4 = true, + } + } + if !(saw_v6 && saw_v4) { + eprintln!("skipping: `localhost` does not resolve to both v6 and v4 here"); + return; + } + + let url = format!("http://localhost:{port}/health_check"); + let handle = std::thread::spawn(move || super::do_health_check(&url)); + // The IPv6 listener fires first (no stagger). Accept on it; + // the IPv4 listener intentionally never accepts. + serve_once(&v6, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + let result = handle.join().unwrap(); + drop(v4); + assert!(result.is_ok(), "IPv6 should have won the connect race"); +} diff --git a/packages/index-health-check/tests/health_check.rs b/packages/index-health-check/tests/health_check.rs new file mode 100644 index 000000000..1949bdd6f --- /dev/null +++ b/packages/index-health-check/tests/health_check.rs @@ -0,0 +1,80 @@ +//! # Health-check integration tests +//! +//! | Test | What it covers | +//! |-----------------------------------------|-----------------------------------------| +//! | `success_on_healthy_server` | 200 OK → Ok with status 200 | +//! | `failure_on_sick_server` | 503 → Err | +//! | `failure_on_unreachable` | Connection refused → Err | +//! | `failure_on_unsupported_scheme` | https:// → Err | +//! | `output_contains_expected_fields` | Output has target, status, elapsed_ms | + +use std::io::{Read, Write}; +use std::net::TcpListener; + +use torrust_index_health_check::do_health_check; + +/// Bind an ephemeral-port listener and return (listener, url). +fn ephemeral_server() -> (TcpListener, String) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let url = format!("http://127.0.0.1:{}/health_check", addr.port()); + (listener, url) +} + +/// Accept one connection, read the request, respond with `response`. +fn serve_once(listener: &TcpListener, response: &[u8]) { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0u8; 1024]; + let _n = stream.read(&mut buf); + stream.write_all(response).unwrap(); + stream.flush().unwrap(); +} + +#[test] +fn success_on_healthy_server() { + let (listener, url) = ephemeral_server(); + let handle = std::thread::spawn(move || do_health_check(&url)); + serve_once(&listener, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + let result = handle.join().unwrap(); + assert!(result.is_ok()); + assert_eq!(result.unwrap().status, 200); +} + +#[test] +fn failure_on_sick_server() { + let (listener, url) = ephemeral_server(); + let handle = std::thread::spawn(move || do_health_check(&url)); + serve_once(&listener, b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n"); + let result = handle.join().unwrap(); + assert!(result.is_err()); +} + +#[test] +fn failure_on_unreachable() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let url = format!("http://127.0.0.1:{port}/health_check"); + let result = do_health_check(&url); + assert!(result.is_err()); +} + +#[test] +fn failure_on_unsupported_scheme() { + let result = do_health_check("https://localhost:3001/health_check"); + assert!(result.is_err()); +} + +#[test] +fn output_contains_expected_fields() { + let (listener, url) = ephemeral_server(); + let handle = std::thread::spawn(move || do_health_check(&url)); + serve_once(&listener, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + let output = handle.join().unwrap().unwrap(); + + let json = serde_json::to_value(&output).unwrap(); + assert!(json.get("target").is_some(), "missing 'target' field"); + assert!(json.get("status").is_some(), "missing 'status' field"); + assert!(json.get("elapsed_ms").is_some(), "missing 'elapsed_ms' field"); + assert_eq!(json["status"], 200); +} diff --git a/share/container/entry_script_lib_sh b/share/container/entry_script_lib_sh new file mode 100644 index 000000000..d7afe7e04 --- /dev/null +++ b/share/container/entry_script_lib_sh @@ -0,0 +1,153 @@ +# shellcheck shell=sh +# Pure helper functions for share/container/entry_script_sh. +# +# Sourced by the entry script and by host-side tests under +# share/container/tests/. Contains no top-level statements +# beyond function definitions, so sourcing has no side +# effects. +# +# Conventions: +# - All functions are POSIX sh; no bashisms. +# - Functions use only `[ ... ]` and `case` for tests. +# - Functions emit human-readable diagnostics on stderr +# and use `exit 1` for unrecoverable input violations +# (auth-key validation, seed_sqlite path errors). When +# sourced for tests, callers must invoke each function +# in a subshell to observe the exit status without +# terminating the test driver. + +# install -D wrapper: copy $1 to $2 only if $1 exists and $2 +# does not yet exist. Mode 0640, owner torrust:torrust. +# The bare `if ... fi` (no `else`) is `set -e`-safe per +# POSIX §2.9.4.1. +inst() { + if [ -n "$1" ] && [ -n "$2" ] && [ -e "$1" ] && [ ! -e "$2" ]; then + install -D -m 0640 -o torrust -g torrust "$1" "$2"; fi; } + +# Returns 0 if $1 is one of the closed-set source values +# {pem,path}; 1 otherwise. Tests positively against the +# allow-list so a future probe value is rejected explicitly +# instead of being treated as "configured". Only ever called +# inside a conditional position (set -e safe). +key_configured() { + case $1 in + pem|path) return 0 ;; + *) return 1 ;; + esac +} + +# Validate the resolved auth-key probe output from §7.1. +# Enforces three invariants and exits 1 with a diagnostic on +# the first violation: +# +# 1. Mutual exclusion: within one key, PEM and PATH cannot +# both be set (regardless of source). +# 2. Pair completeness: both keys configured, or neither +# (no half-pair). +# 3. Cross-pair source consistency: both keys must use the +# same delivery mechanism (no mixed PEM/PATH). +# +# Arguments (positional): +# $1 priv_pem_set "true" / "false" +# $2 priv_path_set "true" / "false" +# $3 priv_source "pem" / "path" / "none" +# $4 pub_pem_set "true" / "false" +# $5 pub_path_set "true" / "false" +# $6 pub_source "pem" / "path" / "none" +validate_auth_keys() { + _priv_pem_set=$1; _priv_path_set=$2; _priv_src=$3 + _pub_pem_set=$4; _pub_path_set=$5; _pub_src=$6 + + if [ "$_priv_pem_set" = true ] && [ "$_priv_path_set" = true ]; then + echo "ERROR: both PRIVATE_KEY_PEM and PRIVATE_KEY_PATH are set;" \ + "these are mutually exclusive — pick one." >&2 + exit 1 + fi + if [ "$_pub_pem_set" = true ] && [ "$_pub_path_set" = true ]; then + echo "ERROR: both PUBLIC_KEY_PEM and PUBLIC_KEY_PATH are set;" \ + "these are mutually exclusive — pick one." >&2 + exit 1 + fi + + _priv_has=0; key_configured "$_priv_src" && _priv_has=1 + _pub_has=0; key_configured "$_pub_src" && _pub_has=1 + if [ "$_priv_has" -ne "$_pub_has" ]; then + echo "ERROR: auth keys must be configured as a complete pair;" \ + "one key is configured but the other is not." >&2 + exit 1 + fi + + if [ "$_priv_has" -eq 1 ] && [ "$_pub_has" -eq 1 ] \ + && [ "$_priv_src" != "$_pub_src" ]; then + echo "ERROR: private key source is '$_priv_src'" \ + "but public key source is '$_pub_src';" \ + "mixed PEM/PATH across the key pair is not supported" \ + "— use the same delivery mechanism for both keys." >&2 + exit 1 + fi +} + +# Seed an empty SQLite database file at the resolved path. +# $1 = absolute path from the probe's database.path field. +# Handles five outcomes (see ADR-T-009 §7.2 seeding rules): +# - empty path → probe bug, error +# - :memory: → info, no-op +# - relative path → warn, no-op +# - absolute, exists → leave alone +# - absolute, missing → volumes-only mkdir + inst() +seed_sqlite() { + _path=$1 + _template=/usr/share/torrust/default/database/index.sqlite3.db + + if [ -z "$_path" ]; then + echo "ERROR: probe reported sqlite driver but" \ + "database.path is empty — possible probe bug" >&2 + exit 1 + fi + + if [ "$_path" = ":memory:" ]; then + echo "INFO: SQLite :memory: — no database file to seed" >&2 + return 0 + fi + + case $_path in + /*) ;; + *) + echo "WARN: relative SQLite path '$_path';" \ + "not seeding — application will create on" \ + "first connect if mode=rwc" >&2 + return 0 + ;; + esac + + if [ -s "$_path" ]; then + return 0 + fi + + if [ -e "$_path" ] && [ ! -s "$_path" ]; then + rm -f "$_path" + fi + + _dir=$(dirname "$_path") + if [ ! -d "$_dir" ]; then + case "$_dir" in + /etc/torrust/index|/etc/torrust/index/*|\ + /var/lib/torrust/index|/var/lib/torrust/index/*|\ + /var/log/torrust/index|/var/log/torrust/index/*) + mkdir -p "$_dir" + chown torrust:torrust "$_dir" + chmod 0750 "$_dir" + ;; + *) + echo "ERROR: database path $_dir is outside the" \ + "volumes the entry script manages." >&2 + echo " Pre-create it with appropriate" \ + "ownership, or use a path under" \ + "/var/lib/torrust/index/." >&2 + exit 1 + ;; + esac + fi + + inst "$_template" "$_path" +} diff --git a/share/container/entry_script_sh b/share/container/entry_script_sh old mode 100644 new mode 100755 index 90f560a9a..a4c94cf2a --- a/share/container/entry_script_sh +++ b/share/container/entry_script_sh @@ -1,75 +1,135 @@ #!/bin/sh -set -x +[ "${DEBUG:-}" = "1" ] && set -x -to_lc() { echo "$1" | tr '[:upper:]' '[:lower:]'; } -clean() { echo "$1" | tr -d -c 'a-zA-Z0-9-' ; } -cmp_lc() { [ "$(to_lc "$(clean "$1")")" = "$(to_lc "$(clean "$2")")" ]; } +# ── Canonical env-var manifest (ADR-T-009 Acceptance Criterion #7) ── +# Single source of truth for every environment variable the +# entry script consults — including dynamically constructed +# names (e.g. AUTH__{PRIVATE,PUBLIC}_KEY_{PEM,PATH} built +# via "${uc_pair}_PEM" / "${uc_pair}_PATH") that a naive +# grep would miss. CI verifies that every name listed +# between the sentinels appears somewhere in +# docs/containers.md. Keep the block in sync when adding +# or removing env vars. +# +# ENTRY_ENV_VARS: +# TORRUST_INDEX_CONFIG_TOML_PATH +# TORRUST_INDEX_DATABASE_DRIVER +# TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PEM +# TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PRIVATE_KEY_PATH +# TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PEM +# TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__PUBLIC_KEY_PATH +# TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN +# TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL +# USER_ID +# API_PORT +# IMPORTER_API_PORT +# DEBUG +# TZ +# RUNTIME +# END_ENTRY_ENV_VARS +# ── Shell discipline (Phase 7, ADR-T-009 §D3) ──────────── +# `set -e` aborts on unchecked failures; `set -u` catches +# typos in variable names. The `${var:-}` patterns below are +# `set -u`-safe by construction. Helper functions defined +# in this file are written to be `set -e`-safe (see the +# entry-script audit notes in ADR-T-009 §D3). +set -eu -inst() { - if [ -n "$1" ] && [ -n "$2" ] && [ -e "$1" ] && [ ! -e "$2" ]; then - install -D -m 0640 -o torrust -g torrust "$1" "$2"; fi; } +# ── Helper functions (sourced) ──────────────────────────── +# Pure functions live in entry_script_lib_sh so the +# Rust integration tests in packages/index-entry-script/ +# can source them directly. The library has no top-level +# side effects. +# shellcheck disable=SC1091 # runtime path; not resolvable at lint time +. /usr/local/lib/torrust/entry_script_lib_sh - -# Add torrust user, based upon supplied user-id. -if [ -z "$USER_ID" ] && [ "$USER_ID" -lt 1000 ]; then - echo "ERROR: USER_ID is not set, or less than 1000" +# ── User account setup ──────────────────────────────────── +# D7: validate that USER_ID is numeric and is not 0 (root). +case ${USER_ID:-} in + ''|*[!0-9]*) + echo "ERROR: USER_ID is unset or not numeric" >&2 + exit 1 + ;; +esac +if [ "$USER_ID" -eq 0 ]; then + echo "ERROR: USER_ID is 0 (root) — refusing to run as root" >&2 exit 1 fi -adduser --disabled-password --shell "/bin/sh" --uid "$USER_ID" "torrust" +# Use the busybox `adduser` short-option form so the same +# invocation works on both runtime bases (release uses the +# lean distroless base + a single root-only busybox; debug +# inherits the donor's full /busybox/ tree). Distroless +# `cc-debian13` ships /etc/passwd and /etc/group but not +# /etc/shadow; `-D` honours that. Busybox `adduser -s` does +# not consult /etc/shells, which the lean base also lacks. +# +# Busybox `adduser -D -u UID NAME` writes only `/etc/passwd`; +# it does not synthesise a matching `/etc/group` entry, so a +# downstream `install -g torrust …` would fail with +# `unknown group torrust`. Create the group first with +# `addgroup`, then attach the user to it via `-G`. +# +# Idempotency: container restarts (e.g. under a restart +# policy) re-execute the entry script against the same +# writable layer, so the `torrust` group/user already exist +# on subsequent boots. Both steps are guarded so the restart +# path is a no-op rather than a fatal exit under `set -e`. +if ! grep -q '^torrust:' /etc/group; then + addgroup -g "$USER_ID" torrust +fi +if ! grep -q '^torrust:' /etc/passwd; then + adduser -D -s /bin/sh -u "$USER_ID" -G torrust torrust +fi -# Configure Permissions for Torrust Folders -mkdir -p /var/lib/torrust/index/database/ /etc/torrust/index/ +# ── Volume directory ownership ──────────────────────────── +mkdir -p /var/lib/torrust/index/database/ /var/log/torrust/index/ /etc/torrust/index/ chown -R "${USER_ID}" /var/lib/torrust /var/log/torrust /etc/torrust chmod -R 2770 /var/lib/torrust /var/log/torrust /etc/torrust - -# Install the database and config: -if [ -n "$TORRUST_INDEX_DATABASE_DRIVER" ]; then - if cmp_lc "$TORRUST_INDEX_DATABASE_DRIVER" "sqlite3"; then - - # Select sqlite3 empty database - default_database="/usr/share/torrust/default/database/index.sqlite3.db" - - # Select sqlite3 default configuration +# ── Default TOML installation ───────────────────────────── +# `TORRUST_INDEX_DATABASE_DRIVER` is now a Containerfile- +# level selector for which default TOML to seed at first +# boot; it no longer drives runtime database decisions +# (those come from the config probe's database.driver field, +# below). See ADR-T-009 §7.4. +case ${TORRUST_INDEX_DATABASE_DRIVER:-} in + sqlite3|SQLITE3|Sqlite3) default_config="/usr/share/torrust/default/config/index.container.sqlite3.toml" - - elif cmp_lc "$TORRUST_INDEX_DATABASE_DRIVER" "mysql"; then - - # (no database file needed for mysql) - - # Select default mysql configuration + ;; + mysql|MYSQL|MySQL|Mysql) default_config="/usr/share/torrust/default/config/index.container.mysql.toml" - - else - echo "Error: Unsupported Database Type: \"$TORRUST_INDEX_DATABASE_DRIVER\"." - echo "Please Note: Supported Database Types: \"sqlite3\", \"mysql\"." + ;; + '') + echo "ERROR: \$TORRUST_INDEX_DATABASE_DRIVER was not set!" >&2 exit 1 - fi -else - echo "Error: \"\$TORRUST_INDEX_DATABASE_DRIVER\" was not set!"; exit 1; -fi + ;; + *) + echo "ERROR: unsupported database driver:" \ + "\"$TORRUST_INDEX_DATABASE_DRIVER\"" >&2 + echo " Supported values: sqlite3, mysql." >&2 + exit 1 + ;; +esac install_config="/etc/torrust/index/index.toml" -install_database="/var/lib/torrust/index/database/sqlite3.db" - inst "$default_config" "$install_config" -inst "$default_database" "$install_database" - -# Make Minimal Message of the Day -if cmp_lc "$RUNTIME" "runtime"; then - printf '\n in runtime \n' >> /etc/motd; -elif cmp_lc "$RUNTIME" "debug"; then - printf '\n in debug mode \n' >> /etc/motd; -elif cmp_lc "$RUNTIME" "release"; then - printf '\n in release mode \n' >> /etc/motd; -else - echo "ERROR: running in unknown mode: \"$RUNTIME\""; exit 1 -fi + +# ── Message of the day ──────────────────────────────────── +case ${RUNTIME:-} in + runtime) printf '\n in runtime \n' >> /etc/motd ;; + debug) printf '\n in debug mode \n' >> /etc/motd ;; + release) printf '\n in release mode \n' >> /etc/motd ;; + *) + echo "ERROR: running in unknown mode: \"${RUNTIME:-}\"" >&2 + exit 1 + ;; +esac if [ -e "/usr/share/torrust/container/message" ]; then - cat "/usr/share/torrust/container/message" >> /etc/motd; chmod 0644 /etc/motd + cat "/usr/share/torrust/container/message" >> /etc/motd + chmod 0644 /etc/motd fi # Load message of the day from Profile @@ -78,30 +138,162 @@ echo '[ ! -z "$TERM" -a -r /etc/motd ] && cat /etc/motd' >> /etc/profile cd /home/torrust || exit 1 -# Generate auth keys if not already present on the volume. -auth_dir="/etc/torrust/index/auth" -private_key="$auth_dir/private.pem" -public_key="$auth_dir/public.pem" -tmpfile=$(mktemp /tmp/auth_keys.XXXXXX) -chmod 0600 "$tmpfile" -trap 'rm -f "$tmpfile"' EXIT - -if [ ! -s "$private_key" ] || [ ! -s "$public_key" ]; then - mkdir -p "$auth_dir" - chown torrust:torrust "$auth_dir" - chmod 0700 "$auth_dir" - - if ! torrust-generate-auth-keypair > "$tmpfile"; then - echo "ERROR: Failed to generate auth keypair" >&2 - exit 1 +# ── Config probe (ADR-T-009 §7.2) ───────────────────────── +# Resolve the operator's true configuration (TOML + env var +# overrides) via the same loader the application uses. The +# probe runs *before* this script exports any +# TORRUST_INDEX_CONFIG_OVERRIDE_* of its own, so its output +# reflects only operator-supplied values. The probe emits +# one JSON object on stdout (P9 contract). +probe_json=$(/usr/bin/torrust-index-config-probe) + +# Schema version gate — fail fast on probe/script mismatch. +probe_schema=$(printf '%s' "$probe_json" | jq -r '.schema') +if [ "$probe_schema" != "1" ]; then + echo "ERROR: config probe emitted schema=$probe_schema" \ + "but this entry script expects schema=1" \ + "— possible probe/script version mismatch" >&2 + exit 1 +fi + +# jq field extraction. Each variable is assigned directly +# from a jq call. `// empty` yields the empty string for +# absent/null fields. The `_pem_set` / `_path_set` / +# `_source` variables are passed positionally to +# `validate_auth_keys`; the `_source` / `_path` ones are +# also dereferenced via `eval` in the dispatch and +# materialisation loops below (computed variable names of +# the form `auth_${pair}_source`). +database_driver=$(printf '%s' "$probe_json" | jq -r '.database.driver') +database_path=$(printf '%s' "$probe_json" | jq -r '.database.path // empty') + +auth_private_key_pem_set=$(printf '%s' "$probe_json" | jq -r '.auth.private_key.pem_set') +auth_private_key_path_set=$(printf '%s' "$probe_json" | jq -r '.auth.private_key.path_set') +auth_private_key_source=$(printf '%s' "$probe_json" | jq -r '.auth.private_key.source') +# shellcheck disable=SC2034 # dereferenced via eval in auth-key loops +auth_private_key_path=$(printf '%s' "$probe_json" | jq -r '.auth.private_key.path // empty') + +auth_public_key_pem_set=$(printf '%s' "$probe_json" | jq -r '.auth.public_key.pem_set') +auth_public_key_path_set=$(printf '%s' "$probe_json" | jq -r '.auth.public_key.path_set') +auth_public_key_source=$(printf '%s' "$probe_json" | jq -r '.auth.public_key.source') +# shellcheck disable=SC2034 # dereferenced via eval in auth-key loops +auth_public_key_path=$(printf '%s' "$probe_json" | jq -r '.auth.public_key.path // empty') + +# ── Auth-key validation (post-probe) ────────────────────── +# Three invariants enforced together (see validate_auth_keys +# in entry_script_lib_sh): mutual exclusion within each key, +# pair-completeness across both keys, and cross-pair source +# consistency. +validate_auth_keys \ + "$auth_private_key_pem_set" "$auth_private_key_path_set" "$auth_private_key_source" \ + "$auth_public_key_pem_set" "$auth_public_key_path_set" "$auth_public_key_source" + +# ── Three-way auth-key path resolution (cases 1/2/3) ────── +# After this loop, ${pair}_path is set for every non-PEM key. +for pair in private_key public_key; do + src_var="auth_${pair}_source" + pth_var="auth_${pair}_path" + eval "src=\"\$$src_var\"" + eval "pth=\"\$$pth_var\"" + uc_pair=$(printf '%s' "$pair" | tr '[:lower:]' '[:upper:]') + + # shellcheck disable=SC2154 # src assigned by eval above + case $src in + pem) + # Case 1: app loads from env var directly. Nothing to do. + continue + ;; + path) + # Case 2: operator (or TOML) configured a path. + eval "${pair}_path=\"\$pth\"" + ;; + none) + # Case 3: no auth source configured — apply the + # container default and inform the application + # via the same override channel operators use. + case $pair in + private_key) default=/etc/torrust/index/auth/private.pem ;; + public_key) default=/etc/torrust/index/auth/public.pem ;; + esac + eval "${pair}_path=\"\$default\"" + export "TORRUST_INDEX_CONFIG_OVERRIDE_AUTH__${uc_pair}_PATH=$default" + ;; + esac +done + +# ── Volumes-only directory guard for auth keys ──────────── +# Applies to cases 2 and 3. The script auto-creates parent +# directories only inside the volumes it owns; anything +# else is the operator's responsibility. +for pair in private_key public_key; do + src_var="auth_${pair}_source" + eval "src=\"\$$src_var\"" + # shellcheck disable=SC2154 # src assigned by eval above + [ "$src" = pem ] && continue + + eval "keypath=\"\${${pair}_path}\"" + # shellcheck disable=SC2154 # keypath assigned by eval above + d=$(dirname "$keypath") + [ -d "$d" ] && continue + case "$d" in + /etc/torrust/index|/etc/torrust/index/*|\ + /var/lib/torrust/index|/var/lib/torrust/index/*|\ + /var/log/torrust/index|/var/log/torrust/index/*) + mkdir -p "$d" + chown torrust:torrust "$d" + chmod 0700 "$d" + ;; + *) + echo "ERROR: auth key path $d is outside the volumes" \ + "the entry script manages." >&2 + echo " Pre-create it with appropriate ownership," \ + "or place keys under /etc/torrust/index/ or" \ + "/var/lib/torrust/index/." >&2 + exit 1 + ;; + esac +done + +# ── Key materialisation (cases 2 and 3) ─────────────────── +# Both keys are generated together as a pair (the generator +# emits a matched keypair in one invocation). If either file +# is missing or empty, regenerate both — a half-pair is not +# useful. +if [ -n "${private_key_path:-}" ] && [ -n "${public_key_path:-}" ]; then + if [ ! -s "$private_key_path" ] || [ ! -s "$public_key_path" ]; then + keypair_json=$(/usr/bin/torrust-index-auth-keypair) + printf '%s' "$keypair_json" | jq -r .private_key_pem > "$private_key_path" + printf '%s' "$keypair_json" | jq -r .public_key_pem > "$public_key_path" + chown torrust:torrust "$private_key_path" "$public_key_path" + chmod 0400 "$private_key_path" + chmod 0400 "$public_key_path" fi - sed -n '/BEGIN PRIVATE KEY/,/END PRIVATE KEY/p' "$tmpfile" > "$private_key" - sed -n '/BEGIN PUBLIC KEY/,/END PUBLIC KEY/p' "$tmpfile" > "$public_key" - rm -f "$tmpfile" - chown torrust:torrust "$private_key" "$public_key" - chmod 0400 "$private_key" - chmod 0440 "$public_key" fi -# Switch to torrust user +# ── Database seeding dispatch (probe-driven) ────────────── +# `database_driver` comes from the probe's +# `.database.driver` field, derived from `connect_url`'s URL +# scheme — not from `TORRUST_INDEX_DATABASE_DRIVER`. +case $database_driver in + sqlite) + seed_sqlite "$database_path" + ;; + mysql) + # No file to seed; the application connects directly. + # Note: `mariadb` is rejected upstream by the probe + # (exit 5) because the application's loader does not + # recognise the scheme — no arm needed here. + : + ;; + *) + # The probe rejects unknown schemes before reaching + # this point, so this branch indicates a probe-vs- + # script version mismatch. + echo "ERROR: unexpected database.driver='$database_driver'" \ + "from config probe — possible probe/script version mismatch" >&2 + exit 1 + ;; +esac + +# ── Drop privileges and exec the application ────────────── exec /bin/su-exec torrust "$@" diff --git a/share/default/config/index.container.sqlite3.toml b/share/default/config/index.container.sqlite3.toml deleted file mode 100644 index d5c1902bb..000000000 --- a/share/default/config/index.container.sqlite3.toml +++ /dev/null @@ -1,29 +0,0 @@ -[metadata] -app = "torrust-index" -purpose = "configuration" -schema_version = "2.0.0" - -[logging] -#threshold = "off" -#threshold = "error" -#threshold = "warn" -threshold = "info" -#threshold = "debug" -#threshold = "trace" - -[tracker] -token = "MyAccessToken" - -[auth] -private_key_path = "/etc/torrust/index/auth/private.pem" -public_key_path = "/etc/torrust/index/auth/public.pem" - -[database] -connect_url = "sqlite:///var/lib/torrust/index/database/sqlite3.db?mode=rwc" - -[mail.smtp] -port = 1025 -server = "mailcatcher" - -[registration] -[registration.email] diff --git a/share/default/config/index.container.mysql.toml b/share/default/config/index.container.toml similarity index 50% rename from share/default/config/index.container.mysql.toml rename to share/default/config/index.container.toml index fd8ae638a..ba8b059f6 100644 --- a/share/default/config/index.container.mysql.toml +++ b/share/default/config/index.container.toml @@ -12,18 +12,8 @@ threshold = "info" #threshold = "trace" [tracker] -token = "MyAccessToken" - -[auth] -private_key_path = "/etc/torrust/index/auth/private.pem" -public_key_path = "/etc/torrust/index/auth/public.pem" [database] -connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index" - -[mail.smtp] -port = 1025 -server = "mailcatcher" [registration] [registration.email] diff --git a/share/default/config/index.development.sqlite3.toml b/share/default/config/index.development.sqlite3.toml index 647406797..2af5e3889 100644 --- a/share/default/config/index.development.sqlite3.toml +++ b/share/default/config/index.development.sqlite3.toml @@ -13,15 +13,14 @@ threshold = "info" #threshold = "debug" #threshold = "trace" -[tracker] -token = "MyAccessToken" - -[auth] - -# Uncomment if you want to enable TSL for development -#[net.tsl] +# Uncomment if you want to enable TLS for development +#[net.tls] #ssl_cert_path = "./storage/index/lib/tls/localhost.crt" #ssl_key_path = "./storage/index/lib/tls/localhost.key" +[tracker] + +[database] + [registration] [registration.email] diff --git a/share/default/config/index.private.e2e.container.sqlite3.toml b/share/default/config/index.private.e2e.container.sqlite3.toml index 6137ed6dc..4bfb3d6c5 100644 --- a/share/default/config/index.private.e2e.container.sqlite3.toml +++ b/share/default/config/index.private.e2e.container.sqlite3.toml @@ -15,19 +15,9 @@ threshold = "info" api_url = "http://tracker:1212" listed = false private = true -token = "MyAccessToken" url = "http://tracker:7070" -[auth] -private_key_path = "/etc/torrust/index/auth/private.pem" -public_key_path = "/etc/torrust/index/auth/public.pem" - [database] -connect_url = "sqlite:///var/lib/torrust/index/database/e2e_testing_sqlite3.db?mode=rwc" - -[mail.smtp] -port = 1025 -server = "mailcatcher" [registration] [registration.email] diff --git a/share/default/config/index.public.e2e.container.sqlite3.toml b/share/default/config/index.public.e2e.container.sqlite3.toml deleted file mode 100644 index 62c726dad..000000000 --- a/share/default/config/index.public.e2e.container.sqlite3.toml +++ /dev/null @@ -1,31 +0,0 @@ -[metadata] -app = "torrust-index" -purpose = "configuration" -schema_version = "2.0.0" - -[logging] -#threshold = "off" -#threshold = "error" -#threshold = "warn" -threshold = "info" -#threshold = "debug" -#threshold = "trace" - -[tracker] -api_url = "http://tracker:1212" -token = "MyAccessToken" -url = "udp://tracker:6969" - -[auth] -private_key_path = "/etc/torrust/index/auth/private.pem" -public_key_path = "/etc/torrust/index/auth/public.pem" - -[database] -connect_url = "sqlite:///var/lib/torrust/index/database/e2e_testing_sqlite3.db?mode=rwc" - -[mail.smtp] -port = 1025 -server = "mailcatcher" - -[registration] -[registration.email] diff --git a/share/default/config/index.public.e2e.container.mysql.toml b/share/default/config/index.public.e2e.container.toml similarity index 54% rename from share/default/config/index.public.e2e.container.mysql.toml rename to share/default/config/index.public.e2e.container.toml index d36248512..1561d655a 100644 --- a/share/default/config/index.public.e2e.container.mysql.toml +++ b/share/default/config/index.public.e2e.container.toml @@ -13,19 +13,11 @@ threshold = "info" [tracker] api_url = "http://tracker:1212" -token = "MyAccessToken" +listed = false +private = false url = "udp://tracker:6969" -[auth] -private_key_path = "/etc/torrust/index/auth/private.pem" -public_key_path = "/etc/torrust/index/auth/public.pem" - [database] -connect_url = "mysql://root:root_secret_password@mysql:3306/torrust_index_e2e_testing" - -[mail.smtp] -port = 1025 -server = "mailcatcher" [registration] [registration.email] diff --git a/share/default/config/tracker.private.e2e.container.sqlite3.toml b/share/default/config/tracker.private.e2e.container.sqlite3.toml index 154fc2493..7e421fd5a 100644 --- a/share/default/config/tracker.private.e2e.container.sqlite3.toml +++ b/share/default/config/tracker.private.e2e.container.sqlite3.toml @@ -5,7 +5,6 @@ schema_version = "2.0.0" threshold = "info" [tracker] -token = "MyAccessToken" [auth] diff --git a/share/default/config/tracker.public.e2e.container.sqlite3.toml b/share/default/config/tracker.public.e2e.container.sqlite3.toml index 88f70b31c..0caf1e69f 100644 --- a/share/default/config/tracker.public.e2e.container.sqlite3.toml +++ b/share/default/config/tracker.public.e2e.container.sqlite3.toml @@ -5,7 +5,6 @@ schema_version = "2.0.0" threshold = "info" [tracker] -token = "MyAccessToken" [auth] diff --git a/src/app.rs b/src/app.rs index eccc0e574..5f7527056 100644 --- a/src/app.rs +++ b/src/app.rs @@ -62,7 +62,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running let importer_port = settings.tracker_statistics_importer.port; // From [net] config let config_bind_address = settings.net.bind_address; - let opt_net_tsl = settings.net.tsl.clone(); + let opt_net_tls = settings.net.tls.clone(); // From [permissions] config let permission_overrides = settings.permissions.overrides.clone(); @@ -201,7 +201,7 @@ pub async fn run(configuration: Configuration, api_version: &Version) -> Running ); // Start API server - let running_api = web::api::start(app_data, config_bind_address, opt_net_tsl, api_version).await; + let running_api = web::api::start(app_data, config_bind_address, opt_net_tls, api_version).await; // Full running application Running { diff --git a/src/bin/generate_auth_keypair.rs b/src/bin/generate_auth_keypair.rs deleted file mode 100644 index 0488137fd..000000000 --- a/src/bin/generate_auth_keypair.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Generate an RSA-2048 key pair for JWT authentication. -//! -//! Outputs both PEM blocks (private key first, then public key) to -//! **stdout**. Diagnostic messages go to **stderr** via `tracing`. -//! -//! The tool refuses to run if stdout is a terminal to prevent -//! accidental display of key material on screen. -//! -//! # Usage -//! -//! ```sh -//! tmpfile=$(mktemp /tmp/auth_keys.XXXXXX) -//! chmod 0600 "$tmpfile" -//! torrust-generate-auth-keypair > "$tmpfile" -//! sed -n '/BEGIN PRIVATE KEY/,/END PRIVATE KEY/p' "$tmpfile" > private.pem -//! sed -n '/BEGIN PUBLIC KEY/,/END PUBLIC KEY/p' "$tmpfile" > public.pem -//! rm -f "$tmpfile" -//! ``` -//! -//! See ADR-T-007 Phase 6 for full context. - -use std::io::{self, IsTerminal, Write}; -use std::process; - -use clap::Parser; -use rsa::RsaPrivateKey; -use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; -use tracing::{error, info}; - -#[derive(Parser)] -#[command( - name = "torrust-generate-auth-keypair", - about = "Generate an RSA-2048 key pair for Torrust Index JWT authentication" -)] -struct Args { - /// Enable debug-level logging output on stderr. - #[arg(long)] - debug: bool, -} - -fn main() { - let args = Args::parse(); - - // Initialise tracing subscriber with structured JSON output on stderr. - let level = if args.debug { - tracing::Level::DEBUG - } else { - tracing::Level::INFO - }; - tracing_subscriber::fmt() - .json() - .with_max_level(level) - .with_writer(io::stderr) - .init(); - - // Refuse to run if stdout is a terminal. - if io::stdout().is_terminal() { - error!( - hint = "pipe the output to a file to avoid displaying key material on screen", - example = concat!( - "tmpfile=$(mktemp /tmp/auth_keys.XXXXXX) && ", - "chmod 0600 \"$tmpfile\" && ", - "torrust-generate-auth-keypair > \"$tmpfile\" && ", - "sed -n '/BEGIN PRIVATE KEY/,/END PRIVATE KEY/p' \"$tmpfile\" > private.pem && ", - "sed -n '/BEGIN PUBLIC KEY/,/END PUBLIC KEY/p' \"$tmpfile\" > public.pem && ", - "rm -f \"$tmpfile\"", - ), - "stdout is a terminal" - ); - process::exit(1); - } - - info!("Generating RSA-2048 key pair..."); - - let mut rng = rsa::rand_core::OsRng; - let private_key = RsaPrivateKey::new(&mut rng, 2048).unwrap_or_else(|e| { - error!(error = %e, "RSA key generation failed"); - process::exit(1); - }); - - let private_pem = private_key.to_pkcs8_pem(LineEnding::LF).unwrap_or_else(|e| { - error!(error = %e, "private key PEM export failed"); - process::exit(1); - }); - - let public_pem = private_key - .to_public_key() - .to_public_key_pem(LineEnding::LF) - .unwrap_or_else(|e| { - error!(error = %e, "public key PEM export failed"); - process::exit(1); - }); - - info!("Key pair generated successfully."); - - let stdout = io::stdout(); - let mut out = stdout.lock(); - out.write_all(private_pem.as_bytes()).unwrap_or_else(|e| { - error!(error = %e, "failed to write private key"); - process::exit(1); - }); - out.write_all(public_pem.as_bytes()).unwrap_or_else(|e| { - error!(error = %e, "failed to write public key"); - process::exit(1); - }); - out.flush().unwrap_or_else(|e| { - error!(error = %e, "failed to flush stdout"); - process::exit(1); - }); -} diff --git a/src/bin/health_check.rs b/src/bin/health_check.rs deleted file mode 100644 index ff0591292..000000000 --- a/src/bin/health_check.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Minimal `curl` or `wget` to be used for container health checks. -//! -//! It's convenient to avoid using third-party libraries because: -//! -//! - They are harder to maintain. -//! - They introduce new attack vectors. -use std::time::Duration; -use std::{env, process}; - -use reqwest::Client; - -#[tokio::main] -async fn main() { - let args: Vec = env::args().collect(); - if args.len() != 2 { - eprintln!("Usage: cargo run --bin health_check "); - eprintln!("Example: cargo run --bin health_check http://127.0.0.1:3001/health_check"); - std::process::exit(1); - } - - println!("Health check ..."); - - let url = &args[1].clone(); - - let client = Client::builder().timeout(Duration::from_secs(5)).build().unwrap(); - - let result = client.get(url).send().await; - - match result { - Ok(response) => { - if response.status().is_success() { - println!("STATUS: {}", response.status()); - process::exit(0); - } else { - println!("Non-success status received."); - process::exit(1); - } - } - Err(err) => { - println!("ERROR: {err}"); - process::exit(1); - } - } -} diff --git a/src/bootstrap/config.rs b/src/bootstrap/config.rs index 018a40059..9ef4e3026 100644 --- a/src/bootstrap/config.rs +++ b/src/bootstrap/config.rs @@ -4,10 +4,13 @@ // Environment variables -use crate::config::{Configuration, Info}; - // Default values -pub const DEFAULT_PATH_CONFIG: &str = "./share/default/config/index.development.sqlite3.toml"; +/// Re-exported from `torrust-index-config` so the application, +/// helper binaries, and integration tests share one source of +/// truth for the default config-TOML location. +pub use torrust_index_config::DEFAULT_CONFIG_TOML_PATH as DEFAULT_PATH_CONFIG; + +use crate::config::{Configuration, Info}; /// If present, CORS will be permissive. pub const ENV_VAR_CORS_PERMISSIVE: &str = "TORRUST_INDEX_API_CORS_PERMISSIVE"; diff --git a/src/config/mod.rs b/src/config/mod.rs index b0c634574..ec22a75a4 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,366 +1,57 @@ //! Configuration for the application. -pub mod v2; -pub mod validator; - -use std::env; -use std::sync::Arc; - -use camino::Utf8PathBuf; -use derive_more::Display; -use figment::Figment; -use figment::providers::{Env, Format, Serialized, Toml}; -use serde::{Deserialize, Serialize}; -use serde_with::{NoneAsEmptyString, serde_as}; -use thiserror::Error; +//! +//! Re-exports the parsing surface from the +//! [`torrust_index_config`] crate (extracted per ADR-T-009 phase 3) +//! and adds the runtime [`Configuration`] wrapper that holds +//! settings under a `tokio::sync::RwLock`. +//! +//! Existing `use crate::config::Settings;` (and similar) call +//! sites continue to compile via the wildcard re-export below. use tokio::sync::RwLock; - -use crate::web::api::server::DynError; - -pub type Settings = v2::Settings; - -pub type Api = v2::api::Api; - -pub type Registration = v2::registration::Registration; -pub type Email = v2::registration::Email; - -pub type Auth = v2::auth::Auth; -pub type PasswordConstraints = v2::auth::PasswordConstraints; - -pub type Database = v2::database::Database; - -pub type ImageCache = v2::image_cache::ImageCache; - -pub type Mail = v2::mail::Mail; -pub type Smtp = v2::mail::Smtp; -pub type Credentials = v2::mail::Credentials; - -pub type Network = v2::net::Network; - -pub type TrackerStatisticsImporter = v2::tracker_statistics_importer::TrackerStatisticsImporter; - -pub type Tracker = v2::tracker::Tracker; -pub type ApiToken = v2::tracker::ApiToken; - -pub type Logging = v2::logging::Logging; -pub type Threshold = v2::logging::Threshold; - -pub type Website = v2::website::Website; -pub type Demo = v2::website::Demo; -pub type Terms = v2::website::Terms; -pub type TermsPage = v2::website::TermsPage; -pub type TermsUpload = v2::website::TermsUpload; -pub type Markdown = v2::website::Markdown; - -/// Configuration version -const VERSION_2: &str = "2.0.0"; - -/// Prefix for env vars that overwrite configuration options. -const CONFIG_OVERRIDE_PREFIX: &str = "TORRUST_INDEX_CONFIG_OVERRIDE_"; - -/// Path separator in env var names for nested values in configuration. -const CONFIG_OVERRIDE_SEPARATOR: &str = "__"; - -/// The whole `index.toml` file content. It has priority over the config file. -/// Even if the file is not on the default path. -pub const ENV_VAR_CONFIG_TOML: &str = "TORRUST_INDEX_CONFIG_TOML"; - -/// The `index.toml` file location. -pub const ENV_VAR_CONFIG_TOML_PATH: &str = "TORRUST_INDEX_CONFIG_TOML_PATH"; - -pub const LATEST_VERSION: &str = "2.0.0"; - -/// Info about the configuration specification. -#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)] -#[display("Metadata(app: {app}, purpose: {purpose}, schema_version: {schema_version})")] -pub struct Metadata { - /// The application this configuration is valid for. - #[serde(default = "Metadata::default_app")] - app: App, - - /// The purpose of this parsed file. - #[serde(default = "Metadata::default_purpose")] - purpose: Purpose, - - /// The schema version for the configuration. - #[serde(default = "Metadata::default_schema_version")] - #[serde(flatten)] - schema_version: Version, -} - -impl Default for Metadata { - fn default() -> Self { - Self { - app: Self::default_app(), - purpose: Self::default_purpose(), - schema_version: Self::default_schema_version(), - } - } -} - -impl Metadata { - const fn default_app() -> App { - App::TorrustIndex - } - - const fn default_purpose() -> Purpose { - Purpose::Configuration - } - - fn default_schema_version() -> Version { - Version::latest() - } -} - -#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)] -#[serde(rename_all = "kebab-case")] -pub enum App { - TorrustIndex, -} - -#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)] -#[serde(rename_all = "lowercase")] -pub enum Purpose { - Configuration, -} - -/// The configuration version. -#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Display, Clone)] -#[serde(rename_all = "lowercase")] -pub struct Version { - #[serde(default = "Version::default_semver")] - schema_version: String, -} - -impl Default for Version { - fn default() -> Self { - Self { - schema_version: Self::default_semver(), - } - } -} - -impl Version { - fn new(semver: &str) -> Self { - Self { - schema_version: semver.to_owned(), - } - } - - fn latest() -> Self { - Self { - schema_version: LATEST_VERSION.to_string(), - } - } - - fn default_semver() -> String { - LATEST_VERSION.to_string() - } -} - -/// Information required for loading config -#[derive(Debug, Default, Clone)] -pub struct Info { - pub(crate) config_toml: Option, - pub(crate) config_toml_path: String, -} - -impl Info { - /// Build configuration Info. - /// - /// # Errors - /// - /// Will return `Err` if unable to obtain a configuration. - /// - #[allow(clippy::needless_pass_by_value)] - pub fn new(default_config_toml_path: String) -> Result { - let env_var_config_toml = ENV_VAR_CONFIG_TOML.to_string(); - let env_var_config_toml_path = ENV_VAR_CONFIG_TOML_PATH.to_string(); - - let config_toml = env::var(env_var_config_toml).ok().map(|config_toml| { - println!("Loading extra configuration from environment variable {config_toml} ..."); - config_toml - }); - - let config_toml_path = env::var(env_var_config_toml_path).map_or_else( - |_| { - println!("Loading extra configuration from default configuration file: `{default_config_toml_path}` ..."); - default_config_toml_path - }, - |config_toml_path| { - println!("Loading extra configuration from file: `{config_toml_path}` ..."); - config_toml_path - }, - ); - - Ok(Self { - config_toml, - config_toml_path, - }) - } - - #[must_use] - pub fn from_toml(config_toml: &str) -> Self { - Self { - config_toml: Some(config_toml.to_owned()), - config_toml_path: String::new(), - } - } -} - -/// Errors that can occur when loading the configuration. -#[derive(Error, Debug)] -pub enum Error { - /// Unable to load the configuration from the environment variable. - /// This error only occurs if there is no configuration file and the - /// `TORRUST_INDEX_CONFIG_TOML` environment variable is not set. - #[error("Unable to load from Environmental Variable: {source}")] - UnableToLoadFromEnvironmentVariable { source: DynError }, - - #[error("Unable to load from Config File: {source}")] - UnableToLoadFromConfigFile { source: DynError }, - - /// Unable to load the configuration from the configuration file. - #[error("Failed processing the configuration: {source}")] - ConfigError { source: DynError }, - - #[error("The error for errors that can never happen.")] - Infallible, - - #[error("Unsupported configuration version: {version}")] - UnsupportedVersion { version: Version }, - - #[error("Missing mandatory configuration option. Option path: {path}")] - MissingMandatoryOption { path: String }, -} - -impl From for Error { - fn from(err: figment::Error) -> Self { - tracing::error!(%err, "Failed processing the configuration"); - Self::ConfigError { source: Arc::new(err) } - } -} - -/// Port number representing that the OS will choose one randomly from the available ports. -/// -/// It's the port number `0` -pub const FREE_PORT: u16 = 0; - -#[serde_as] -#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone, Default)] -pub struct Tsl { - /// Path to the SSL certificate file. - #[serde_as(as = "NoneAsEmptyString")] - #[serde(default = "Tsl::default_ssl_cert_path")] - pub ssl_cert_path: Option, - /// Path to the SSL key file. - #[serde_as(as = "NoneAsEmptyString")] - #[serde(default = "Tsl::default_ssl_key_path")] - pub ssl_key_path: Option, -} - -impl Tsl { - #[allow(clippy::unnecessary_wraps)] - fn default_ssl_cert_path() -> Option { - Some(Utf8PathBuf::new()) - } - - #[allow(clippy::unnecessary_wraps)] - fn default_ssl_key_path() -> Option { - Some(Utf8PathBuf::new()) - } -} +pub use torrust_index_config::*; /// The configuration service. +/// +/// `Configuration` no longer implements `Default`. After ADR-T-009 +/// §D2, `tracker.token` and `database.connect_url` are mandatory at +/// the schema level; there is no `Settings::default()` to build a +/// runtime wrapper from. Construct one via [`Configuration::load`] +/// with an [`Info`], or use `Configuration::for_tests` (test-only) +/// in unit tests. #[derive(Debug)] pub struct Configuration { /// The state of the configuration. pub settings: RwLock, } -impl Default for Configuration { - fn default() -> Self { - Self { - settings: RwLock::new(Settings::default()), - } - } -} - impl Configuration { - /// Loads the configuration from the `Info` struct. + /// Loads the configuration from the [`Info`] struct. /// /// # Errors /// - /// Will return `Err` if the environment variable does not exist or has a bad configuration. + /// Will return `Err` if the environment variable does not exist + /// or has a bad configuration. pub fn load(info: &Info) -> Result { - let settings = Self::load_settings(info)?; + let settings = load_settings(info)?; Ok(Self { settings: RwLock::new(settings), }) } - /// Loads the settings from the `Info` struct. The whole - /// configuration in toml format is included in the `info.index_toml` string. + /// Loads the settings from the [`Info`] struct. /// - /// Configuration provided via env var has priority over config file path. + /// Thin wrapper retained for backwards compatibility with call + /// sites that previously called `Configuration::load_settings`. + /// New code should call [`torrust_index_config::load_settings`] + /// directly. /// /// # Errors /// - /// Will return `Err` if the environment variable does not exist or has a bad configuration. + /// Will return `Err` if the environment variable does not exist + /// or has a bad configuration. pub fn load_settings(info: &Info) -> Result { - // Load configuration provided by the user, prioritizing env vars - let figment = info.config_toml.as_ref().map_or_else( - || { - Figment::from(Toml::file(&info.config_toml_path)) - .merge(Env::prefixed(CONFIG_OVERRIDE_PREFIX).split(CONFIG_OVERRIDE_SEPARATOR)) - }, - |config_toml| { - // Config in env var has priority over config file path - Figment::from(Toml::string(config_toml)) - .merge(Env::prefixed(CONFIG_OVERRIDE_PREFIX).split(CONFIG_OVERRIDE_SEPARATOR)) - }, - ); - - // Make sure user has provided the mandatory options. - Self::check_mandatory_options(&figment)?; - - // Fill missing options with default values. - let figment = figment.join(Serialized::defaults(Settings::default())); - - // Build final configuration. - let settings: Settings = figment.extract()?; - - if settings.metadata.schema_version != Version::new(VERSION_2) { - return Err(Error::UnsupportedVersion { - version: settings.metadata.schema_version, - }); - } - - Ok(settings) - } - - /// Some configuration options are mandatory. The tracker will panic if - /// the user doesn't provide an explicit value for them from one of the - /// configuration sources: TOML or ENV VARS. - /// - /// # Errors - /// - /// Will return an error if a mandatory configuration option is only - /// obtained by default value (code), meaning the user hasn't overridden it. - fn check_mandatory_options(figment: &Figment) -> Result<(), Error> { - let mandatory_options = ["logging.threshold", "metadata.schema_version", "tracker.token"]; - - for mandatory_option in mandatory_options { - let found = figment.find_value(mandatory_option).is_ok(); - - if !found { - return Err(Error::MissingMandatoryOption { - path: mandatory_option.to_owned(), - }); - } - } - - Ok(()) + load_settings(info) } pub async fn get_all(&self) -> Settings { @@ -380,3 +71,35 @@ impl Configuration { settings_lock.net.base_url.as_ref().map(std::string::ToString::to_string) } } + +#[cfg(test)] +impl Configuration { + /// Build a `Configuration` from the shared placeholder TOML for + /// use in tests. Replaces the previous `Configuration::default()` + /// — `Settings` no longer carries an `impl Default` (ADR-T-009 §D2). + /// + /// The TOML literal lives in + /// [`torrust_index_config::test_helpers::PLACEHOLDER_TOML`] so + /// every crate-boundary test fixture stays in sync. + /// + /// # Hermeticity caveat + /// + /// This is **not** hermetic with respect to the ambient process + /// environment. `Configuration::load` ultimately calls + /// [`torrust_index_config::load_settings`], which always merges + /// any `TORRUST_INDEX_CONFIG_OVERRIDE_*` (and `TORRUST_INDEX_CONFIG_TOML[_PATH]`) + /// variables on top of the placeholder TOML via figment's `Env` + /// provider. E2E runner scripts and developer shells routinely + /// export these (notably + /// `TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL`), so a + /// caller that asserts on placeholder values must scrub the + /// environment first — typically by wrapping the test in + /// `figment::Jail::expect_with(|_| { clear_inherited_config_env(); + /// … })`, as `src/tests/config/mod.rs` does. Without that guard, + /// `for_tests()` silently picks up whatever overrides happen to be + /// in scope. + #[must_use] + pub(crate) fn for_tests() -> Self { + Self::load(&Info::from_toml(torrust_index_config::test_helpers::PLACEHOLDER_TOML)).expect("placeholder TOML must load") + } +} diff --git a/src/jwt.rs b/src/jwt.rs index 7a2452ef8..970443d8a 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -33,12 +33,12 @@ //! disk; sessions do not survive server restarts. Deployers who want //! persistent sessions supply their own key pair via config. //! -//! **Phase 6 — `generate-auth-keypair` CLI.** A standalone binary -//! (`torrust-generate-auth-keypair`) generates an RSA-2048 key pair -//! and writes both PEM blocks to stdout. The container entry script -//! uses it to auto-generate persistent keys on first boot. See -//! `src/bin/generate_auth_keypair.rs` for the binary and ADR-T-007 -//! Phase 6 for full context. +//! **Phase 6 — `torrust-index-auth-keypair` CLI.** A standalone binary +//! (`torrust-index-auth-keypair`) generates an RSA-2048 key pair +//! and writes a JSON object with both PEM blocks to stdout. The container +//! entry script uses it to auto-generate persistent keys on first boot. +//! See `packages/index-auth-keypair/src/main.rs` for the binary and +//! ADR-T-007 Phase 6 / ADR-T-009 Phase 2 for full context. //! //! **Phase 7 — Consolidated session validation.** `validate_session` //! is the sole entry point for session-token validation: it verifies diff --git a/src/lib.rs b/src/lib.rs index 1af118503..c2287e201 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -116,7 +116,12 @@ //! //! ## Run with docker //! -//! You can run the index with a pre-built docker image: +//! You can run the index with a pre-built docker image. Per ADR-T-009 §D2 +//! the shipped TOMLs no longer carry default values for `tracker.token` or +//! `database.connect_url`, so both must be supplied via env-var overrides +//! (or a populated `index.toml`) at startup — a bare `docker run` without +//! them now fails with a serde `missing field` error rather than booting +//! against hidden defaults: //! //! ```text //! cd /tmp \ @@ -125,10 +130,11 @@ //! && mkdir -p ./storage/index/lib/database \ //! && mkdir -p ./storage/index/log \ //! && mkdir -p ./storage/index/etc \ -//! && sqlite3 "./storage/index/lib/database/sqlite3.db" "VACUUM;" \ //! && export USER_ID=1000 \ //! && docker run -it \ //! --env USER_ID="$USER_ID" \ +//! --env TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN="MyAccessToken" \ +//! --env TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL="sqlite:///var/lib/torrust/index/database/index.sqlite3.db?mode=rwc" \ //! --publish 3001:3001/tcp \ //! --volume "$(pwd)/storage/index/lib":"/var/lib/torrust/index" \ //! --volume "$(pwd)/storage/index/log":"/var/log/torrust/index" \ @@ -136,7 +142,11 @@ //! torrust/index:develop //! ``` //! -//! For more information about using docker visit the [tracker docker documentation](https://github.com/torrust/torrust-index/tree/develop/docker). +//! For more information see the [container guide](https://github.com/torrust/torrust-index/blob/develop/docs/containers.md) +//! and [ADR-T-009](https://github.com/torrust/torrust-index/blob/develop/adr/009-container-infrastructure-refactor.md). +//! For local-stack development the repository ships a Compose split +//! (`compose.yaml` baseline + auto-loaded `compose.override.yaml` dev +//! sandbox) wrapped by `make up-dev` / `make up-prod`. //! //! ## Development //! @@ -168,21 +178,31 @@ //! //! # Configuration //! -//! The index uses a TOML configuration file. If you run the index without -//! providing a configuration, a default one is generated on first startup. +//! The index uses a TOML configuration file. Per ADR-T-009 §D2 two fields +//! are mandatory at the schema level — `tracker.token` and +//! `database.connect_url`. They must be supplied either inline in the TOML +//! or via the corresponding `TORRUST_INDEX_CONFIG_OVERRIDE_*` env vars; a +//! missing value fails at deserialisation with a precise serde +//! `missing field` error. //! //! See [`share/default/config/`](https://github.com/torrust/torrust-index/tree/develop/share/default/config) //! for example configuration files, or the [`config`] module documentation -//! for the full schema reference. +//! for the full schema reference. The parsing surface lives in the +//! standalone `torrust-index-config` crate +//! ([`packages/index-config/`](https://github.com/torrust/torrust-index/tree/develop/packages/index-config)) +//! so that helper binaries (notably `torrust-index-config-probe`) can load +//! the same `Settings` the application loads, without pulling in the +//! runtime stack. //! //! Key sections: //! -//! - `[tracker]` — Tracker connection (API URL, token). +//! - `[tracker]` — Tracker connection (API URL, **mandatory** `token`). //! - `[auth]` — Password constraints. Optionally supply RSA key paths //! (`auth.private_key_path` / `auth.public_key_path`) for persistent //! JWT sessions; otherwise an ephemeral key pair is auto-generated. -//! - `[database]` — `SQLite` or `MySQL` connection URL. -//! - `[net]` — Bind address (default: `0.0.0.0:3001`). +//! - `[database]` — `SQLite` or `MySQL` **mandatory** `connect_url`. +//! - `[net]` — Bind address (default: `0.0.0.0:3001`). Optional +//! `[net.tls]` block for TLS termination. //! - `[[permissions.overrides]]` — Optional TOML-based permission //! overrides (see ADR-T-008). //! @@ -201,7 +221,7 @@ //! //! > **NOTICE**: The `TORRUST_INDEX_CONFIG_TOML` env var has priority over the `config.toml` file. //! -//! > **NOTICE**: You can also change the location for the configuration file with the `TORRUST_INDEX_CONFIG_PATH` env var. +//! > **NOTICE**: You can also change the location for the configuration file with the `TORRUST_INDEX_CONFIG_TOML_PATH` env var. //! //! # Usage //! diff --git a/src/services/authorization.rs b/src/services/authorization.rs index 73833326e..bf954c4f5 100644 --- a/src/services/authorization.rs +++ b/src/services/authorization.rs @@ -11,6 +11,15 @@ //! - [`Permissions`] trait — abstraction consumed by the //! `RequirePermission` extractor (see `extractors::require_permission`). //! +//! # Crate layout (ADR-T-009 phase 3) +//! +//! The *value* types ([`Role`], [`Action`], [`Effect`], +//! [`PermissionOverride`], [`RoleParseError`]) live in the +//! `torrust-index-config` crate so the configuration schema can +//! reference them without depending on the service layer. They are +//! re-exported here for backwards compatibility with existing call +//! sites. +//! //! # Compile-time guarantees //! //! - Adding a `Role` variant without updating `default_grant` is a @@ -21,136 +30,10 @@ //! emits a `const` assertion that its marker count equals //! `Action::ALL.len()`, catching marker/enum drift at compile time. use std::collections::HashSet; -use std::fmt; -use std::str::FromStr; -use serde::{Deserialize, Serialize}; +pub use torrust_index_config::permissions::{Action, Effect, PermissionOverride, Role, RoleParseError}; use tracing::warn; -// ── Role ───────────────────────────────────────────────────────────── - -/// User privilege level. -/// -/// Stored as a lowercase string in the `torrust_users.role` column. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Role { - Guest, - Registered, - Moderator, - Admin, -} - -impl Role { - /// All variants (compile-time safe — see tests). - pub const ALL: &[Self] = &[Self::Guest, Self::Registered, Self::Moderator, Self::Admin]; -} - -impl fmt::Display for Role { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = match self { - Self::Guest => "guest", - Self::Registered => "registered", - Self::Moderator => "moderator", - Self::Admin => "admin", - }; - write!(f, "{s}") - } -} - -impl FromStr for Role { - type Err = RoleParseError; - - fn from_str(s: &str) -> Result { - match s { - "guest" => Ok(Self::Guest), - "registered" => Ok(Self::Registered), - "moderator" => Ok(Self::Moderator), - "admin" => Ok(Self::Admin), - _ => Err(RoleParseError(s.to_owned())), - } - } -} - -/// Error returned when a string cannot be parsed into a [`Role`]. -#[derive(Debug, Clone)] -pub struct RoleParseError(pub String); - -impl fmt::Display for RoleParseError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "unknown role: {:?}", self.0) - } -} - -impl std::error::Error for RoleParseError {} - -// ── Action ─────────────────────────────────────────────────────────── - -/// An operation that may be authorized. -/// -/// Adding a variant without updating `PermissionMatrix::default_grant` -/// is a compile error (the exhaustive `match` has no wildcard). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Action { - GetAboutPage, - GetLicensePage, - AddCategory, - DeleteCategory, - GetCategories, - GetImageByUrl, - GetSettingsSecret, - GetPublicSettings, - GetSiteName, - AddTag, - DeleteTag, - GetTags, - AddTorrent, - GetTorrent, - DeleteTorrent, - GetTorrentInfo, - GenerateTorrentInfoListing, - ChangePassword, - BanUser, - /// Render a user profile as a PNG image (admin-only). - GenerateUserProfileSpecification, - UpdateTorrent, - GetMyPermissions, -} - -impl Action { - /// Every variant in declaration order. - pub const ALL: &[Self] = &[ - Self::GetAboutPage, - Self::GetLicensePage, - Self::AddCategory, - Self::DeleteCategory, - Self::GetCategories, - Self::GetImageByUrl, - Self::GetSettingsSecret, - Self::GetPublicSettings, - Self::GetSiteName, - Self::AddTag, - Self::DeleteTag, - Self::GetTags, - Self::AddTorrent, - Self::GetTorrent, - Self::DeleteTorrent, - Self::GetTorrentInfo, - Self::GenerateTorrentInfoListing, - Self::ChangePassword, - Self::BanUser, - Self::GenerateUserProfileSpecification, - Self::UpdateTorrent, - Self::GetMyPermissions, - ]; -} - -impl fmt::Display for Action { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Debug::fmt(self, f) - } -} - // ── Permissions trait ──────────────────────────────────────────────── /// Abstraction over any permission-checking backend. @@ -372,24 +255,3 @@ impl Permissions for PermissionMatrix { self.allowed.contains(&(*role, action)) } } - -// ── PermissionOverride ─────────────────────────────────────────────── - -/// Effect of a permission override. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Effect { - Allow, - Deny, -} - -/// A single operator-supplied permission override. -/// -/// Loaded from the `[[permissions.overrides]]` TOML array and applied -/// on top of the default matrix at startup. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PermissionOverride { - pub role: Role, - pub action: Action, - pub effect: Effect, -} diff --git a/src/tests/bootstrap/config.rs b/src/tests/bootstrap/config.rs index 4b79735ff..a7f6f1748 100644 --- a/src/tests/bootstrap/config.rs +++ b/src/tests/bootstrap/config.rs @@ -10,6 +10,16 @@ fn it_should_load_with_default_config() { )); jail.set_env("TORRUST_INDEX_CONFIG_TOML", config_toml); + // Per ADR-T-009 §D2, the shipped dev sample no longer carries + // `tracker.token` or `database.connect_url` — the operator + // supplies them at runtime via env-var overrides. Mirror that + // workflow here so bootstrap can resolve the mandatory fields. + jail.set_env("TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN", "MyAccessToken"); + jail.set_env( + "TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL", + "sqlite://data.db?mode=rwc", + ); + drop(initialize_configuration()); Ok(()) }); diff --git a/src/tests/config/mod.rs b/src/tests/config/mod.rs index daba68e99..00199e6bf 100644 --- a/src/tests/config/mod.rs +++ b/src/tests/config/mod.rs @@ -4,6 +4,38 @@ use url::Url; use crate::config::{ApiToken, Configuration, Info, Settings}; +/// Remove `TORRUST_INDEX_CONFIG_*` env vars that may have been +/// inherited from the surrounding shell (e.g. the e2e runner scripts +/// export `TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL` +/// before invoking `cargo test`). Tests that assert on the *default* +/// configuration must scrub these before loading settings; otherwise +/// figment's `Env` provider merges the ambient overrides on top of +/// the fixture and the equality assertions diverge. +/// +/// Safe to call from inside a `figment::Jail` closure: the jail holds +/// a global mutex while running and snapshots/restores the process +/// environment around the closure. +fn clear_inherited_config_env() { + let names: Vec = std::env::vars() + .map(|(k, _)| k) + .filter(|k| { + k == "TORRUST_INDEX_CONFIG_TOML" + || k == "TORRUST_INDEX_CONFIG_TOML_PATH" + || k.starts_with("TORRUST_INDEX_CONFIG_OVERRIDE_") + }) + .collect(); + for name in names { + // SAFETY: Edition 2024 marks `remove_var` unsafe because env + // mutation is not thread-safe. We only call this from inside + // a `figment::Jail` closure, which serialises tests via a + // global mutex and restores the snapshot on drop. + #[allow(unsafe_code)] + unsafe { + std::env::remove_var(name); + } + } +} + fn default_config_toml() -> String { use std::fs; use std::path::PathBuf; @@ -35,22 +67,40 @@ fn default_settings() -> Settings { settings } -#[tokio::test] -async fn configuration_should_have_a_default_constructor() { - let settings = Configuration::default().get_all().await; +#[test] +#[allow(clippy::result_large_err)] +fn configuration_should_have_a_test_constructor() { + // Wrapped in `figment::Jail` so it serialises (via figment's + // global mutex) with sibling tests that mutate + // `TORRUST_INDEX_CONFIG_OVERRIDE_*` env vars. Without the jail, + // those overrides leak across parallel tests and `for_tests()` + // reads the leaked token instead of the PLACEHOLDER_TOML value. + // + // The test is intentionally `#[test]` (not `#[tokio::test]`) so + // we can call `RwLock::blocking_read` inside the synchronous + // Jail closure without panicking under a tokio runtime. + figment::Jail::expect_with(|_| { + clear_inherited_config_env(); + + let settings = Configuration::for_tests().settings.blocking_read().clone(); + + // Mandatory fields from PLACEHOLDER_TOML are present. + assert_eq!(settings.tracker.token.to_string(), "MyAccessToken"); + assert_eq!(settings.database.connect_url.as_str(), "sqlite://data.db?mode=rwc"); - assert_eq!(settings, default_settings()); + Ok(()) + }); } #[tokio::test] async fn configuration_should_return_the_site_name() { - let configuration = Configuration::default(); + let configuration = Configuration::for_tests(); assert_eq!(configuration.get_site_name().await, "Torrust".to_string()); } #[tokio::test] async fn configuration_should_return_the_api_base_url() { - let configuration = Configuration::default(); + let configuration = Configuration::for_tests(); assert_eq!(configuration.get_api_base_url().await, None); let mut settings_lock = configuration.settings.write().await; @@ -64,6 +114,8 @@ async fn configuration_should_return_the_api_base_url() { #[allow(clippy::result_large_err)] async fn configuration_could_be_loaded_from_a_toml_string() { figment::Jail::expect_with(|jail| { + clear_inherited_config_env(); + jail.create_dir("templates")?; jail.create_file("templates/verify.html", "EMAIL TEMPLATE")?; @@ -74,7 +126,7 @@ async fn configuration_could_be_loaded_from_a_toml_string() { let settings = Configuration::load_settings(&info).expect("Failed to load configuration from info"); - assert_eq!(settings, Settings::default()); + assert_eq!(settings, default_settings()); Ok(()) }); @@ -84,6 +136,8 @@ async fn configuration_could_be_loaded_from_a_toml_string() { #[allow(clippy::result_large_err)] fn configuration_should_use_the_default_values_when_only_the_mandatory_options_are_provided_by_the_user_via_toml_file() { figment::Jail::expect_with(|jail| { + clear_inherited_config_env(); + jail.create_file( "index.toml", r#" @@ -96,6 +150,9 @@ fn configuration_should_use_the_default_values_when_only_the_mandatory_options_a [tracker] token = "MyAccessToken" + [database] + connect_url = "sqlite://data.db?mode=rwc" + [auth] "#, )?; @@ -107,7 +164,7 @@ fn configuration_should_use_the_default_values_when_only_the_mandatory_options_a let settings = Configuration::load_settings(&info).expect("Could not load configuration from file"); - assert_eq!(settings, Settings::default()); + assert_eq!(settings, default_settings()); Ok(()) }); @@ -117,6 +174,8 @@ fn configuration_should_use_the_default_values_when_only_the_mandatory_options_a #[allow(clippy::result_large_err)] fn configuration_should_use_the_default_values_when_only_the_mandatory_options_are_provided_by_the_user_via_toml_content() { figment::Jail::expect_with(|_jail| { + clear_inherited_config_env(); + let config_toml = r#" [metadata] schema_version = "2.0.0" @@ -127,6 +186,9 @@ fn configuration_should_use_the_default_values_when_only_the_mandatory_options_a [tracker] token = "MyAccessToken" + [database] + connect_url = "sqlite://data.db?mode=rwc" + [auth] "# .to_string(); @@ -138,7 +200,7 @@ fn configuration_should_use_the_default_values_when_only_the_mandatory_options_a let settings = Configuration::load_settings(&info).expect("Could not load configuration from file"); - assert_eq!(settings, Settings::default()); + assert_eq!(settings, default_settings()); Ok(()) }); @@ -199,7 +261,7 @@ mod semantic_validation { #[tokio::test] async fn udp_trackers_in_private_mode_are_not_supported() { - let configuration = Configuration::default(); + let configuration = Configuration::for_tests(); let mut settings_lock = configuration.settings.write().await; settings_lock.tracker.private = true; diff --git a/src/tests/jwt.rs b/src/tests/jwt.rs index e7bd65371..6b00f7294 100644 --- a/src/tests/jwt.rs +++ b/src/tests/jwt.rs @@ -36,7 +36,7 @@ use crate::models::user::UserCompact; /// Build a `JsonWebToken` service using ephemeral auto-generated keys /// (the default when no key paths are configured). async fn jwt_service() -> JsonWebToken { - let cfg = Arc::new(Configuration::default()); + let cfg = Arc::new(Configuration::for_tests()); JsonWebToken::new(cfg).await } diff --git a/src/tests/services/settings.rs b/src/tests/services/settings.rs index 0ad8d6810..3e65b629c 100644 --- a/src/tests/services/settings.rs +++ b/src/tests/services/settings.rs @@ -3,7 +3,7 @@ use crate::services::settings::{ConfigurationPublic, EmailOnSignup, extract_publ #[tokio::test] async fn configuration_should_return_only_public_settings() { - let configuration = Configuration::default(); + let configuration = Configuration::for_tests(); let all_settings = configuration.get_all().await; let email_on_signup = all_settings diff --git a/src/tests/web/require_permission.rs b/src/tests/web/require_permission.rs index f07f95c29..bdac86b65 100644 --- a/src/tests/web/require_permission.rs +++ b/src/tests/web/require_permission.rs @@ -140,7 +140,7 @@ async fn jwt_service(cfg: Arc) -> JsonWebToken { /// are constructed with defaults to satisfy the `AppData` struct. #[allow(clippy::too_many_lines)] async fn test_app_data(db_path: &str) -> Arc { - let cfg = Arc::new(Configuration::default()); + let cfg = Arc::new(Configuration::for_tests()); // Point config at the temp database. { diff --git a/src/web/api/mod.rs b/src/web/api/mod.rs index 6615a5d56..574af320f 100644 --- a/src/web/api/mod.rs +++ b/src/web/api/mod.rs @@ -16,7 +16,7 @@ use tokio::task::JoinHandle; use self::server::signals::Halted; use crate::common::AppData; -use crate::config::Tsl; +use crate::config::Tls; use crate::web::api; /// API versions. @@ -39,10 +39,10 @@ pub struct Running { pub async fn start( app_data: Arc, config_bind_address: SocketAddr, - opt_tsl: Option, + opt_tls: Option, implementation: &Version, ) -> api::Running { match implementation { - Version::V1 => server::start(app_data, config_bind_address, opt_tsl).await, + Version::V1 => server::start(app_data, config_bind_address, opt_tls).await, } } diff --git a/src/web/api/server/mod.rs b/src/web/api/server/mod.rs index f04498682..c63f42cec 100644 --- a/src/web/api/server/mod.rs +++ b/src/web/api/server/mod.rs @@ -15,19 +15,21 @@ use v1::routes::router; use self::signals::{Halted, Started}; use super::Running; use crate::common::AppData; -use crate::config::Tsl; +// Re-export the type alias defined by the extracted config crate so +// existing `crate::web::api::server::DynError` call sites keep working +// without the root crate carrying its own duplicate definition. +pub use crate::config::DynError; +use crate::config::Tls; use crate::web::api::server::custom_axum::TimeoutAcceptor; use crate::web::api::server::signals::graceful_shutdown; -pub type DynError = Arc; - /// Starts the API server. /// /// # Panics /// /// Panics if the API server can't be started. -pub async fn start(app_data: Arc, config_bind_address: SocketAddr, opt_tsl: Option) -> Running { - let opt_rust_tls_config = make_rust_tls(&opt_tsl) +pub async fn start(app_data: Arc, config_bind_address: SocketAddr, opt_tls: Option) -> Running { + let opt_rust_tls_config = make_rust_tls(&opt_tls) .await .map(|tls| tls.expect("it should have a valid net tls configuration")); @@ -97,7 +99,7 @@ async fn start_server( Some(tls) => custom_axum::from_tcp_rustls_with_timeouts(socket, tls) .expect("Could not create rustls server from tcp listener") .handle(handle) - // The TimeoutAcceptor is commented because TSL does not work with it. + // The TimeoutAcceptor is commented because TLS does not work with it. // See: https://github.com/torrust/torrust-index/issues/204 //.acceptor(TimeoutAcceptor) .serve(router.into_make_service_with_connect_info::()) @@ -128,9 +130,9 @@ pub enum Error { }, } -pub async fn make_rust_tls(tsl_config: &Option) -> Option> { - if let Some(tsl) = tsl_config { - if let (Some(cert), Some(key)) = (tsl.ssl_cert_path.clone(), tsl.ssl_key_path.clone()) { +pub async fn make_rust_tls(tls_config: &Option) -> Option> { + if let Some(tls) = tls_config { + if let (Some(cert), Some(key)) = (tls.ssl_cert_path.clone(), tls.ssl_key_path.clone()) { info!("Using https. Cert path: {cert}."); info!("Using https. Key path: {key}."); diff --git a/src/web/api/server/v1/contexts/settings/mod.rs b/src/web/api/server/v1/contexts/settings/mod.rs index 80262da94..2687bd213 100644 --- a/src/web/api/server/v1/contexts/settings/mod.rs +++ b/src/web/api/server/v1/contexts/settings/mod.rs @@ -51,7 +51,7 @@ //! "net": { //! "base_url": null, //! "bind_address": "0.0.0.0:3001", -//! "tsl": null +//! "tls": null //! }, //! "auth": { //! "private_key_pem": null, diff --git a/src/web/api/server/v1/contexts/torrent/handlers.rs b/src/web/api/server/v1/contexts/torrent/handlers.rs index 8d55afa36..4199bdb95 100644 --- a/src/web/api/server/v1/contexts/torrent/handlers.rs +++ b/src/web/api/server/v1/contexts/torrent/handlers.rs @@ -326,7 +326,7 @@ pub async fn create_random_torrent_handler(State(_app_data): State> ) } -/// Extracts the [`TorrentRequest`] from the multipart form payload. +/// Extracts the [`AddTorrentRequest`] from the multipart form payload. /// /// # Errors /// diff --git a/src/web/api/server/v1/extractors/require_permission.rs b/src/web/api/server/v1/extractors/require_permission.rs index a7ad77d98..cdcab8c23 100644 --- a/src/web/api/server/v1/extractors/require_permission.rs +++ b/src/web/api/server/v1/extractors/require_permission.rs @@ -189,6 +189,14 @@ where } } Err(err) => { + // Preserve the underlying error category: only the + // genuine "user not found" case becomes a 404. DB + // / IO failures must surface as `DatabaseError` + // (500), not as a misleading 404 that masks the + // outage from operators. + if !matches!(err, crate::databases::database::Error::UserNotFound) { + tracing::error!(user_id, %err, "failed to load user for permission check"); + } return Err(AuthError::from(err).into_response()); } }; diff --git a/tests/e2e/config.rs b/tests/e2e/config.rs index be46b7e20..3d8e6bc81 100644 --- a/tests/e2e/config.rs +++ b/tests/e2e/config.rs @@ -43,7 +43,31 @@ mod tests { use torrust_index::bootstrap::config::initialize_configuration; #[test] + #[allow(clippy::result_large_err)] fn it_should_load_with_default_config() { - drop(initialize_configuration()); + figment::Jail::expect_with(|jail| { + // `figment::Jail` swaps the cwd to a temp directory, so the + // bootstrap loader cannot resolve the relative + // `./share/default/config/...` path. Inject the dev sample + // contents directly via `TORRUST_INDEX_CONFIG_TOML`. + let config_toml = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/share/default/config/index.development.sqlite3.toml" + )); + jail.set_env("TORRUST_INDEX_CONFIG_TOML", config_toml); + + // Per ADR-T-009 §D2, the shipped dev sample no longer carries + // `tracker.token` or `database.connect_url` — the operator + // supplies them at runtime via env-var overrides. Mirror that + // workflow here so bootstrap can resolve the mandatory fields. + jail.set_env("TORRUST_INDEX_CONFIG_OVERRIDE_TRACKER__TOKEN", "MyAccessToken"); + jail.set_env( + "TORRUST_INDEX_CONFIG_OVERRIDE_DATABASE__CONNECT_URL", + "sqlite://data.db?mode=rwc", + ); + + drop(initialize_configuration()); + Ok(()) + }); } } diff --git a/tests/e2e/web/api/v1/contexts/settings/contract.rs b/tests/e2e/web/api/v1/contexts/settings/contract.rs index db58e3581..7ca857ad8 100644 --- a/tests/e2e/web/api/v1/contexts/settings/contract.rs +++ b/tests/e2e/web/api/v1/contexts/settings/contract.rs @@ -76,9 +76,28 @@ async fn it_should_allow_admins_to_get_all_the_settings() { let response = client.get_settings().await; - let res: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); - - assert_eq!(res.data, env.server_settings_masking_secrets().unwrap()); + let mut actual: AllSettingsResponse = serde_json::from_str(&response.body).unwrap(); + let mut expected = env.server_settings_masking_secrets().unwrap(); + + // Normalise environment-specific fields that legitimately differ + // between the host-side loader (which builds `expected` from the + // shipped TOML plus the host's env overrides) and the container's + // effective configuration (which the entry script may augment — + // e.g. defaulting `auth.{private,public}_key_path` to + // `/etc/torrust/index/auth/{private,public}.pem` when neither + // PEM nor path is supplied; ADR-T-009 §7). The DB connect URL + // similarly differs because the container path + // (`/var/lib/torrust/index/database/...`) is the bind-mount + // target of the host path the test runner uses + // (`./storage/index/lib/database/...`). + actual.data.auth.private_key_path = None; + actual.data.auth.public_key_path = None; + actual.data.database.connect_url.clear(); + expected.auth.private_key_path = None; + expected.auth.public_key_path = None; + expected.database.connect_url.clear(); + + assert_eq!(actual.data, expected); assert_json_ok_response(&response); } diff --git a/tests/environments/isolated.rs b/tests/environments/isolated.rs index afbdace8e..c12a7b1bc 100644 --- a/tests/environments/isolated.rs +++ b/tests/environments/isolated.rs @@ -92,7 +92,11 @@ impl Default for TestEnv { /// Provides a configuration with ephemeral data for testing. fn ephemeral(temp_dir: &TempDir) -> config::Settings { - let mut configuration = config::Settings::default(); + // After ADR-T-009 §D2, `tracker.token` and `database.connect_url` + // are mandatory at the schema level (no `Settings::default()`). + // Reuse the shared placeholder fixture so every crate-boundary + // test baseline stays in sync; per-test knobs are overridden below. + let mut configuration = config::test_helpers::placeholder_settings(); configuration.logging.threshold = Threshold::Off; // Change to `debug` for tests debugging