use axum::Router;
use std::net::SocketAddr;
use tokio::signal;
use crate::config::RuniqueConfig;
use crate::utils::aliases::AEngine;
use crate::utils::trad::{t, tf};
use super::builder::RuniqueAppBuilder;
pub struct RuniqueApp {
pub engine: AEngine,
pub router: Router,
pub _log_guards: Vec<tracing_appender::non_blocking::WorkerGuard>,
}
impl RuniqueApp {
pub fn builder(config: RuniqueConfig) -> RuniqueAppBuilder {
RuniqueAppBuilder::new(config)
}
pub async fn run(self) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(not(feature = "acme"))]
if crate::utils::config::env::env_flag("ACME_ENABLED", false) {
eprintln!("⚠ {}", t("server.acme_not_compiled"));
eprintln!(" {}", t("server.acme_add_feature"));
}
#[cfg(feature = "acme")]
if self.engine.config.security.acme_enabled {
return self.run_with_acme().await;
}
self.run_http().await
}
async fn run_http(self) -> Result<(), Box<dyn std::error::Error>> {
let addr = format!(
"{}:{}",
self.engine.config.server.ip_server, self.engine.config.server.port
);
println!(" {}", t("server.operational"));
println!(
" └──> {}",
tf("server.launched", &[format!("http://{addr}")])
);
#[cfg(feature = "orm")]
{
let moteur_db = self.engine.db.get_database_backend();
let db_name = std::env::var("DB_NAME").unwrap_or_else(|_| "runique_db".to_string());
println!(
" └──> {}",
tf("server.connected_db", &[format!("{moteur_db:?}"), db_name])
);
#[cfg(feature = "test-utils")]
eprintln!(" └──> {}", t("server.test_utils_feature_enabled"));
println!(" └──> {}", t("server.stop_hint"));
}
let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(
listener,
self.router
.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async {
signal::ctrl_c().await.expect("Error signal Ctrl+C");
println!("\n{}", t("server.shutting_down"));
})
.await?;
Ok(())
}
}
#[cfg(feature = "acme")]
async fn cert_needs_renewal(expires_path: &std::path::Path) -> bool {
let Ok(content) = tokio::fs::read_to_string(expires_path).await else {
return true;
};
let Ok(renew_after) = content.trim().parse::<chrono::DateTime<chrono::Utc>>() else {
return true;
};
chrono::Utc::now() >= renew_after
}
impl RuniqueApp {
#[cfg(feature = "acme")]
async fn run_with_acme(self) -> Result<(), Box<dyn std::error::Error>> {
let _ = rustls::crypto::ring::default_provider().install_default();
use crate::utils::acme::{ChallengeStore, obtain_certificate};
use axum::{
extract::{Path, State},
response::Redirect,
routing::get,
};
use std::{collections::HashMap, path::Path as FsPath, sync::Arc};
use tokio::sync::RwLock;
let domain = self
.engine
.config
.security
.acme_domain
.as_deref()
.ok_or("ACME_ENABLED=true but ACME_DOMAIN is not set")?
.to_string();
let email = self
.engine
.config
.security
.acme_email
.as_deref()
.ok_or("ACME_ENABLED=true but ACME_EMAIL is not set")?
.to_string();
let ip = self.engine.config.server.ip_server.clone();
let challenge_store: ChallengeStore = Arc::new(RwLock::new(HashMap::new()));
let store_clone = challenge_store.clone();
let domain_clone = domain.clone();
let http_app = Router::new()
.route(
"/.well-known/acme-challenge/{token}",
get(
|Path(token): Path<String>, State(store): State<ChallengeStore>| async move {
let map = store.read().await;
match map.get(&token) {
Some(key_auth) => key_auth.clone(),
None => String::from("not found"),
}
},
),
)
.fallback(move |uri: axum::http::Uri| {
let domain = domain_clone.clone();
async move {
let path = uri.path_and_query().map_or("/", |pq| pq.as_str());
Redirect::permanent(&format!("https://{domain}{path}"))
}
})
.with_state(store_clone);
let http_addr = format!("{ip}:80");
let http_listener = tokio::net::TcpListener::bind(&http_addr).await?;
tokio::spawn(async move {
axum::serve(http_listener, http_app).await.ok();
});
let certs_dir = &self.engine.config.security.acme_certs_dir;
let cert_str = format!("{certs_dir}/cert.pem");
let key_str = format!("{certs_dir}/key.pem");
let expires_str = format!("{certs_dir}/expires.txt");
let cert_path = FsPath::new(&cert_str);
let key_path = FsPath::new(&key_str);
let expires_path = FsPath::new(&expires_str);
let needs_renewal =
!cert_path.exists() || !key_path.exists() || cert_needs_renewal(expires_path).await;
let (cert_pem, key_pem) = if !needs_renewal {
tracing::info!(certs_dir, "Loading existing TLS certificate");
(
tokio::fs::read(cert_path).await?,
tokio::fs::read(key_path).await?,
)
} else {
match obtain_certificate(
&domain,
&email,
challenge_store,
false, )
.await
{
Ok((cert, key)) => {
tokio::fs::create_dir_all(certs_dir).await?;
tokio::fs::write(&cert_path, &cert).await?;
tokio::fs::write(&key_path, &key).await?;
let renew_after = chrono::Utc::now() + chrono::Duration::days(60);
tokio::fs::write(expires_path, renew_after.to_rfc3339()).await?;
tracing::info!(
"Certificate will be renewed after {}",
renew_after.format("%Y-%m-%d")
);
(cert, key)
}
Err(e) => {
if cert_path.exists() && key_path.exists() {
tracing::warn!(
error = %e,
"ACME renewal failed — using existing certificate from ./certs/"
);
(
tokio::fs::read(cert_path).await?,
tokio::fs::read(key_path).await?,
)
} else {
return Err(e);
}
}
}
};
let https_addr = format!("{ip}:443");
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem(cert_pem, key_pem).await?;
println!(" {}", t("server.operational"));
println!(
" └──> {}",
tf("server.launched", &[format!("https://{domain}")])
);
#[cfg(feature = "orm")]
{
let moteur_db = self.engine.db.get_database_backend();
let db_name = std::env::var("DB_NAME").unwrap_or_else(|_| "runique_db".to_string());
println!(
" └──> {}",
tf("server.connected_db", &[format!("{moteur_db:?}"), db_name])
);
#[cfg(feature = "test-utils")]
eprintln!(" └──> {}", t("server.test_utils_feature_enabled"));
}
println!(" └──> ctrl + c to stop");
axum_server::bind_rustls(https_addr.parse()?, tls_config)
.serve(
self.router
.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await?;
Ok(())
}
}
#[cfg(all(test, feature = "acme"))]
mod cert_renewal_tests {
use super::cert_needs_renewal;
#[tokio::test]
async fn a_certificate_is_renewed_once_its_date_is_past_or_unreadable() {
let dir = std::env::temp_dir().join(format!("rq_acme_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("expires.txt");
let write =
|when: chrono::DateTime<chrono::Utc>| std::fs::write(&path, when.to_rfc3339()).unwrap();
assert!(cert_needs_renewal(&path).await, "no renewal date on disk");
std::fs::write(&path, "not a date").unwrap();
assert!(cert_needs_renewal(&path).await, "unreadable date");
write(chrono::Utc::now() + chrono::Duration::days(30));
assert!(!cert_needs_renewal(&path).await, "still valid");
write(chrono::Utc::now() - chrono::Duration::seconds(1));
assert!(cert_needs_renewal(&path).await, "date passed");
let _ = std::fs::remove_dir_all(&dir);
}
}