acme_proxy/audit/mod.rs
1//! The CA's audit trail: who asked this server to sign or withdraw a
2//! certificate, from where, and how it ended.
3//!
4//! Three things live here, and they are deliberately one module rather than
5//! three:
6//!
7//! - the **vocabulary** ([`AuditEvent`], [`Actor`], [`AuditRecord`]) every call
8//! site builds a row from;
9//! - the **reverse lookup**, which is the only part that touches the network and
10//! the only part an operator can switch off (`audit.reverse_dns`);
11//! - the **write**, which is best-effort by design — see [`Auditor::record`].
12//!
13//! ## Why this is not [`notify`](crate::notify)
14//!
15//! The two fire at nearly the same call sites and carry nearly the same fields,
16//! which invites merging them. They answer different questions. A notification
17//! is *outbound and lossy*: it goes to a chat room, it is fire-and-forget, and a
18//! backend that is down loses the event with a warning. An audit row is
19//! *inbound and durable*: it is the record the CA is answerable for, it is
20//! queried months later by serial or by account, and it exists for the events
21//! nobody wants a notification about — the refusals. Notifications also fire for
22//! things that never touch the CA (`account_created`, `challenge_failed`), and
23//! the audit trail records things nothing is notified about. Sharing a type
24//! would mean every future field arguing about which of the two it is for.
25
26use std::net::IpAddr;
27use std::sync::Arc;
28use std::time::Duration;
29
30use axum::extract::FromRequestParts;
31use axum::http::request::Parts;
32use tracing::{debug, error, info};
33
34use crate::config::{AuditConfig, DnsConfig};
35use crate::dns::{HickoryResolver, Resolver, resolver_addr};
36use crate::sqlite::audit::AuditEntry;
37use crate::sqlite::db::Database;
38
39/// The four things this trail records: each CA action, and its refusal.
40///
41/// A refusal is an audit record in its own right. "Who tried to revoke this
42/// certificate and was turned away" is the question the successes cannot
43/// answer, and it is the one asked after something has gone wrong.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum AuditEvent {
46 CertificateIssued,
47 CertificateIssueFailed,
48 CertificateRevoked,
49 CertificateRevokeFailed,
50}
51
52impl AuditEvent {
53 /// The stored form, matching the `CHECK` in
54 /// `migrations/20260809120000_add_audit_log.sql`.
55 #[must_use]
56 pub fn as_str(&self) -> &'static str {
57 match self {
58 Self::CertificateIssued => "certificate_issued",
59 Self::CertificateIssueFailed => "certificate_issue_failed",
60 Self::CertificateRevoked => "certificate_revoked",
61 Self::CertificateRevokeFailed => "certificate_revoke_failed",
62 }
63 }
64
65 /// `success` or `failure`, and the **only** definition of which is which.
66 ///
67 /// The column exists so "show me everything that was refused" is an index
68 /// lookup rather than `event LIKE '%_failed'` written out in the CLI, the
69 /// API and the page. Deriving it here rather than at each insert is what
70 /// stops the two columns ever disagreeing.
71 #[must_use]
72 pub fn outcome(&self) -> &'static str {
73 match self {
74 Self::CertificateIssued | Self::CertificateRevoked => "success",
75 Self::CertificateIssueFailed | Self::CertificateRevokeFailed => "failure",
76 }
77 }
78
79 /// Parses the stored form back. `None` for anything the `CHECK` would have
80 /// refused, which is also how the CLI validates `--event`.
81 #[must_use]
82 pub fn parse(value: &str) -> Option<Self> {
83 Some(match value {
84 "certificate_issued" => Self::CertificateIssued,
85 "certificate_issue_failed" => Self::CertificateIssueFailed,
86 "certificate_revoked" => Self::CertificateRevoked,
87 "certificate_revoke_failed" => Self::CertificateRevokeFailed,
88 _ => return None,
89 })
90 }
91}
92
93/// Every [`AuditEvent`], for the CLI's `--event` help text and the page's filter.
94pub const ALL_AUDIT_EVENTS: &[AuditEvent] = &[
95 AuditEvent::CertificateIssued,
96 AuditEvent::CertificateIssueFailed,
97 AuditEvent::CertificateRevoked,
98 AuditEvent::CertificateRevokeFailed,
99];
100
101/// Which front end acted.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum ActorKind {
104 /// A certificate client over the ACME API.
105 Acme,
106 /// An operator through the web admin.
107 Admin,
108 /// `acme-proxy order revoke` on the host.
109 Cli,
110 /// The `relay` signer's background task, settling an issuance this
111 /// server already answered `processing`. The one actor with no request
112 /// behind it, and therefore no address — see [`Actor::system`].
113 System,
114}
115
116impl ActorKind {
117 #[must_use]
118 pub fn as_str(&self) -> &'static str {
119 match self {
120 Self::Acme => "acme",
121 Self::Admin => "admin",
122 Self::Cli => "cli",
123 Self::System => "system",
124 }
125 }
126}
127
128/// Who acted, and their identity within that kind.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct Actor {
131 pub kind: ActorKind,
132 pub id: Option<String>,
133}
134
135impl Actor {
136 /// An ACME client acting as a known account.
137 #[must_use]
138 pub fn acme(account_id: impl Into<String>) -> Self {
139 Self {
140 kind: ActorKind::Acme,
141 id: Some(account_id.into()),
142 }
143 }
144
145 /// An ACME client that proved possession of the certificate's own key pair
146 /// and named no account — RFC 8555 §7.6's accountless revocation.
147 ///
148 /// The `None` is the honest answer and not a gap: there is no identity to
149 /// record beyond "whoever holds this certificate's private key", which the
150 /// `cert_serial` on the same row already says.
151 #[must_use]
152 pub fn acme_certificate_key() -> Self {
153 Self {
154 kind: ActorKind::Acme,
155 id: None,
156 }
157 }
158
159 /// An operator signed in to the web admin.
160 #[must_use]
161 pub fn admin(username: impl Into<String>) -> Self {
162 Self {
163 kind: ActorKind::Admin,
164 id: Some(username.into()),
165 }
166 }
167
168 /// The command line, identified by whichever of `$USER`/`$LOGNAME` is set.
169 ///
170 /// Advisory only, and unavoidably so: anything running this binary can set
171 /// those variables. It narrows "somebody on the host" to "somebody on the
172 /// host, probably this account", which is the most a process can say about
173 /// its own invoker without help from the audit subsystem of the OS.
174 #[must_use]
175 pub fn cli() -> Self {
176 let id = std::env::var("USER")
177 .or_else(|_| std::env::var("LOGNAME"))
178 .ok()
179 .filter(|value| !value.is_empty());
180 Self {
181 kind: ActorKind::Cli,
182 id,
183 }
184 }
185
186 /// This server's own background work.
187 #[must_use]
188 pub fn system() -> Self {
189 Self {
190 kind: ActorKind::System,
191 id: None,
192 }
193 }
194}
195
196/// The request a row came from: address, its reverse name, and the two headers
197/// worth keeping.
198///
199/// Entirely empty for [`ActorKind::Cli`] and [`ActorKind::System`], which is
200/// why every field is optional rather than a placeholder string — "there was no
201/// client" and "the client sent no User-Agent" are both `None`, and the
202/// `actor_kind` on the row already tells them apart.
203#[derive(Debug, Clone, Default, PartialEq, Eq)]
204pub struct ClientContext {
205 pub ip: Option<String>,
206 pub ptr: Option<String>,
207 pub user_agent: Option<String>,
208 pub request_id: Option<String>,
209}
210
211/// What a request carries before the reverse lookup has run.
212///
213/// An extractor rather than three `Extension`s at each call site: a handler
214/// that records an audit row wants all of this or none of it, and gathering it
215/// in one place is what keeps `User-Agent`'s truncation rule (below) from being
216/// re-decided per handler. Resolve it into a [`ClientContext`] with
217/// [`Auditor::client`].
218#[derive(Debug, Clone, Default)]
219pub struct RequestContext {
220 pub ip: Option<IpAddr>,
221 pub user_agent: Option<String>,
222 pub request_id: Option<String>,
223}
224
225/// Longest `User-Agent` kept. Real ones are well under this; the header is
226/// attacker-controlled and ends up in a database column and an HTML page, so it
227/// gets a ceiling rather than trust.
228const USER_AGENT_MAX: usize = 256;
229
230impl RequestContext {
231 /// Reads the address the filter middleware resolved, plus the two headers.
232 ///
233 /// Free of the request body, so this composes with `AcmeRequest<T>` — which
234 /// consumes it — in the usual axum order.
235 pub fn from_parts(parts: &Parts) -> Self {
236 Self::gather(&parts.headers, &parts.extensions)
237 }
238
239 /// Same, from a whole request.
240 ///
241 /// `verify_jws` needs this: it is handed the `Request` and consumes it into
242 /// a body string, so it has to read the context *before* the point where
243 /// the extractor machinery would hand it `Parts`.
244 pub fn from_request<B>(request: &axum::http::Request<B>) -> Self {
245 Self::gather(request.headers(), request.extensions())
246 }
247
248 fn gather(headers: &axum::http::HeaderMap, extensions: &axum::http::Extensions) -> Self {
249 let ip = extensions
250 .get::<crate::filter::ClientIp>()
251 .and_then(|client| client.0);
252 let user_agent = headers
253 .get(axum::http::header::USER_AGENT)
254 .and_then(|value| value.to_str().ok())
255 .map(|value| value.chars().take(USER_AGENT_MAX).collect::<String>())
256 .filter(|value| !value.is_empty());
257 let request_id = extensions
258 .get::<crate::middlewares::access::RequestId>()
259 .map(|id| id.0.clone());
260 Self {
261 ip,
262 user_agent,
263 request_id,
264 }
265 }
266}
267
268impl<S: Send + Sync> FromRequestParts<S> for RequestContext {
269 type Rejection = std::convert::Infallible;
270
271 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
272 Ok(Self::from_parts(parts))
273 }
274}
275
276/// One row, before it is written.
277///
278/// Built with [`AuditRecord::new`] plus the `with_*` setters rather than a
279/// struct literal: the four events populate different subsets — an issuance
280/// failure has no serial, an accountless revocation has no account — and a
281/// literal would mean a column of `None`s at every call site.
282#[derive(Debug, Clone)]
283pub struct AuditRecord {
284 pub event: AuditEvent,
285 pub profile: String,
286 pub actor: Actor,
287 pub account_id: Option<String>,
288 pub order_id: Option<String>,
289 pub cert_serial: Option<String>,
290 pub identifiers: Vec<String>,
291 pub client: ClientContext,
292 pub reason: Option<String>,
293 pub detail: Option<String>,
294}
295
296impl AuditRecord {
297 #[must_use]
298 pub fn new(event: AuditEvent, profile: impl Into<String>, actor: Actor) -> Self {
299 Self {
300 event,
301 profile: profile.into(),
302 actor,
303 account_id: None,
304 order_id: None,
305 cert_serial: None,
306 identifiers: Vec::new(),
307 client: ClientContext::default(),
308 reason: None,
309 detail: None,
310 }
311 }
312
313 /// Fills in the subject from the order: its id, its account and the names
314 /// it covers, the last frozen into the row rather than joined back — the
315 /// order may be deleted long before the row is read.
316 #[must_use]
317 pub fn with_order(mut self, order: &crate::sqlite::order::Order) -> Self {
318 self.order_id = Some(order.id.clone());
319 self.account_id = Some(order.account_id.clone());
320 self.identifiers = order
321 .identifiers
322 .iter()
323 .map(|identifier| identifier.value.clone())
324 .collect();
325 self
326 }
327
328 #[must_use]
329 pub fn with_account(mut self, account_id: impl Into<String>) -> Self {
330 self.account_id = Some(account_id.into());
331 self
332 }
333
334 #[must_use]
335 pub fn with_serial(mut self, serial: impl Into<String>) -> Self {
336 self.cert_serial = Some(serial.into());
337 self
338 }
339
340 #[must_use]
341 pub fn with_client(mut self, client: ClientContext) -> Self {
342 self.client = client;
343 self
344 }
345
346 /// The RFC 8555 problem type on a refusal, or the RFC 5280 reason code on a
347 /// revocation. Never both — see the column comment in the migration.
348 #[must_use]
349 pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
350 self.reason = Some(reason.into());
351 self
352 }
353
354 #[must_use]
355 pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
356 self.detail = Some(detail.into());
357 self
358 }
359}
360
361/// Writes audit rows, and resolves the reverse names that go in them.
362///
363/// One per process, shared by the ACME listener ([`crate::AppState`]), the web
364/// admin ([`crate::webadmin::AdminState`]) and the CLI. Process-wide because
365/// `[audit]` is: the trail describes the CA, not one of its endpoints.
366pub struct Auditor {
367 database: Arc<Database>,
368 /// `None` when `audit.reverse_dns` is off, which is what makes the switch
369 /// structural: there is no resolver to call rather than a boolean checked
370 /// at each call site. Same shape as `ChallengeRegistry`'s bypass flag
371 /// refusing to *construct* the validators.
372 resolver: Option<Arc<dyn Resolver>>,
373 ptr_timeout: Duration,
374 /// The process's Prometheus counters.
375 ///
376 /// `None` only for an auditor built through [`Auditor::with_resolver`],
377 /// which is test scaffolding. The serving path goes through
378 /// [`Auditor::from_config`], where it is a **required argument** rather
379 /// than a builder step — see that constructor.
380 metrics: Option<Arc<crate::metrics::Metrics>>,
381}
382
383impl Auditor {
384 /// Builds the auditor, and with it the **cached** resolver its PTR lookups
385 /// go through.
386 ///
387 /// Cached, unlike the shared resolver `Profile::build_all` threads through
388 /// the challenge and signer subsystems, and for the reason
389 /// `filter::reverse_dns` makes the same choice: a PTR record for an address
390 /// that keeps connecting is exactly what a cache is for, and there is no
391 /// just-published-record problem here — the answer being a few minutes old
392 /// is not a failure mode for a column that says "the name this address had
393 /// at the time".
394 ///
395 /// A second cached resolver rather than sharing `reverse_dns`'s: that one
396 /// is per-profile and built only when the filter is enabled, and reaching
397 /// across for it would tie the audit trail's completeness to whether an
398 /// unrelated filter happens to be switched on.
399 /// `metrics` is a required argument and deliberately not a builder step.
400 /// It was one, briefly, and the omission it invited happened immediately:
401 /// the serving path built its auditor without ever calling the builder, so
402 /// `acme_proxy_certificates_issued_total` stayed at zero in production
403 /// while every test passed — the test harness wired the registry itself, so
404 /// what the tests proved was the harness's wiring and not the server's. A
405 /// parameter cannot be forgotten. Test scaffolding that genuinely has no
406 /// registry uses [`Auditor::with_resolver`] instead.
407 pub fn from_config(
408 cfg: &AuditConfig,
409 dns: &DnsConfig,
410 database: Arc<Database>,
411 metrics: Arc<crate::metrics::Metrics>,
412 ) -> anyhow::Result<Self> {
413 let resolver: Option<Arc<dyn Resolver>> = if cfg.reverse_dns {
414 Some(Arc::new(match resolver_addr(dns)? {
415 Some(addr) => HickoryResolver::from_address(addr)
416 .map_err(|error| anyhow::anyhow!("audit.reverse_dns: {error}"))?,
417 None => HickoryResolver::from_system()
418 .map_err(|error| anyhow::anyhow!("audit.reverse_dns: {error}"))?,
419 }))
420 } else {
421 None
422 };
423 info!(
424 event = "audit_loaded",
425 outcome = "success",
426 reverse_dns = cfg.reverse_dns,
427 reverse_dns_timeout_ms = cfg.reverse_dns_timeout_ms,
428 retention_days = cfg.retention_days,
429 );
430 Ok(Self {
431 database,
432 resolver,
433 ptr_timeout: Duration::from_millis(cfg.reverse_dns_timeout_ms),
434 metrics: Some(metrics),
435 })
436 }
437
438 /// Same, against a caller-supplied resolver — or none, for the reverse
439 /// lookup switched off. Used by tests and by [`Self::from_config`].
440 #[must_use]
441 pub fn with_resolver(
442 database: Arc<Database>,
443 resolver: Option<Arc<dyn Resolver>>,
444 ptr_timeout: Duration,
445 ) -> Self {
446 Self {
447 database,
448 resolver,
449 ptr_timeout,
450 metrics: None,
451 }
452 }
453
454 /// The reverse name for `ip`, or `None`.
455 ///
456 /// Every failure is `None`: no PTR record, a resolver that timed out, a
457 /// SERVFAIL, `audit.reverse_dns` off, or no client address at all. Nothing
458 /// downstream distinguishes them, because nothing downstream *authorises*
459 /// on this value — it is a label on a row, and a label that is sometimes
460 /// missing is worth more than a request that failed to get one.
461 ///
462 /// The first name only when several PTR records answer. Storing all of them
463 /// would make the column a list nothing queries; `filter.reverse_dns` is
464 /// where multiple candidates genuinely matter, and it looks them up itself.
465 pub async fn reverse(&self, ip: Option<IpAddr>) -> Option<String> {
466 let (resolver, ip) = (self.resolver.as_ref()?, ip?);
467 match tokio::time::timeout(self.ptr_timeout, resolver.reverse(ip)).await {
468 Ok(Ok(names)) => names.into_iter().next(),
469 Ok(Err(error)) => {
470 debug!(event = "audit_reverse_dns_failed", outcome = "failure", ip = %ip, error = %error);
471 None
472 }
473 Err(_) => {
474 debug!(
475 event = "audit_reverse_dns_timeout",
476 outcome = "failure",
477 ip = %ip,
478 timeout_ms = crate::millis(self.ptr_timeout),
479 );
480 None
481 }
482 }
483 }
484
485 /// Resolves a [`RequestContext`] into the [`ClientContext`] a row stores,
486 /// running the reverse lookup on the way.
487 pub async fn client(&self, request: &RequestContext) -> ClientContext {
488 let canonical = request.ip.map(crate::filter::canonical);
489 ClientContext {
490 ip: canonical.map(|ip| ip.to_string()),
491 ptr: self.reverse(canonical).await,
492 user_agent: request.user_agent.clone(),
493 request_id: request.request_id.clone(),
494 }
495 }
496
497 /// Attaches the Prometheus registry to an auditor built by
498 /// [`Auditor::with_resolver`].
499 ///
500 /// Exists for the test harness, which builds its auditor with a stub
501 /// resolver and still wants the counters. The serving path does **not** use
502 /// this — [`Auditor::from_config`] takes the registry as a parameter, so it
503 /// cannot be left off.
504 #[must_use]
505 pub fn with_metrics(mut self, metrics: Arc<crate::metrics::Metrics>) -> Self {
506 self.metrics = Some(metrics);
507 self
508 }
509
510 /// Writes one row, and counts it.
511 ///
512 /// The counter is driven off the *same* [`AuditRecord`] that is about to be
513 /// stored, which is what makes "how many certificates did we issue" answer
514 /// identically whether it is asked of the metrics endpoint or of
515 /// `acme-proxy audit list`. A second set of call sites incrementing
516 /// counters beside the audit writes would have been free to drift.
517 ///
518 /// See [`write()`], which this is the stateful spelling of.
519 pub async fn record(&self, record: AuditRecord) {
520 if let Some(metrics) = &self.metrics {
521 metrics.record_audit(&record);
522 }
523 write(record, &self.database).await;
524 }
525}
526
527/// Writes one row against a bare database handle.
528///
529/// The free function exists for the `relay` backend: it settles an issuance
530/// from a background task that holds an `Arc<Database>` and no [`Auditor`], and
531/// it needs no reverse lookup either — the address it records was resolved
532/// during the finalize request and stored on the `upstream_orders` row. Giving
533/// that task an `Auditor` would have meant threading one through
534/// `signer::build_backends` and `Profile::build_all` for the sake of a resolver
535/// it would never call.
536///
537/// **A failed write is logged and swallowed.** The alternative — failing the
538/// request — would turn a certificate this CA has already signed into a 500 the
539/// client retries, issuing a second one, which is a worse outcome for the same
540/// underlying fault. It is also nearly unreachable in practice: this is the
541/// same SQLite file the order was just written to, so a failure here means the
542/// write that preceded it had already failed. The `error!` carries the record's
543/// identifying fields, so the trail survives in the log even when the table did
544/// not get it.
545pub async fn write(record: AuditRecord, database: &Database) {
546 let (event, profile) = (record.event, record.profile.clone());
547 let (order_id, serial) = (record.order_id.clone(), record.cert_serial.clone());
548 if let Err(error) = AuditEntry::insert(record, database).await {
549 error!(
550 event = "audit_write_failed",
551 outcome = "failure",
552 audit_event = event.as_str(),
553 profile = %profile,
554 order_id = ?order_id,
555 cert_serial = ?serial,
556 error = %error,
557 "the action succeeded but its audit row was not written"
558 );
559 }
560}
561
562impl std::fmt::Debug for Auditor {
563 /// Renders the configured policy. `dyn Resolver` is not `Debug`, so the
564 /// resolver shows as whether there is one — which is the whole of what it
565 /// contributes to behaviour here.
566 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
567 f.debug_struct("Auditor")
568 .field("reverse_dns", &self.resolver.is_some())
569 .field("ptr_timeout", &self.ptr_timeout)
570 .finish()
571 }
572}
573
574#[cfg(test)]
575mod tests;