use axum::Router;
use std::net::SocketAddr;
use tokio::signal;
use crate::config::RuniqueConfig;
use crate::utils::aliases::AEngine;
use super::builder::RuniqueAppBuilder;
pub struct RuniqueApp {
pub engine: AEngine,
pub router: Router,
}
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 std::env::var("ACME_ENABLED").as_deref() == Ok("true") {
eprintln!("⚠ ACME_ENABLED=true but the `acme` feature is not compiled in.");
eprintln!(" Add features = [\"acme\"] to your Cargo.toml.");
}
#[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!(" Runique is operational");
println!(" └──> Server launched on 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!(
" └──> Connected to database {:?} -> {}",
moteur_db, db_name
);
println!(" └──> ctrl + c to stop");
}
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!("\nShutting down Runique server...");
})
.await?;
Ok(())
}
#[cfg(feature = "acme")]
async fn _cert_needs_renewal_placeholder() {}
}
#[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 || {
let domain = domain_clone.clone();
async move { Redirect::permanent(&format!("https://{domain}")) }
})
.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!(" Runique is operational");
println!(" └──> Server launched on 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!(
" └──> Connected to database {:?} -> {}",
moteur_db, db_name
);
}
println!(" └──> ctrl + c to stop");
axum_server::bind_rustls(https_addr.parse()?, tls_config)
.serve(self.router.into_make_service())
.await?;
Ok(())
}
}