use std::net::IpAddr;
use std::sync::Arc;
use std::time::Duration;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use tracing::{debug, error, info};
use crate::config::{AuditConfig, DnsConfig};
use crate::dns::{HickoryResolver, Resolver, resolver_addr};
use crate::sqlite::audit::AuditEntry;
use crate::sqlite::db::Database;
pub mod admin;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditEvent {
CertificateIssued,
CertificateIssueFailed,
CertificateRevoked,
CertificateRevokeFailed,
AccountDeactivated,
AccountContactUpdated,
AccountDeleted,
OrderDeleted,
EabCreated,
EabRevoked,
OperatorCreated,
OperatorRoleChanged,
OperatorContactUpdated,
OperatorPasswordChanged,
OperatorDisabled,
OperatorEnabled,
OperatorDeleted,
OperatorTotpEnrolled,
OperatorTotpDisabled,
OperatorRecoveryCodesRegenerated,
SessionRevoked,
JobCancelled,
JobAdvanced,
NonceCleanupCompleted,
AuditPruned,
}
impl AuditEvent {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::CertificateIssued => "certificate_issued",
Self::CertificateIssueFailed => "certificate_issue_failed",
Self::CertificateRevoked => "certificate_revoked",
Self::CertificateRevokeFailed => "certificate_revoke_failed",
Self::AccountDeactivated => "account_deactivated",
Self::AccountContactUpdated => "account_contact_updated",
Self::AccountDeleted => "account_deleted",
Self::OrderDeleted => "order_deleted",
Self::EabCreated => "eab_created",
Self::EabRevoked => "eab_revoked",
Self::OperatorCreated => "operator_created",
Self::OperatorRoleChanged => "operator_role_changed",
Self::OperatorContactUpdated => "operator_contact_updated",
Self::OperatorPasswordChanged => "operator_password_changed",
Self::OperatorDisabled => "operator_disabled",
Self::OperatorEnabled => "operator_enabled",
Self::OperatorDeleted => "operator_deleted",
Self::OperatorTotpEnrolled => "operator_totp_enrolled",
Self::OperatorTotpDisabled => "operator_totp_disabled",
Self::OperatorRecoveryCodesRegenerated => "operator_recovery_codes_regenerated",
Self::SessionRevoked => "session_revoked",
Self::JobCancelled => "job_cancelled",
Self::JobAdvanced => "job_advanced",
Self::NonceCleanupCompleted => "nonce_cleanup_completed",
Self::AuditPruned => "audit_pruned",
}
}
#[must_use]
pub fn outcome(&self) -> &'static str {
match self {
Self::CertificateIssueFailed | Self::CertificateRevokeFailed => "failure",
Self::CertificateIssued
| Self::CertificateRevoked
| Self::AccountDeactivated
| Self::AccountContactUpdated
| Self::AccountDeleted
| Self::OrderDeleted
| Self::EabCreated
| Self::EabRevoked
| Self::OperatorCreated
| Self::OperatorRoleChanged
| Self::OperatorContactUpdated
| Self::OperatorPasswordChanged
| Self::OperatorDisabled
| Self::OperatorEnabled
| Self::OperatorDeleted
| Self::OperatorTotpEnrolled
| Self::OperatorTotpDisabled
| Self::OperatorRecoveryCodesRegenerated
| Self::SessionRevoked
| Self::JobCancelled
| Self::JobAdvanced
| Self::NonceCleanupCompleted
| Self::AuditPruned => "success",
}
}
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
ALL_AUDIT_EVENTS
.iter()
.copied()
.find(|event| event.as_str() == value)
}
}
pub const ALL_AUDIT_EVENTS: &[AuditEvent] = &[
AuditEvent::CertificateIssued,
AuditEvent::CertificateIssueFailed,
AuditEvent::CertificateRevoked,
AuditEvent::CertificateRevokeFailed,
AuditEvent::AccountDeactivated,
AuditEvent::AccountContactUpdated,
AuditEvent::AccountDeleted,
AuditEvent::OrderDeleted,
AuditEvent::EabCreated,
AuditEvent::EabRevoked,
AuditEvent::OperatorCreated,
AuditEvent::OperatorRoleChanged,
AuditEvent::OperatorContactUpdated,
AuditEvent::OperatorPasswordChanged,
AuditEvent::OperatorDisabled,
AuditEvent::OperatorEnabled,
AuditEvent::OperatorDeleted,
AuditEvent::OperatorTotpEnrolled,
AuditEvent::OperatorTotpDisabled,
AuditEvent::OperatorRecoveryCodesRegenerated,
AuditEvent::SessionRevoked,
AuditEvent::JobCancelled,
AuditEvent::JobAdvanced,
AuditEvent::NonceCleanupCompleted,
AuditEvent::AuditPruned,
];
const EVENT_COUNT: usize = 25;
const _: () = assert!(
ALL_AUDIT_EVENTS.len() == EVENT_COUNT,
"ALL_AUDIT_EVENTS and EVENT_COUNT disagree: a variant was added to one and not the other"
);
#[allow(dead_code)]
const fn event_count_is_exhaustive(event: AuditEvent) -> usize {
match event {
AuditEvent::CertificateIssued => 1,
AuditEvent::CertificateIssueFailed => 2,
AuditEvent::CertificateRevoked => 3,
AuditEvent::CertificateRevokeFailed => 4,
AuditEvent::AccountDeactivated => 5,
AuditEvent::AccountContactUpdated => 6,
AuditEvent::AccountDeleted => 7,
AuditEvent::OrderDeleted => 8,
AuditEvent::EabCreated => 9,
AuditEvent::EabRevoked => 10,
AuditEvent::OperatorCreated => 11,
AuditEvent::OperatorRoleChanged => 12,
AuditEvent::OperatorContactUpdated => 13,
AuditEvent::OperatorPasswordChanged => 14,
AuditEvent::OperatorDisabled => 15,
AuditEvent::OperatorEnabled => 16,
AuditEvent::OperatorDeleted => 17,
AuditEvent::OperatorTotpEnrolled => 18,
AuditEvent::OperatorTotpDisabled => 19,
AuditEvent::OperatorRecoveryCodesRegenerated => 20,
AuditEvent::SessionRevoked => 21,
AuditEvent::JobCancelled => 22,
AuditEvent::JobAdvanced => 23,
AuditEvent::NonceCleanupCompleted => 24,
AuditEvent::AuditPruned => EVENT_COUNT,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActorKind {
Acme,
Admin,
Cli,
System,
}
impl ActorKind {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Acme => "acme",
Self::Admin => "admin",
Self::Cli => "cli",
Self::System => "system",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Actor {
pub kind: ActorKind,
pub id: Option<String>,
}
impl Actor {
#[must_use]
pub fn acme(account_id: impl Into<String>) -> Self {
Self {
kind: ActorKind::Acme,
id: Some(account_id.into()),
}
}
#[must_use]
pub fn acme_certificate_key() -> Self {
Self {
kind: ActorKind::Acme,
id: None,
}
}
#[must_use]
pub fn admin(username: impl Into<String>) -> Self {
Self {
kind: ActorKind::Admin,
id: Some(username.into()),
}
}
#[must_use]
pub fn cli() -> Self {
let id = std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.ok()
.filter(|value| !value.is_empty());
Self {
kind: ActorKind::Cli,
id,
}
}
#[must_use]
pub fn system() -> Self {
Self {
kind: ActorKind::System,
id: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClientContext {
pub ip: Option<String>,
pub ptr: Option<String>,
pub user_agent: Option<String>,
pub request_id: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct RequestContext {
pub ip: Option<IpAddr>,
pub user_agent: Option<String>,
pub request_id: Option<String>,
}
pub(crate) const USER_AGENT_MAX: usize = 256;
impl RequestContext {
pub fn from_parts(parts: &Parts) -> Self {
Self::gather(&parts.headers, &parts.extensions)
}
pub fn from_request<B>(request: &axum::http::Request<B>) -> Self {
Self::gather(request.headers(), request.extensions())
}
fn gather(headers: &axum::http::HeaderMap, extensions: &axum::http::Extensions) -> Self {
let ip = extensions
.get::<crate::filter::ClientIp>()
.and_then(|client| client.0);
let user_agent = headers
.get(axum::http::header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(|value| value.chars().take(USER_AGENT_MAX).collect::<String>())
.filter(|value| !value.is_empty());
let request_id = extensions
.get::<crate::middlewares::access::RequestId>()
.map(|id| id.0.clone());
Self {
ip,
user_agent,
request_id,
}
}
}
impl<S: Send + Sync> FromRequestParts<S> for RequestContext {
type Rejection = std::convert::Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Ok(Self::from_parts(parts))
}
}
#[derive(Debug, Clone)]
pub struct AuditRecord {
pub event: AuditEvent,
pub profile: String,
pub actor: Actor,
pub account_id: Option<String>,
pub order_id: Option<String>,
pub cert_serial: Option<String>,
pub identifiers: Vec<String>,
pub client: ClientContext,
pub reason: Option<String>,
pub detail: Option<String>,
}
impl AuditRecord {
#[must_use]
pub fn new(event: AuditEvent, profile: impl Into<String>, actor: Actor) -> Self {
Self {
event,
profile: profile.into(),
actor,
account_id: None,
order_id: None,
cert_serial: None,
identifiers: Vec::new(),
client: ClientContext::default(),
reason: None,
detail: None,
}
}
#[must_use]
pub fn admin(event: AuditEvent, actor: Actor) -> Self {
Self::new(event, String::new(), actor)
}
#[must_use]
pub fn with_order(mut self, order: &crate::sqlite::order::Order) -> Self {
self.order_id = Some(order.id.clone().to_string());
self.account_id = Some(order.account_id.clone().to_string());
self.identifiers = order
.identifiers
.iter()
.map(|identifier| identifier.value.clone())
.collect();
self
}
#[must_use]
pub fn with_account(mut self, account_id: impl Into<String>) -> Self {
self.account_id = Some(account_id.into());
self
}
#[must_use]
pub fn with_serial(mut self, serial: impl Into<String>) -> Self {
self.cert_serial = Some(serial.into());
self
}
#[must_use]
pub fn with_client(mut self, client: ClientContext) -> Self {
self.client = client;
self
}
#[must_use]
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
#[must_use]
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
}
pub struct Auditor {
database: Arc<Database>,
resolver: Option<Arc<dyn Resolver>>,
ptr_timeout: Duration,
metrics: Option<Arc<crate::metrics::Metrics>>,
}
impl Auditor {
pub fn from_config(
cfg: &AuditConfig,
dns: &DnsConfig,
database: Arc<Database>,
metrics: Arc<crate::metrics::Metrics>,
) -> anyhow::Result<Self> {
let resolver: Option<Arc<dyn Resolver>> = if cfg.reverse_dns {
Some(Arc::new(match resolver_addr(dns)? {
Some(addr) => HickoryResolver::from_address(addr)
.map_err(|error| anyhow::anyhow!("audit.reverse_dns: {error}"))?,
None => HickoryResolver::from_system()
.map_err(|error| anyhow::anyhow!("audit.reverse_dns: {error}"))?,
}))
} else {
None
};
info!(
event = "audit_loaded",
outcome = "success",
reverse_dns = cfg.reverse_dns,
reverse_dns_timeout_ms = cfg.reverse_dns_timeout_ms,
retention_days = cfg.retention_days,
);
Ok(Self {
database,
resolver,
ptr_timeout: Duration::from_millis(cfg.reverse_dns_timeout_ms),
metrics: Some(metrics),
})
}
#[must_use]
pub fn with_resolver(
database: Arc<Database>,
resolver: Option<Arc<dyn Resolver>>,
ptr_timeout: Duration,
) -> Self {
Self {
database,
resolver,
ptr_timeout,
metrics: None,
}
}
pub async fn reverse(&self, ip: Option<IpAddr>) -> Option<String> {
let (resolver, ip) = (self.resolver.as_ref()?, ip?);
match tokio::time::timeout(self.ptr_timeout, resolver.reverse(ip)).await {
Ok(Ok(names)) => names.into_iter().next(),
Ok(Err(error)) => {
debug!(event = "audit_reverse_dns_failed", outcome = "failure", ip = %ip, error = %error);
None
}
Err(_) => {
debug!(
event = "audit_reverse_dns_timeout",
outcome = "failure",
ip = %ip,
timeout_ms = crate::millis(self.ptr_timeout),
);
None
}
}
}
pub async fn client(&self, request: &RequestContext) -> ClientContext {
let canonical = request.ip.map(crate::filter::canonical);
ClientContext {
ip: canonical.map(|ip| ip.to_string()),
ptr: self.reverse(canonical).await,
user_agent: request.user_agent.clone(),
request_id: request.request_id.clone(),
}
}
#[must_use]
pub fn with_metrics(mut self, metrics: Arc<crate::metrics::Metrics>) -> Self {
self.metrics = Some(metrics);
self
}
#[must_use]
pub fn metrics(&self) -> Option<&Arc<crate::metrics::Metrics>> {
self.metrics.as_ref()
}
pub async fn record(&self, record: AuditRecord) {
if let Some(metrics) = &self.metrics {
metrics.record_audit(&record);
}
write(record, &self.database).await;
}
}
pub async fn write(record: AuditRecord, database: &Database) {
let (event, profile) = (record.event, record.profile.clone());
let (order_id, serial) = (record.order_id.clone(), record.cert_serial.clone());
if let Err(error) = AuditEntry::insert(record, database).await {
error!(
event = "audit_write_failed",
outcome = "failure",
audit_event = event.as_str(),
profile = %profile,
order_id = ?order_id,
cert_serial = ?serial,
error = %error,
"the action succeeded but its audit row was not written"
);
}
}
impl std::fmt::Debug for Auditor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Auditor")
.field("reverse_dns", &self.resolver.is_some())
.field("ptr_timeout", &self.ptr_timeout)
.finish()
}
}
#[cfg(test)]
mod tests;