init
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
target/
|
||||
.git/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,27 @@
|
||||
# Rust build artefacts
|
||||
target/
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
|
||||
# Local env overrides (real config lives in deploy/.env)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# SQLx offline cache (regenerate via `cargo sqlx prepare`)
|
||||
.sqlx-tmp/
|
||||
|
||||
# Local SQLite databases / data dirs created by `cargo run`
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
/data/
|
||||
|
||||
# Claude / editor / OS noise
|
||||
.claude/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
.DS_Store
|
||||
*.log
|
||||
Generated
+3399
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "smgw-pki-automator"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
axum = "0.7"
|
||||
async-trait = "0.1"
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
time = { version = "0.3", features = ["serde", "serde-well-known", "macros"] }
|
||||
|
||||
# Storage
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite", "time", "macros", "migrate"] }
|
||||
|
||||
# HSM (PKCS#11)
|
||||
cryptoki = "0.7"
|
||||
|
||||
# HTTP / SOAP
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "rustls-tls-native-roots"] }
|
||||
quick-xml = { version = "0.36", features = ["serialize"] }
|
||||
base64 = "0.22"
|
||||
|
||||
# Mail
|
||||
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder"] }
|
||||
|
||||
# Cron
|
||||
tokio-cron-scheduler = "0.11"
|
||||
|
||||
# OpenAPI / HTTP middleware
|
||||
utoipa = { version = "5", features = ["axum_extras", "time", "uuid"] }
|
||||
utoipa-axum = "0.1"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
@@ -0,0 +1,37 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Build stage — cached deps, then app sources.
|
||||
FROM rust:1-bookworm AS builder
|
||||
WORKDIR /app
|
||||
|
||||
ENV CARGO_TERM_COLOR=always
|
||||
ENV CARGO_INCREMENTAL=0
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
# Touch a stub main to allow dependency-only build for caching.
|
||||
RUN mkdir -p src && echo 'fn main() {}' > src/main.rs \
|
||||
&& cargo build --release \
|
||||
&& rm -rf src target/release/deps/smgw_pki_automator*
|
||||
|
||||
COPY src ./src
|
||||
COPY migrations ./migrations
|
||||
RUN cargo build --release
|
||||
|
||||
# Runtime stage — minimal Debian with TLS + xmlsec1 (for InitialConfigBuilder),
|
||||
# softhsm2 libs available at runtime when needed.
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
libssl3 \
|
||||
xmlsec1 \
|
||||
libsofthsm2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/smgw
|
||||
COPY --from=builder /app/target/release/smgw-pki-automator /usr/local/bin/smgw-pki-automator
|
||||
|
||||
ENV RUST_LOG=info
|
||||
ENV BIND_ADDR=0.0.0.0:8443
|
||||
EXPOSE 8443
|
||||
ENTRYPOINT ["/usr/local/bin/smgw-pki-automator"]
|
||||
@@ -0,0 +1,64 @@
|
||||
# smgw-pki-automator
|
||||
|
||||
Automatisierungs-Tool für die Smart-Meter-Gateway (SMGW) PKI-Prozesse in einer
|
||||
Test-/Labor-Umgebung. Erzeugt Schlüsselmaterial in einem HSM, beantragt
|
||||
Zertifikate bei einer Sub-CA gemäß **BSI TR-03129-4**, erzeugt signierte
|
||||
Initial-Konfigurationen gemäß **BSI TR-03109-1** und überwacht
|
||||
Zertifikatslaufzeiten gemäß den Vorgaben der SM-PKI Certificate Policy.
|
||||
|
||||
## Ziel
|
||||
|
||||
- Asynchrone `RequestCertificate`-Aufrufe an die Test-Sub-CA (mTLS, SOAP).
|
||||
- Asynchroner Callback-Endpunkt zur Annahme fertiger Zertifikate.
|
||||
- Generierung signierter `iconfig.xml` + `iconfig.sig`, verpackt in
|
||||
`iconfig.tar`.
|
||||
- Periodische Prüfung auf ablaufende Zertifikate (Standard: 30 Tage vor Ablauf)
|
||||
und automatische Erneuerung.
|
||||
- Alerting per SMTP bei Fehlern.
|
||||
|
||||
## Architektur
|
||||
|
||||
Hexagonale Architektur (Ports & Adapters). Die fachliche Kernlogik
|
||||
(`src/domain/`) kennt keine Infrastruktur. Sie spricht ausschließlich gegen
|
||||
Ports (`src/ports/`). Konkrete Implementierungen liegen in `src/adapters/`.
|
||||
|
||||
Details: [`../docs/architecture.md`](../docs/architecture.md).
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
src/
|
||||
├── domain/ Geschäftslogik, Entities (Certificate, Gateway)
|
||||
├── ports/ Traits (Inbound/Outbound) — Schnittstellen
|
||||
├── adapters/ Konkrete Impl.: HSM, Sub-CA, SQLite, SMTP, Clock
|
||||
├── builders/ Builder für SOAP-Requests und iconfig.xml
|
||||
├── app.rs Composition Root (Dependency Injection)
|
||||
└── main.rs Tokio-Runtime, Tracing, Boot
|
||||
```
|
||||
|
||||
## Dokumentation
|
||||
|
||||
- [`../docs/architecture.md`](../docs/architecture.md) — Hexagonale Architektur, Ports & Adapters, Datenflüsse.
|
||||
- [`../docs/bsi-compliance.md`](../docs/bsi-compliance.md) — Mapping BSI-Vorgaben → Code (TR-03129-4, TR-03109-1, SM-PKI CP).
|
||||
- [`../docs/development.md`](../docs/development.md) — Lokales Setup (SoftHSM2-Container, mTLS-Testzertifikate, Build & Run).
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
cargo check
|
||||
cargo run
|
||||
```
|
||||
|
||||
Für die volle Lab-Umgebung siehe [`../docs/development.md`](../docs/development.md).
|
||||
|
||||
## Status
|
||||
|
||||
Skeleton. Ports und Domäne stehen. Adapter sind Stubs, die `not implemented`
|
||||
zurückgeben. Reihenfolge der Umsetzung siehe
|
||||
[`../docs/architecture.md`](../docs/architecture.md#umsetzungsreihenfolge).
|
||||
|
||||
## Sicherheitshinweis
|
||||
|
||||
Dieses Tool ist **ausschließlich** für Test- und Labor-Umgebungen gedacht. Der
|
||||
SoftHSMv2 erfüllt die "Security Level 1"-Anforderung der SM-PKI CP nur für
|
||||
Entwicklungszwecke; für Produktion ist ein zertifiziertes HSM zwingend.
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Smart Meter Gateways under PKI management.
|
||||
CREATE TABLE IF NOT EXISTS gateways (
|
||||
id TEXT PRIMARY KEY,
|
||||
serial_number TEXT NOT NULL,
|
||||
admin_key_label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Issued end-entity certificates per gateway and usage.
|
||||
-- not_before/not_after stored as RFC3339 UTC text (sorts lexicographically).
|
||||
CREATE TABLE IF NOT EXISTS certificates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
gateway_id TEXT NOT NULL REFERENCES gateways(id) ON DELETE CASCADE,
|
||||
serial TEXT NOT NULL,
|
||||
usage TEXT NOT NULL CHECK (usage IN ('tls', 'signature', 'encryption')),
|
||||
pem TEXT NOT NULL,
|
||||
not_before TEXT NOT NULL,
|
||||
not_after TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (gateway_id, usage)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_certificates_not_after
|
||||
ON certificates (not_after);
|
||||
|
||||
-- TR-03129-4 messageID -> gateway_id lookup for asynchronous CA callbacks.
|
||||
CREATE TABLE IF NOT EXISTS pending_requests (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
gateway_id TEXT NOT NULL REFERENCES gateways(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_unresolved
|
||||
ON pending_requests (resolved_at)
|
||||
WHERE resolved_at IS NULL;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(¬_before_s, &Rfc3339).map_err(parse_err)?,
|
||||
not_after: OffsetDateTime::parse(¬_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));
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod clock;
|
||||
pub mod db;
|
||||
pub mod hsm;
|
||||
pub mod mail;
|
||||
pub mod sub_ca;
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod iconfig;
|
||||
pub mod soap_req;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Gateway {
|
||||
pub id: String,
|
||||
pub serial_number: String,
|
||||
pub admin_key_label: String,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod certificate;
|
||||
pub mod gateway;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
))
|
||||
}
|
||||
@@ -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"],
|
||||
}))
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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",
|
||||
))
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use axum::http::StatusCode;
|
||||
|
||||
pub async fn handler() -> (StatusCode, &'static str) {
|
||||
(StatusCode::OK, "ok")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod inbound;
|
||||
pub mod outbound;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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>>;
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod callback;
|
||||
pub mod renew;
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user