Skip to main content

acme_proxy_admin/webadmin/handlers/
misc.rs

1//! `/api/nonces` and `/api/profiles` — the two small read surfaces.
2//!
3//! Nonce values are bearer credentials, so `/api/nonces` answers a count and
4//! never a value. The profile list is what is *mounted*, which between an edit
5//! and its `SIGHUP` may differ from what the file says.
6
7use axum::Json;
8use axum::extract::State;
9use serde::Deserialize;
10use serde_json::{Value, json};
11use std::time::Duration;
12
13use crate::admin;
14use crate::webadmin::AdminState;
15use crate::webadmin::error::AdminError;
16use crate::webadmin::handlers::Caller;
17use crate::webadmin::session::{Authenticated, AuthenticatedWrite};
18use acme_proxy_store::nonce::Nonce;
19
20/// The optional body of `POST /api/nonces/cleanup`.
21#[derive(Debug, Deserialize, Default)]
22pub struct CleanupRequest {
23    /// Age past which a nonce is swept. Absent means `nonce.ttl_seconds`.
24    #[serde(rename = "ttlSeconds")]
25    pub ttl_seconds: Option<u64>,
26}
27
28/// `GET /api/nonces` — how many rows the table holds.
29///
30/// The shape is [`admin::render_nonce_stats_json`], which `nonce count --json`
31/// also answers with: a count is not worth two spellings.
32pub async fn get_nonces(
33    State(state): State<AdminState>,
34    _auth: Authenticated,
35) -> Result<Json<Value>, AdminError> {
36    let count = Nonce::count(&state.database).await?;
37    Ok(Json(admin::render_nonce_stats_json(
38        count,
39        state.config.nonce.ttl_seconds,
40    )))
41}
42
43/// `POST /api/nonces/cleanup` — sweep now, rather than waiting for the reaper.
44pub async fn cleanup_nonces(
45    State(state): State<AdminState>,
46    AuthenticatedWrite(auth): AuthenticatedWrite,
47    request_context: acme_proxy_core::audit::RequestContext,
48    body: Option<Json<CleanupRequest>>,
49) -> Result<Json<Value>, AdminError> {
50    let seconds = body
51        .and_then(|Json(body)| body.ttl_seconds)
52        .unwrap_or(state.config.nonce.ttl_seconds);
53
54    let removed =
55        apply_cleanup_nonces(&state, &Caller::api(&auth, &request_context), seconds).await?;
56    Ok(Json(json!({ "removed": removed })))
57}
58
59/// Deletes every nonce older than `seconds`, answering how many went.
60pub(crate) async fn apply_cleanup_nonces(
61    state: &AdminState,
62    caller: &Caller<'_>,
63    seconds: u64,
64) -> Result<u64, AdminError> {
65    let removed =
66        admin::cleanup_nonces(Duration::from_secs(seconds), state.database.clone()).await?;
67    // Only when it removed something, the rule `audit cleanup` follows: a
68    // sweep that changed nothing is not an administrative action worth a row.
69    if removed > 0 {
70        state
71            .record_admin_action(caller.request, caller.username(), |actor, client| {
72                acme_proxy_jobs::auditor::admin::nonce_cleanup_completed(actor, client, removed)
73            })
74            .await;
75    }
76    tracing::info!(event = "admin_nonces_cleaned",
77                   outcome = "success",
78                   surface = caller.surface,
79                   rows_removed = removed,
80                   ttl_seconds = seconds,
81                   username = %caller.username());
82    Ok(removed)
83}
84
85/// `GET /api/profiles` — the endpoints this process is serving.
86///
87/// Read straight from the mounted [`acme_proxy_protocol::profile::Profile`]s rather than from
88/// configuration, so it describes what is actually running: a profile parked
89/// with `enabled = false` is absent here, which is the honest answer.
90pub async fn list_profiles(State(state): State<AdminState>, _auth: Authenticated) -> Json<Value> {
91    Json(Value::Array(profile_rows(&state)))
92}
93
94/// The mounted endpoints, name-sorted.
95///
96/// Shared with the `/ui` pages, which show the same list on the overview, on
97/// its own page, and in the profile filter of every list -- one assembly, so
98/// the two front ends cannot come to describe an endpoint differently.
99pub(crate) fn profile_rows(state: &AdminState) -> Vec<Value> {
100    let mut names: Vec<&String> = state.profiles.keys().collect();
101    names.sort();
102
103    names
104        .into_iter()
105        .filter_map(|name| state.profiles.get(name))
106        .map(|profile| profile_row(profile))
107        .collect()
108}
109
110/// One endpoint, as every surface describes it.
111///
112/// Split out of [`profile_rows`] for the filter-policy page, which shows one
113/// endpoint rather than the list and still has to say the same things about it
114/// -- `challengeBypass` above all, since that page is where the warning
115/// matters most.
116///
117/// The document itself is [`admin::render_profile_json`], which
118/// `acme-proxy profile list` renders too. The two reach it from opposite
119/// directions -- a mounted profile here, a resolved configuration there -- and
120/// [`admin::ProfileSummary`] is where that difference is written down.
121pub(crate) fn profile_row(profile: &acme_proxy_protocol::profile::Profile) -> Value {
122    admin::render_profile_json(&admin::ProfileSummary::mounted(profile))
123}