diff --git a/Cargo.toml b/Cargo.toml index fd6230f80..2316a1edf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,5 @@ [package] +default-run = "torrust-tracker" name = "torrust-tracker" readme = "README.md" @@ -50,6 +51,7 @@ r2d2 = "0" r2d2_mysql = "24" r2d2_sqlite = { version = "0", features = ["bundled"] } rand = "0" +reqwest = "0" serde = { version = "1", features = ["derive"] } serde_bencode = "0" serde_json = "1" diff --git a/Containerfile b/Containerfile index be71017db..8ea555c2d 100644 --- a/Containerfile +++ b/Containerfile @@ -85,7 +85,7 @@ COPY --from=build \ RUN cargo nextest run --workspace-remap /test/src/ --extract-to /test/src/ --no-run --archive-file /test/torrust-tracker.tar.zst RUN cargo nextest run --workspace-remap /test/src/ --target-dir-remap /test/src/target/ --cargo-metadata /test/src/target/nextest/cargo-metadata.json --binaries-metadata /test/src/target/nextest/binaries-metadata.json -RUN mkdir -p /app/bin/; cp -l /test/src/target/release/torrust-tracker /app/bin/torrust-tracker +RUN mkdir -p /app/bin/; cp -l /test/src/target/release/torrust-tracker /app/bin/torrust-tracker; cp -l /test/src/target/release/http_health_check /app/bin/http_health_check RUN mkdir -p /app/lib/; cp -l $(realpath $(ldd /app/bin/torrust-tracker | 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 @@ -101,6 +101,7 @@ ARG USER_ID=1000 ARG UDP_PORT=6969 ARG HTTP_PORT=7070 ARG API_PORT=1212 +ARG HEALTH_CHECK_API_PORT=1313 ENV TORRUST_TRACKER_PATH_CONFIG=${TORRUST_TRACKER_PATH_CONFIG} ENV TORRUST_TRACKER_DATABASE_DRIVER=${TORRUST_TRACKER_DATABASE_DRIVER} @@ -108,11 +109,13 @@ ENV USER_ID=${USER_ID} ENV UDP_PORT=${UDP_PORT} ENV HTTP_PORT=${HTTP_PORT} ENV API_PORT=${API_PORT} +ENV HEALTH_CHECK_API_PORT=${HEALTH_CHECK_API_PORT} ENV TZ=Etc/UTC EXPOSE ${UDP_PORT}/udp EXPOSE ${HTTP_PORT}/tcp EXPOSE ${API_PORT}/tcp +EXPOSE ${HEALTH_CHECK_API_PORT}/tcp RUN mkdir -p /var/lib/torrust/tracker /var/log/torrust/tracker /etc/torrust/tracker @@ -136,5 +139,7 @@ CMD ["sh"] FROM runtime as release ENV RUNTIME="release" COPY --from=test /app/ /usr/ -# HEALTHCHECK CMD ["/usr/bin/wget", "--no-verbose", "--tries=1", "--spider", "localhost:${API_PORT}/version"] +HEALTHCHECK --interval=5s --timeout=5s --start-period=3s --retries=3 \ + CMD /usr/bin/http_health_check http://localhost:${HEALTH_CHECK_API_PORT}/health_check \ + || exit 1 CMD ["/usr/bin/torrust-tracker"] diff --git a/docs/containers.md b/docs/containers.md index 737ce40a0..2b06c0f76 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -146,6 +146,7 @@ The following environmental variables can be set: - `UDP_PORT` - The port for the UDP tracker. This should match the port used in the configuration, (default `6969`). - `HTTP_PORT` - The port for the HTTP tracker. This should match the port used in the configuration, (default `7070`). - `API_PORT` - The port for the tracker API. This should match the port used in the configuration, (default `1212`). +- `HEALTH_CHECK_API_PORT` - The port for the Health Check API. This should match the port used in the configuration, (default `1313`). ### Sockets diff --git a/packages/configuration/src/lib.rs b/packages/configuration/src/lib.rs index 059316a26..217f8a8be 100644 --- a/packages/configuration/src/lib.rs +++ b/packages/configuration/src/lib.rs @@ -191,40 +191,43 @@ //! The default configuration is: //! //! ```toml -//! log_level = "info" -//! mode = "public" +//! announce_interval = 120 //! db_driver = "Sqlite3" //! db_path = "./storage/tracker/lib/database/sqlite3.db" -//! announce_interval = 120 -//! min_announce_interval = 120 +//! external_ip = "0.0.0.0" +//! inactive_peer_cleanup_interval = 600 +//! log_level = "info" //! max_peer_timeout = 900 +//! min_announce_interval = 120 +//! mode = "public" //! on_reverse_proxy = false -//! external_ip = "0.0.0.0" -//! tracker_usage_statistics = true //! persistent_torrent_completed_stat = false -//! inactive_peer_cleanup_interval = 600 //! remove_peerless_torrents = true +//! tracker_usage_statistics = true //! //! [[udp_trackers]] -//! enabled = false //! bind_address = "0.0.0.0:6969" +//! enabled = false //! //! [[http_trackers]] -//! enabled = false //! bind_address = "0.0.0.0:7070" -//! ssl_enabled = false +//! enabled = false //! ssl_cert_path = "" +//! ssl_enabled = false //! ssl_key_path = "" //! //! [http_api] -//! enabled = true //! bind_address = "127.0.0.1:1212" -//! ssl_enabled = false +//! enabled = true //! ssl_cert_path = "" +//! ssl_enabled = false //! ssl_key_path = "" //! //! [http_api.access_tokens] //! admin = "MyAccessToken" +//! +//! [health_check_api] +//! bind_address = "127.0.0.1:1313" //!``` use std::collections::{HashMap, HashSet}; use std::net::IpAddr; @@ -342,7 +345,7 @@ 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`. + /// system to choose a random port, use port `0`. pub bind_address: String, /// Weather the HTTP API will use SSL or not. pub ssl_enabled: bool, @@ -363,9 +366,7 @@ impl HttpApi { fn override_admin_token(&mut self, api_admin_token: &str) { self.access_tokens.insert("admin".to_string(), api_admin_token.to_string()); } -} -impl HttpApi { /// Checks if the given token is one of the token in the configuration. #[must_use] pub fn contains_token(&self, token: &str) -> bool { @@ -375,6 +376,17 @@ impl HttpApi { } } +/// 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`. + pub bind_address: String, +} + /// Core configuration for the tracker. #[allow(clippy::struct_excessive_bools)] #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)] @@ -465,6 +477,8 @@ pub struct Configuration { pub http_trackers: Vec, /// The HTTP API configuration. pub http_api: HttpApi, + /// The Health Check API configuration. + pub health_check_api: HealthCheckApi, } /// Errors that can occur when loading the configuration. @@ -529,6 +543,9 @@ impl Default for Configuration { .cloned() .collect(), }, + health_check_api: HealthCheckApi { + bind_address: String::from("127.0.0.1:1313"), + }, }; configuration.udp_trackers.push(UdpTracker { enabled: false, @@ -676,6 +693,9 @@ mod tests { [http_api.access_tokens] admin = "MyAccessToken" + + [health_check_api] + bind_address = "127.0.0.1:1313" "# .lines() .map(str::trim_start) diff --git a/packages/test-helpers/src/configuration.rs b/packages/test-helpers/src/configuration.rs index 437475ee2..388d0151f 100644 --- a/packages/test-helpers/src/configuration.rs +++ b/packages/test-helpers/src/configuration.rs @@ -37,6 +37,10 @@ pub fn ephemeral() -> Configuration { config.http_api.enabled = true; config.http_api.bind_address = format!("127.0.0.1:{}", &api_port); + // Ephemeral socket address for Health Check API + let health_check_api_port = 0u16; + config.health_check_api.bind_address = format!("127.0.0.1:{}", &health_check_api_port); + // Ephemeral socket address for UDP tracker let udp_port = 0u16; config.udp_trackers[0].enabled = true; @@ -140,3 +144,15 @@ pub fn ephemeral_ipv6() -> Configuration { cfg } + +/// Ephemeral without running any services. +#[must_use] +pub fn ephemeral_with_no_services() -> Configuration { + let mut cfg = ephemeral(); + + cfg.http_api.enabled = false; + cfg.http_trackers[0].enabled = false; + cfg.udp_trackers[0].enabled = false; + + cfg +} diff --git a/share/default/config/tracker.container.mysql.toml b/share/default/config/tracker.container.mysql.toml index fb9cbf789..e7714c229 100644 --- a/share/default/config/tracker.container.mysql.toml +++ b/share/default/config/tracker.container.mysql.toml @@ -36,3 +36,6 @@ ssl_key_path = "/var/lib/torrust/tracker/tls/localhost.key" [http_api.access_tokens] admin = "MyAccessToken" + +[health_check_api] +bind_address = "127.0.0.1:1313" diff --git a/share/default/config/tracker.container.sqlite3.toml b/share/default/config/tracker.container.sqlite3.toml index 54cfd4023..4ec055c56 100644 --- a/share/default/config/tracker.container.sqlite3.toml +++ b/share/default/config/tracker.container.sqlite3.toml @@ -36,3 +36,6 @@ ssl_key_path = "/var/lib/torrust/tracker/tls/localhost.key" [http_api.access_tokens] admin = "MyAccessToken" + +[health_check_api] +bind_address = "127.0.0.1:1313" diff --git a/share/default/config/tracker.development.sqlite3.toml b/share/default/config/tracker.development.sqlite3.toml index 20f95ac5d..04934dd8a 100644 --- a/share/default/config/tracker.development.sqlite3.toml +++ b/share/default/config/tracker.development.sqlite3.toml @@ -32,3 +32,6 @@ ssl_key_path = "" [http_api.access_tokens] admin = "MyAccessToken" + +[health_check_api] +bind_address = "127.0.0.1:1313" diff --git a/src/app.rs b/src/app.rs index 3fc790a23..e749f9c64 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,11 @@ //! - Loading data from the database when it's needed. //! - Starting some jobs depending on the configuration. //! -//! The started jobs may be: +//! Jobs executed always: +//! +//! - Health Check API +//! +//! Optional jobs: //! //! - Torrent cleaner: it removes inactive peers and (optionally) peerless torrents. //! - UDP trackers: the user can enable multiple UDP tracker on several ports. @@ -23,13 +27,16 @@ use log::warn; use tokio::task::JoinHandle; use torrust_tracker_configuration::Configuration; -use crate::bootstrap::jobs::{http_tracker, torrent_cleanup, tracker_apis, udp_tracker}; +use crate::bootstrap::jobs::{health_check_api, http_tracker, torrent_cleanup, tracker_apis, udp_tracker}; use crate::servers::http::Version; use crate::tracker; /// # Panics /// -/// Will panic if the socket address for API can't be parsed. +/// Will panic if: +/// +/// - Can't retrieve tracker keys from database. +/// - Can't load whitelist from database. pub async fn start(config: Arc, tracker: Arc) -> Vec> { let mut jobs: Vec> = Vec::new(); @@ -78,10 +85,13 @@ pub async fn start(config: Arc, tracker: Arc) - jobs.push(tracker_apis::start_job(&config.http_api, tracker.clone()).await); } - // Remove torrents without peers, every interval + // Start runners to remove torrents without peers, every interval if config.inactive_peer_cleanup_interval > 0 { jobs.push(torrent_cleanup::start_job(&config, &tracker)); } + // Start Health Check API + jobs.push(health_check_api::start_job(config).await); + jobs } diff --git a/src/bin/http_health_check.rs b/src/bin/http_health_check.rs new file mode 100644 index 000000000..313f44045 --- /dev/null +++ b/src/bin/http_health_check.rs @@ -0,0 +1,37 @@ +//! 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::{env, process}; + +#[tokio::main] +async fn main() { + let args: Vec = env::args().collect(); + if args.len() != 2 { + eprintln!("Usage: cargo run --bin http_health_check "); + eprintln!("Example: cargo run --bin http_health_check http://127.0.0.1:1212/health_check"); + std::process::exit(1); + } + + println!("Health check ..."); + + let url = &args[1].clone(); + + match reqwest::get(url).await { + 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/jobs/health_check_api.rs b/src/bootstrap/jobs/health_check_api.rs new file mode 100644 index 000000000..96a703afc --- /dev/null +++ b/src/bootstrap/jobs/health_check_api.rs @@ -0,0 +1,76 @@ +//! Health Check API job starter. +//! +//! The [`health_check_api::start_job`](crate::bootstrap::jobs::health_check_api::start_job) +//! function starts the Health Check REST API. +//! +//! The [`health_check_api::start_job`](crate::bootstrap::jobs::health_check_api::start_job) +//! function spawns a new asynchronous task, that tasks is the "**launcher**". +//! The "**launcher**" starts the actual server and sends a message back +//! to the main application. The main application waits until receives +//! the message [`ApiServerJobStarted`] +//! from the "**launcher**". +//! +//! The "**launcher**" is an intermediary thread that decouples the Health Check +//! API server from the process that handles it. +//! +//! Refer to the [configuration documentation](https://docs.rs/torrust-tracker-configuration) +//! for the API configuration options. +use std::net::SocketAddr; +use std::sync::Arc; + +use log::info; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use torrust_tracker_configuration::Configuration; + +use crate::servers::health_check_api::server; + +/// This is the message that the "launcher" spawned task sends to the main +/// application process to notify the API server was successfully started. +/// +/// > **NOTICE**: it does not mean the API server is ready to receive requests. +/// It only means the new server started. It might take some time to the server +/// to be ready to accept request. +#[derive(Debug)] +pub struct ApiServerJobStarted { + pub bound_addr: SocketAddr, +} + +/// This function starts a new Health Check API server with the provided +/// configuration. +/// +/// The functions starts a new concurrent task that will run the API server. +/// This task will send a message to the main application process to notify +/// that the API server was successfully started. +/// +/// # Panics +/// +/// It would panic if unable to send the `ApiServerJobStarted` notice. +pub async fn start_job(config: Arc) -> JoinHandle<()> { + let bind_addr = config + .health_check_api + .bind_address + .parse::() + .expect("Health Check API bind_address invalid."); + + let (tx, rx) = oneshot::channel::(); + + // Run the API server + let join_handle = tokio::spawn(async move { + info!("Starting Health Check API server: http://{}", bind_addr); + + let handle = server::start(bind_addr, tx, config.clone()); + + if let Ok(()) = handle.await { + info!("Health Check API server on http://{} stopped", bind_addr); + } + }); + + // Wait until the API server job is running + match rx.await { + Ok(_msg) => info!("Torrust Health Check API server started"), + Err(e) => panic!("the Health Check API server was dropped: {e}"), + } + + join_handle +} diff --git a/src/bootstrap/jobs/mod.rs b/src/bootstrap/jobs/mod.rs index c519a9f4b..8c85ba45b 100644 --- a/src/bootstrap/jobs/mod.rs +++ b/src/bootstrap/jobs/mod.rs @@ -6,6 +6,7 @@ //! 2. Launch all the application services as concurrent jobs. //! //! This modules contains all the functions needed to start those jobs. +pub mod health_check_api; pub mod http_tracker; pub mod torrent_cleanup; pub mod tracker_apis; diff --git a/src/lib.rs b/src/lib.rs index c2e70a8b1..8d453f177 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -148,41 +148,44 @@ //! The default configuration is: //! //! ```toml -//! log_level = "info" -//! mode = "public" +//! announce_interval = 120 //! db_driver = "Sqlite3" //! db_path = "./storage/tracker/lib/database/sqlite3.db" -//! announce_interval = 120 -//! min_announce_interval = 120 +//! external_ip = "0.0.0.0" +//! inactive_peer_cleanup_interval = 600 +//! log_level = "info" //! max_peer_timeout = 900 +//! min_announce_interval = 120 +//! mode = "public" //! on_reverse_proxy = false -//! external_ip = "0.0.0.0" -//! tracker_usage_statistics = true //! persistent_torrent_completed_stat = false -//! inactive_peer_cleanup_interval = 600 //! remove_peerless_torrents = true +//! tracker_usage_statistics = true //! //! [[udp_trackers]] -//! enabled = false //! bind_address = "0.0.0.0:6969" +//! enabled = false //! //! [[http_trackers]] -//! enabled = false //! bind_address = "0.0.0.0:7070" -//! ssl_enabled = false +//! enabled = false //! ssl_cert_path = "" +//! ssl_enabled = false //! ssl_key_path = "" //! //! [http_api] -//! enabled = true //! bind_address = "127.0.0.1:1212" -//! ssl_enabled = false +//! enabled = true //! ssl_cert_path = "" +//! ssl_enabled = false //! ssl_key_path = "" //! //! [http_api.access_tokens] //! admin = "MyAccessToken" -//! ``` +//! +//! [health_check_api] +//! bind_address = "127.0.0.1:1313" +//!``` //! //! The default configuration includes one disabled UDP server, one disabled HTTP server and the enabled API. //! diff --git a/src/servers/apis/routes.rs b/src/servers/apis/routes.rs index 7801389f3..0740e1f6a 100644 --- a/src/servers/apis/routes.rs +++ b/src/servers/apis/routes.rs @@ -7,10 +7,12 @@ //! first path segment. For example: `/api/v1/torrents`. use std::sync::Arc; +use axum::routing::get; use axum::{middleware, Router}; use tower_http::compression::CompressionLayer; use super::v1; +use super::v1::context::health_check::handlers::health_check_handler; use crate::tracker::Tracker; /// Add all API routes to the router. @@ -18,14 +20,15 @@ use crate::tracker::Tracker; pub fn router(tracker: Arc) -> Router { let router = Router::new(); - let prefix = "/api"; + let api_url_prefix = "/api"; - let router = v1::routes::add(prefix, router, tracker.clone()); + let router = v1::routes::add(api_url_prefix, router, tracker.clone()); router .layer(middleware::from_fn_with_state( tracker.config.clone(), v1::middlewares::auth::auth, )) + .route("/health_check", get(health_check_handler)) .layer(CompressionLayer::new()) } diff --git a/src/servers/apis/v1/context/health_check/handlers.rs b/src/servers/apis/v1/context/health_check/handlers.rs new file mode 100644 index 000000000..bfbeab549 --- /dev/null +++ b/src/servers/apis/v1/context/health_check/handlers.rs @@ -0,0 +1,11 @@ +//! API handlers for the [`stats`](crate::servers::apis::v1::context::health_check) +//! API context. + +use axum::Json; + +use super::resources::{Report, Status}; + +/// Endpoint for container health check. +pub async fn health_check_handler() -> Json { + Json(Report { status: Status::Ok }) +} diff --git a/src/servers/apis/v1/context/health_check/mod.rs b/src/servers/apis/v1/context/health_check/mod.rs new file mode 100644 index 000000000..c62c5e97b --- /dev/null +++ b/src/servers/apis/v1/context/health_check/mod.rs @@ -0,0 +1,34 @@ +//! API health check endpoint. +//! +//! It is used to check is the service is running. Especially for containers. +//! +//! # Endpoints +//! +//! - [Health Check](#health-check) +//! +//! # Health Check +//! +//! `GET /health_check` +//! +//! Returns the API status. +//! +//! **Example request** +//! +//! ```bash +//! curl "http://127.0.0.1:1212/health_check" +//! ``` +//! +//! **Example response** `200` +//! +//! ```json +//! { +//! "status": "Ok", +//! } +//! ``` +//! +//! **Resource** +//! +//! Refer to the API [`Stats`](crate::servers::apis::v1::context::health_check::resources::Report) +//! resource for more information about the response attributes. +pub mod handlers; +pub mod resources; diff --git a/src/servers/apis/v1/context/health_check/resources.rs b/src/servers/apis/v1/context/health_check/resources.rs new file mode 100644 index 000000000..9830e643c --- /dev/null +++ b/src/servers/apis/v1/context/health_check/resources.rs @@ -0,0 +1,14 @@ +//! API resources for the [`stats`](crate::servers::apis::v1::context::health_check) +//! API context. +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum Status { + Ok, + Error, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct Report { + pub status: Status, +} diff --git a/src/servers/apis/v1/context/mod.rs b/src/servers/apis/v1/context/mod.rs index 5e268a429..be67cd96a 100644 --- a/src/servers/apis/v1/context/mod.rs +++ b/src/servers/apis/v1/context/mod.rs @@ -3,6 +3,7 @@ //! Each context is a module that contains the API endpoints related to a //! specific resource group. pub mod auth_key; +pub mod health_check; pub mod stats; pub mod torrent; pub mod whitelist; diff --git a/src/servers/health_check_api/handlers.rs b/src/servers/health_check_api/handlers.rs new file mode 100644 index 000000000..109b89bb4 --- /dev/null +++ b/src/servers/health_check_api/handlers.rs @@ -0,0 +1,135 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use aquatic_udp_protocol::{ConnectRequest, Response, TransactionId}; +use axum::extract::State; +use axum::Json; +use torrust_tracker_configuration::{Configuration, HttpApi, HttpTracker, UdpTracker}; + +use super::resources::Report; +use super::responses; +use crate::shared::bit_torrent::udp::client::new_udp_tracker_client_connected; + +/// If port 0 is specified in the configuration the OS will automatically +/// assign a free port. But we do now know in from the configuration. +/// We can only know it after starting the socket. +const UNKNOWN_PORT: u16 = 0; + +/// Endpoint for container health check. +/// +/// This endpoint only checks services when we know the port from the +/// configuration. If port 0 is specified in the configuration the health check +/// for that service is skipped. +pub(crate) async fn health_check_handler(State(config): State>) -> Json { + if let Some(err_response) = api_health_check(&config.http_api).await { + return err_response; + } + + if let Some(err_response) = http_trackers_health_check(&config.http_trackers).await { + return err_response; + } + + if let Some(err_response) = udp_trackers_health_check(&config.udp_trackers).await { + return err_response; + } + + responses::ok() +} + +async fn api_health_check(config: &HttpApi) -> Option> { + // todo: when port 0 is specified in the configuration get the port from the + // running service, after starting it as we do for testing with ephemeral + // configurations. + + if config.enabled { + let addr: SocketAddr = config.bind_address.parse().expect("invalid socket address for API"); + + if addr.port() != UNKNOWN_PORT { + let health_check_url = format!("http://{addr}/health_check"); + + if !get_req_is_ok(&health_check_url).await { + return Some(responses::error(format!( + "API is not healthy. Health check endpoint: {health_check_url}" + ))); + } + } + } + + None +} + +async fn http_trackers_health_check(http_trackers: &Vec) -> Option> { + // todo: when port 0 is specified in the configuration get the port from the + // running service, after starting it as we do for testing with ephemeral + // configurations. + + for http_tracker_config in http_trackers { + if !http_tracker_config.enabled { + continue; + } + + let addr: SocketAddr = http_tracker_config + .bind_address + .parse() + .expect("invalid socket address for HTTP tracker"); + + if addr.port() != UNKNOWN_PORT { + let health_check_url = format!("http://{addr}/health_check"); + + if !get_req_is_ok(&health_check_url).await { + return Some(responses::error(format!( + "HTTP Tracker is not healthy. Health check endpoint: {health_check_url}" + ))); + } + } + } + + None +} + +async fn udp_trackers_health_check(udp_trackers: &Vec) -> Option> { + // todo: when port 0 is specified in the configuration get the port from the + // running service, after starting it as we do for testing with ephemeral + // configurations. + + for udp_tracker_config in udp_trackers { + if !udp_tracker_config.enabled { + continue; + } + + let addr: SocketAddr = udp_tracker_config + .bind_address + .parse() + .expect("invalid socket address for UDP tracker"); + + if addr.port() != UNKNOWN_PORT && !can_connect_to_udp_tracker(&addr.to_string()).await { + return Some(responses::error(format!( + "UDP Tracker is not healthy. Can't connect to: {addr}" + ))); + } + } + + None +} + +async fn get_req_is_ok(url: &str) -> bool { + match reqwest::get(url).await { + Ok(response) => response.status().is_success(), + Err(_err) => false, + } +} + +/// Tries to connect to an UDP tracker. It returns true if it succeeded. +async fn can_connect_to_udp_tracker(url: &str) -> bool { + let client = new_udp_tracker_client_connected(url).await; + + let connect_request = ConnectRequest { + transaction_id: TransactionId(123), + }; + + client.send(connect_request.into()).await; + + let response = client.receive().await; + + matches!(response, Response::Connect(_connect_response)) +} diff --git a/src/servers/health_check_api/mod.rs b/src/servers/health_check_api/mod.rs new file mode 100644 index 000000000..ec608387d --- /dev/null +++ b/src/servers/health_check_api/mod.rs @@ -0,0 +1,4 @@ +pub mod handlers; +pub mod resources; +pub mod responses; +pub mod server; diff --git a/src/servers/health_check_api/resources.rs b/src/servers/health_check_api/resources.rs new file mode 100644 index 000000000..3fadcf456 --- /dev/null +++ b/src/servers/health_check_api/resources.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum Status { + Ok, + Error, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct Report { + pub status: Status, + pub message: String, +} + +impl Report { + #[must_use] + pub fn ok() -> Report { + Self { + status: Status::Ok, + message: String::new(), + } + } + + #[must_use] + pub fn error(message: String) -> Report { + Self { + status: Status::Error, + message, + } + } +} diff --git a/src/servers/health_check_api/responses.rs b/src/servers/health_check_api/responses.rs new file mode 100644 index 000000000..043e271db --- /dev/null +++ b/src/servers/health_check_api/responses.rs @@ -0,0 +1,11 @@ +use axum::Json; + +use super::resources::Report; + +pub fn ok() -> Json { + Json(Report::ok()) +} + +pub fn error(message: String) -> Json { + Json(Report::error(message)) +} diff --git a/src/servers/health_check_api/server.rs b/src/servers/health_check_api/server.rs new file mode 100644 index 000000000..562772a87 --- /dev/null +++ b/src/servers/health_check_api/server.rs @@ -0,0 +1,49 @@ +//! Logic to run the Health Check HTTP API server. +//! +//! This API is intended to be used by the container infrastructure to check if +//! the whole application is healthy. +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::routing::get; +use axum::{Json, Router}; +use futures::Future; +use log::info; +use serde_json::json; +use tokio::sync::oneshot::Sender; +use torrust_tracker_configuration::Configuration; + +use crate::bootstrap::jobs::health_check_api::ApiServerJobStarted; +use crate::servers::health_check_api::handlers::health_check_handler; + +/// Starts Health Check API server. +/// +/// # Panics +/// +/// Will panic if binding to the socket address fails. +pub fn start( + socket_addr: SocketAddr, + tx: Sender, + config: Arc, +) -> impl Future> { + let app = Router::new() + .route("/", get(|| async { Json(json!({})) })) + .route("/health_check", get(health_check_handler)) + .with_state(config); + + let server = axum::Server::bind(&socket_addr).serve(app.into_make_service()); + + let bound_addr = server.local_addr(); + + info!("Health Check API server listening on http://{}", bound_addr); + + let running = server.with_graceful_shutdown(async move { + tokio::signal::ctrl_c().await.expect("Failed to listen to shutdown signal."); + info!("Stopping Torrust Health Check API server o http://{} ...", socket_addr); + }); + + tx.send(ApiServerJobStarted { bound_addr }) + .expect("the Health Check API server should not be dropped"); + + running +} diff --git a/src/servers/http/v1/handlers/health_check.rs b/src/servers/http/v1/handlers/health_check.rs new file mode 100644 index 000000000..b15af6255 --- /dev/null +++ b/src/servers/http/v1/handlers/health_check.rs @@ -0,0 +1,18 @@ +use axum::Json; +use serde::{Deserialize, Serialize}; + +#[allow(clippy::unused_async)] +pub async fn handler() -> Json { + Json(Report { status: Status::Ok }) +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum Status { + Ok, + Error, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct Report { + pub status: Status, +} diff --git a/src/servers/http/v1/handlers/mod.rs b/src/servers/http/v1/handlers/mod.rs index d78dee7d5..d7fd05838 100644 --- a/src/servers/http/v1/handlers/mod.rs +++ b/src/servers/http/v1/handlers/mod.rs @@ -7,6 +7,7 @@ use crate::tracker::error::Error; pub mod announce; pub mod common; +pub mod health_check; pub mod scrape; impl From for responses::error::Error { diff --git a/src/servers/http/v1/routes.rs b/src/servers/http/v1/routes.rs index 6546dcbb8..0b6b419c1 100644 --- a/src/servers/http/v1/routes.rs +++ b/src/servers/http/v1/routes.rs @@ -6,7 +6,7 @@ use axum::Router; use axum_client_ip::SecureClientIpSource; use tower_http::compression::CompressionLayer; -use super::handlers::{announce, scrape}; +use super::handlers::{announce, health_check, scrape}; use crate::tracker::Tracker; /// It adds the routes to the router. @@ -16,6 +16,8 @@ use crate::tracker::Tracker; #[allow(clippy::needless_pass_by_value)] pub fn router(tracker: Arc) -> Router { Router::new() + // Health check + .route("/health_check", get(health_check::handler)) // Announce request .route("/announce", get(announce::handle_without_key).with_state(tracker.clone())) .route("/announce/:key", get(announce::handle_with_key).with_state(tracker.clone())) diff --git a/src/servers/mod.rs b/src/servers/mod.rs index 38b4b70cd..077109f35 100644 --- a/src/servers/mod.rs +++ b/src/servers/mod.rs @@ -1,5 +1,6 @@ //! Servers. Services that can be started and stopped. pub mod apis; +pub mod health_check_api; pub mod http; pub mod signals; pub mod udp; diff --git a/src/servers/udp/mod.rs b/src/servers/udp/mod.rs index 630867218..a50fffd37 100644 --- a/src/servers/udp/mod.rs +++ b/src/servers/udp/mod.rs @@ -652,9 +652,3 @@ pub type Port = u16; /// The transaction id. A random number generated byt the peer that is used to /// match requests and responses. pub type TransactionId = i64; - -/// The maximum number of bytes in a UDP packet. -pub const MAX_PACKET_SIZE: usize = 1496; -/// A magic 64-bit integer constant defined in the protocol that is used to -/// identify the protocol. -pub const PROTOCOL_ID: i64 = 0x0417_2710_1980; diff --git a/src/servers/udp/server.rs b/src/servers/udp/server.rs index 428b76fa1..31e87481e 100644 --- a/src/servers/udp/server.rs +++ b/src/servers/udp/server.rs @@ -30,7 +30,7 @@ use tokio::task::JoinHandle; use crate::servers::signals::shutdown_signal; use crate::servers::udp::handlers::handle_packet; -use crate::servers::udp::MAX_PACKET_SIZE; +use crate::shared::bit_torrent::udp::MAX_PACKET_SIZE; use crate::tracker::Tracker; /// Error that can occur when starting or stopping the UDP server. diff --git a/src/shared/bit_torrent/mod.rs b/src/shared/bit_torrent/mod.rs index eba90b4ab..872203a1f 100644 --- a/src/shared/bit_torrent/mod.rs +++ b/src/shared/bit_torrent/mod.rs @@ -69,3 +69,4 @@ //!Bencode & bdecode in your browser | pub mod common; pub mod info_hash; +pub mod udp; diff --git a/tests/servers/udp/client.rs b/src/shared/bit_torrent/udp/client.rs similarity index 79% rename from tests/servers/udp/client.rs rename to src/shared/bit_torrent/udp/client.rs index d267adaba..d5c4c9adf 100644 --- a/tests/servers/udp/client.rs +++ b/src/shared/bit_torrent/udp/client.rs @@ -3,9 +3,8 @@ use std::sync::Arc; use aquatic_udp_protocol::{Request, Response}; use tokio::net::UdpSocket; -use torrust_tracker::servers::udp::MAX_PACKET_SIZE; -use crate::servers::udp::source_address; +use crate::shared::bit_torrent::udp::{source_address, MAX_PACKET_SIZE}; #[allow(clippy::module_name_repetitions)] pub struct UdpClient { @@ -13,6 +12,9 @@ pub struct UdpClient { } impl UdpClient { + /// # Panics + /// + /// Will panic if the local address can't be bound. pub async fn bind(local_address: &str) -> Self { let socket = UdpSocket::bind(local_address).await.unwrap(); Self { @@ -20,15 +22,30 @@ impl UdpClient { } } + /// # Panics + /// + /// Will panic if can't connect to the socket. pub async fn connect(&self, remote_address: &str) { self.socket.connect(remote_address).await.unwrap(); } + /// # Panics + /// + /// Will panic if: + /// + /// - Can't write to the socket. + /// - Can't send data. pub async fn send(&self, bytes: &[u8]) -> usize { self.socket.writable().await.unwrap(); self.socket.send(bytes).await.unwrap() } + /// # Panics + /// + /// Will panic if: + /// + /// - Can't read from the socket. + /// - Can't receive data. pub async fn receive(&self, bytes: &mut [u8]) -> usize { self.socket.readable().await.unwrap(); self.socket.recv(bytes).await.unwrap() @@ -49,6 +66,9 @@ pub struct UdpTrackerClient { } impl UdpTrackerClient { + /// # Panics + /// + /// Will panic if can't write request to bytes. pub async fn send(&self, request: Request) -> usize { // Write request into a buffer let request_buffer = vec![0u8; MAX_PACKET_SIZE]; @@ -68,6 +88,9 @@ impl UdpTrackerClient { self.udp_client.send(request_data).await } + /// # Panics + /// + /// Will panic if can't create response from the received payload (bytes buffer). pub async fn receive(&self) -> Response { let mut response_buffer = [0u8; MAX_PACKET_SIZE]; diff --git a/src/shared/bit_torrent/udp/mod.rs b/src/shared/bit_torrent/udp/mod.rs new file mode 100644 index 000000000..9322ef045 --- /dev/null +++ b/src/shared/bit_torrent/udp/mod.rs @@ -0,0 +1,12 @@ +pub mod client; + +/// The maximum number of bytes in a UDP packet. +pub const MAX_PACKET_SIZE: usize = 1496; +/// A magic 64-bit integer constant defined in the protocol that is used to +/// identify the protocol. +pub const PROTOCOL_ID: i64 = 0x0417_2710_1980; + +/// Generates the source address for the UDP client +fn source_address(port: u16) -> String { + format!("127.0.0.1:{port}") +} diff --git a/tests/servers/api/v1/client.rs b/tests/servers/api/v1/client.rs index 2b6db2e77..61e98e742 100644 --- a/tests/servers/api/v1/client.rs +++ b/tests/servers/api/v1/client.rs @@ -101,7 +101,7 @@ impl Client { } } -async fn get(path: &str, query: Option) -> Response { +pub async fn get(path: &str, query: Option) -> Response { match query { Some(params) => reqwest::Client::builder() .build() diff --git a/tests/servers/api/v1/contract/context/health_check.rs b/tests/servers/api/v1/contract/context/health_check.rs new file mode 100644 index 000000000..3b6c98374 --- /dev/null +++ b/tests/servers/api/v1/contract/context/health_check.rs @@ -0,0 +1,20 @@ +use torrust_tracker::servers::apis::v1::context::health_check::resources::{Report, Status}; +use torrust_tracker_test_helpers::configuration; + +use crate::servers::api::test_environment::running_test_environment; +use crate::servers::api::v1::client::get; + +#[tokio::test] +async fn health_check_endpoint_should_return_status_ok_if_api_is_running() { + let test_env = running_test_environment(configuration::ephemeral()).await; + + let url = format!("http://{}/health_check", test_env.get_connection_info().bind_address); + + let response = get(&url, None).await; + + assert_eq!(response.status(), 200); + assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); + assert_eq!(response.json::().await.unwrap(), Report { status: Status::Ok }); + + test_env.stop().await; +} diff --git a/tests/servers/api/v1/contract/context/mod.rs b/tests/servers/api/v1/contract/context/mod.rs index 6d3fb7566..032e13b0b 100644 --- a/tests/servers/api/v1/contract/context/mod.rs +++ b/tests/servers/api/v1/contract/context/mod.rs @@ -1,4 +1,5 @@ pub mod auth_key; +pub mod health_check; pub mod stats; pub mod torrent; pub mod whitelist; diff --git a/tests/servers/health_check_api/client.rs b/tests/servers/health_check_api/client.rs new file mode 100644 index 000000000..3d8bdc7d6 --- /dev/null +++ b/tests/servers/health_check_api/client.rs @@ -0,0 +1,5 @@ +use reqwest::Response; + +pub async fn get(path: &str) -> Response { + reqwest::Client::builder().build().unwrap().get(path).send().await.unwrap() +} diff --git a/tests/servers/health_check_api/contract.rs b/tests/servers/health_check_api/contract.rs new file mode 100644 index 000000000..6b816b85f --- /dev/null +++ b/tests/servers/health_check_api/contract.rs @@ -0,0 +1,22 @@ +use torrust_tracker::servers::health_check_api::resources::Report; +use torrust_tracker_test_helpers::configuration; + +use crate::servers::health_check_api::client::get; +use crate::servers::health_check_api::test_environment; + +#[tokio::test] +async fn health_check_endpoint_should_return_status_ok_when_no_service_is_running() { + let configuration = configuration::ephemeral_with_no_services(); + + let (bound_addr, test_env) = test_environment::start(configuration.into()).await; + + let url = format!("http://{bound_addr}/health_check"); + + let response = get(&url).await; + + assert_eq!(response.status(), 200); + assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); + assert_eq!(response.json::().await.unwrap(), Report::ok()); + + test_env.abort(); +} diff --git a/tests/servers/health_check_api/mod.rs b/tests/servers/health_check_api/mod.rs new file mode 100644 index 000000000..89f19a334 --- /dev/null +++ b/tests/servers/health_check_api/mod.rs @@ -0,0 +1,3 @@ +pub mod client; +pub mod contract; +pub mod test_environment; diff --git a/tests/servers/health_check_api/test_environment.rs b/tests/servers/health_check_api/test_environment.rs new file mode 100644 index 000000000..46e54dc47 --- /dev/null +++ b/tests/servers/health_check_api/test_environment.rs @@ -0,0 +1,34 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use torrust_tracker::bootstrap::jobs::health_check_api::ApiServerJobStarted; +use torrust_tracker::servers::health_check_api::server; +use torrust_tracker_configuration::Configuration; + +/// Start the test environment for the Health Check API. +/// It runs the API server. +pub async fn start(config: Arc) -> (SocketAddr, JoinHandle<()>) { + let bind_addr = config + .health_check_api + .bind_address + .parse::() + .expect("Health Check API bind_address invalid."); + + let (tx, rx) = oneshot::channel::(); + + let join_handle = tokio::spawn(async move { + let handle = server::start(bind_addr, tx, config.clone()); + if let Ok(()) = handle.await { + panic!("Health Check API server on http://{bind_addr} stopped"); + } + }); + + let bound_addr = match rx.await { + Ok(msg) => msg.bound_addr, + Err(e) => panic!("the Health Check API server was dropped: {e}"), + }; + + (bound_addr, join_handle) +} diff --git a/tests/servers/http/client.rs b/tests/servers/http/client.rs index 0dbdd9cf6..03ed9aee4 100644 --- a/tests/servers/http/client.rs +++ b/tests/servers/http/client.rs @@ -60,6 +60,10 @@ impl Client { .await } + pub async fn health_check(&self) -> Response { + self.get(&self.build_path("health_check")).await + } + pub async fn get(&self, path: &str) -> Response { self.reqwest.get(self.build_url(path)).send().await.unwrap() } diff --git a/tests/servers/http/v1/contract.rs b/tests/servers/http/v1/contract.rs index 2e24af6b7..b19009454 100644 --- a/tests/servers/http/v1/contract.rs +++ b/tests/servers/http/v1/contract.rs @@ -13,6 +13,26 @@ async fn test_environment_should_be_started_and_stopped() { mod for_all_config_modes { + use torrust_tracker::servers::http::v1::handlers::health_check::{Report, Status}; + use torrust_tracker_test_helpers::configuration; + + use crate::servers::http::client::Client; + use crate::servers::http::test_environment::running_test_environment; + use crate::servers::http::v1::contract::V1; + + #[tokio::test] + async fn health_check_endpoint_should_return_ok_if_the_http_tracker_is_running() { + let test_env = running_test_environment::(configuration::ephemeral_with_reverse_proxy()).await; + + let response = Client::new(*test_env.bind_address()).health_check().await; + + assert_eq!(response.status(), 200); + assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); + assert_eq!(response.json::().await.unwrap(), Report { status: Status::Ok }); + + test_env.stop().await; + } + mod and_running_on_reverse_proxy { use torrust_tracker_test_helpers::configuration; diff --git a/tests/servers/mod.rs b/tests/servers/mod.rs index 7c30b6f40..65e9a665b 100644 --- a/tests/servers/mod.rs +++ b/tests/servers/mod.rs @@ -1,3 +1,4 @@ mod api; +pub mod health_check_api; mod http; mod udp; diff --git a/tests/servers/udp/contract.rs b/tests/servers/udp/contract.rs index 3187d9871..72124fc3f 100644 --- a/tests/servers/udp/contract.rs +++ b/tests/servers/udp/contract.rs @@ -6,11 +6,11 @@ use core::panic; use aquatic_udp_protocol::{ConnectRequest, ConnectionId, Response, TransactionId}; -use torrust_tracker::servers::udp::MAX_PACKET_SIZE; +use torrust_tracker::shared::bit_torrent::udp::client::{new_udp_client_connected, UdpTrackerClient}; +use torrust_tracker::shared::bit_torrent::udp::MAX_PACKET_SIZE; use torrust_tracker_test_helpers::configuration; use crate::servers::udp::asserts::is_error_response; -use crate::servers::udp::client::{new_udp_client_connected, UdpTrackerClient}; use crate::servers::udp::test_environment::running_test_environment; fn empty_udp_request() -> [u8; MAX_PACKET_SIZE] { @@ -51,10 +51,10 @@ async fn should_return_a_bad_request_response_when_the_client_sends_an_empty_req mod receiving_a_connection_request { use aquatic_udp_protocol::{ConnectRequest, TransactionId}; + use torrust_tracker::shared::bit_torrent::udp::client::new_udp_tracker_client_connected; use torrust_tracker_test_helpers::configuration; use crate::servers::udp::asserts::is_connect_response; - use crate::servers::udp::client::new_udp_tracker_client_connected; use crate::servers::udp::test_environment::running_test_environment; #[tokio::test] @@ -82,10 +82,10 @@ mod receiving_an_announce_request { AnnounceEvent, AnnounceRequest, ConnectionId, InfoHash, NumberOfBytes, NumberOfPeers, PeerId, PeerKey, Port, TransactionId, }; + use torrust_tracker::shared::bit_torrent::udp::client::new_udp_tracker_client_connected; use torrust_tracker_test_helpers::configuration; use crate::servers::udp::asserts::is_ipv4_announce_response; - use crate::servers::udp::client::new_udp_tracker_client_connected; use crate::servers::udp::contract::send_connection_request; use crate::servers::udp::test_environment::running_test_environment; @@ -124,10 +124,10 @@ mod receiving_an_announce_request { mod receiving_an_scrape_request { use aquatic_udp_protocol::{ConnectionId, InfoHash, ScrapeRequest, TransactionId}; + use torrust_tracker::shared::bit_torrent::udp::client::new_udp_tracker_client_connected; use torrust_tracker_test_helpers::configuration; use crate::servers::udp::asserts::is_scrape_response; - use crate::servers::udp::client::new_udp_tracker_client_connected; use crate::servers::udp::contract::send_connection_request; use crate::servers::udp::test_environment::running_test_environment; diff --git a/tests/servers/udp/mod.rs b/tests/servers/udp/mod.rs index d39c37153..4759350dc 100644 --- a/tests/servers/udp/mod.rs +++ b/tests/servers/udp/mod.rs @@ -1,9 +1,3 @@ pub mod asserts; -pub mod client; pub mod contract; pub mod test_environment; - -/// Generates the source address for the UDP client -fn source_address(port: u16) -> String { - format!("127.0.0.1:{port}") -}