use crate::audit::ClientContext;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
pub(crate) fn test_metrics(
database: Arc<crate::sqlite::db::Database>,
) -> Arc<crate::metrics::Metrics> {
Arc::new(crate::metrics::Metrics::new(database))
}
#[derive(Clone)]
pub(crate) struct SpanFields {
name: &'static str,
fields: Arc<Mutex<HashMap<String, String>>>,
}
impl SpanFields {
pub(crate) fn capturing(name: &'static str) -> Self {
Self {
name,
fields: Arc::new(Mutex::new(HashMap::new())),
}
}
pub(crate) fn get(&self, field: &str) -> Option<String> {
self.fields.lock().unwrap().get(field).cloned()
}
}
impl tracing::field::Visit for SpanFields {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.fields
.lock()
.unwrap()
.insert(field.name().to_string(), format!("{value:?}"));
}
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for SpanFields {
fn on_new_span(
&self,
attrs: &tracing::span::Attributes<'_>,
_id: &tracing::Id,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
if attrs.metadata().name() == self.name {
attrs.record(&mut self.clone());
}
}
fn on_record(
&self,
_id: &tracing::Id,
values: &tracing::span::Record<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
values.record(&mut self.clone());
}
}
pub(crate) async fn capture_request_span<F, T>(body: F) -> SpanFields
where
F: std::future::Future<Output = T>,
{
use tracing_subscriber::layer::SubscriberExt;
let captured = SpanFields::capturing("request");
let subscriber = tracing_subscriber::registry().with(captured.clone());
let _guard = tracing::subscriber::set_default(subscriber);
body.await;
captured
}
pub(crate) struct TempDir(PathBuf);
impl TempDir {
pub(crate) fn new(label: &str) -> Self {
let path =
std::env::temp_dir().join(format!("acme-proxy-{label}-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&path).expect("temp directory must be creatable");
Self(path)
}
pub(crate) fn path(&self) -> &Path {
&self.0
}
pub(crate) fn join(&self, name: &str) -> PathBuf {
self.0.join(name)
}
pub(crate) fn write(&self, name: &str, contents: &str) -> PathBuf {
let path = self.join(name);
std::fs::write(&path, contents).expect("temp file must be writable");
path
}
}
impl AsRef<Path> for TempDir {
fn as_ref(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[cfg(unix)]
pub(crate) fn write_script(dir: &TempDir, name: &str, body: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.write(name, body);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("script must be made executable");
path
}
pub(crate) struct EnvGuard {
keys: Vec<String>,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl EnvGuard {
pub(crate) fn new(vars: &[(&str, &str)]) -> Self {
let _lock = crate::config::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut keys = vec!["ACME_PROXY_CONFIG".to_string()];
unsafe {
std::env::set_var("ACME_PROXY_CONFIG", "/nonexistent/acme-proxy-config");
for (key, value) in vars {
std::env::set_var(key, value);
keys.push((*key).to_string());
}
}
Self { keys, _lock }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
for key in &self.keys {
std::env::remove_var(key);
}
}
}
}
pub(crate) fn no_proxies() -> std::sync::Arc<crate::proxy::OutboundProxies> {
std::sync::Arc::new(crate::proxy::OutboundProxies::direct())
}
pub(crate) fn outbound_with(
resolver: std::sync::Arc<dyn crate::dns::Resolver>,
) -> crate::http_client::Outbound {
crate::http_client::Outbound::new(resolver, no_proxies())
}
pub(crate) fn idle_job_queue(
database: std::sync::Arc<crate::sqlite::db::Database>,
) -> crate::jobs::JobQueue {
crate::jobs::JobQueue::new(database, &crate::config::JobsConfig::default())
}
pub(crate) fn egress_with(
resolver: std::sync::Arc<dyn crate::dns::Resolver>,
) -> std::sync::Arc<crate::Egress> {
std::sync::Arc::new(crate::Egress {
resolver,
proxies: no_proxies(),
identity: "test".to_string(),
})
}
pub(crate) fn signer_parts(
database: std::sync::Arc<crate::sqlite::db::Database>,
resolver: std::sync::Arc<dyn crate::dns::Resolver>,
) -> crate::signer::SignerParts {
crate::signer::SignerParts {
database: database.clone(),
notifiers: std::collections::HashMap::new().into(),
metrics: test_metrics(database.clone()),
egress: egress_with(resolver),
jobs: idle_job_queue(database),
}
}
#[cfg(test)]
#[derive(Clone, Copy)]
pub(crate) enum ProxyBehaviour {
Tunnel {
status: &'static str,
force_port: Option<u16>,
},
Refuse(&'static str),
Forward(&'static str),
}
#[cfg(test)]
pub(crate) struct FakeProxy {
pub port: u16,
requests: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
connections: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
#[cfg(test)]
impl FakeProxy {
pub(crate) async fn start(behaviour: ProxyBehaviour) -> Self {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let connections = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seen = requests.clone();
let counter = connections.clone();
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
return;
};
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let seen = seen.clone();
tokio::spawn(async move {
let mut head = Vec::new();
let mut byte = [0u8; 1];
while stream.read_exact(&mut byte).await.is_ok() {
head.push(byte[0]);
if head.ends_with(b"\r\n\r\n") {
break;
}
}
let head = String::from_utf8_lossy(&head).into_owned();
let target = head
.split_whitespace()
.nth(1)
.unwrap_or_default()
.to_string();
seen.lock().unwrap().push(head);
match behaviour {
ProxyBehaviour::Refuse(response) => {
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
}
ProxyBehaviour::Forward(response) => {
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
}
ProxyBehaviour::Tunnel { status, force_port } => {
let target = match force_port {
Some(port) => format!("127.0.0.1:{port}"),
None => target,
};
let Ok(mut upstream) = tokio::net::TcpStream::connect(&target).await
else {
let _ = stream.write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n").await;
return;
};
let _ = stream.write_all(status.as_bytes()).await;
let _ = stream.write_all(b"\r\n").await;
let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await;
}
}
});
}
});
Self {
port,
requests,
connections,
}
}
pub(crate) fn url(&self) -> String {
format!("http://127.0.0.1:{}", self.port)
}
pub(crate) fn requests(&self) -> Vec<String> {
self.requests.lock().unwrap().clone()
}
pub(crate) fn connections(&self) -> usize {
self.connections.load(std::sync::atomic::Ordering::SeqCst)
}
}
#[cfg(test)]
pub(crate) fn identifiers(pairs: &[(&str, &str)]) -> Vec<crate::sqlite::order::Identifier> {
pairs
.iter()
.map(|(typ, value)| crate::sqlite::order::Identifier::new(*typ, *value))
.collect()
}
#[cfg(test)]
pub(crate) fn dns_identifiers(values: &[&str]) -> Vec<crate::sqlite::order::Identifier> {
values
.iter()
.map(|value| crate::sqlite::order::Identifier::dns(*value))
.collect()
}
#[cfg(test)]
pub(crate) async fn account_id(database: &std::sync::Arc<crate::sqlite::db::Database>) -> String {
let (account, _) = crate::sqlite::account::Account::find_or_create(
"default",
&[1u8, 2, 3],
vec![],
&ClientContext::default(),
database,
)
.await
.expect("an in-memory database always accepts an account");
account.id
}
#[cfg(test)]
pub(crate) fn client_context(ip: Option<&str>, ptr: Option<&str>) -> ClientContext {
ClientContext {
ip: ip.map(str::to_string),
ptr: ptr.map(str::to_string),
..ClientContext::default()
}
}
#[cfg(test)]
pub(crate) async fn account_seen_from(
pubkey: &[u8],
client: &ClientContext,
database: &std::sync::Arc<crate::sqlite::db::Database>,
) -> crate::sqlite::account::Account {
crate::sqlite::account::Account::find_or_create(
"default",
pubkey,
vec!["mailto:a@example.com".to_string()],
client,
database,
)
.await
.expect("an in-memory database always accepts an account")
.0
}
#[cfg(test)]
pub(crate) fn order_fixture(
account_id: &str,
status: crate::sqlite::status::OrderStatus,
) -> crate::sqlite::order::Order {
let mut order = crate::sqlite::order::Order::new(
"default",
account_id,
vec![crate::sqlite::order::Identifier::dns("example.com")],
0,
None,
None,
);
order.status = status;
order
}
#[cfg(test)]
pub(crate) fn audit_entry() -> crate::sqlite::audit::AuditEntry {
crate::sqlite::audit::AuditEntry {
id: 41_812,
created_at: 1_700_000_000,
event: "certificate_issued".to_string(),
outcome: "success".to_string(),
profile: "le".to_string(),
actor_kind: "acme".to_string(),
actor_id: Some("acct-1".to_string()),
account_id: Some("acct-1".to_string()),
order_id: Some("order-1".to_string()),
cert_serial: Some("0a0b".to_string()),
identifiers: vec!["a.example.com".to_string(), "b.example.com".to_string()],
client_ip: Some("203.0.113.7".to_string()),
client_ptr: Some("host.example.com".to_string()),
user_agent: Some("certbot/2.9.0".to_string()),
request_id: Some("req-1".to_string()),
reason: None,
detail: None,
}
}
#[cfg(test)]
pub(crate) fn admin_user_fixture() -> crate::sqlite::admin_user::AdminUser {
crate::sqlite::admin_user::AdminUser {
id: "11111111-2222-3333-4444-555555555555".to_string(),
username: "alice".to_string(),
password_hash: "pbkdf2-sha256$600000$c2FsdA$aGFzaA".to_string(),
status: "active".to_string(),
totp_secret: None,
totp_pending_secret: None,
totp_last_step: None,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
last_login_at: None,
}
}
#[cfg(test)]
pub(crate) fn admin_session_fixture() -> crate::sqlite::admin_session::AdminSession {
crate::sqlite::admin_session::AdminSession {
token_hash: "0123456789abcdef0123456789abcdef".to_string(),
user_id: "11111111-2222-3333-4444-555555555555".to_string(),
csrf_token: "the-csrf-token".to_string(),
state: "active".to_string(),
mfa_attempts: 0,
created_at: 1_700_000_000,
expires_at: 1_700_043_200,
last_seen_at: 1_700_000_000,
created_ip: Some("192.0.2.1".to_string()),
user_agent: Some("curl/8".to_string()),
}
}