From cce0aba6ce5ec580f2b4c534da99b7d88b1e408f Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 20 Jul 2026 10:06:06 +0100 Subject: [PATCH 1/3] feat(configuration): copy v2_0_0 schema to v3_0_0 as baseline (#1979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subissue #1 of EPIC #1978 — Configuration Overhaul (schema v3.0.0). - Copy `packages/configuration/src/v2_0_0/` to `v3_0_0/` as the starting point for all breaking changes - Update all internal `v2_0_0` references in `v3_0_0/` to `v3_0_0` (doc links, VERSION constant, test imports, schema_version strings) - Merge the crate-root `logging.rs` behaviour (TraceStyle, setup, tracing_init) into both `v2_0_0/logging.rs` and `v3_0_0/logging.rs`, making each versioned module fully self-contained - Add `pub mod v3_0_0` to `lib.rs` alongside `pub mod v2_0_0` - Add `Metadata::with_schema_version` helper constructor to `lib.rs` - Add `v3_0_0::Configuration::Default` impl that sets schema version to "3.0.0" explicitly - Add smoke tests: v3 loads "3.0.0" configs and rejects "2.0.0" configs - Global re-exports and default config files stay at v2 until the final cleanup subissue #1980 switches all consumers atomically T6 (update default config files) and T7 (wire bootstrap to v3) are deferred to #1980: the bootstrap uses the global `Configuration` re-export (= v2_0_0) and cannot be switched without migrating all consumers at once. All 48 test suites pass; `linter all` exits 0. Closes #1979 --- ...-configuration-schema-v2-to-v3-baseline.md | 51 +- packages/configuration/src/lib.rs | 16 +- packages/configuration/src/logging.rs | 2 +- packages/configuration/src/v2_0_0/logging.rs | 73 ++ packages/configuration/src/v3_0_0/core.rs | 120 +++ packages/configuration/src/v3_0_0/database.rs | 91 ++ .../src/v3_0_0/health_check_api.rs | 30 + .../configuration/src/v3_0_0/http_tracker.rs | 67 ++ packages/configuration/src/v3_0_0/logging.rs | 114 +++ packages/configuration/src/v3_0_0/mod.rs | 898 ++++++++++++++++++ packages/configuration/src/v3_0_0/network.rs | 93 ++ .../configuration/src/v3_0_0/tracker_api.rs | 88 ++ .../configuration/src/v3_0_0/udp_tracker.rs | 73 ++ 13 files changed, 1689 insertions(+), 27 deletions(-) create mode 100644 packages/configuration/src/v3_0_0/core.rs create mode 100644 packages/configuration/src/v3_0_0/database.rs create mode 100644 packages/configuration/src/v3_0_0/health_check_api.rs create mode 100644 packages/configuration/src/v3_0_0/http_tracker.rs create mode 100644 packages/configuration/src/v3_0_0/logging.rs create mode 100644 packages/configuration/src/v3_0_0/mod.rs create mode 100644 packages/configuration/src/v3_0_0/network.rs create mode 100644 packages/configuration/src/v3_0_0/tracker_api.rs create mode 100644 packages/configuration/src/v3_0_0/udp_tracker.rs diff --git a/docs/issues/open/1979-1978-copy-configuration-schema-v2-to-v3-baseline.md b/docs/issues/open/1979-1978-copy-configuration-schema-v2-to-v3-baseline.md index d7c89f594..22aa9143e 100644 --- a/docs/issues/open/1979-1978-copy-configuration-schema-v2-to-v3-baseline.md +++ b/docs/issues/open/1979-1978-copy-configuration-schema-v2-to-v3-baseline.md @@ -59,17 +59,17 @@ This approach: ## Implementation Plan -| ID | Status | Task | Notes | -| --- | ------ | ------------------------------------------------------------ | ----------------------------------------------------------------------------- | -| T1 | TODO | Copy `v2_0_0/` directory to `v3_0_0/` | `cp -r packages/configuration/src/v2_0_0/ packages/configuration/src/v3_0_0/` | -| T2 | TODO | Update `v3_0_0/mod.rs` to use `crate::v3_0_0` internal paths | Fix module references within the copied files | -| T3 | TODO | Copy `logging.rs` into `v2_0_0/logging.rs` | Crate-root `logging.rs` (TraceStyle, setup, tracing_init) → v2_0_0 | -| T4 | TODO | Copy `logging.rs` into `v3_0_0/logging.rs` | Same content as T3; v3 gets its own copy | -| T5 | TODO | Update `lib.rs` to expose `pub mod v3_0_0` | Alongside existing `pub mod v2_0_0` | -| T6 | TODO | Update default config files to `schema_version = "3.0.0"` | In `share/default/config/` | -| T7 | TODO | Wire application entry point to use `v3_0_0` by default | Update `lib.rs` or `container.rs` default schema selection | -| T8 | TODO | Add smoke tests: deserialize default v3 config | Verify v3_0_0 can parse the default config | -| T9 | TODO | Run `linter all` and full test suite | | +| ID | Status | Task | Notes | +| --- | ---------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| T1 | DONE | Copy `v2_0_0/` directory to `v3_0_0/` | `cp -r packages/configuration/src/v2_0_0/ packages/configuration/src/v3_0_0/` | +| T2 | DONE | Update `v3_0_0/mod.rs` to use `crate::v3_0_0` internal paths | Fixed all doc links, VERSION constant, test imports, and schema_version strings | +| T3 | DONE | Copy `logging.rs` into `v2_0_0/logging.rs` | Merged TraceStyle/setup/tracing_init into the versioned logging.rs; added module-level doc comment | +| T4 | DONE | Copy `logging.rs` into `v3_0_0/logging.rs` | Same content as T3; v3 gets its own copy | +| T5 | DONE | Update `lib.rs` to expose `pub mod v3_0_0` | Added alongside existing `pub mod v2_0_0`; added `Metadata::with_schema_version` helper; global re-exports stay at v2 | +| T6 | DEFERRED → #1980 | Update default config files to `schema_version = "3.0.0"` | Cannot be done while bootstrap still uses `v2_0_0::Configuration`; config files and bootstrap switch together in #1980 | +| T7 | DEFERRED → #1980 | Wire application entry point to use `v3_0_0` by default | Requires updating bootstrap + all consumers; this is exactly the scope of subissue #1980 | +| T8 | DONE | Add smoke tests: deserialize default v3 config | Added `smoke::v3_configuration_should_load_when_schema_version_is_3_0_0` and `smoke::v3_configuration_should_reject_schema_version_2_0_0` | +| T9 | DONE | Run `linter all` and full test suite | All 48 test suites pass (0 failures) | ## Progress Tracking @@ -88,16 +88,17 @@ This approach: - 2026-07-13 21:00 UTC - josecelano - Initial spec drafted - 2026-07-15 00:00 UTC - josecelano - GitHub issue #1979 created; spec moved to `docs/issues/open/1979-1978-copy-configuration-schema-v2-to-v3-baseline.md` +- 2026-07-20 00:00 UTC - agent - Implementation completed: T1–T5 and T8–T9 done; T6/T7 deferred to #1980 (consumer migration must happen atomically) ## Acceptance Criteria -- [ ] AC1: `packages/configuration/src/v3_0_0/` exists as an exact copy of `v2_0_0/` -- [ ] AC2: `lib.rs` exposes both `v2_0_0` and `v3_0_0` modules -- [ ] AC3: Application uses `v3_0_0` by default -- [ ] AC4: All existing tests pass (v2 unchanged) -- [ ] AC5: Default config files reference `schema_version = "3.0.0"` -- [ ] `linter all` exits with code `0` -- [ ] Relevant tests pass +- [x] AC1: `packages/configuration/src/v3_0_0/` exists as an exact copy of `v2_0_0/` +- [x] AC2: `lib.rs` exposes both `v2_0_0` and `v3_0_0` modules +- [ ] AC3: Application uses `v3_0_0` by default — **DEFERRED to #1980** (requires switching bootstrap + all consumers atomically) +- [x] AC4: All existing tests pass (v2 unchanged) +- [ ] AC5: Default config files reference `schema_version = "3.0.0"` — **DEFERRED to #1980** (config files must match the active parser) +- [x] `linter all` exits with code `0` +- [x] Relevant tests pass (48 suites, 0 failures) ## Verification Plan @@ -116,13 +117,13 @@ This approach: ### Acceptance Verification -| AC ID | Status | Evidence | -| ----- | ------ | -------- | -| AC1 | TODO | | -| AC2 | TODO | | -| AC3 | TODO | | -| AC4 | TODO | | -| AC5 | TODO | | +| AC ID | Status | Evidence | +| ----- | -------- | -------------------------------------------------------------------------------- | +| AC1 | DONE | `packages/configuration/src/v3_0_0/` exists with all 9 files mirroring `v2_0_0/` | +| AC2 | DONE | `lib.rs` has `pub mod v2_0_0` and `pub mod v3_0_0` | +| AC3 | DEFERRED | Deferred to #1980; requires switching bootstrap and all consumers atomically | +| AC4 | DONE | All 48 test suites pass; v2_0_0 tests unchanged | +| AC5 | DEFERRED | Deferred to #1980; config files must match the parser the bootstrap uses | ## Risks and Trade-offs diff --git a/packages/configuration/src/lib.rs b/packages/configuration/src/lib.rs index 30fc909c2..d2ded8384 100644 --- a/packages/configuration/src/lib.rs +++ b/packages/configuration/src/lib.rs @@ -3,9 +3,13 @@ //! This module contains the configuration data structures for the //! Torrust Tracker, which is a `BitTorrent` tracker server. //! -//! The current version for configuration is [`v2_0_0`]. +//! The current schema version is `v3_0_0` (in progress). +//! The previous version [`v2_0_0`] is kept for backward compatibility. +//! Global re-exports still point to `v2_0_0` and will be migrated to `v3_0_0` +//! in the final cleanup subissue (#1980) once all v3 changes are complete. pub mod logging; pub mod v2_0_0; +pub mod v3_0_0; pub mod validator; use std::collections::HashMap; @@ -71,6 +75,16 @@ impl Default for Metadata { } impl Metadata { + /// Creates a `Metadata` with a specific schema version, keeping other fields at their defaults. + #[must_use] + pub fn with_schema_version(schema_version: Version) -> Self { + Self { + app: Self::default_app(), + purpose: Self::default_purpose(), + schema_version, + } + } + fn default_app() -> App { App::TorrustTracker } diff --git a/packages/configuration/src/logging.rs b/packages/configuration/src/logging.rs index b8db27b8c..3d2270d1d 100644 --- a/packages/configuration/src/logging.rs +++ b/packages/configuration/src/logging.rs @@ -15,7 +15,7 @@ use std::sync::Once; use tracing::level_filters::LevelFilter; -use crate::{Logging, Threshold}; +use crate::v2_0_0::logging::{Logging, Threshold}; static INIT: Once = Once::new(); diff --git a/packages/configuration/src/v2_0_0/logging.rs b/packages/configuration/src/v2_0_0/logging.rs index e7dbe146c..f9233e99c 100644 --- a/packages/configuration/src/v2_0_0/logging.rs +++ b/packages/configuration/src/v2_0_0/logging.rs @@ -1,4 +1,13 @@ +//! Logging configuration and setup for `v2_0_0`. +//! +//! Contains the `Logging` configuration struct, the `Threshold` level enum, +//! the `TraceStyle` enum, and the `setup()` / `tracing_init()` helpers. +use std::sync::Once; + use serde::{Deserialize, Serialize}; +use tracing::level_filters::LevelFilter; + +static INIT: Once = Once::new(); #[allow(clippy::struct_excessive_bools)] #[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] @@ -39,3 +48,67 @@ pub enum Threshold { /// Corresponds to the `Trace` security level. Trace, } + +/// Redirects log output to stdout at the threshold defined in the configuration. +pub fn setup(cfg: &Logging) { + let tracing_level = map_to_tracing_level_filter(&cfg.threshold); + + if tracing_level == LevelFilter::OFF { + return; + } + + INIT.call_once(|| { + tracing_init(tracing_level, &TraceStyle::Default); + }); +} + +fn map_to_tracing_level_filter(threshold: &Threshold) -> LevelFilter { + match threshold { + Threshold::Off => LevelFilter::OFF, + Threshold::Error => LevelFilter::ERROR, + Threshold::Warn => LevelFilter::WARN, + Threshold::Info => LevelFilter::INFO, + Threshold::Debug => LevelFilter::DEBUG, + Threshold::Trace => LevelFilter::TRACE, + } +} + +fn tracing_init(filter: LevelFilter, style: &TraceStyle) { + let builder = tracing_subscriber::fmt() + .with_max_level(filter) + .with_ansi(true) + .with_test_writer(); + + let () = match style { + TraceStyle::Default => builder.init(), + TraceStyle::Pretty(display_filename) => builder.pretty().with_file(*display_filename).init(), + TraceStyle::Compact => builder.compact().init(), + TraceStyle::Json => builder.json().init(), + }; + + tracing::info!("Logging initialized"); +} + +#[derive(Debug)] +pub enum TraceStyle { + Default, + Pretty(bool), + Compact, + Json, +} + +impl std::fmt::Display for TraceStyle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let style = match self { + TraceStyle::Default => "Default Style", + TraceStyle::Pretty(path) => match path { + true => "Pretty Style with File Paths", + false => "Pretty Style without File Paths", + }, + TraceStyle::Compact => "Compact Style", + TraceStyle::Json => "Json Format", + }; + + f.write_str(style) + } +} diff --git a/packages/configuration/src/v3_0_0/core.rs b/packages/configuration/src/v3_0_0/core.rs new file mode 100644 index 000000000..3ae0cfd67 --- /dev/null +++ b/packages/configuration/src/v3_0_0/core.rs @@ -0,0 +1,120 @@ +use serde::{Deserialize, Serialize}; +use torrust_tracker_primitives::announce::AnnouncePolicy; +use torrust_tracker_primitives::{PrivateMode, TrackerPolicy}; + +use super::network::Network; +use crate::v3_0_0::database::Database; +use crate::validator::{SemanticValidationError, Validator}; + +#[allow(clippy::struct_excessive_bools)] +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub struct Core { + /// Announce policy configuration. + #[serde(default = "Core::default_announce_policy")] + pub announce_policy: AnnouncePolicy, + + /// Database configuration. + #[serde(default = "Core::default_database")] + pub database: Database, + + /// Interval in seconds that the cleanup job will run to remove inactive + /// peers from the torrent peer list. + #[serde(default = "Core::default_inactive_peer_cleanup_interval")] + pub inactive_peer_cleanup_interval: u64, + + /// When `true` only approved torrents can be announced in the tracker. + #[serde(default = "Core::default_listed")] + pub listed: bool, + + /// Network configuration. + #[serde(default = "Core::default_network")] + pub net: Network, + + /// When `true` clients require a key to connect and use the tracker. + #[serde(default = "Core::default_private")] + pub private: bool, + + /// Configuration specific when the tracker is running in private mode. + #[serde(default = "Core::default_private_mode")] + pub private_mode: Option, + + /// Tracker policy configuration. + #[serde(default = "Core::default_tracker_policy")] + pub tracker_policy: TrackerPolicy, + + /// Weather the tracker should collect statistics about tracker usage. + /// If enabled, the tracker will collect statistics like the number of + /// connections handled, the number of announce requests handled, etc. + /// Refer to the [`Tracker`](https://docs.rs/torrust-tracker) for more + /// information about the collected metrics. + #[serde(default = "Core::default_tracker_usage_statistics")] + pub tracker_usage_statistics: bool, +} + +impl Default for Core { + fn default() -> Self { + Self { + announce_policy: Self::default_announce_policy(), + database: Self::default_database(), + inactive_peer_cleanup_interval: Self::default_inactive_peer_cleanup_interval(), + listed: Self::default_listed(), + net: Self::default_network(), + private: Self::default_private(), + private_mode: Self::default_private_mode(), + tracker_policy: Self::default_tracker_policy(), + tracker_usage_statistics: Self::default_tracker_usage_statistics(), + } + } +} + +impl Core { + fn default_announce_policy() -> AnnouncePolicy { + AnnouncePolicy::default() + } + + fn default_database() -> Database { + Database::default() + } + + fn default_inactive_peer_cleanup_interval() -> u64 { + 600 + } + + fn default_listed() -> bool { + false + } + + fn default_network() -> Network { + Network::default() + } + + fn default_private() -> bool { + false + } + + fn default_private_mode() -> Option { + if Self::default_private() { + Some(PrivateMode::default()) + } else { + None + } + } + + fn default_tracker_policy() -> TrackerPolicy { + TrackerPolicy::default() + } + + fn default_tracker_usage_statistics() -> bool { + true + } +} + +impl Validator for Core { + fn validate(&self) -> Result<(), SemanticValidationError> { + if self.private_mode.is_some() && !self.private { + return Err(SemanticValidationError::UselessPrivateModeSection); + } + + Ok(()) + } +} diff --git a/packages/configuration/src/v3_0_0/database.rs b/packages/configuration/src/v3_0_0/database.rs new file mode 100644 index 000000000..85b39fad1 --- /dev/null +++ b/packages/configuration/src/v3_0_0/database.rs @@ -0,0 +1,91 @@ +use serde::{Deserialize, Serialize}; +use torrust_tracker_primitives::Driver; +use url::Url; + +#[allow(clippy::struct_excessive_bools)] +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub struct Database { + // Database configuration + /// Database driver. Possible values are: `sqlite3`, `mysql`, and `postgresql`. + #[serde(default = "Database::default_driver")] + pub driver: Driver, + + /// Database connection string. The format depends on the database driver. + /// For `sqlite3`, the format is `path/to/database.db`, for example: + /// `./storage/tracker/lib/database/sqlite3.db`. + /// For `mysql`, the format is `mysql://db_user:db_user_password@host:port/db_name`, for + /// example: `mysql://root:password@localhost:3306/torrust`. + /// For `postgresql`, the format is `postgresql://db_user:db_user_password@host:port/db_name`, + /// for example: `postgresql://postgres:password@localhost:5432/torrust`. + /// If the password contains reserved URL characters (for example `+` or `/`), + /// percent-encode it in the URL. + #[serde(default = "Database::default_path")] + pub path: String, +} + +impl Default for Database { + fn default() -> Self { + Self { + driver: Self::default_driver(), + path: Self::default_path(), + } + } +} + +impl Database { + fn default_driver() -> Driver { + Driver::Sqlite3 + } + + fn default_path() -> String { + String::from("./storage/tracker/lib/database/sqlite3.db") + } + + /// Masks secrets in the configuration. + /// + /// # Panics + /// + /// Will panic if the database path for `MySQL` or `PostgreSQL` is not a valid URL. + pub fn mask_secrets(&mut self) { + match self.driver { + Driver::Sqlite3 => { + // Nothing to mask + } + Driver::MySQL | Driver::PostgreSQL => { + let mut url = Url::parse(&self.path).expect("path for MySQL/PostgreSQL driver should be a valid URL"); + url.set_password(Some("***")).expect("url password should be changed"); + self.path = url.to_string(); + } + } + } +} + +#[cfg(test)] +mod tests { + + use super::{Database, Driver}; + + #[test] + fn it_should_allow_masking_the_mysql_user_password() { + let mut database = Database { + driver: Driver::MySQL, + path: "mysql://root:password@localhost:3306/torrust".to_string(), + }; + + database.mask_secrets(); + + assert_eq!(database.path, "mysql://root:***@localhost:3306/torrust".to_string()); + } + + #[test] + fn it_should_allow_masking_the_postgresql_user_password() { + let mut database = Database { + driver: Driver::PostgreSQL, + path: "postgresql://postgres:password@localhost:5432/torrust".to_string(), + }; + + database.mask_secrets(); + + assert_eq!(database.path, "postgresql://postgres:***@localhost:5432/torrust".to_string()); + } +} diff --git a/packages/configuration/src/v3_0_0/health_check_api.rs b/packages/configuration/src/v3_0_0/health_check_api.rs new file mode 100644 index 000000000..368f26c42 --- /dev/null +++ b/packages/configuration/src/v3_0_0/health_check_api.rs @@ -0,0 +1,30 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; + +/// Configuration for the Health Check API. +#[serde_as] +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub struct HealthCheckApi { + /// The address the API will bind to. + /// The format is `ip:port`, for example `127.0.0.1:1313`. If you want to + /// listen to all interfaces, use `0.0.0.0`. If you want the operating + /// system to choose a random port, use port `0`. + #[serde(default = "HealthCheckApi::default_bind_address")] + pub bind_address: SocketAddr, +} + +impl Default for HealthCheckApi { + fn default() -> Self { + Self { + bind_address: Self::default_bind_address(), + } + } +} + +impl HealthCheckApi { + fn default_bind_address() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1313) + } +} diff --git a/packages/configuration/src/v3_0_0/http_tracker.rs b/packages/configuration/src/v3_0_0/http_tracker.rs new file mode 100644 index 000000000..c7d9039f5 --- /dev/null +++ b/packages/configuration/src/v3_0_0/http_tracker.rs @@ -0,0 +1,67 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; + +use crate::TslConfig; + +/// Configuration for each HTTP tracker. +#[serde_as] +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub struct HttpTracker { + /// The address the tracker will bind to. + /// The format is `ip:port`, for example `0.0.0.0:6969`. If you want to + /// listen to all interfaces, use `0.0.0.0`. If you want the operating + /// system to choose a random port, use port `0`. + #[serde(default = "HttpTracker::default_bind_address")] + pub bind_address: SocketAddr, + + /// TSL config. + #[serde(default = "HttpTracker::default_tsl_config")] + pub tsl_config: Option, + + /// Weather the tracker should collect statistics about tracker usage. + #[serde(default = "HttpTracker::default_tracker_usage_statistics")] + pub tracker_usage_statistics: bool, + + /// Whether to set `IPV6_V6ONLY=1` on IPv6 sockets. + /// + /// When `true` (IPv6-only), the tracker must also bind an IPv4 socket + /// (e.g. `0.0.0.0:`) to accept IPv4 connections. + /// When `false` (default), the socket option is not overridden and the + /// OS default applies (dual-stack on Linux, IPv6-only on other platforms). + /// + /// > **Platform note**: On OpenBSD, `IPV6_V6ONLY` is always `1` and cannot + /// > be disabled; setting this to `false` is a no-op. + #[serde(default = "HttpTracker::default_ipv6_v6only")] + pub ipv6_v6only: bool, +} + +impl Default for HttpTracker { + fn default() -> Self { + Self { + bind_address: Self::default_bind_address(), + tsl_config: Self::default_tsl_config(), + tracker_usage_statistics: Self::default_tracker_usage_statistics(), + ipv6_v6only: Self::default_ipv6_v6only(), + } + } +} + +impl HttpTracker { + fn default_bind_address() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 7070) + } + + fn default_tsl_config() -> Option { + None + } + + fn default_tracker_usage_statistics() -> bool { + false + } + + fn default_ipv6_v6only() -> bool { + false + } +} diff --git a/packages/configuration/src/v3_0_0/logging.rs b/packages/configuration/src/v3_0_0/logging.rs new file mode 100644 index 000000000..0876b6362 --- /dev/null +++ b/packages/configuration/src/v3_0_0/logging.rs @@ -0,0 +1,114 @@ +//! Logging configuration and setup for `v3_0_0`. +//! +//! Contains the `Logging` configuration struct, the `Threshold` level enum, +//! the `TraceStyle` enum, and the `setup()` / `tracing_init()` helpers. +use std::sync::Once; + +use serde::{Deserialize, Serialize}; +use tracing::level_filters::LevelFilter; + +static INIT: Once = Once::new(); + +#[allow(clippy::struct_excessive_bools)] +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub struct Logging { + /// Logging level. Possible values are: `Off`, `Error`, `Warn`, `Info`, + /// `Debug` and `Trace`. Default is `Info`. + #[serde(default = "Logging::default_threshold")] + pub threshold: Threshold, +} + +impl Default for Logging { + fn default() -> Self { + Self { + threshold: Self::default_threshold(), + } + } +} + +impl Logging { + fn default_threshold() -> Threshold { + Threshold::Info + } +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Debug, Hash, Clone)] +#[serde(rename_all = "lowercase")] +pub enum Threshold { + /// A threshold lower than all security levels. + Off, + /// Corresponds to the `Error` security level. + Error, + /// Corresponds to the `Warn` security level. + Warn, + /// Corresponds to the `Info` security level. + Info, + /// Corresponds to the `Debug` security level. + Debug, + /// Corresponds to the `Trace` security level. + Trace, +} + +/// Redirects log output to stdout at the threshold defined in the configuration. +pub fn setup(cfg: &Logging) { + let tracing_level = map_to_tracing_level_filter(&cfg.threshold); + + if tracing_level == LevelFilter::OFF { + return; + } + + INIT.call_once(|| { + tracing_init(tracing_level, &TraceStyle::Default); + }); +} + +fn map_to_tracing_level_filter(threshold: &Threshold) -> LevelFilter { + match threshold { + Threshold::Off => LevelFilter::OFF, + Threshold::Error => LevelFilter::ERROR, + Threshold::Warn => LevelFilter::WARN, + Threshold::Info => LevelFilter::INFO, + Threshold::Debug => LevelFilter::DEBUG, + Threshold::Trace => LevelFilter::TRACE, + } +} + +fn tracing_init(filter: LevelFilter, style: &TraceStyle) { + let builder = tracing_subscriber::fmt() + .with_max_level(filter) + .with_ansi(true) + .with_test_writer(); + + let () = match style { + TraceStyle::Default => builder.init(), + TraceStyle::Pretty(display_filename) => builder.pretty().with_file(*display_filename).init(), + TraceStyle::Compact => builder.compact().init(), + TraceStyle::Json => builder.json().init(), + }; + + tracing::info!("Logging initialized"); +} + +#[derive(Debug)] +pub enum TraceStyle { + Default, + Pretty(bool), + Compact, + Json, +} + +impl std::fmt::Display for TraceStyle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let style = match self { + TraceStyle::Default => "Default Style", + TraceStyle::Pretty(path) => match path { + true => "Pretty Style with File Paths", + false => "Pretty Style without File Paths", + }, + TraceStyle::Compact => "Compact Style", + TraceStyle::Json => "Json Format", + }; + + f.write_str(style) + } +} diff --git a/packages/configuration/src/v3_0_0/mod.rs b/packages/configuration/src/v3_0_0/mod.rs new file mode 100644 index 000000000..bbf628fd7 --- /dev/null +++ b/packages/configuration/src/v3_0_0/mod.rs @@ -0,0 +1,898 @@ +//! Version `1` for [Torrust Tracker](https://docs.rs/torrust-tracker) +//! configuration data structures. +//! +//! This module contains the configuration data structures for the +//! Torrust Tracker, which is a `BitTorrent` tracker server. +//! +//! The configuration is loaded from a [TOML](https://toml.io/en/) file +//! `tracker.toml` in the project root folder or from an environment variable +//! with the same content as the file. +//! +//! Configuration can not only be loaded from a file, but also from an +//! environment variable `TORRUST_TRACKER_CONFIG_TOML`. This is useful when running +//! the tracker in a Docker container or environments where you do not have a +//! persistent storage or you cannot inject a configuration file. Refer to +//! [`Torrust Tracker documentation`](https://docs.rs/torrust-tracker) for more +//! information about how to pass configuration to the tracker. +//! +//! When you run the tracker without providing the configuration via a file or +//! env var, the default configuration is used. +//! +//! # Table of contents +//! +//! - [Sections](#sections) +//! - [Port binding](#port-binding) +//! - [TSL support](#tsl-support) +//! - [Generating self-signed certificates](#generating-self-signed-certificates) +//! - [Default configuration](#default-configuration) +//! +//! ## Sections +//! +//! Each section in the toml structure is mapped to a data structure. For +//! example, the `[http_api]` section (configuration for the tracker HTTP API) +//! is mapped to the [`HttpApi`] structure. +//! +//! > **NOTICE**: some sections are arrays of structures. For example, the +//! > `[[udp_trackers]]` section is an array of [`UdpTracker`] since +//! > you can have multiple running UDP trackers bound to different ports. +//! +//! Please refer to the documentation of each structure for more information +//! about each section. +//! +//! - [`Core configuration`](crate::v3_0_0::Configuration) +//! - [`HTTP API configuration`](crate::v3_0_0::tracker_api::HttpApi) +//! - [`HTTP Tracker configuration`](crate::v3_0_0::http_tracker::HttpTracker) +//! - [`UDP Tracker configuration`](crate::v3_0_0::udp_tracker::UdpTracker) +//! - [`Health Check API configuration`](crate::v3_0_0::health_check_api::HealthCheckApi) +//! +//! ## Port binding +//! +//! For the API, HTTP and UDP trackers you can bind to a random port by using +//! port `0`. For example, if you want to bind to a random port on all +//! interfaces, use `0.0.0.0:0`. The OS will choose a random free port. +//! +//! ## TSL support +//! +//! For the API and HTTP tracker you can enable TSL by setting `ssl_enabled` to +//! `true` and setting the paths to the certificate and key files. +//! +//! Typically, you will have a `storage` directory like the following: +//! +//! ```text +//! storage/ +//! ├── config.toml +//! └── tracker +//! ├── etc +//! │ └── tracker.toml +//! ├── lib +//! │ ├── database +//! │ │ ├── sqlite3.db +//! │ │ └── sqlite.db +//! │ └── tls +//! │ ├── localhost.crt +//! │ └── localhost.key +//! └── log +//! ``` +//! +//! where the application stores all the persistent data. +//! +//! Alternatively, you could setup a reverse proxy like Nginx or Apache to +//! handle the SSL/TLS part and forward the requests to the tracker. If you do +//! that, you should set [`on_reverse_proxy`](crate::v3_0_0::network::Network::on_reverse_proxy) +//! to `true` in the configuration file. It's out of scope for this +//! documentation to explain in detail how to setup a reverse proxy, but the +//! configuration file should be something like this: +//! +//! For [NGINX](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/): +//! +//! ```text +//! # HTTPS only (with SSL - force redirect to HTTPS) +//! +//! server { +//! listen 80; +//! server_name tracker.torrust.com; +//! +//! return 301 https://$host$request_uri; +//! } +//! +//! server { +//! listen 443; +//! server_name tracker.torrust.com; +//! +//! ssl_certificate CERT_PATH +//! ssl_certificate_key CERT_KEY_PATH; +//! +//! location / { +//! proxy_set_header X-Forwarded-For $remote_addr; +//! proxy_pass http://127.0.0.1:6969; +//! } +//! } +//! ``` +//! +//! For [Apache](https://httpd.apache.org/docs/2.4/howto/reverse_proxy.html): +//! +//! ```text +//! # HTTPS only (with SSL - force redirect to HTTPS) +//! +//! +//! ServerAdmin webmaster@tracker.torrust.com +//! ServerName tracker.torrust.com +//! +//! +//! RewriteEngine on +//! RewriteCond %{HTTPS} off +//! RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent] +//! +//! +//! +//! +//! +//! ServerAdmin webmaster@tracker.torrust.com +//! ServerName tracker.torrust.com +//! +//! +//! Order allow,deny +//! Allow from all +//! +//! +//! ProxyPreserveHost On +//! ProxyRequests Off +//! AllowEncodedSlashes NoDecode +//! +//! ProxyPass / http://localhost:3000/ +//! ProxyPassReverse / http://localhost:3000/ +//! ProxyPassReverse / http://tracker.torrust.com/ +//! +//! RequestHeader set X-Forwarded-Proto "https" +//! RequestHeader set X-Forwarded-Port "443" +//! +//! ErrorLog ${APACHE_LOG_DIR}/tracker.torrust.com-error.log +//! CustomLog ${APACHE_LOG_DIR}/tracker.torrust.com-access.log combined +//! +//! SSLCertificateFile CERT_PATH +//! SSLCertificateKeyFile CERT_KEY_PATH +//! +//! +//! ``` +//! +//! ## Generating self-signed certificates +//! +//! For testing purposes, you can use self-signed certificates. +//! +//! Refer to [Let's Encrypt - Certificates for localhost](https://letsencrypt.org/docs/certificates-for-localhost/) +//! for more information. +//! +//! Running the following command will generate a certificate (`localhost.crt`) +//! and key (`localhost.key`) file in your current directory: +//! +//! ```s +//! openssl req -x509 -out localhost.crt -keyout localhost.key \ +//! -newkey rsa:2048 -nodes -sha256 \ +//! -subj '/CN=localhost' -extensions EXT -config <( \ +//! printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth") +//! ``` +//! +//! You can then use the generated files in the configuration file: +//! +//! ```s +//! [[http_trackers]] +//! ... +//! +//! [http_trackers.tsl_config] +//! ssl_cert_path = "./storage/tracker/lib/tls/localhost.crt" +//! ssl_key_path = "./storage/tracker/lib/tls/localhost.key" +//! +//! [http_api] +//! ... +//! +//! [http_api.tsl_config] +//! ssl_cert_path = "./storage/tracker/lib/tls/localhost.crt" +//! ssl_key_path = "./storage/tracker/lib/tls/localhost.key" +//! ``` +//! +//! ## Default configuration +//! +//! The default configuration is: +//! +//! ```toml +//! [logging] +//! threshold = "info" +//! +//! [core] +//! inactive_peer_cleanup_interval = 600 +//! listed = false +//! private = false +//! tracker_usage_statistics = true +//! +//! [core.announce_policy] +//! interval = 120 +//! interval_min = 120 +//! max_peers_per_announce = 74 +//! +//! [core.database] +//! driver = "sqlite3" +//! path = "./storage/tracker/lib/database/sqlite3.db" +//! +//! [core.net] +//! on_reverse_proxy = false +//! +//! [core.tracker_policy] +//! max_peer_timeout = 900 +//! persistent_torrent_completed_stat = false +//! remove_peerless_torrents = true +//! +//! [http_api] +//! bind_address = "127.0.0.1:1212" +//! +//! [http_api.access_tokens] +//! admin = "MyAccessToken" +//! [health_check_api] +//! bind_address = "127.0.0.1:1313" +//!``` +pub mod core; +pub mod database; +pub mod health_check_api; +pub mod http_tracker; +pub mod logging; +pub mod network; +pub mod tracker_api; +pub mod udp_tracker; + +use std::fs; +use std::net::IpAddr; + +use figment::Figment; +use figment::providers::{Env, Format, Serialized, Toml}; +use logging::Logging; +use serde::{Deserialize, Serialize}; + +use self::core::Core; +use self::health_check_api::HealthCheckApi; +use self::http_tracker::HttpTracker; +use self::tracker_api::HttpApi; +use self::udp_tracker::UdpTracker; +use crate::validator::{SemanticValidationError, Validator}; +use crate::{Error, Info, Metadata, Version}; + +/// This configuration version +const VERSION_3_0_0: &str = "3.0.0"; + +/// Prefix for env vars that overwrite configuration options. +const CONFIG_OVERRIDE_PREFIX: &str = "TORRUST_TRACKER_CONFIG_OVERRIDE_"; + +/// Path separator in env var names for nested values in configuration. +const CONFIG_OVERRIDE_SEPARATOR: &str = "__"; + +/// Core configuration for the tracker. +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub struct Configuration { + /// Configuration metadata. + pub metadata: Metadata, + + /// Logging configuration + pub logging: Logging, + + /// Core configuration. + pub core: Core, + + /// The list of UDP trackers the tracker is running. Each UDP tracker + /// represents a UDP server that the tracker is running and it has its own + /// configuration. + pub udp_trackers: Option>, + + /// The list of HTTP trackers the tracker is running. Each HTTP tracker + /// represents a HTTP server that the tracker is running and it has its own + /// configuration. + pub http_trackers: Option>, + + /// The HTTP API configuration. + pub http_api: Option, + + /// The Health Check API configuration. + pub health_check_api: HealthCheckApi, +} + +impl Default for Configuration { + fn default() -> Self { + Self { + metadata: Metadata::with_schema_version(Version::new(VERSION_3_0_0)), + logging: Logging::default(), + core: Core::default(), + udp_trackers: None, + http_trackers: None, + http_api: None, + health_check_api: HealthCheckApi::default(), + } + } +} + +impl Configuration { + /// Returns the tracker public IP address id defined in the configuration, + /// and `None` otherwise. + #[must_use] + pub fn get_ext_ip(&self) -> Option { + self.core.net.external_ip.map(Into::into) + } + + /// Saves the default configuration at the given path. + /// + /// # Errors + /// + /// Will return `Err` if `path` is not a valid path or the configuration + /// file cannot be created. + pub fn create_default_configuration_file(path: &str) -> Result { + let config = Configuration::default(); + config.save_to_file(path)?; + Ok(config) + } + + /// Loads the configuration from the `Info` struct. The whole + /// configuration in toml format is included in the `info.tracker_toml` + /// string. + /// + /// Configuration provided via env var has priority over config file path. + /// + /// # Errors + /// + /// Will return `Err` if the environment variable does not exist or has a bad configuration. + pub fn load(info: &Info) -> Result { + // Load configuration provided by the user, prioritizing env vars + let figment = if let Some(config_toml) = &info.config_toml { + Figment::from(Toml::string(config_toml)).merge(Env::prefixed(CONFIG_OVERRIDE_PREFIX).split(CONFIG_OVERRIDE_SEPARATOR)) + } else { + Figment::from(Toml::file(&info.config_toml_path)) + .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(Configuration::default())); + + // Build final configuration. + let config: Configuration = figment.extract()?; + + // Make sure the provided schema version matches this version. + if config.metadata.schema_version != Version::new(VERSION_3_0_0) { + return Err(Error::UnsupportedVersion { + version: config.metadata.schema_version, + }); + } + + Ok(config) + } + + /// 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 = ["metadata.schema_version", "logging.threshold", "core.private", "core.listed"]; + + for mandatory_option in mandatory_options { + figment + .find_value(mandatory_option) + .map_err(|_err| Error::MissingMandatoryOption { + path: mandatory_option.to_owned(), + })?; + } + + Ok(()) + } + + /// Saves the configuration to the configuration file. + /// + /// # Errors + /// + /// Will return `Err` if `filename` does not exist or the user does not have + /// permission to read it. Will also return `Err` if the configuration is + /// not valid or cannot be encoded to TOML. + /// + /// # Panics + /// + /// Will panic if the configuration cannot be written into the file. + pub fn save_to_file(&self, path: &str) -> Result<(), Error> { + fs::write(path, self.to_toml()).expect("Could not write to file!"); + Ok(()) + } + + /// Encodes the configuration to TOML. + /// + /// # Panics + /// + /// Will panic if it can't be converted to TOML. + #[must_use] + fn to_toml(&self) -> String { + // code-review: do we need to use Figment also to serialize into toml? + toml::to_string(self).expect("Could not encode TOML value") + } + + /// Encodes the configuration to JSON. + /// + /// # Panics + /// + /// Will panic if it can't be converted to JSON. + #[must_use] + pub fn to_json(&self) -> String { + // code-review: do we need to use Figment also to serialize into json? + serde_json::to_string_pretty(self).expect("Could not encode JSON value") + } + + /// Masks secrets in the configuration. + #[must_use] + pub fn mask_secrets(mut self) -> Self { + self.core.database.mask_secrets(); + + if let Some(ref mut api) = self.http_api { + api.mask_secrets(); + } + + self + } +} + +impl Validator for Configuration { + fn validate(&self) -> Result<(), SemanticValidationError> { + self.core.validate() + } +} + +#[cfg(test)] +mod tests { + + use std::convert::TryFrom; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + use crate::Info; + use crate::v3_0_0::Configuration; + use crate::v3_0_0::network::ExternalIp; + + #[cfg(test)] + fn default_config_toml() -> String { + r#"[metadata] + app = "torrust-tracker" + purpose = "configuration" + schema_version = "3.0.0" + + [logging] + threshold = "info" + + [core] + inactive_peer_cleanup_interval = 600 + listed = false + private = false + tracker_usage_statistics = true + + [core.announce_policy] + interval = 120 + interval_min = 120 + max_peers_per_announce = 74 + + [core.database] + driver = "sqlite3" + path = "./storage/tracker/lib/database/sqlite3.db" + + [core.net] + on_reverse_proxy = false + + [core.tracker_policy] + max_peer_timeout = 900 + persistent_torrent_completed_stat = false + remove_peerless_torrents = true + + [health_check_api] + bind_address = "127.0.0.1:1313" + "# + .lines() + .map(str::trim_start) + .collect::>() + .join("\n") + } + + #[test] + fn configuration_should_have_default_values() { + let configuration = Configuration::default(); + + let toml = toml::to_string(&configuration).expect("Could not encode TOML value"); + + assert_eq!(toml, default_config_toml()); + } + + #[test] + fn configuration_should_not_contain_an_external_ip_by_default() { + let configuration = Configuration::default(); + + assert_eq!(configuration.core.net.external_ip, None); + } + + #[test] + fn configuration_should_be_saved_in_a_toml_config_file() { + use std::{env, fs}; + + use uuid::Uuid; + + // Build temp config file path + let temp_directory = env::temp_dir(); + let temp_file = temp_directory.join(format!("test_config_{}.toml", Uuid::new_v4())); + + // Convert to argument type for Configuration::save_to_file + let config_file_path = temp_file; + let path = config_file_path.to_string_lossy().to_string(); + + let default_configuration = Configuration::default(); + + default_configuration + .save_to_file(&path) + .expect("Could not save configuration to file"); + + let contents = fs::read_to_string(&path).expect("Something went wrong reading the file"); + + assert_eq!(contents, default_config_toml()); + } + + #[test] + #[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| { + jail.create_file( + "tracker.toml", + r#" + [metadata] + schema_version = "3.0.0" + + [logging] + threshold = "info" + + [core] + listed = false + private = false + "#, + )?; + + let info = Info { + config_toml: None, + config_toml_path: "tracker.toml".to_string(), + }; + + let configuration = Configuration::load(&info).expect("Could not load configuration from file"); + + assert_eq!(configuration, Configuration::default()); + + Ok(()) + }); + } + + #[test] + #[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| { + let config_toml = r#" + [metadata] + schema_version = "3.0.0" + + [logging] + threshold = "info" + + [core] + listed = false + private = false + "# + .to_string(); + + let info = Info { + config_toml: Some(config_toml), + config_toml_path: String::new(), + }; + + let configuration = Configuration::load(&info).expect("Could not load configuration from file"); + + assert_eq!(configuration, Configuration::default()); + + Ok(()) + }); + } + + #[test] + #[allow(clippy::result_large_err)] + fn default_configuration_could_be_overwritten_from_a_single_env_var_with_toml_contents() { + figment::Jail::expect_with(|_jail| { + let config_toml = r#" + [metadata] + schema_version = "3.0.0" + + [logging] + threshold = "info" + + [core] + listed = false + private = false + + [core.database] + path = "OVERWRITTEN DEFAULT DB PATH" + "# + .to_string(); + + let info = Info { + config_toml: Some(config_toml), + config_toml_path: String::new(), + }; + + let configuration = Configuration::load(&info).expect("Could not load configuration from file"); + + assert_eq!(configuration.core.database.path, "OVERWRITTEN DEFAULT DB PATH".to_string()); + + Ok(()) + }); + } + + #[test] + #[allow(clippy::result_large_err)] + fn default_configuration_could_be_overwritten_from_a_toml_config_file() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "tracker.toml", + r#" + [metadata] + schema_version = "3.0.0" + + [logging] + threshold = "info" + + [core] + listed = false + private = false + + [core.database] + path = "OVERWRITTEN DEFAULT DB PATH" + "#, + )?; + + let info = Info { + config_toml: None, + config_toml_path: "tracker.toml".to_string(), + }; + + let configuration = Configuration::load(&info).expect("Could not load configuration from file"); + + assert_eq!(configuration.core.database.path, "OVERWRITTEN DEFAULT DB PATH".to_string()); + + Ok(()) + }); + } + + #[allow(clippy::result_large_err)] + #[test] + fn configuration_should_allow_to_overwrite_the_default_tracker_api_token_for_admin_with_an_env_var() { + figment::Jail::expect_with(|jail| { + jail.set_env("TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN", "NewToken"); + + let info = Info { + config_toml: Some(default_config_toml()), + config_toml_path: String::new(), + }; + + let configuration = Configuration::load(&info).expect("Could not load configuration from file"); + + assert_eq!( + configuration.http_api.unwrap().access_tokens.get("admin"), + Some("NewToken".to_owned()).as_ref() + ); + + Ok(()) + }); + } + + #[test] + fn external_ip_should_reject_unspecified_ipv4_address() { + let result = ExternalIp::try_from(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + assert!(result.is_err()); + } + + #[test] + fn external_ip_should_reject_unspecified_ipv6_address() { + let result = ExternalIp::try_from(IpAddr::V6(Ipv6Addr::UNSPECIFIED)); + assert!(result.is_err()); + } + + #[test] + fn external_ip_should_accept_valid_ipv4_address() { + let result = ExternalIp::try_from(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5))); + assert!(result.is_ok()); + } + + #[test] + fn external_ip_should_parse_from_str() { + let ip: Result = "203.0.113.5".parse(); + assert!(ip.is_ok()); + let ip: Result = "0.0.0.0".parse(); + assert!(ip.is_err()); + let ip: Result = "::".parse(); + assert!(ip.is_err()); + } + + #[cfg(test)] + mod deserialization { + use std::net::{IpAddr, Ipv4Addr}; + + use figment::Jail; + + use crate::Info; + use crate::v3_0_0::Configuration; + + #[allow(clippy::result_large_err)] + #[test] + fn should_deserialize_valid_external_ip_from_toml() { + Jail::expect_with(|jail| { + jail.create_file( + "tracker.toml", + r#" + [metadata] + schema_version = "3.0.0" + + [logging] + threshold = "info" + + [core] + listed = false + private = false + + [core.net] + external_ip = "203.0.113.5" + on_reverse_proxy = false + "#, + )?; + + let info = Info { + config_toml: None, + config_toml_path: "tracker.toml".to_string(), + }; + + let config = Configuration::load(&info).expect("Should load config"); + assert_eq!( + config.core.net.external_ip, + Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5)).try_into().expect("valid IP")) + ); + + Ok(()) + }); + } + + #[allow(clippy::result_large_err)] + #[test] + fn should_reject_unspecified_ipv4_external_ip_in_toml() { + Jail::expect_with(|jail| { + jail.create_file( + "tracker.toml", + r#" + [metadata] + schema_version = "3.0.0" + + [logging] + threshold = "info" + + [core] + listed = false + private = false + + [core.net] + external_ip = "0.0.0.0" + on_reverse_proxy = false + "#, + )?; + + let info = Info { + config_toml: None, + config_toml_path: "tracker.toml".to_string(), + }; + + let result = Configuration::load(&info); + assert!(result.is_err()); + + Ok(()) + }); + } + + #[allow(clippy::result_large_err)] + #[test] + fn should_reject_unspecified_ipv6_external_ip_in_toml() { + Jail::expect_with(|jail| { + jail.create_file( + "tracker.toml", + r#" + [metadata] + schema_version = "3.0.0" + + [logging] + threshold = "info" + + [core] + listed = false + private = false + + [core.net] + external_ip = "::" + on_reverse_proxy = false + "#, + )?; + + let info = Info { + config_toml: None, + config_toml_path: "tracker.toml".to_string(), + }; + + let result = Configuration::load(&info); + assert!(result.is_err()); + + Ok(()) + }); + } + } + + mod smoke { + use crate::Info; + use crate::v3_0_0::Configuration; + + #[allow(clippy::result_large_err)] + #[test] + fn v3_configuration_should_load_when_schema_version_is_3_0_0() { + figment::Jail::expect_with(|_jail| { + let config_toml = r#" + [metadata] + schema_version = "3.0.0" + + [logging] + threshold = "info" + + [core] + listed = false + private = false + "# + .to_string(); + + let info = Info { + config_toml: Some(config_toml), + config_toml_path: String::new(), + }; + + let result = Configuration::load(&info); + assert!(result.is_ok(), "v3 configuration should load with schema_version 3.0.0"); + + Ok(()) + }); + } + + #[allow(clippy::result_large_err)] + #[test] + fn v3_configuration_should_reject_schema_version_2_0_0() { + figment::Jail::expect_with(|_jail| { + let config_toml = r#" + [metadata] + schema_version = "2.0.0" + + [logging] + threshold = "info" + + [core] + listed = false + private = false + "# + .to_string(); + + let info = Info { + config_toml: Some(config_toml), + config_toml_path: String::new(), + }; + + let result = Configuration::load(&info); + assert!(result.is_err(), "v3 configuration should reject schema_version 2.0.0"); + + Ok(()) + }); + } + } +} diff --git a/packages/configuration/src/v3_0_0/network.rs b/packages/configuration/src/v3_0_0/network.rs new file mode 100644 index 000000000..75ae69a45 --- /dev/null +++ b/packages/configuration/src/v3_0_0/network.rs @@ -0,0 +1,93 @@ +use std::convert::TryFrom; +use std::fmt; +use std::net::IpAddr; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub struct Network { + /// The external IP address of the tracker. If the client is using a + /// loopback IP address, this IP address will be used instead. If the peer + /// is using a loopback IP address, the tracker assumes that the peer is + /// in the same network as the tracker and will use the tracker's IP + /// address instead. + #[serde(default = "Network::default_external_ip")] + pub external_ip: Option, + + /// Whether the tracker is behind a reverse proxy or not. + /// If the tracker is behind a reverse proxy, the `X-Forwarded-For` header + /// sent from the proxy will be used to get the client's IP address. + #[serde(default = "Network::default_on_reverse_proxy")] + pub on_reverse_proxy: bool, +} + +impl Default for Network { + fn default() -> Self { + Self { + external_ip: Self::default_external_ip(), + on_reverse_proxy: Self::default_on_reverse_proxy(), + } + } +} + +impl Network { + fn default_external_ip() -> Option { + None + } + + fn default_on_reverse_proxy() -> bool { + false + } +} +/// A validated external IP address that is guaranteed not to be a wildcard +/// address (`0.0.0.0` or `::`). +/// +/// Wildcard addresses are never valid external IPs. This type enforces that +/// constraint at construction time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +pub struct ExternalIp(IpAddr); + +impl TryFrom for ExternalIp { + type Error = &'static str; + + fn try_from(ip: IpAddr) -> Result { + if ip.is_unspecified() { + Err("wildcard/unspecified IP address is not a valid external IP") + } else { + Ok(Self(ip)) + } + } +} + +impl FromStr for ExternalIp { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + let ip: IpAddr = s.parse().map_err(|_| "invalid IP address format")?; + ExternalIp::try_from(ip) + } +} + +impl From for IpAddr { + fn from(ip: ExternalIp) -> Self { + ip.0 + } +} + +impl fmt::Display for ExternalIp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +// Custom deserialize to reject unspecified addresses +impl<'de> Deserialize<'de> for ExternalIp { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let ip = IpAddr::deserialize(deserializer)?; + ExternalIp::try_from(ip).map_err(serde::de::Error::custom) + } +} diff --git a/packages/configuration/src/v3_0_0/tracker_api.rs b/packages/configuration/src/v3_0_0/tracker_api.rs new file mode 100644 index 000000000..045271570 --- /dev/null +++ b/packages/configuration/src/v3_0_0/tracker_api.rs @@ -0,0 +1,88 @@ +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; + +use crate::TslConfig; + +pub type AccessTokens = HashMap; + +/// Configuration for the HTTP API. +#[serde_as] +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub struct HttpApi { + /// The address the tracker will bind to. + /// The format is `ip:port`, for example `0.0.0.0:6969`. If you want to + /// listen to all interfaces, use `0.0.0.0`. If you want the operating + /// system to choose a random port, use port `0`. + #[serde(default = "HttpApi::default_bind_address")] + pub bind_address: SocketAddr, + + /// TSL config. Only used if `ssl_enabled` is true. + #[serde(default = "HttpApi::default_tsl_config")] + pub tsl_config: Option, + + /// Access tokens for the HTTP API. The key is a label identifying the + /// token and the value is the token itself. The token is used to + /// authenticate the user. All tokens are valid for all endpoints and have + /// all permissions. + #[serde(default = "HttpApi::default_access_tokens")] + pub access_tokens: AccessTokens, +} + +impl Default for HttpApi { + fn default() -> Self { + Self { + bind_address: Self::default_bind_address(), + tsl_config: Self::default_tsl_config(), + access_tokens: Self::default_access_tokens(), + } + } +} + +impl HttpApi { + fn default_bind_address() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1212) + } + + #[allow(clippy::unnecessary_wraps)] + fn default_tsl_config() -> Option { + None + } + + fn default_access_tokens() -> AccessTokens { + [].iter().cloned().collect() + } + + pub fn add_token(&mut self, key: &str, token: &str) { + self.access_tokens.insert(key.to_string(), token.to_string()); + } + + pub fn mask_secrets(&mut self) { + for token in self.access_tokens.values_mut() { + *token = "***".to_string(); + } + } +} + +#[cfg(test)] +mod tests { + use crate::v3_0_0::tracker_api::HttpApi; + + #[test] + fn default_http_api_configuration_should_not_contains_any_token() { + let configuration = HttpApi::default(); + + assert_eq!(configuration.access_tokens.values().len(), 0); + } + + #[test] + fn http_api_configuration_should_allow_adding_tokens() { + let mut configuration = HttpApi::default(); + + configuration.add_token("admin", "MyAccessToken"); + + assert!(configuration.access_tokens.values().any(|t| t == "MyAccessToken")); + } +} diff --git a/packages/configuration/src/v3_0_0/udp_tracker.rs b/packages/configuration/src/v3_0_0/udp_tracker.rs new file mode 100644 index 000000000..2a71aa539 --- /dev/null +++ b/packages/configuration/src/v3_0_0/udp_tracker.rs @@ -0,0 +1,73 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)] +pub struct UdpTracker { + /// The address the tracker will bind to. + /// The format is `ip:port`, for example `0.0.0.0:6969`. If you want to + /// listen to all interfaces, use `0.0.0.0`. If you want the operating + /// system to choose a random port, use port `0`. + #[serde(default = "UdpTracker::default_bind_address")] + pub bind_address: SocketAddr, + + /// The lifetime of the server-generated connection cookie, that is passed + /// the client as the `ConnectionId`. + #[serde(default = "UdpTracker::default_cookie_lifetime")] + pub cookie_lifetime: Duration, + + /// Weather the tracker should collect statistics about tracker usage. + #[serde(default = "UdpTracker::default_tracker_usage_statistics")] + pub tracker_usage_statistics: bool, + + /// Whether to set `IPV6_V6ONLY=1` on IPv6 sockets. + /// + /// When `true` (IPv6-only), the tracker must also bind an IPv4 socket + /// (e.g. `0.0.0.0:`) to accept IPv4 connections. + /// When `false` (default), the socket option is not overridden and the + /// OS default applies (dual-stack on Linux, IPv6-only on other platforms). + /// + /// > **Platform note**: On OpenBSD, `IPV6_V6ONLY` is always `1` and cannot + /// > be disabled; setting this to `false` is a no-op. + #[serde(default = "UdpTracker::default_ipv6_v6only")] + pub ipv6_v6only: bool, + + /// The maximum number of connection ID errors per IP before the client is + /// banned. Default is `10`. + #[serde(default = "UdpTracker::default_max_connection_id_errors_per_ip")] + pub max_connection_id_errors_per_ip: u32, +} +impl Default for UdpTracker { + fn default() -> Self { + Self { + bind_address: Self::default_bind_address(), + cookie_lifetime: Self::default_cookie_lifetime(), + tracker_usage_statistics: Self::default_tracker_usage_statistics(), + ipv6_v6only: Self::default_ipv6_v6only(), + max_connection_id_errors_per_ip: Self::default_max_connection_id_errors_per_ip(), + } + } +} + +impl UdpTracker { + fn default_bind_address() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 6969) + } + + fn default_cookie_lifetime() -> Duration { + Duration::from_secs(120) + } + + fn default_tracker_usage_statistics() -> bool { + false + } + + fn default_ipv6_v6only() -> bool { + false + } + + fn default_max_connection_id_errors_per_ip() -> u32 { + 10 + } +} From cd786f1708f54e9635857b7393861ebce21a784b Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 20 Jul 2026 10:36:44 +0100 Subject: [PATCH 2/3] chore(issue-1979): set related-pr to 1999 in spec --- .../1979-1978-copy-configuration-schema-v2-to-v3-baseline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/issues/open/1979-1978-copy-configuration-schema-v2-to-v3-baseline.md b/docs/issues/open/1979-1978-copy-configuration-schema-v2-to-v3-baseline.md index 22aa9143e..086c0fb25 100644 --- a/docs/issues/open/1979-1978-copy-configuration-schema-v2-to-v3-baseline.md +++ b/docs/issues/open/1979-1978-copy-configuration-schema-v2-to-v3-baseline.md @@ -6,7 +6,7 @@ priority: p0 github-issue: 1979 spec-path: docs/issues/open/1979-1978-copy-configuration-schema-v2-to-v3-baseline.md branch: "config-copy-v2-to-v3-baseline" -related-pr: null +related-pr: 1999 last-updated-utc: 2026-07-13 21:00 semantic-links: skill-links: From 2b6bc1a342df5ff0298bffda22f92b834d5063b6 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 20 Jul 2026 10:53:21 +0100 Subject: [PATCH 3/3] fix(configuration): address Copilot review comments on PR #1999 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix "Weather" → "Whether" typo in doc comments for `tracker_usage_statistics` field in v2_0_0 and v3_0_0 (udp_tracker.rs, http_tracker.rs, core.rs) - Fix v3_0_0/mod.rs module doc: "Version `1`" → "Version `3`" - Fix v3_0_0/mod.rs TSL section: replace reference to non-existent `ssl_enabled` flag with accurate description of `tsl_config` section - Fix v3_0_0/tracker_api.rs: remove reference to non-existent `ssl_enabled` field in `tsl_config` doc comment - Simplify `default_access_tokens()` in v3_0_0/tracker_api.rs: replace `[].iter().cloned().collect()` with `HashMap::new()` --- packages/configuration/src/v2_0_0/core.rs | 2 +- packages/configuration/src/v2_0_0/http_tracker.rs | 2 +- packages/configuration/src/v2_0_0/udp_tracker.rs | 2 +- packages/configuration/src/v3_0_0/core.rs | 2 +- packages/configuration/src/v3_0_0/http_tracker.rs | 2 +- packages/configuration/src/v3_0_0/mod.rs | 7 ++++--- packages/configuration/src/v3_0_0/tracker_api.rs | 4 ++-- packages/configuration/src/v3_0_0/udp_tracker.rs | 2 +- 8 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/configuration/src/v2_0_0/core.rs b/packages/configuration/src/v2_0_0/core.rs index cd05daf6c..daf7f8abb 100644 --- a/packages/configuration/src/v2_0_0/core.rs +++ b/packages/configuration/src/v2_0_0/core.rs @@ -42,7 +42,7 @@ pub struct Core { #[serde(default = "Core::default_tracker_policy")] pub tracker_policy: TrackerPolicy, - /// Weather the tracker should collect statistics about tracker usage. + /// Whether the tracker should collect statistics about tracker usage. /// If enabled, the tracker will collect statistics like the number of /// connections handled, the number of announce requests handled, etc. /// Refer to the [`Tracker`](https://docs.rs/torrust-tracker) for more diff --git a/packages/configuration/src/v2_0_0/http_tracker.rs b/packages/configuration/src/v2_0_0/http_tracker.rs index c7d9039f5..9dfb33eda 100644 --- a/packages/configuration/src/v2_0_0/http_tracker.rs +++ b/packages/configuration/src/v2_0_0/http_tracker.rs @@ -20,7 +20,7 @@ pub struct HttpTracker { #[serde(default = "HttpTracker::default_tsl_config")] pub tsl_config: Option, - /// Weather the tracker should collect statistics about tracker usage. + /// Whether the tracker should collect statistics about tracker usage. #[serde(default = "HttpTracker::default_tracker_usage_statistics")] pub tracker_usage_statistics: bool, diff --git a/packages/configuration/src/v2_0_0/udp_tracker.rs b/packages/configuration/src/v2_0_0/udp_tracker.rs index 2a71aa539..bd8973932 100644 --- a/packages/configuration/src/v2_0_0/udp_tracker.rs +++ b/packages/configuration/src/v2_0_0/udp_tracker.rs @@ -17,7 +17,7 @@ pub struct UdpTracker { #[serde(default = "UdpTracker::default_cookie_lifetime")] pub cookie_lifetime: Duration, - /// Weather the tracker should collect statistics about tracker usage. + /// Whether the tracker should collect statistics about tracker usage. #[serde(default = "UdpTracker::default_tracker_usage_statistics")] pub tracker_usage_statistics: bool, diff --git a/packages/configuration/src/v3_0_0/core.rs b/packages/configuration/src/v3_0_0/core.rs index 3ae0cfd67..a87ce6ac4 100644 --- a/packages/configuration/src/v3_0_0/core.rs +++ b/packages/configuration/src/v3_0_0/core.rs @@ -42,7 +42,7 @@ pub struct Core { #[serde(default = "Core::default_tracker_policy")] pub tracker_policy: TrackerPolicy, - /// Weather the tracker should collect statistics about tracker usage. + /// Whether the tracker should collect statistics about tracker usage. /// If enabled, the tracker will collect statistics like the number of /// connections handled, the number of announce requests handled, etc. /// Refer to the [`Tracker`](https://docs.rs/torrust-tracker) for more diff --git a/packages/configuration/src/v3_0_0/http_tracker.rs b/packages/configuration/src/v3_0_0/http_tracker.rs index c7d9039f5..9dfb33eda 100644 --- a/packages/configuration/src/v3_0_0/http_tracker.rs +++ b/packages/configuration/src/v3_0_0/http_tracker.rs @@ -20,7 +20,7 @@ pub struct HttpTracker { #[serde(default = "HttpTracker::default_tsl_config")] pub tsl_config: Option, - /// Weather the tracker should collect statistics about tracker usage. + /// Whether the tracker should collect statistics about tracker usage. #[serde(default = "HttpTracker::default_tracker_usage_statistics")] pub tracker_usage_statistics: bool, diff --git a/packages/configuration/src/v3_0_0/mod.rs b/packages/configuration/src/v3_0_0/mod.rs index bbf628fd7..2f2d03a2c 100644 --- a/packages/configuration/src/v3_0_0/mod.rs +++ b/packages/configuration/src/v3_0_0/mod.rs @@ -1,4 +1,4 @@ -//! Version `1` for [Torrust Tracker](https://docs.rs/torrust-tracker) +//! Version `3` for [Torrust Tracker](https://docs.rs/torrust-tracker) //! configuration data structures. //! //! This module contains the configuration data structures for the @@ -53,8 +53,9 @@ //! //! ## TSL support //! -//! For the API and HTTP tracker you can enable TSL by setting `ssl_enabled` to -//! `true` and setting the paths to the certificate and key files. +//! For the API and HTTP tracker you can enable TSL by providing a +//! `[http_api.tsl_config]` or `[[http_trackers]].tsl_config` section with +//! the paths to the certificate and key files. //! //! Typically, you will have a `storage` directory like the following: //! diff --git a/packages/configuration/src/v3_0_0/tracker_api.rs b/packages/configuration/src/v3_0_0/tracker_api.rs index 045271570..66b990f70 100644 --- a/packages/configuration/src/v3_0_0/tracker_api.rs +++ b/packages/configuration/src/v3_0_0/tracker_api.rs @@ -19,7 +19,7 @@ pub struct HttpApi { #[serde(default = "HttpApi::default_bind_address")] pub bind_address: SocketAddr, - /// TSL config. Only used if `ssl_enabled` is true. + /// TSL config. Provide this section to enable TLS for the HTTP API. #[serde(default = "HttpApi::default_tsl_config")] pub tsl_config: Option, @@ -52,7 +52,7 @@ impl HttpApi { } fn default_access_tokens() -> AccessTokens { - [].iter().cloned().collect() + HashMap::new() } pub fn add_token(&mut self, key: &str, token: &str) { diff --git a/packages/configuration/src/v3_0_0/udp_tracker.rs b/packages/configuration/src/v3_0_0/udp_tracker.rs index 2a71aa539..bd8973932 100644 --- a/packages/configuration/src/v3_0_0/udp_tracker.rs +++ b/packages/configuration/src/v3_0_0/udp_tracker.rs @@ -17,7 +17,7 @@ pub struct UdpTracker { #[serde(default = "UdpTracker::default_cookie_lifetime")] pub cookie_lifetime: Duration, - /// Weather the tracker should collect statistics about tracker usage. + /// Whether the tracker should collect statistics about tracker usage. #[serde(default = "UdpTracker::default_tracker_usage_statistics")] pub tracker_usage_statistics: bool,