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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "torrust-actix"
version = "4.1.0"
version = "4.1.1"
edition = "2024"
license = "AGPL-3.0"
authors = [
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ UDP_0_SIMPLE_PROXY_PROTOCOL <true | false>

### ChangeLog

#### v4.1.1
* Added hot reloading of SSL certificates for renewal
* API has an extra endpoint to run the hot reloading
* Some more code optimizations

#### v4.1.0
* Added a full Cluster first version through WebSockets
* Option to run the app in Stand-Alone (which is default, as single server), or using the cluster mode
Expand Down
2 changes: 1 addition & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ FROM rust:alpine
RUN apk update --no-interactive
RUN apk add git musl-dev curl pkgconfig openssl-dev openssl-libs-static --no-interactive
RUN git clone https://github.com/Power2All/torrust-actix.git /app/torrust-actix
RUN cd /app/torrust-actix && git checkout tags/v4.1.0
RUN cd /app/torrust-actix && git checkout tags/v4.1.1
WORKDIR /app/torrust-actix
RUN cd /app/torrust-actix
RUN cargo build --release && rm -Rf target/release/.fingerprint target/release/build target/release/deps target/release/examples target/release/incremental
Expand Down
4 changes: 2 additions & 2 deletions docker/build.bat
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
@echo off

docker build --no-cache -t power2all/torrust-actix:v4.1.0 -t power2all/torrust-actix:latest .
docker push power2all/torrust-actix:v4.1.0
docker build --no-cache -t power2all/torrust-actix:v4.1.1 -t power2all/torrust-actix:latest .
docker push power2all/torrust-actix:v4.1.1
docker push power2all/torrust-actix:latest
48 changes: 25 additions & 23 deletions src/api/api.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::api::api_blacklists::{api_service_blacklist_delete, api_service_blacklist_get, api_service_blacklist_post, api_service_blacklists_delete, api_service_blacklists_get, api_service_blacklists_post};
use crate::api::api_certificate::{api_service_certificate_reload, api_service_certificate_status};
use crate::api::api_keys::{api_service_key_delete, api_service_key_get, api_service_key_post, api_service_keys_delete, api_service_keys_get, api_service_keys_post};
use crate::api::api_stats::{api_service_prom_get, api_service_stats_get};
use crate::api::api_torrents::{api_service_torrent_delete, api_service_torrent_get, api_service_torrent_post, api_service_torrents_delete, api_service_torrents_get, api_service_torrents_post};
Expand All @@ -8,6 +9,8 @@ use crate::api::structs::api_service_data::ApiServiceData;
use crate::common::structs::custom_error::CustomError;
use crate::config::structs::api_trackers_config::ApiTrackersConfig;
use crate::config::structs::configuration::Configuration;
use crate::ssl::certificate_resolver::DynamicCertificateResolver;
use crate::ssl::certificate_store::ServerIdentifier;
use crate::stats::enums::stats_event::StatsEvent;
use crate::tracker::structs::torrent_tracker::TorrentTracker;
use actix_cors::Cors;
Expand All @@ -18,9 +21,7 @@ use actix_web::{http, web, App, HttpRequest, HttpResponse, HttpServer};
use futures_util::StreamExt;
use log::{error, info};
use serde_json::json;
use std::fs::File;
use std::future::Future;
use std::io::BufReader;
use std::net::{IpAddr, SocketAddr};
use std::process::exit;
use std::str::FromStr;
Expand Down Expand Up @@ -101,6 +102,12 @@ pub fn api_service_routes(data: Arc<ApiServiceData>) -> Box<dyn Fn(&mut ServiceC
.route(web::post().to(api_service_users_post))
.route(web::delete().to(api_service_users_delete))
);
cfg.service(web::resource("api/certificate/reload")
.route(web::post().to(api_service_certificate_reload))
);
cfg.service(web::resource("api/certificate/status")
.route(web::get().to(api_service_certificate_status))
);
if data.torrent_tracker.config.tracker_config.swagger {
cfg.service(SwaggerUi::new("/swagger-ui/{_:.*}").config(Config::new(["/api/openapi.json"])));
cfg.service(web::resource("/api/openapi.json")
Expand Down Expand Up @@ -139,29 +146,24 @@ pub async fn api_service(
error!("[APIS] No SSL key or SSL certificate given, exiting...");
exit(1);
}
let key_file = &mut BufReader::new(File::open(api_server_object.ssl_key.clone()).unwrap_or_else(|data| {
sentry::capture_error(&data);
panic!("[APIS] SSL key unreadable: {data}");
}));
let certs_file = &mut BufReader::new(File::open(api_server_object.ssl_cert.clone()).unwrap_or_else(|data| {
sentry::capture_error(&data);
panic!("[APIS] SSL cert unreadable: {data}");
}));
let tls_certs = rustls_pemfile::certs(certs_file).collect::<Result<Vec<_>, _>>().unwrap_or_else(|data| {
sentry::capture_error(&data);
panic!("[APIS] SSL cert couldn't be extracted: {data}");
});
let tls_key = rustls_pemfile::pkcs8_private_keys(key_file).next().unwrap().unwrap_or_else(|data| {
sentry::capture_error(&data);
panic!("[APIS] SSL key couldn't be extracted: {data}");
});
let server_id = ServerIdentifier::ApiServer(addr.to_string());
if let Err(e) = data.certificate_store.load_certificate(
server_id.clone(),
&api_server_object.ssl_cert,
&api_server_object.ssl_key,
) {
panic!("[APIS] Failed to load SSL certificate: {}", e);
}
let resolver = match DynamicCertificateResolver::new(
Arc::clone(&data.certificate_store),
server_id,
) {
Ok(resolver) => Arc::new(resolver),
Err(e) => panic!("[APIS] Failed to create certificate resolver: {}", e),
};
let tls_config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(tls_certs, rustls::pki_types::PrivateKeyDer::Pkcs8(tls_key))
.unwrap_or_else(|data| {
sentry::capture_error(&data);
panic!("[APIS] SSL config couldn't be created: {data}");
});
.with_cert_resolver(resolver);
let server = HttpServer::new(app_factory)
.keep_alive(Duration::from_secs(keep_alive))
.client_request_timeout(Duration::from_secs(request_timeout))
Expand Down
153 changes: 153 additions & 0 deletions src/api/api_certificate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
use crate::api::api::{api_service_token, api_validation};
use crate::api::structs::api_service_data::ApiServiceData;
use crate::api::structs::query_token::QueryToken;
use crate::ssl::certificate_store::ServerIdentifier;
use actix_web::http::header::ContentType;
use actix_web::web::Data;
use actix_web::{web, HttpRequest, HttpResponse};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;

#[derive(Debug, Deserialize)]
pub struct CertificateReloadRequest {
pub server_type: Option<String>,
pub bind_address: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct CertificateStatusItem {
pub server_type: String,
pub bind_address: String,
pub cert_path: String,
pub key_path: String,
pub loaded_at: String,
}

#[derive(Debug, Serialize)]
pub struct CertificateReloadResult {
pub server_type: String,
pub bind_address: String,
pub loaded_at: String,
}

#[derive(Debug, Serialize)]
pub struct CertificateReloadError {
pub server_type: String,
pub bind_address: String,
pub error: String,
}

#[tracing::instrument(level = "debug")]
pub async fn api_service_certificate_reload(
request: HttpRequest,
data: Data<Arc<ApiServiceData>>,
body: Option<web::Json<CertificateReloadRequest>>,
) -> HttpResponse {
if let Some(error_return) = api_validation(&request, &data).await {
return error_return;
}
let params = web::Query::<QueryToken>::from_query(request.query_string()).unwrap();
if let Some(response) = api_service_token(params.token.clone(), Arc::clone(&data.torrent_tracker.config)).await {
return response;
}
let certificate_store = &data.torrent_tracker.certificate_store;
let (server_type_filter, bind_address_filter) = match body {
Some(req) => (req.server_type.clone(), req.bind_address.clone()),
None => (None, None),
};
let certificates_to_reload: Vec<ServerIdentifier> = {
let certificates = certificate_store.get_all_certificates();
certificates
.into_iter()
.filter(|(server_id, _)| {
if let Some(ref filter) = server_type_filter
&& server_id.server_type() != filter.to_lowercase()
{
return false;
}
if let Some(ref filter) = bind_address_filter
&& server_id.bind_address() != filter
{
return false;
}
true
})
.map(|(server_id, _)| server_id)
.collect()
};
if certificates_to_reload.is_empty() {
return HttpResponse::Ok().content_type(ContentType::json()).json(json!({
"status": "no_certificates",
"message": "No SSL certificates found to reload"
}));
}
let mut reloaded: Vec<CertificateReloadResult> = Vec::with_capacity(certificates_to_reload.len());
let mut errors: Vec<CertificateReloadError> = Vec::new();
for server_id in certificates_to_reload {
match certificate_store.reload_certificate(&server_id) {
Ok(()) => {
let loaded_at = certificate_store
.get_certificate(&server_id)
.map(|bundle| bundle.loaded_at.to_rfc3339())
.unwrap_or_else(|| "unknown".to_string());
reloaded.push(CertificateReloadResult {
server_type: server_id.server_type().to_string(),
bind_address: server_id.bind_address().to_string(),
loaded_at,
});
}
Err(e) => {
errors.push(CertificateReloadError {
server_type: server_id.server_type().to_string(),
bind_address: server_id.bind_address().to_string(),
error: e.to_string(),
});
}
}
}
let status = if errors.is_empty() {
"ok"
} else if reloaded.is_empty() {
"failed"
} else {
"partial"
};
HttpResponse::Ok().content_type(ContentType::json()).json(json!({
"status": status,
"reloaded": reloaded,
"errors": errors
}))
}

#[tracing::instrument(level = "debug")]
pub async fn api_service_certificate_status(
request: HttpRequest,
data: Data<Arc<ApiServiceData>>,
) -> HttpResponse {
if let Some(error_return) = api_validation(&request, &data).await {
return error_return;
}
let params = web::Query::<QueryToken>::from_query(request.query_string()).unwrap();
if let Some(response) = api_service_token(params.token.clone(), Arc::clone(&data.torrent_tracker.config)).await {
return response;
}
let certificate_store = &data.torrent_tracker.certificate_store;
let certificates = certificate_store.get_all_certificates();
let status_items: Vec<CertificateStatusItem> = certificates
.into_iter()
.map(|(server_id, bundle)| {
CertificateStatusItem {
server_type: server_id.server_type().to_string(),
bind_address: server_id.bind_address().to_string(),
cert_path: bundle.cert_path.clone(),
key_path: bundle.key_path.clone(),
loaded_at: bundle.loaded_at.to_rfc3339(),
}
})
.collect();
HttpResponse::Ok().content_type(ContentType::json()).json(json!({
"status": "ok",
"certificates": status_items
}))
}
1 change: 1 addition & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod structs;
#[allow(clippy::module_inception)]
pub mod api;
pub mod api_blacklists;
pub mod api_certificate;
pub mod api_keys;
pub mod api_torrents;
pub mod api_users;
Expand Down
8 changes: 3 additions & 5 deletions src/common/common.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
use crate::common::structs::custom_error::CustomError;
use crate::config::structs::configuration::Configuration;
use async_std::future;
use byteorder::{BigEndian, ReadBytesExt};
use fern::colors::{Color, ColoredLevelConfig};
use log::info;
use smallvec::SmallVec;
use std::collections::HashMap;
use std::fmt;
use std::fmt::Formatter;
use std::io::Cursor;
use std::time::{Duration, SystemTime};
use tokio_shutdown::Shutdown;

Expand Down Expand Up @@ -162,9 +160,9 @@ pub fn convert_int_to_bytes(number: &u64) -> Vec<u8> {
#[inline]
pub fn convert_bytes_to_int(array: &[u8]) -> u64 {
let mut array_fixed = [0u8; 8];
let start_idx = 8 - array.len();
array_fixed[start_idx..].copy_from_slice(array);
Cursor::new(array_fixed).read_u64::<BigEndian>().unwrap()
let len = array.len().min(8);
array_fixed[8 - len..].copy_from_slice(&array[..len]);
u64::from_be_bytes(array_fixed)
}

pub async fn shutdown_waiting(timeout: Duration, shutdown_handler: Shutdown) -> bool {
Expand Down
Loading