This commit is contained in:
2026-05-12 19:25:14 +02:00
commit 0f3173d93e
93 changed files with 11865 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
use time::OffsetDateTime;
use crate::ports::outbound::ClockPort;
pub struct SystemClock;
impl ClockPort for SystemClock {
fn now(&self) -> OffsetDateTime {
OffsetDateTime::now_utc()
}
}
+283
View File
@@ -0,0 +1,283 @@
use std::str::FromStr;
use async_trait::async_trait;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{Row, SqlitePool};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use crate::domain::certificate::{Certificate, CertificateUsage};
use crate::domain::gateway::Gateway;
use crate::ports::outbound::{StorageError, StoragePort};
static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
pub struct SqliteAdapter {
pool: SqlitePool,
}
impl SqliteAdapter {
pub async fn new(url: &str) -> Result<Self, StorageError> {
let opts = SqliteConnectOptions::from_str(url)
.map_err(|e| StorageError::Backend(format!("parse url: {e}")))?
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(opts)
.await
.map_err(|e| StorageError::Backend(e.to_string()))?;
MIGRATOR
.run(&pool)
.await
.map_err(|e| StorageError::Backend(format!("migrate: {e}")))?;
Ok(Self { pool })
}
pub async fn new_in_memory() -> Result<Self, StorageError> {
Self::new("sqlite::memory:").await
}
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
}
#[async_trait]
impl StoragePort for SqliteAdapter {
async fn get_expiring_certificates(
&self,
now: OffsetDateTime,
days_left: u32,
) -> Result<Vec<Certificate>, StorageError> {
let now_s = now.format(&Rfc3339).map_err(fmt_err)?;
let cutoff = (now + time::Duration::days(days_left as i64))
.format(&Rfc3339)
.map_err(fmt_err)?;
let rows = sqlx::query(
"SELECT gateway_id, serial, usage, pem, not_before, not_after \
FROM certificates \
WHERE not_after >= ?1 AND not_after <= ?2 \
ORDER BY not_after ASC",
)
.bind(&now_s)
.bind(&cutoff)
.fetch_all(&self.pool)
.await
.map_err(backend_err)?;
rows.into_iter().map(row_to_certificate).collect()
}
async fn list_certificates(&self) -> Result<Vec<Certificate>, StorageError> {
let rows = sqlx::query(
"SELECT gateway_id, serial, usage, pem, not_before, not_after \
FROM certificates \
ORDER BY not_after ASC",
)
.fetch_all(&self.pool)
.await
.map_err(backend_err)?;
rows.into_iter().map(row_to_certificate).collect()
}
async fn list_gateways(&self) -> Result<Vec<Gateway>, StorageError> {
let rows = sqlx::query(
"SELECT id, serial_number, admin_key_label FROM gateways ORDER BY id ASC",
)
.fetch_all(&self.pool)
.await
.map_err(backend_err)?;
rows.into_iter()
.map(|row| {
Ok(Gateway {
id: row.try_get("id").map_err(backend_err)?,
serial_number: row.try_get("serial_number").map_err(backend_err)?,
admin_key_label: row.try_get("admin_key_label").map_err(backend_err)?,
})
})
.collect()
}
async fn save_pending_request(
&self,
message_id: &str,
gateway_id: &str,
) -> Result<(), StorageError> {
let created_at = OffsetDateTime::now_utc().format(&Rfc3339).map_err(fmt_err)?;
sqlx::query(
"INSERT INTO pending_requests (message_id, gateway_id, created_at) \
VALUES (?1, ?2, ?3)",
)
.bind(message_id)
.bind(gateway_id)
.bind(created_at)
.execute(&self.pool)
.await
.map_err(backend_err)?;
Ok(())
}
async fn update_certificate(
&self,
gateway_id: &str,
new_cert_pem: &str,
) -> Result<(), StorageError> {
// NOTE: parsing serial/not_before/not_after from PEM is the caller's
// responsibility today. The port signature should grow a structured
// Certificate argument; until then we only refresh PEM + updated_at.
let updated_at = OffsetDateTime::now_utc().format(&Rfc3339).map_err(fmt_err)?;
let result = sqlx::query(
"UPDATE certificates SET pem = ?1, updated_at = ?2 WHERE gateway_id = ?3",
)
.bind(new_cert_pem)
.bind(updated_at)
.bind(gateway_id)
.execute(&self.pool)
.await
.map_err(backend_err)?;
if result.rows_affected() == 0 {
return Err(StorageError::NotFound);
}
Ok(())
}
}
fn row_to_certificate(row: sqlx::sqlite::SqliteRow) -> Result<Certificate, StorageError> {
let gateway_id: String = row.try_get("gateway_id").map_err(backend_err)?;
let serial: String = row.try_get("serial").map_err(backend_err)?;
let usage_s: String = row.try_get("usage").map_err(backend_err)?;
let pem: String = row.try_get("pem").map_err(backend_err)?;
let not_before_s: String = row.try_get("not_before").map_err(backend_err)?;
let not_after_s: String = row.try_get("not_after").map_err(backend_err)?;
Ok(Certificate {
gateway_id,
serial,
usage: parse_usage(&usage_s)?,
pem,
not_before: OffsetDateTime::parse(&not_before_s, &Rfc3339).map_err(parse_err)?,
not_after: OffsetDateTime::parse(&not_after_s, &Rfc3339).map_err(parse_err)?,
})
}
fn parse_usage(s: &str) -> Result<CertificateUsage, StorageError> {
match s {
"tls" => Ok(CertificateUsage::Tls),
"signature" => Ok(CertificateUsage::Signature),
"encryption" => Ok(CertificateUsage::Encryption),
other => Err(StorageError::Backend(format!("unknown usage: {other}"))),
}
}
pub fn usage_str(u: &CertificateUsage) -> &'static str {
match u {
CertificateUsage::Tls => "tls",
CertificateUsage::Signature => "signature",
CertificateUsage::Encryption => "encryption",
}
}
fn backend_err<E: std::fmt::Display>(e: E) -> StorageError {
StorageError::Backend(e.to_string())
}
fn fmt_err<E: std::fmt::Display>(e: E) -> StorageError {
StorageError::Backend(format!("format: {e}"))
}
fn parse_err<E: std::fmt::Display>(e: E) -> StorageError {
StorageError::Backend(format!("parse: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
async fn seed_gateway(pool: &SqlitePool, id: &str) {
let now = OffsetDateTime::now_utc().format(&Rfc3339).unwrap();
sqlx::query(
"INSERT INTO gateways (id, serial_number, admin_key_label, created_at) \
VALUES (?1, ?2, ?3, ?4)",
)
.bind(id)
.bind("SN-1")
.bind("ADMIN-LABEL")
.bind(now)
.execute(pool)
.await
.unwrap();
}
async fn seed_cert(
pool: &SqlitePool,
gateway_id: &str,
usage: CertificateUsage,
not_after: OffsetDateTime,
) {
let nb = (not_after - time::Duration::days(365)).format(&Rfc3339).unwrap();
let na = not_after.format(&Rfc3339).unwrap();
let updated = OffsetDateTime::now_utc().format(&Rfc3339).unwrap();
sqlx::query(
"INSERT INTO certificates (gateway_id, serial, usage, pem, not_before, not_after, updated_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)
.bind(gateway_id)
.bind("SERIAL")
.bind(usage_str(&usage))
.bind("-----BEGIN CERTIFICATE-----\nold\n-----END CERTIFICATE-----")
.bind(nb)
.bind(na)
.bind(updated)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn get_expiring_returns_only_in_window() {
let db = SqliteAdapter::new_in_memory().await.unwrap();
seed_gateway(db.pool(), "gw-1").await;
let now = OffsetDateTime::now_utc();
seed_cert(db.pool(), "gw-1", CertificateUsage::Tls, now + time::Duration::days(10)).await;
seed_cert(db.pool(), "gw-1", CertificateUsage::Signature, now + time::Duration::days(90)).await;
let result = db.get_expiring_certificates(now, 30).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].usage, CertificateUsage::Tls);
}
#[tokio::test]
async fn save_pending_then_update_certificate_roundtrip() {
let db = SqliteAdapter::new_in_memory().await.unwrap();
seed_gateway(db.pool(), "gw-1").await;
let now = OffsetDateTime::now_utc();
seed_cert(db.pool(), "gw-1", CertificateUsage::Tls, now + time::Duration::days(5)).await;
db.save_pending_request("msg-abc", "gw-1").await.unwrap();
db.update_certificate("gw-1", "-----BEGIN CERTIFICATE-----\nnew\n-----END CERTIFICATE-----")
.await
.unwrap();
let row = sqlx::query("SELECT pem FROM certificates WHERE gateway_id = 'gw-1'")
.fetch_one(db.pool())
.await
.unwrap();
let pem: String = row.try_get("pem").unwrap();
assert!(pem.contains("new"));
}
#[tokio::test]
async fn update_certificate_unknown_gateway_is_not_found() {
let db = SqliteAdapter::new_in_memory().await.unwrap();
let err = db.update_certificate("nope", "pem").await.unwrap_err();
assert!(matches!(err, StorageError::NotFound));
}
}
+33
View File
@@ -0,0 +1,33 @@
use crate::ports::outbound::{HsmError, HsmPort};
pub struct SoftHsmAdapter {
_module_path: String,
_pin: String,
}
impl SoftHsmAdapter {
pub fn new(module_path: impl Into<String>, pin: impl Into<String>) -> Self {
Self {
_module_path: module_path.into(),
_pin: pin.into(),
}
}
pub fn new_stub() -> Self {
Self::new("/usr/lib/softhsm/libsofthsm2.so", "1234")
}
}
impl HsmPort for SoftHsmAdapter {
fn generate_key_pair(&self, _label: &str) -> Result<String, HsmError> {
Err(HsmError::Other("not implemented".into()))
}
fn sign_csr(&self, _key_id: &str, _payload: &[u8]) -> Result<Vec<u8>, HsmError> {
Err(HsmError::Other("not implemented".into()))
}
fn sign_xml(&self, _key_id: &str, _xml_data: &str) -> Result<String, HsmError> {
Err(HsmError::Other("not implemented".into()))
}
}
+29
View File
@@ -0,0 +1,29 @@
use async_trait::async_trait;
use crate::ports::outbound::{NotificationError, NotificationPort};
pub struct SmtpAdapter {
_host: String,
_port: u16,
}
impl SmtpAdapter {
pub fn new(host: impl Into<String>, port: u16) -> Self {
Self {
_host: host.into(),
_port: port,
}
}
pub fn new_stub() -> Self {
Self::new("smtp.local", 587)
}
}
#[async_trait]
impl NotificationPort for SmtpAdapter {
async fn send_alert(&self, subject: &str, body: &str) -> Result<(), NotificationError> {
tracing::info!(subject, body, "alert (stub)");
Ok(())
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod clock;
pub mod db;
pub mod hsm;
pub mod mail;
pub mod sub_ca;
+27
View File
@@ -0,0 +1,27 @@
use async_trait::async_trait;
use crate::domain::certificate::CertificateRequest;
use crate::ports::outbound::{CaError, CertificateCaPort};
pub struct SubCaSoapAdapter {
_endpoint: String,
}
impl SubCaSoapAdapter {
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
_endpoint: endpoint.into(),
}
}
pub fn new_stub() -> Self {
Self::new("https://test-ca.local/soap")
}
}
#[async_trait]
impl CertificateCaPort for SubCaSoapAdapter {
async fn request_certificate(&self, _csr: CertificateRequest) -> Result<String, CaError> {
Err(CaError::Transport("not implemented".into()))
}
}
+111
View File
@@ -0,0 +1,111 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::{RwLock, Semaphore};
use crate::adapters;
use crate::http::{self, HttpState};
use crate::ports::inbound::{HandleCaCallback, RenewExpiringCertificates};
use crate::ports::outbound::{CertificateCaPort, ClockPort, HsmPort, NotificationPort, StoragePort};
use crate::scheduler;
use crate::state::{
RuntimeConfig, SchedulerState, SessionStore, SharedAlerts, SharedScheduler, SharedSessions,
};
use crate::usecases;
#[derive(Clone)]
pub struct AppState {
pub hsm: Arc<dyn HsmPort>,
pub ca: Arc<dyn CertificateCaPort>,
pub storage: Arc<dyn StoragePort>,
pub mail: Arc<dyn NotificationPort>,
pub clock: Arc<dyn ClockPort>,
}
pub async fn run() -> Result<()> {
let state = build_state().await?;
let config = Arc::new(RwLock::new(RuntimeConfig::from_env()));
let cfg_snapshot = config.read().await.clone();
let scheduler_state: SharedScheduler = Arc::new(RwLock::new(SchedulerState {
cron_schedule: cfg_snapshot.cron_schedule.clone(),
days_window: cfg_snapshot.days_window,
..Default::default()
}));
let alerts: SharedAlerts = Arc::new(RwLock::new(Vec::new()));
let sessions: SharedSessions = Arc::new(RwLock::new(SessionStore::default()));
let run_lock = Arc::new(Semaphore::new(1));
let renew: Arc<dyn RenewExpiringCertificates> = Arc::new(usecases::renew::RenewService {
storage: state.storage.clone(),
ca: state.ca.clone(),
hsm: state.hsm.clone(),
clock: state.clock.clone(),
notifier: state.mail.clone(),
});
let callback: Arc<dyn HandleCaCallback> = Arc::new(usecases::callback::CallbackService {
storage: state.storage.clone(),
});
let sched_handle = scheduler::start(
&cfg_snapshot.cron_schedule,
cfg_snapshot.days_window,
renew.clone(),
scheduler_state.clone(),
run_lock.clone(),
)
.await?;
let cors_origin = std::env::var("CORS_ALLOW_ORIGIN").ok();
let dev_auth = std::env::var("DEV_AUTH").map(|v| v == "1").unwrap_or(false);
let http_state = HttpState {
callback,
renew,
storage: state.storage.clone(),
mail: state.mail.clone(),
clock: state.clock.clone(),
config: config.clone(),
scheduler: scheduler_state.clone(),
alerts,
sessions,
run_lock,
dev_auth,
};
let router = http::router(http_state, cors_origin);
let bind = cfg_snapshot.bind_addr.clone();
let listener = tokio::net::TcpListener::bind(&bind)
.await
.with_context(|| format!("bind {bind}"))?;
tracing::info!(%bind, "http server up");
axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal())
.await
.context("axum serve")?;
sched_handle.shutdown().await?;
Ok(())
}
async fn build_state() -> Result<AppState> {
let hsm = Arc::new(adapters::hsm::SoftHsmAdapter::new_stub());
let ca = Arc::new(adapters::sub_ca::SubCaSoapAdapter::new_stub());
let storage = Arc::new(adapters::db::SqliteAdapter::new_in_memory().await?);
let mail = Arc::new(adapters::mail::SmtpAdapter::new_stub());
let clock = Arc::new(adapters::clock::SystemClock);
Ok(AppState {
hsm,
ca,
storage,
mail,
clock,
})
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("shutdown signal received");
}
+33
View File
@@ -0,0 +1,33 @@
use std::sync::Arc;
use crate::ports::outbound::HsmPort;
pub struct InitialConfigBuilder {
hsm: Arc<dyn HsmPort>,
admin_key_id: String,
gateway_id: Option<String>,
}
impl InitialConfigBuilder {
pub fn new(hsm: Arc<dyn HsmPort>, admin_key_id: impl Into<String>) -> Self {
Self {
hsm,
admin_key_id: admin_key_id.into(),
gateway_id: None,
}
}
pub fn gateway_id(mut self, id: impl Into<String>) -> Self {
self.gateway_id = Some(id.into());
self
}
pub fn build_signed(self) -> Result<String, String> {
let gw = self.gateway_id.ok_or("gateway_id required")?;
// TODO: build iconfig.xml via quick-xml, C14N, sign via HSM
let raw_xml = format!("<iconfig gateway=\"{}\"/>", gw);
self.hsm
.sign_xml(&self.admin_key_id, &raw_xml)
.map_err(|e| e.to_string())
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod iconfig;
pub mod soap_req;
+38
View File
@@ -0,0 +1,38 @@
use crate::domain::certificate::CertificateRequest;
pub struct SoapRequestBuilder<'a> {
csr: Option<&'a CertificateRequest>,
message_id: Option<String>,
}
impl<'a> SoapRequestBuilder<'a> {
pub fn new() -> Self {
Self {
csr: None,
message_id: None,
}
}
pub fn csr(mut self, csr: &'a CertificateRequest) -> Self {
self.csr = Some(csr);
self
}
pub fn message_id(mut self, id: impl Into<String>) -> Self {
self.message_id = Some(id.into());
self
}
pub fn build_request_certificate(self) -> Result<String, &'static str> {
let _csr = self.csr.ok_or("csr required")?;
let _mid = self.message_id.ok_or("message_id required")?;
// TODO: TR-03129-4 RequestCertificate envelope, base64(csr_der)
Err("not implemented")
}
}
impl<'a> Default for SoapRequestBuilder<'a> {
fn default() -> Self {
Self::new()
}
}
+36
View File
@@ -0,0 +1,36 @@
use time::OffsetDateTime;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CertificateUsage {
Tls,
Signature,
Encryption,
}
#[derive(Debug, Clone)]
pub struct Certificate {
pub gateway_id: String,
pub serial: String,
pub usage: CertificateUsage,
pub pem: String,
pub not_before: OffsetDateTime,
pub not_after: OffsetDateTime,
}
impl Certificate {
pub fn days_until_expiry(&self, now: OffsetDateTime) -> i64 {
(self.not_after - now).whole_days()
}
pub fn is_expiring_within(&self, now: OffsetDateTime, days: u32) -> bool {
let d = self.days_until_expiry(now);
d >= 0 && d <= days as i64
}
}
#[derive(Debug, Clone)]
pub struct CertificateRequest {
pub gateway_id: String,
pub usage: CertificateUsage,
pub csr_der: Vec<u8>,
}
+6
View File
@@ -0,0 +1,6 @@
#[derive(Debug, Clone)]
pub struct Gateway {
pub id: String,
pub serial_number: String,
pub admin_key_label: String,
}
+2
View File
@@ -0,0 +1,2 @@
pub mod certificate;
pub mod gateway;
+81
View File
@@ -0,0 +1,81 @@
use axum::extract::State;
use axum::Json;
use serde::Deserialize;
use time::OffsetDateTime;
use utoipa::ToSchema;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use super::error::ApiResult;
use crate::http::HttpState;
use crate::state::{AlertEntry, AlertSeverity};
pub fn router() -> OpenApiRouter<HttpState> {
OpenApiRouter::new()
.routes(routes!(list_alerts))
.routes(routes!(send_test_alert))
}
#[derive(Debug, serde::Serialize, ToSchema)]
pub struct AlertListResponse {
pub items: Vec<AlertEntry>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct TestAlertRequest {
pub subject: Option<String>,
pub body: Option<String>,
}
#[utoipa::path(
get,
path = "",
tag = "alerts",
responses(
(status = 200, description = "Recent alerts", body = AlertListResponse),
)
)]
pub async fn list_alerts(State(state): State<HttpState>) -> Json<AlertListResponse> {
let items = state.alerts.read().await.clone();
Json(AlertListResponse { items })
}
#[utoipa::path(
post,
path = "/test",
tag = "alerts",
request_body = TestAlertRequest,
responses(
(status = 200, description = "Sent (or stub-logged)"),
)
)]
pub async fn send_test_alert(
State(state): State<HttpState>,
Json(req): Json<TestAlertRequest>,
) -> ApiResult<()> {
let subject = req
.subject
.unwrap_or_else(|| "smgw-pki-automator: test alert".into());
let body = req
.body
.unwrap_or_else(|| "If you see this, SMTP wiring works.".into());
let send_result = state.mail.send_alert(&subject, &body).await;
let mut alerts = state.alerts.write().await;
alerts.push(AlertEntry {
at: OffsetDateTime::now_utc(),
severity: if send_result.is_ok() {
AlertSeverity::Info
} else {
AlertSeverity::Error
},
subject,
body,
});
// Trim ringbuffer.
let len = alerts.len();
if len > 200 {
alerts.drain(0..(len - 200));
}
Ok(())
}
+160
View File
@@ -0,0 +1,160 @@
use axum::extract::State;
use axum::http::header::{HeaderMap, SET_COOKIE};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use serde::{Deserialize, Serialize};
use time::{Duration, OffsetDateTime};
use utoipa::ToSchema;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use uuid::Uuid;
use super::error::{ApiResponseError, ApiResult};
use crate::http::HttpState;
use crate::state::Session;
pub const SESSION_COOKIE: &str = "smgw_session";
pub const CERT_SUBJECT_HEADER: &str = "x-forwarded-cert-subject";
pub const SESSION_TTL_MINUTES: i64 = 60 * 8;
pub fn router() -> OpenApiRouter<HttpState> {
OpenApiRouter::new()
.routes(routes!(create_session))
.routes(routes!(end_session))
.routes(routes!(whoami))
}
#[derive(Debug, Serialize, ToSchema)]
pub struct SessionResponse {
pub subject: String,
#[serde(with = "time::serde::rfc3339")]
pub expires_at: OffsetDateTime,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct LoginRequest {
/// Optional fallback subject for dev mode when no mTLS header is present.
pub dev_subject: Option<String>,
}
/// Exchange mTLS client cert (passed via `X-Forwarded-Cert-Subject`) for a
/// server-issued session cookie. The reverse proxy terminating mTLS is the
/// trust anchor.
#[utoipa::path(
post,
path = "/session",
tag = "auth",
request_body = LoginRequest,
responses(
(status = 200, description = "Session issued", body = SessionResponse),
(status = 403, description = "No client cert subject", body = super::error::ApiError),
)
)]
pub async fn create_session(
State(state): State<HttpState>,
headers: HeaderMap,
Json(body): Json<LoginRequest>,
) -> ApiResult<impl IntoResponse> {
let subject = headers
.get(CERT_SUBJECT_HEADER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.or_else(|| {
if state.dev_auth {
body.dev_subject
} else {
None
}
})
.ok_or_else(ApiResponseError::forbidden)?;
let now = OffsetDateTime::now_utc();
let expires_at = now + Duration::minutes(SESSION_TTL_MINUTES);
let session = Session {
id: Uuid::new_v4().to_string(),
subject: subject.clone(),
issued_at: now,
expires_at,
};
{
let mut store = state.sessions.write().await;
store.gc(now);
store.insert(session.clone());
}
let cookie = format!(
"{}={}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}{}",
SESSION_COOKIE,
session.id,
SESSION_TTL_MINUTES * 60,
if state.dev_auth { "" } else { "; Secure" },
);
Ok((
StatusCode::OK,
[(SET_COOKIE, cookie)],
Json(SessionResponse {
subject,
expires_at,
}),
))
}
/// Revoke the current session and clear cookie.
#[utoipa::path(
delete,
path = "/session",
tag = "auth",
responses(
(status = 204, description = "Session ended"),
)
)]
pub async fn end_session(
State(state): State<HttpState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Some(id) = session_id_from_cookie(&headers) {
state.sessions.write().await.remove(&id);
}
let clear = format!(
"{}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0",
SESSION_COOKIE
);
(StatusCode::NO_CONTENT, [(SET_COOKIE, clear)])
}
/// Inspect the current session.
#[utoipa::path(
get,
path = "/me",
tag = "auth",
responses(
(status = 200, description = "Current session", body = SessionResponse),
(status = 401, description = "No active session", body = super::error::ApiError),
)
)]
pub async fn whoami(
State(state): State<HttpState>,
headers: HeaderMap,
) -> ApiResult<Json<SessionResponse>> {
let id = session_id_from_cookie(&headers).ok_or_else(ApiResponseError::unauthorized)?;
let store = state.sessions.read().await;
let session = store.get(&id).ok_or_else(ApiResponseError::unauthorized)?;
if session.expires_at <= OffsetDateTime::now_utc() {
return Err(ApiResponseError::unauthorized());
}
Ok(Json(SessionResponse {
subject: session.subject.clone(),
expires_at: session.expires_at,
}))
}
pub fn session_id_from_cookie(headers: &HeaderMap) -> Option<String> {
let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
raw.split(';').find_map(|kv| {
let (k, v) = kv.split_once('=')?;
(k.trim() == SESSION_COOKIE).then(|| v.trim().to_string())
})
}
+148
View File
@@ -0,0 +1,148 @@
use axum::extract::{Path, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use utoipa::ToSchema;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use super::error::{ApiResponseError, ApiResult};
use crate::domain::certificate::{Certificate, CertificateUsage};
use crate::http::HttpState;
pub fn router() -> OpenApiRouter<HttpState> {
OpenApiRouter::new()
.routes(routes!(list_certificates))
.routes(routes!(renew_certificate))
}
#[derive(Debug, Serialize, ToSchema)]
pub struct CertificateDto {
pub gateway_id: String,
pub serial: String,
pub usage: CertificateUsageDto,
#[serde(with = "time::serde::rfc3339")]
pub not_before: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub not_after: OffsetDateTime,
pub days_to_expiry: i64,
pub state: CertState,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum CertificateUsageDto {
Tls,
Signature,
Encryption,
}
impl From<&CertificateUsage> for CertificateUsageDto {
fn from(u: &CertificateUsage) -> Self {
match u {
CertificateUsage::Tls => Self::Tls,
CertificateUsage::Signature => Self::Signature,
CertificateUsage::Encryption => Self::Encryption,
}
}
}
impl From<CertificateUsageDto> for CertificateUsage {
fn from(u: CertificateUsageDto) -> Self {
match u {
CertificateUsageDto::Tls => Self::Tls,
CertificateUsageDto::Signature => Self::Signature,
CertificateUsageDto::Encryption => Self::Encryption,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum CertState {
Valid,
Expiring,
Expired,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct CertListResponse {
pub items: Vec<CertificateDto>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct RenewAccepted {
pub message_id: String,
}
fn to_dto(c: &Certificate, now: OffsetDateTime, days_window: u32) -> CertificateDto {
let d = c.days_until_expiry(now);
let state = if d < 0 {
CertState::Expired
} else if d <= days_window as i64 {
CertState::Expiring
} else {
CertState::Valid
};
CertificateDto {
gateway_id: c.gateway_id.clone(),
serial: c.serial.clone(),
usage: (&c.usage).into(),
not_before: c.not_before,
not_after: c.not_after,
days_to_expiry: d,
state,
}
}
/// List all known end-entity certificates with derived state.
#[utoipa::path(
get,
path = "",
tag = "certs",
responses(
(status = 200, description = "Certificates", body = CertListResponse),
)
)]
pub async fn list_certificates(
State(state): State<HttpState>,
) -> ApiResult<Json<CertListResponse>> {
let certs = state
.storage
.list_certificates()
.await
.map_err(|e| ApiResponseError::internal(e.to_string()))?;
let now = state.clock.now();
let cfg = state.config.read().await.clone();
let items = certs
.iter()
.map(|c| to_dto(c, now, cfg.days_window))
.collect();
Ok(Json(CertListResponse { items }))
}
/// Trigger an out-of-band renewal for a specific (gateway, usage) pair.
/// Returns the SOAP `messageID` so the caller can correlate the async callback.
#[utoipa::path(
post,
path = "/{gateway_id}/{usage}/renew",
tag = "certs",
params(
("gateway_id" = String, Path, description = "Gateway identifier"),
("usage" = CertificateUsageDto, Path, description = "Certificate usage"),
),
responses(
(status = 202, description = "Renewal accepted", body = RenewAccepted),
(status = 501, description = "Sub-CA adapter not implemented yet", body = super::error::ApiError),
)
)]
pub async fn renew_certificate(
State(_state): State<HttpState>,
Path((gateway_id, usage)): Path<(String, CertificateUsageDto)>,
) -> ApiResult<Json<RenewAccepted>> {
// TODO wire to RenewService once SubCaSoapAdapter is real.
tracing::info!(%gateway_id, ?usage, "manual renewal requested");
Err(ApiResponseError::not_implemented(
"manual renewal pending SubCaSoapAdapter",
))
}
+88
View File
@@ -0,0 +1,88 @@
use axum::extract::State;
use axum::Json;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use super::error::ApiResult;
use crate::http::HttpState;
use crate::state::{HsmConfig, RuntimeConfig, SmtpConfig, SubCaConfig};
pub fn router() -> OpenApiRouter<HttpState> {
OpenApiRouter::new()
.routes(routes!(get_config))
.routes(routes!(update_config))
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ConfigView {
pub config: RuntimeConfig,
pub restart_required_fields: Vec<&'static str>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct ConfigUpdate {
pub cron_schedule: Option<String>,
pub days_window: Option<u32>,
pub sub_ca: Option<SubCaConfig>,
pub smtp: Option<SmtpConfig>,
pub hsm: Option<HsmConfig>,
}
#[utoipa::path(
get,
path = "",
tag = "config",
responses(
(status = 200, description = "Current runtime config", body = ConfigView),
)
)]
pub async fn get_config(State(state): State<HttpState>) -> ApiResult<Json<ConfigView>> {
let cfg = state.config.read().await.clone();
Ok(Json(ConfigView {
config: cfg,
restart_required_fields: vec!["bind_addr", "database_url"],
}))
}
#[utoipa::path(
put,
path = "",
tag = "config",
request_body = ConfigUpdate,
responses(
(status = 200, description = "Updated runtime config", body = ConfigView),
)
)]
pub async fn update_config(
State(state): State<HttpState>,
Json(patch): Json<ConfigUpdate>,
) -> ApiResult<Json<ConfigView>> {
let mut cfg = state.config.write().await;
if let Some(v) = patch.cron_schedule {
cfg.cron_schedule = v;
}
if let Some(v) = patch.days_window {
cfg.days_window = v;
}
if let Some(v) = patch.sub_ca {
cfg.sub_ca = v;
}
if let Some(v) = patch.smtp {
cfg.smtp = v;
}
if let Some(v) = patch.hsm {
cfg.hsm = v;
}
// Mirror scheduler-affecting fields.
{
let mut sch = state.scheduler.write().await;
sch.cron_schedule = cfg.cron_schedule.clone();
sch.days_window = cfg.days_window;
}
Ok(Json(ConfigView {
config: cfg.clone(),
restart_required_fields: vec!["bind_addr", "database_url"],
}))
}
+77
View File
@@ -0,0 +1,77 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Debug, Serialize, ToSchema)]
pub struct ApiError {
pub code: &'static str,
pub message: String,
}
impl ApiError {
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
pub struct ApiResponseError {
pub status: StatusCode,
pub body: ApiError,
}
impl ApiResponseError {
pub fn bad_request(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
body: ApiError::new("bad_request", msg),
}
}
pub fn unauthorized() -> Self {
Self {
status: StatusCode::UNAUTHORIZED,
body: ApiError::new("unauthorized", "session missing or expired"),
}
}
pub fn forbidden() -> Self {
Self {
status: StatusCode::FORBIDDEN,
body: ApiError::new("forbidden", "client certificate not accepted"),
}
}
pub fn not_found() -> Self {
Self {
status: StatusCode::NOT_FOUND,
body: ApiError::new("not_found", "resource not found"),
}
}
pub fn not_implemented(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_IMPLEMENTED,
body: ApiError::new("not_implemented", msg),
}
}
pub fn internal(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
body: ApiError::new("internal", msg),
}
}
}
impl IntoResponse for ApiResponseError {
fn into_response(self) -> Response {
(self.status, Json(self.body)).into_response()
}
}
pub type ApiResult<T> = Result<T, ApiResponseError>;
+52
View File
@@ -0,0 +1,52 @@
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use utoipa::ToSchema;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use super::error::{ApiResponseError, ApiResult};
use crate::http::HttpState;
pub fn router() -> OpenApiRouter<HttpState> {
OpenApiRouter::new().routes(routes!(list_gateways))
}
#[derive(Debug, Serialize, ToSchema)]
pub struct GatewayDto {
pub id: String,
pub serial_number: String,
pub admin_key_label: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct GatewayListResponse {
pub items: Vec<GatewayDto>,
}
#[utoipa::path(
get,
path = "",
tag = "gateways",
responses(
(status = 200, description = "Gateways", body = GatewayListResponse),
)
)]
pub async fn list_gateways(
State(state): State<HttpState>,
) -> ApiResult<Json<GatewayListResponse>> {
let rows = state
.storage
.list_gateways()
.await
.map_err(|e| ApiResponseError::internal(e.to_string()))?;
let items = rows
.into_iter()
.map(|g| GatewayDto {
id: g.id,
serial_number: g.serial_number,
admin_key_label: g.admin_key_label,
})
.collect();
Ok(Json(GatewayListResponse { items }))
}
+70
View File
@@ -0,0 +1,70 @@
use axum::extract::State;
use axum::Json;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use super::error::{ApiResponseError, ApiResult};
use crate::http::HttpState;
pub fn router() -> OpenApiRouter<HttpState> {
OpenApiRouter::new()
.routes(routes!(preview_iconfig))
.routes(routes!(build_iconfig))
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct IconfigRequest {
pub gateway_id: String,
pub admin_key_label: String,
pub profile: String,
pub extras: Option<serde_json::Value>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct IconfigPreview {
pub xml: String,
}
/// Render the unsigned `iconfig.xml` for review. Does not touch the HSM.
#[utoipa::path(
post,
path = "/preview",
tag = "iconfig",
request_body = IconfigRequest,
responses(
(status = 200, description = "Preview XML", body = IconfigPreview),
)
)]
pub async fn preview_iconfig(
State(_state): State<HttpState>,
Json(req): Json<IconfigRequest>,
) -> ApiResult<Json<IconfigPreview>> {
// TODO replace with InitialConfigBuilder once it produces canonical XML.
let xml = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<InitialConfig gatewayId=\"{}\" profile=\"{}\">\n <AdminKeyLabel>{}</AdminKeyLabel>\n</InitialConfig>\n",
req.gateway_id, req.profile, req.admin_key_label
);
Ok(Json(IconfigPreview { xml }))
}
/// Build, sign via HSM, and stream back `iconfig.tar`.
#[utoipa::path(
post,
path = "/build",
tag = "iconfig",
request_body = IconfigRequest,
responses(
(status = 200, description = "iconfig.tar", content_type = "application/x-tar"),
(status = 501, description = "HSM signature not implemented", body = super::error::ApiError),
)
)]
pub async fn build_iconfig(
State(_state): State<HttpState>,
Json(_req): Json<IconfigRequest>,
) -> ApiResult<()> {
Err(ApiResponseError::not_implemented(
"iconfig build pending InitialConfigBuilder + HsmPort::sign_xml",
))
}
+104
View File
@@ -0,0 +1,104 @@
pub mod alerts;
pub mod auth;
pub mod certs;
pub mod config;
pub mod error;
pub mod gateways;
pub mod iconfig;
pub mod scheduler;
use std::sync::Arc;
use axum::Router;
use tower_http::cors::{AllowOrigin, CorsLayer};
use utoipa::OpenApi;
use utoipa_axum::router::OpenApiRouter;
use crate::http::HttpState;
#[derive(OpenApi)]
#[openapi(
info(
title = "smgw-pki-automator",
version = env!("CARGO_PKG_VERSION"),
description = "Control + observation surface for the SMGW PKI automation tool. Test/lab use only.",
),
tags(
(name = "auth", description = "mTLS-bridged session management"),
(name = "certs", description = "Certificate lifecycle"),
(name = "gateways", description = "Smart Meter Gateways"),
(name = "config", description = "Runtime configuration"),
(name = "scheduler", description = "Renewal scheduler"),
(name = "iconfig", description = "BSI TR-03109-1 initial config"),
(name = "alerts", description = "Operator alerts"),
),
)]
pub struct ApiDoc;
fn build() -> OpenApiRouter<HttpState> {
OpenApiRouter::with_openapi(ApiDoc::openapi())
.nest("/auth", auth::router())
.nest("/certs", certs::router())
.nest("/gateways", gateways::router())
.nest("/config", config::router())
.nest("/scheduler", scheduler::router())
.nest("/iconfig", iconfig::router())
.nest("/alerts", alerts::router())
}
/// Build the OpenAPI document without instantiating runtime state. Used for
/// static spec emission feeding the frontend client generator.
pub fn openapi_spec() -> utoipa::openapi::OpenApi {
let (_, api) = build().split_for_parts();
api
}
/// Mount `/api/*` and emit OpenAPI at `/api/openapi.json`.
pub fn router(state: HttpState) -> Router {
let (router, api) = build().split_for_parts();
let router = router.with_state(state);
let openapi_json = serde_json::to_string(&api).expect("serialize openapi");
let openapi_arc = Arc::new(openapi_json);
router.route(
"/openapi.json",
axum::routing::get({
let doc = openapi_arc.clone();
move || {
let doc = doc.clone();
async move {
(
[(axum::http::header::CONTENT_TYPE, "application/json")],
doc.as_str().to_string(),
)
}
}
}),
)
}
pub fn cors_layer(allowed_origin: Option<String>) -> CorsLayer {
let base = CorsLayer::new()
.allow_credentials(true)
.allow_methods([
axum::http::Method::GET,
axum::http::Method::POST,
axum::http::Method::PUT,
axum::http::Method::DELETE,
axum::http::Method::OPTIONS,
])
.allow_headers([
axum::http::header::CONTENT_TYPE,
axum::http::header::AUTHORIZATION,
axum::http::HeaderName::from_static("x-forwarded-cert-subject"),
]);
match allowed_origin {
Some(origin) => base.allow_origin(
origin
.parse::<axum::http::HeaderValue>()
.map(AllowOrigin::exact)
.unwrap_or_else(|_| AllowOrigin::any()),
),
None => base.allow_origin(AllowOrigin::any()),
}
}
+96
View File
@@ -0,0 +1,96 @@
use axum::extract::State;
use axum::Json;
use serde::Deserialize;
use utoipa::ToSchema;
use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use super::error::{ApiResponseError, ApiResult};
use crate::http::HttpState;
use crate::state::SchedulerState;
pub fn router() -> OpenApiRouter<HttpState> {
OpenApiRouter::new()
.routes(routes!(get_status))
.routes(routes!(trigger_run))
.routes(routes!(set_paused))
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct PauseRequest {
pub paused: bool,
}
#[utoipa::path(
get,
path = "",
tag = "scheduler",
responses(
(status = 200, description = "Scheduler state", body = SchedulerState),
)
)]
pub async fn get_status(State(state): State<HttpState>) -> Json<SchedulerState> {
Json(state.scheduler.read().await.clone())
}
/// Run renewal once, out of band. Honours the same overlap-lock as the cron job.
#[utoipa::path(
post,
path = "/trigger",
tag = "scheduler",
responses(
(status = 202, description = "Run accepted"),
(status = 409, description = "Run already in progress", body = super::error::ApiError),
)
)]
pub async fn trigger_run(State(state): State<HttpState>) -> ApiResult<()> {
let days = state.scheduler.read().await.days_window;
let renew = state.renew.clone();
let sched = state.scheduler.clone();
if state.run_lock.clone().try_acquire_owned().is_err() {
return Err(ApiResponseError {
status: axum::http::StatusCode::CONFLICT,
body: super::error::ApiError::new("run_in_progress", "previous run still active"),
});
}
tokio::spawn(async move {
let permit = state.run_lock.clone().acquire_owned().await;
let started = time::OffsetDateTime::now_utc();
let outcome = renew.run(days).await;
let mut s = sched.write().await;
s.last_run_at = Some(started);
match outcome {
Ok(n) => {
s.last_run_ok = Some(true);
s.last_handled = Some(n);
s.last_error = None;
}
Err(e) => {
s.last_run_ok = Some(false);
s.last_error = Some(e.to_string());
}
}
drop(permit);
});
Ok(())
}
#[utoipa::path(
post,
path = "/pause",
tag = "scheduler",
request_body = PauseRequest,
responses(
(status = 200, description = "Pause state updated", body = SchedulerState),
)
)]
pub async fn set_paused(
State(state): State<HttpState>,
Json(body): Json<PauseRequest>,
) -> Json<SchedulerState> {
let mut s = state.scheduler.write().await;
s.paused = body.paused;
Json(s.clone())
}
+45
View File
@@ -0,0 +1,45 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use super::HttpState;
/// TR-03129-4 Callback-Endpunkt.
///
/// SECURITY: Vor dem Aufruf der Domäne MÜSSEN geprüft werden:
/// 1. mTLS-Client-Cert der CA (Server-seitig terminiert oder per
/// Connection-Info).
/// 2. XML-Signatur des SOAP-Envelopes.
/// Beides ist hier noch nicht implementiert. Siehe docs/bsi-compliance.md §1.3.
pub async fn handler(State(state): State<HttpState>, body: String) -> impl IntoResponse {
let parsed = parse_callback(&body);
let (message_id, cert) = match parsed {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "callback parse failed");
return (StatusCode::BAD_REQUEST, "bad request");
}
};
match state.callback.handle(&message_id, &cert).await {
Ok(()) => (StatusCode::OK, "ok"),
Err(e) => {
tracing::error!(error = %e, "callback handler failed");
(StatusCode::INTERNAL_SERVER_ERROR, "error")
}
}
}
/// Naive Extraktion. Echte Impl. nutzt quick-xml und prüft den SOAP-Envelope.
fn parse_callback(body: &str) -> Result<(String, String), &'static str> {
let message_id = between(body, "<messageID>", "</messageID>").ok_or("missing messageID")?;
let cert = between(body, "<certificateSeq>", "</certificateSeq>")
.ok_or("missing certificateSeq")?;
Ok((message_id.to_string(), cert.to_string()))
}
fn between<'a>(s: &'a str, start: &str, end: &str) -> Option<&'a str> {
let i = s.find(start)? + start.len();
let j = s[i..].find(end)? + i;
Some(&s[i..j])
}
+5
View File
@@ -0,0 +1,5 @@
use axum::http::StatusCode;
pub async fn handler() -> (StatusCode, &'static str) {
(StatusCode::OK, "ok")
}
+41
View File
@@ -0,0 +1,41 @@
pub mod api;
pub mod callback;
pub mod health;
use std::sync::Arc;
use axum::routing::{get, post};
use axum::Router;
use tokio::sync::Semaphore;
use crate::ports::inbound::{HandleCaCallback, RenewExpiringCertificates};
use crate::ports::outbound::{ClockPort, NotificationPort, StoragePort};
use crate::state::{SharedAlerts, SharedConfig, SharedScheduler, SharedSessions};
/// State shared with every HTTP handler. Cheap to clone (Arc inside).
#[derive(Clone)]
pub struct HttpState {
pub callback: Arc<dyn HandleCaCallback>,
pub renew: Arc<dyn RenewExpiringCertificates>,
pub storage: Arc<dyn StoragePort>,
pub mail: Arc<dyn NotificationPort>,
pub clock: Arc<dyn ClockPort>,
pub config: SharedConfig,
pub scheduler: SharedScheduler,
pub alerts: SharedAlerts,
pub sessions: SharedSessions,
/// Single-flight guard reused by cron scheduler and manual trigger.
pub run_lock: Arc<Semaphore>,
/// In dev (no reverse proxy) accept a `dev_subject` field in /api/auth/session.
pub dev_auth: bool,
}
pub fn router(state: HttpState, cors_origin: Option<String>) -> Router {
let api_router = api::router(state.clone()).layer(api::cors_layer(cors_origin));
Router::new()
.route("/health", get(health::handler))
.route("/pki/callback", post(callback::handler))
.with_state(state)
.nest("/api", api_router)
}
+29
View File
@@ -0,0 +1,29 @@
mod adapters;
mod app;
mod builders;
mod domain;
mod http;
mod ports;
mod scheduler;
mod state;
mod usecases;
use anyhow::Result;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--emit-openapi") {
let spec = http::api::openapi_spec();
let json = serde_json::to_string_pretty(&spec)?;
println!("{json}");
return Ok(());
}
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
app::run().await
}
+20
View File
@@ -0,0 +1,20 @@
use async_trait::async_trait;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum UseCaseError {
#[error("dependency failed: {0}")]
Dependency(String),
#[error("invariant violated: {0}")]
Invariant(String),
}
#[async_trait]
pub trait RenewExpiringCertificates: Send + Sync {
async fn run(&self, days_window: u32) -> Result<usize, UseCaseError>;
}
#[async_trait]
pub trait HandleCaCallback: Send + Sync {
async fn handle(&self, message_id: &str, cert_pem: &str) -> Result<(), UseCaseError>;
}
+2
View File
@@ -0,0 +1,2 @@
pub mod inbound;
pub mod outbound;
+83
View File
@@ -0,0 +1,83 @@
use async_trait::async_trait;
use thiserror::Error;
use time::OffsetDateTime;
use crate::domain::certificate::{Certificate, CertificateRequest};
use crate::domain::gateway::Gateway;
#[derive(Debug, Error)]
pub enum HsmError {
#[error("pkcs11 session error: {0}")]
Session(String),
#[error("key not found: {0}")]
KeyNotFound(String),
#[error("sign failed: {0}")]
Sign(String),
#[error("other: {0}")]
Other(String),
}
#[derive(Debug, Error)]
pub enum CaError {
#[error("transport error: {0}")]
Transport(String),
#[error("soap fault: {0}")]
SoapFault(String),
#[error("malformed response: {0}")]
Malformed(String),
}
#[derive(Debug, Error)]
pub enum StorageError {
#[error("not found")]
NotFound,
#[error("backend error: {0}")]
Backend(String),
}
#[derive(Debug, Error)]
pub enum NotificationError {
#[error("send failed: {0}")]
Send(String),
}
pub trait HsmPort: Send + Sync {
fn generate_key_pair(&self, label: &str) -> Result<String, HsmError>;
fn sign_csr(&self, key_id: &str, payload: &[u8]) -> Result<Vec<u8>, HsmError>;
fn sign_xml(&self, key_id: &str, xml_data: &str) -> Result<String, HsmError>;
}
#[async_trait]
pub trait CertificateCaPort: Send + Sync {
async fn request_certificate(&self, csr: CertificateRequest) -> Result<String, CaError>;
}
#[async_trait]
pub trait StoragePort: Send + Sync {
async fn get_expiring_certificates(
&self,
now: OffsetDateTime,
days_left: u32,
) -> Result<Vec<Certificate>, StorageError>;
async fn list_certificates(&self) -> Result<Vec<Certificate>, StorageError>;
async fn list_gateways(&self) -> Result<Vec<Gateway>, StorageError>;
async fn save_pending_request(
&self,
message_id: &str,
gateway_id: &str,
) -> Result<(), StorageError>;
async fn update_certificate(
&self,
gateway_id: &str,
new_cert_pem: &str,
) -> Result<(), StorageError>;
}
#[async_trait]
pub trait NotificationPort: Send + Sync {
async fn send_alert(&self, subject: &str, body: &str) -> Result<(), NotificationError>;
}
pub trait ClockPort: Send + Sync {
fn now(&self) -> OffsetDateTime;
}
+76
View File
@@ -0,0 +1,76 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use time::OffsetDateTime;
use tokio::sync::Semaphore;
use tokio_cron_scheduler::{Job, JobScheduler};
use crate::ports::inbound::RenewExpiringCertificates;
use crate::state::SharedScheduler;
pub struct SchedulerHandle {
sched: JobScheduler,
}
impl SchedulerHandle {
pub async fn shutdown(mut self) -> Result<()> {
self.sched.shutdown().await.context("scheduler shutdown")?;
Ok(())
}
}
pub async fn start(
cron_expr: &str,
days_window: u32,
renew: Arc<dyn RenewExpiringCertificates>,
state: SharedScheduler,
run_lock: Arc<Semaphore>,
) -> Result<SchedulerHandle> {
let sched = JobScheduler::new().await.context("scheduler new")?;
let renew_for_job = renew.clone();
let lock_for_job = run_lock.clone();
let state_for_job = state.clone();
let job = Job::new_async(cron_expr, move |_uuid, _l| {
let renew = renew_for_job.clone();
let lock = lock_for_job.clone();
let state = state_for_job.clone();
Box::pin(async move {
if state.read().await.paused {
tracing::info!("renew job paused — skipping tick");
return;
}
let _permit = match lock.try_acquire() {
Ok(p) => p,
Err(_) => {
tracing::warn!("renew job overlap — previous run still active, skipping");
return;
}
};
let started = OffsetDateTime::now_utc();
let outcome = renew.run(days_window).await;
let mut s = state.write().await;
s.last_run_at = Some(started);
match outcome {
Ok(n) => {
tracing::info!(handled = n, "renew run finished");
s.last_run_ok = Some(true);
s.last_handled = Some(n);
s.last_error = None;
}
Err(e) => {
tracing::error!(error = %e, "renew run failed");
s.last_run_ok = Some(false);
s.last_error = Some(e.to_string());
}
}
})
})
.context("build renew job")?;
sched.add(job).await.context("scheduler add")?;
sched.start().await.context("scheduler start")?;
tracing::info!(cron = cron_expr, days_window, "scheduler up");
Ok(SchedulerHandle { sched })
}
+152
View File
@@ -0,0 +1,152 @@
use std::collections::HashMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use tokio::sync::RwLock;
use utoipa::ToSchema;
/// Mutable runtime config. Seeded from env on boot; UI may override at runtime
/// for fields flagged `hot_reload`. Restart-only fields (BIND_ADDR) are read
/// but cannot be applied without restart.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct RuntimeConfig {
pub bind_addr: String,
pub cron_schedule: String,
pub days_window: u32,
pub database_url: String,
pub sub_ca: SubCaConfig,
pub smtp: SmtpConfig,
pub hsm: HsmConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SubCaConfig {
pub endpoint: String,
pub client_cert_path: Option<String>,
pub client_key_path: Option<String>,
pub ca_bundle_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub from: String,
pub to: String,
pub starttls: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct HsmConfig {
pub module_path: String,
pub slot: Option<u64>,
pub pin_env_var: String,
}
impl RuntimeConfig {
pub fn from_env() -> Self {
Self {
bind_addr: std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8443".into()),
cron_schedule: std::env::var("CRON_SCHEDULE").unwrap_or_else(|_| "0 0 3 * * *".into()),
days_window: std::env::var("DAYS_WINDOW")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(30),
database_url: std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "sqlite::memory:".into()),
sub_ca: SubCaConfig {
endpoint: std::env::var("SUB_CA_ENDPOINT")
.unwrap_or_else(|_| "https://test-ca.local/soap".into()),
client_cert_path: std::env::var("SUB_CA_CLIENT_CERT").ok(),
client_key_path: std::env::var("SUB_CA_CLIENT_KEY").ok(),
ca_bundle_path: std::env::var("SUB_CA_BUNDLE").ok(),
},
smtp: SmtpConfig {
host: std::env::var("SMTP_HOST").unwrap_or_else(|_| "smtp.local".into()),
port: std::env::var("SMTP_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(587),
from: std::env::var("SMTP_FROM").unwrap_or_else(|_| "pki-bot@local".into()),
to: std::env::var("SMTP_TO").unwrap_or_else(|_| "ops@local".into()),
starttls: std::env::var("SMTP_STARTTLS")
.map(|v| v != "0")
.unwrap_or(true),
},
hsm: HsmConfig {
module_path: std::env::var("HSM_MODULE")
.unwrap_or_else(|_| "/usr/lib/softhsm/libsofthsm2.so".into()),
slot: std::env::var("HSM_SLOT").ok().and_then(|s| s.parse().ok()),
pin_env_var: std::env::var("HSM_PIN_ENV").unwrap_or_else(|_| "HSM_PIN".into()),
},
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
pub struct SchedulerState {
pub cron_schedule: String,
pub days_window: u32,
pub paused: bool,
#[serde(with = "time::serde::rfc3339::option")]
pub last_run_at: Option<OffsetDateTime>,
pub last_run_ok: Option<bool>,
pub last_error: Option<String>,
pub last_handled: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AlertEntry {
#[serde(with = "time::serde::rfc3339")]
pub at: OffsetDateTime,
pub severity: AlertSeverity,
pub subject: String,
pub body: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AlertSeverity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Session {
pub id: String,
pub subject: String,
#[serde(with = "time::serde::rfc3339")]
pub issued_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub expires_at: OffsetDateTime,
}
#[derive(Default)]
pub struct SessionStore {
inner: HashMap<String, Session>,
}
impl SessionStore {
pub fn insert(&mut self, s: Session) {
self.inner.insert(s.id.clone(), s);
}
pub fn get(&self, id: &str) -> Option<&Session> {
self.inner.get(id)
}
pub fn remove(&mut self, id: &str) {
self.inner.remove(id);
}
pub fn gc(&mut self, now: OffsetDateTime) {
self.inner.retain(|_, s| s.expires_at > now);
}
}
pub type SharedConfig = Arc<RwLock<RuntimeConfig>>;
pub type SharedScheduler = Arc<RwLock<SchedulerState>>;
pub type SharedAlerts = Arc<RwLock<Vec<AlertEntry>>>;
pub type SharedSessions = Arc<RwLock<SessionStore>>;
+20
View File
@@ -0,0 +1,20 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::ports::inbound::{HandleCaCallback, UseCaseError};
use crate::ports::outbound::StoragePort;
pub struct CallbackService {
pub storage: Arc<dyn StoragePort>,
}
#[async_trait]
impl HandleCaCallback for CallbackService {
async fn handle(&self, message_id: &str, cert_pem: &str) -> Result<(), UseCaseError> {
tracing::info!(message_id, len = cert_pem.len(), "callback received");
// TODO: pending lookup über message_id, dann update_certificate
let _ = self.storage.as_ref();
Ok(())
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod callback;
pub mod renew;
+32
View File
@@ -0,0 +1,32 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::ports::inbound::{RenewExpiringCertificates, UseCaseError};
use crate::ports::outbound::{
CertificateCaPort, ClockPort, HsmPort, NotificationPort, StoragePort,
};
pub struct RenewService {
pub storage: Arc<dyn StoragePort>,
pub ca: Arc<dyn CertificateCaPort>,
pub hsm: Arc<dyn HsmPort>,
pub clock: Arc<dyn ClockPort>,
pub notifier: Arc<dyn NotificationPort>,
}
#[async_trait]
impl RenewExpiringCertificates for RenewService {
async fn run(&self, days_window: u32) -> Result<usize, UseCaseError> {
let now = self.clock.now();
let expiring = self
.storage
.get_expiring_certificates(now, days_window)
.await
.map_err(|e| UseCaseError::Dependency(e.to_string()))?;
tracing::info!(count = expiring.len(), days_window, "renew scan");
// TODO: für jedes Cert: keypair -> CSR -> sign -> SOAP RequestCertificate
Ok(expiring.len())
}
}