use base64::prelude::*;
use super::style::Palette;
use crate::admin::ops::OrderDetail;
use crate::sqlite::account::{Account, pubkey_fingerprint};
use crate::sqlite::admin_session::AdminSession;
use crate::sqlite::admin_user::AdminUser;
use crate::sqlite::audit::AuditEntry;
use crate::sqlite::eab::Eab;
use crate::sqlite::order::{Order, rfc3339};
fn render_client(ip: Option<&String>, ptr: Option<&String>) -> String {
match (ip, ptr) {
(Some(ip), Some(ptr)) => format!("{ip} ({ptr})"),
(Some(ip), None) => ip.clone(),
_ => "-".to_string(),
}
}
#[must_use]
pub fn render_account_line(account: &Account, palette: Palette) -> String {
format!(
"{} {:<12} {} {:<40} {} {}",
account.id,
account.profile,
palette.status(&format!("{:<11}", account.status)),
render_client(
account.last_seen_ip.as_ref(),
account.last_seen_ptr.as_ref()
),
if account.contact.is_empty() {
"-".to_string()
} else {
account.contact.join(",")
},
rfc3339(account.created_at),
)
}
#[must_use]
pub fn render_account_detail_text(account: &Account, palette: Palette) -> String {
let mut out = format!(
"id {}\nprofile {}\nstatus {}\npubkey {}\ncreated {}\n",
account.id,
account.profile,
palette.status(&account.status),
pubkey_fingerprint(&account.pubkey),
rfc3339(account.created_at),
);
if !account.contact.is_empty() {
out.push_str(&format!("contact {}\n", account.contact.join(",")));
}
if let Some(agreed) = account.terms_of_service_agreed {
out.push_str(&format!("terms {agreed}\n"));
}
if let Some(seen) = account.last_seen_at {
out.push_str(&format!("last_seen {}\n", rfc3339(seen)));
}
for (label, value) in [
("eab_kid", account.eab_kid.as_ref()),
("created_ip", account.created_ip.as_ref()),
("created_ptr", account.created_ptr.as_ref()),
("last_seen_ip", account.last_seen_ip.as_ref()),
("last_seen_ptr", account.last_seen_ptr.as_ref()),
] {
if let Some(value) = value {
out.push_str(&format!("{label:<13} {value}\n"));
}
}
out
}
fn paint_event(event: &str, width: usize, palette: Palette) -> String {
let padded = format!("{event:<width$}");
if event.ends_with("_failed") {
palette.bad(&padded)
} else {
padded
}
}
#[must_use]
pub fn render_audit_line(entry: &AuditEntry, palette: Palette) -> String {
let actor = match &entry.actor_id {
Some(id) => format!("{}:{id}", entry.actor_kind),
None => entry.actor_kind.clone(),
};
let client = render_client(entry.client_ip.as_ref(), entry.client_ptr.as_ref());
let mut line = format!(
"{:<8} {} {} {:<12} {:<24} {:<40} {}",
entry.id,
rfc3339(entry.created_at),
paint_event(&entry.event, 26, palette),
entry.profile,
actor,
client,
entry.identifiers.join(","),
);
if let Some(reason) = &entry.reason {
line.push_str(&format!(" reason={reason}"));
}
line
}
#[must_use]
pub fn render_audit_detail_text(entry: &AuditEntry, palette: Palette) -> String {
let mut out = format!(
"id {}\ncreated {}\nevent {}\noutcome {}\nprofile {}\nactor {}\n",
entry.id,
rfc3339(entry.created_at),
paint_event(&entry.event, 0, palette),
palette.status(&entry.outcome),
entry.profile,
match &entry.actor_id {
Some(id) => format!("{}:{id}", entry.actor_kind),
None => entry.actor_kind.clone(),
},
);
for (label, value) in [
("account", entry.account_id.as_ref()),
("order", entry.order_id.as_ref()),
("serial", entry.cert_serial.as_ref()),
("client_ip", entry.client_ip.as_ref()),
("client_ptr", entry.client_ptr.as_ref()),
("user_agent", entry.user_agent.as_ref()),
("request_id", entry.request_id.as_ref()),
("reason", entry.reason.as_ref()),
("detail", entry.detail.as_ref()),
] {
if let Some(value) = value {
out.push_str(&format!("{label:<12} {value}\n"));
}
}
if !entry.identifiers.is_empty() {
out.push_str(&format!("identifiers {}\n", entry.identifiers.join(",")));
}
out
}
#[must_use]
pub fn render_order_line(order: &Order, palette: Palette) -> String {
let identifiers = order
.identifiers
.iter()
.map(|i| i.value.as_str())
.collect::<Vec<_>>()
.join(",");
let mut line = format!(
"{} {:<12} {} {} {}",
order.id,
order.profile,
palette.status(&format!("{:<9}", order.status)),
identifiers,
rfc3339(order.created_at)
);
if let Some(revoked_at) = order.revoked_at {
line.push_str(&palette.bad(&format!(
" revoked={}{}",
rfc3339(revoked_at),
order
.revocation_reason
.map(|r| format!(" reason={r}"))
.unwrap_or_default()
)));
}
line
}
#[must_use]
pub fn render_order_detail_text(detail: &OrderDetail, palette: Palette) -> String {
let mut out = format!(
"id: {}\nprofile: {}\naccount_id: {}\nstatus: {}\nidentifiers: {}\nexpires: {}\n",
detail.order.id,
detail.order.profile,
detail.order.account_id,
palette.status(&detail.order.status.to_string()),
detail
.order
.identifiers
.iter()
.map(|i| i.value.as_str())
.collect::<Vec<_>>()
.join(","),
rfc3339(detail.order.expires),
);
for (authz, challenges) in &detail.authorizations {
out.push_str(&format!(
" authz {} [{}] {}\n",
authz.id,
palette.status(&authz.status.to_string()),
authz.identifier.value
));
for challenge in challenges {
out.push_str(&format!(
" challenge {} [{}] type={}\n",
challenge.id,
palette.status(&challenge.status.to_string()),
challenge.typ
));
}
}
out
}
#[must_use]
pub fn render_eab_line(eab: &Eab, palette: Palette) -> String {
format!(
"{} {} {} {}",
eab.kid,
palette.status(&format!("{:<8}", eab.status)),
eab.label.as_deref().unwrap_or("-"),
rfc3339(eab.created_at),
)
}
#[must_use]
pub fn render_eab_created_text(eab: &Eab, palette: Palette) -> String {
format!(
"kid: {}\nhmacKey: {}\nlabel: {}\n\n{}\n",
eab.kid,
BASE64_URL_SAFE_NO_PAD.encode(&eab.secret),
eab.label.as_deref().unwrap_or("-"),
palette.warn("Store the hmacKey now: it is shown only this once."),
)
}
#[must_use]
pub fn render_admin_user_line(user: &AdminUser, palette: Palette) -> String {
format!(
"{:<20} {} totp={} {} {}",
user.username,
palette.status(&format!("{:<8}", user.status)),
palette.status(&format!(
"{:<3}",
if user.has_totp() { "on" } else { "off" }
)),
rfc3339(user.created_at),
user.last_login_at.map_or("never".to_string(), rfc3339),
)
}
#[must_use]
pub fn render_admin_totp_line(
user: &AdminUser,
recovery_codes_remaining: i64,
palette: Palette,
) -> String {
let state = if user.has_totp() {
palette.status("enabled")
} else if user.has_pending_totp() {
palette.warn("pending (enrolment started, never confirmed)")
} else {
palette.status("off")
};
format!(
"{:<20} totp={} recovery-codes={}",
user.username, state, recovery_codes_remaining
)
}
#[must_use]
pub fn render_admin_session_line(session: &AdminSession, palette: Palette) -> String {
format!(
"{} {} {} {} expires={} {}",
crate::sqlite::nonce::fingerprint(&session.token_hash),
session.user_id,
palette.status(&format!("{:<11}", session.state)),
rfc3339(session.created_at),
rfc3339(session.expires_at),
session.created_ip.as_deref().unwrap_or("-"),
)
}
pub fn print_rows<T>(
rows: &[T],
json: bool,
to_json: impl Fn(&T) -> serde_json::Value,
to_line: impl Fn(&T) -> String,
) {
if json {
let rendered: Vec<_> = rows.iter().map(to_json).collect();
println!("{}", serde_json::Value::Array(rendered));
} else {
for row in rows {
println!("{}", to_line(row));
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::admin::ops::load_order_detail;
use crate::audit::ClientContext;
use crate::cli::style::ColorChoice;
use crate::sqlite::authz::{Authorization, Challenge};
use crate::sqlite::db::Database;
use crate::sqlite::order::Identifier;
use crate::sqlite::status::OrderStatus;
use crate::testutil::{
account_id, account_seen_from, admin_session_fixture, admin_user_fixture, audit_entry,
client_context, order_fixture,
};
fn colour() -> Palette {
Palette::resolve(ColorChoice::Always, false, None)
}
fn strip_ansi(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find('\x1b') {
out.push_str(&rest[..start]);
let Some(end) = rest[start..].find('m') else {
break;
};
rest = &rest[start + end + 1..];
}
out.push_str(rest);
out
}
#[test]
fn the_audit_line_renders_all_three_shapes_of_client() {
let entry = audit_entry();
let line = render_audit_line(&entry, Palette::plain());
assert!(line.contains("41812"), "{line}");
assert!(line.contains("certificate_issued"), "{line}");
assert!(line.contains("acme:acct-1"), "{line}");
assert!(line.contains("203.0.113.7 (host.example.com)"), "{line}");
assert!(line.contains("a.example.com,b.example.com"), "{line}");
assert!(!line.contains("reason="), "{line}");
let mut no_ptr = audit_entry();
no_ptr.client_ptr = None;
let line = render_audit_line(&no_ptr, Palette::plain());
assert!(line.contains("203.0.113.7"), "{line}");
assert!(!line.contains('('), "{line}");
let mut cli = audit_entry();
cli.actor_kind = "cli".to_string();
cli.actor_id = None;
cli.client_ip = None;
cli.client_ptr = None;
cli.event = "certificate_revoked".to_string();
cli.reason = Some("1".to_string());
let line = render_audit_line(&cli, Palette::plain());
assert!(line.contains(" cli "), "{line}");
assert!(
!line.contains("cli:"),
"an actor with no id must not render a trailing colon: {line}"
);
assert!(line.contains(" - "), "{line}");
assert!(line.ends_with("reason=1"), "{line}");
}
#[test]
fn only_a_failed_audit_event_is_painted() {
let succeeded = render_audit_line(&audit_entry(), colour());
assert!(!succeeded.contains('\x1b'), "{succeeded}");
let mut refused = audit_entry();
refused.event = "certificate_issue_failed".to_string();
refused.outcome = "failure".to_string();
let line = render_audit_line(&refused, colour());
assert!(line.contains("\x1b[31mcertificate_issue_failed"), "{line}");
assert_eq!(
strip_ansi(&line),
render_audit_line(&refused, Palette::plain()),
"colour must not move a column"
);
}
#[test]
fn the_audit_detail_omits_every_field_that_has_no_value() {
let full = render_audit_detail_text(&audit_entry(), Palette::plain());
for expected in [
"id 41812",
"event certificate_issued",
"outcome success",
"profile le",
"actor acme:acct-1",
"order order-1",
"serial 0a0b",
"client_ip 203.0.113.7",
"client_ptr host.example.com",
"user_agent certbot/2.9.0",
"request_id req-1",
"identifiers a.example.com,b.example.com",
] {
assert!(full.contains(expected), "missing `{expected}` in:\n{full}");
}
assert!(!full.contains("reason"), "{full}");
assert!(!full.contains("detail"), "{full}");
let bare = AuditEntry {
actor_id: None,
account_id: None,
order_id: None,
cert_serial: None,
identifiers: vec![],
client_ip: None,
client_ptr: None,
user_agent: None,
request_id: None,
..audit_entry()
};
let text = render_audit_detail_text(&bare, Palette::plain());
assert!(text.contains("actor acme\n"), "{text}");
for absent in ["account", "order", "serial", "client_ip", "identifiers"] {
assert!(
!text.contains(absent),
"`{absent}` should be absent from:\n{text}"
);
}
}
#[tokio::test]
async fn render_account_line_renders_all_three_shapes_of_client() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let both = account_seen_from(
&[1u8, 2, 3],
&client_context(Some("203.0.113.7"), Some("host.example.com")),
&db,
)
.await;
let line = render_account_line(&both, Palette::plain());
assert!(line.contains(&both.id), "{line}");
assert!(line.contains("valid"), "{line}");
assert!(line.contains("mailto:a@example.com"), "{line}");
assert!(line.contains("203.0.113.7 (host.example.com)"), "{line}");
assert!(!line.contains(&pubkey_fingerprint(&both.pubkey)), "{line}");
let address_only = account_seen_from(
&[4u8, 5, 6],
&client_context(Some("203.0.113.7"), None),
&db,
)
.await;
let line = render_account_line(&address_only, Palette::plain());
assert!(line.contains("203.0.113.7"), "{line}");
assert!(!line.contains('('), "{line}");
let neither = account_seen_from(&[7u8, 8, 9], &ClientContext::default(), &db).await;
assert!(render_account_line(&neither, Palette::plain()).contains(" - "));
}
#[tokio::test]
async fn colour_never_moves_the_account_listings_columns() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let account = account_seen_from(&[1u8, 2, 3], &ClientContext::default(), &db).await;
let painted = render_account_line(&account, colour());
assert!(painted.contains("\x1b[32mvalid \x1b[0m"), "{painted}");
assert_eq!(
strip_ansi(&painted),
render_account_line(&account, Palette::plain())
);
}
#[tokio::test]
async fn render_account_detail_text_omits_every_absent_field() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let seen = account_seen_from(
&[1u8, 2, 3],
&client_context(Some("203.0.113.7"), Some("host.example.com")),
&db,
)
.await;
let text = render_account_detail_text(&seen, Palette::plain());
assert!(
text.contains(&format!("id {}", seen.id)),
"{text}"
);
assert!(text.contains("profile default"), "{text}");
assert!(text.contains("status valid"), "{text}");
assert!(
text.contains(&pubkey_fingerprint(&seen.pubkey)),
"the fingerprint the listing gave up must be here: {text}"
);
assert!(
text.contains("contact mailto:a@example.com"),
"{text}"
);
assert!(text.contains("created_ip 203.0.113.7"), "{text}");
assert!(text.contains("created_ptr host.example.com"), "{text}");
assert!(text.contains("last_seen "), "{text}");
assert!(text.contains("last_seen_ip 203.0.113.7"), "{text}");
assert!(text.contains("last_seen_ptr host.example.com"), "{text}");
for line in text.lines() {
assert_eq!(&line[13..14], " ", "misaligned: {line:?}");
assert_ne!(&line[14..15], " ", "misaligned: {line:?}");
}
assert!(!text.contains("eab_kid"), "{text}");
assert!(!text.contains("terms"), "{text}");
let bare =
Account::find_or_create("default", &[9u8], vec![], &ClientContext::default(), &db)
.await
.unwrap()
.0;
let text = render_account_detail_text(&bare, Palette::plain());
for absent in ["contact", "created_ip", "created_ptr", "last_seen_ip"] {
assert!(!text.contains(absent), "{absent} in {text}");
}
assert!(text.contains("last_seen "), "{text}");
}
#[test]
fn render_order_line_includes_expected_fields() {
let order = order_fixture("acct", OrderStatus::Pending);
let line = render_order_line(&order, Palette::plain());
assert!(line.contains(&order.id));
assert!(line.contains("pending"));
assert!(line.contains("example.com"));
}
#[test]
fn the_order_status_column_is_painted_by_what_it_means() {
for (status, code) in [
(OrderStatus::Valid, "32"),
(OrderStatus::Pending, "33"),
(OrderStatus::Invalid, "31"),
] {
let order = order_fixture("acct", status);
let painted = render_order_line(&order, colour());
assert!(
painted.contains(&format!("\x1b[{code}m{}\x1b[0m", status.as_str())),
"{painted}"
);
assert_eq!(
strip_ansi(&painted),
render_order_line(&order, Palette::plain())
);
}
}
#[tokio::test]
async fn render_order_detail_text_surfaces_authorizations_and_challenges() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
&acct,
vec![Identifier::dns("example.com")],
crate::sqlite::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let authz = Authorization::create(
&order.id,
Identifier::dns("example.com"),
crate::sqlite::nonce::now_secs() + 3600,
&db,
)
.await
.unwrap();
Challenge::create(&authz.id, "http-01", &db).await.unwrap();
let detail = load_order_detail(&order.id, db).await.unwrap().unwrap();
let text = render_order_detail_text(&detail, Palette::plain());
assert!(text.contains(&order.id));
assert!(text.contains(&authz.id));
assert!(text.contains("http-01"));
let painted = render_order_detail_text(&detail, colour());
assert_eq!(painted.matches("\x1b[33m").count(), 3, "{painted}");
assert_eq!(strip_ansi(&painted), text);
}
#[test]
fn render_order_line_revoked_includes_reason_and_time() {
let mut order = order_fixture("acct", OrderStatus::Valid);
order.revoked_at = Some(1700000000);
order.revocation_reason = Some(1);
let line = render_order_line(&order, Palette::plain());
assert!(line.contains("revoked="));
assert!(line.contains("reason=1"));
}
#[test]
fn a_revoked_order_paints_its_suffix_even_though_its_status_is_valid() {
let mut order = order_fixture("acct", OrderStatus::Valid);
order.revoked_at = Some(1700000000);
order.revocation_reason = Some(1);
let painted = render_order_line(&order, colour());
assert!(painted.contains("\x1b[32mvalid"), "{painted}");
assert!(painted.contains("\x1b[31m revoked="), "{painted}");
assert!(painted.ends_with("reason=1\x1b[0m"), "{painted}");
assert_eq!(
strip_ansi(&painted),
render_order_line(&order, Palette::plain())
);
}
#[tokio::test]
async fn render_eab_line_includes_expected_fields() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let eab = Eab::create(Some("team-a".to_string()), None, &db)
.await
.unwrap();
let line = render_eab_line(&eab, Palette::plain());
assert!(line.contains(&eab.kid));
assert!(line.contains("active"));
assert!(line.contains("team-a"));
let painted = render_eab_line(&eab, colour());
assert!(painted.contains("\x1b[32mactive \x1b[0m"), "{painted}");
assert_eq!(strip_ansi(&painted), line);
}
#[tokio::test]
async fn render_eab_created_text_includes_kid_and_hmac_key() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let eab = Eab::create(None, None, &db).await.unwrap();
let text = render_eab_created_text(&eab, Palette::plain());
assert!(text.contains(&eab.kid));
assert!(text.contains(&BASE64_URL_SAFE_NO_PAD.encode(&eab.secret)));
assert!(text.contains("Store the hmacKey now"));
}
#[tokio::test]
async fn the_eab_secret_warning_is_the_only_thing_painted() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let eab = Eab::create(None, None, &db).await.unwrap();
let painted = render_eab_created_text(&eab, colour());
assert!(
painted.contains("\x1b[33mStore the hmacKey now: it is shown only this once.\x1b[0m"),
"{painted}"
);
assert_eq!(painted.matches('\x1b').count(), 2, "{painted}");
assert_eq!(
strip_ansi(&painted),
render_eab_created_text(&eab, Palette::plain())
);
}
#[test]
fn render_admin_user_line_never_shows_the_hash_and_says_never_for_no_login() {
let user = admin_user_fixture();
let line = render_admin_user_line(&user, Palette::plain());
assert!(line.contains("alice"));
assert!(line.contains("active"));
assert!(line.contains("totp=off"));
assert!(line.contains("never"));
assert!(
!line.contains("pbkdf2"),
"the stored hash must never reach a terminal: {line}"
);
}
#[test]
fn render_admin_user_line_reflects_totp_and_a_real_last_login() {
let mut user = admin_user_fixture();
user.totp_secret = Some(vec![1, 2, 3]);
user.last_login_at = Some(1_700_000_500);
let line = render_admin_user_line(&user, Palette::plain());
assert!(line.contains("totp=on"));
assert!(!line.contains("never"));
}
#[test]
fn an_operator_without_a_second_factor_stands_out() {
let without = render_admin_user_line(&admin_user_fixture(), colour());
assert!(without.contains("totp=\x1b[31moff\x1b[0m"), "{without}");
let mut user = admin_user_fixture();
user.totp_secret = Some(vec![1, 2, 3]);
user.status = "disabled".to_string();
let with = render_admin_user_line(&user, colour());
assert!(with.contains("totp=\x1b[32mon \x1b[0m"), "{with}");
assert!(with.contains("\x1b[31mdisabled\x1b[0m"), "{with}");
assert_eq!(
strip_ansi(&with),
render_admin_user_line(&user, Palette::plain())
);
}
#[test]
fn the_totp_line_paints_each_of_its_three_states() {
let plain = Palette::plain();
let off = admin_user_fixture();
assert!(
render_admin_totp_line(&off, 0, plain).contains("totp=off"),
"plain output unchanged"
);
assert!(render_admin_totp_line(&off, 0, colour()).contains("totp=\x1b[31moff\x1b[0m"));
let mut pending = admin_user_fixture();
pending.totp_pending_secret = Some(vec![1, 2, 3]);
let line = render_admin_totp_line(&pending, 0, colour());
assert!(
line.contains("\x1b[33mpending (enrolment started, never confirmed)\x1b[0m"),
"{line}"
);
assert_eq!(
strip_ansi(&line),
render_admin_totp_line(&pending, 0, plain)
);
let mut enabled = admin_user_fixture();
enabled.totp_secret = Some(vec![1, 2, 3]);
let line = render_admin_totp_line(&enabled, 7, colour());
assert!(line.contains("totp=\x1b[32menabled\x1b[0m"), "{line}");
assert!(line.contains("recovery-codes=7"), "{line}");
}
#[test]
fn render_admin_session_line_shows_a_fingerprint_not_the_token_hash() {
let line = render_admin_session_line(&admin_session_fixture(), Palette::plain());
assert!(line.contains("01234567"));
assert!(
!line.contains("0123456789abcdef0123456789abcdef"),
"printing the hash would put every live session's lookup key on a terminal: {line}"
);
assert!(!line.contains("the-csrf-token"));
assert!(line.contains("192.0.2.1"));
assert!(line.contains("expires="));
}
#[test]
fn render_admin_session_line_dashes_a_missing_address() {
let mut session = admin_session_fixture();
session.created_ip = None;
assert!(render_admin_session_line(&session, Palette::plain()).contains(" -"));
}
#[test]
fn a_pending_mfa_session_is_painted_apart_from_an_active_one() {
let active = render_admin_session_line(&admin_session_fixture(), colour());
assert!(active.contains("\x1b[32mactive \x1b[0m"), "{active}");
let mut session = admin_session_fixture();
session.state = "pending_mfa".to_string();
let painted = render_admin_session_line(&session, colour());
assert!(painted.contains("\x1b[33mpending_mfa\x1b[0m"), "{painted}");
assert_eq!(
strip_ansi(&painted),
render_admin_session_line(&session, Palette::plain())
);
}
}