acme-api: reusable ACME api implementation.

Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
This commit is contained in:
Dietmar Maurer 2024-03-20 09:59:23 +01:00
parent 870948f1d7
commit cfc155a06b
12 changed files with 1800 additions and 0 deletions

View File

@ -1,6 +1,7 @@
[workspace]
members = [
"proxmox-acme",
"proxmox-acme-api",
"proxmox-api-macro",
"proxmox-apt",
"proxmox-async",
@ -100,6 +101,7 @@ webauthn-rs = "0.3"
zstd = { version = "0.12", features = [ "bindgen" ] }
# workspace dependencies
proxmox-acme = { version = "0.5.2", path = "proxmox-acme", default-features = false }
proxmox-api-macro = { version = "1.0.8", path = "proxmox-api-macro" }
proxmox-async = { version = "0.4.1", path = "proxmox-async" }
proxmox-compression = { version = "0.2.0", path = "proxmox-compression" }

View File

@ -0,0 +1,58 @@
[package]
name = "proxmox-acme-api"
version = "0.0.1"
authors.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
exclude.workspace = true
description = "ACME API implementation"
[dependencies]
anyhow.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
base64 = { workspace = true, optional = true }
hex = { workspace = true, optional = true }
tokio = { workspace = true, optional = true, features = ["fs"] }
hyper = { workspace = true, optional = true }
futures = { workspace = true, optional = true }
http = { workspace = true, optional = true }
log = { workspace = true, optional = true }
nix = { workspace = true, optional = true }
openssl = { workspace = true, optional = true }
lazy_static = { workspace = true, optional = true }
proxmox-serde = { workspace = true, optional = true }
proxmox-section-config = { workspace = true, optional = true }
proxmox-rest-server = { workspace = true, optional = true }
proxmox-router = { workspace = true, optional = true }
proxmox-sys = { workspace = true, optional = true }
proxmox-schema = { workspace = true, features = ["api-macro", "api-types"] }
proxmox-acme = { workspace = true, optional = true, features = ["api-types"] }
[features]
default = ["api-types"]
api-types = ["dep:proxmox-acme", "dep:proxmox-serde"]
impl = [
"api-types",
"dep:proxmox-acme",
"proxmox-acme?/impl",
"proxmox-acme?/async-client",
"dep:proxmox-section-config",
"dep:openssl",
"dep:lazy_static",
"dep:log",
"dep:nix",
"dep:tokio",
"dep:futures",
"dep:http",
"dep:hyper",
"dep:proxmox-sys",
"dep:proxmox-rest-server",
"dep:proxmox-router",
"dep:base64",
"dep:hex",
]

View File

@ -0,0 +1,134 @@
//! ACME account configuration API implementation
use std::ops::ControlFlow;
use anyhow::Error;
use serde_json::json;
use proxmox_acme::async_client::AcmeClient;
use proxmox_acme::types::AccountData as AcmeAccountData;
use proxmox_rest_server::WorkerTask;
use proxmox_sys::task_warn;
use crate::types::{AccountEntry, AccountInfo, AcmeAccountName};
use crate::config::{AcmeApiConfig, DEFAULT_ACME_DIRECTORY_ENTRY};
use crate::account_config::AccountData;
fn account_contact_from_string(s: &str) -> Vec<String> {
s.split(&[' ', ';', ',', '\0'][..])
.map(|s| format!("mailto:{}", s))
.collect()
}
impl AcmeApiConfig {
pub fn list_accounts(&self) -> Result<Vec<AccountEntry>, Error> {
let mut entries = Vec::new();
self.foreach_acme_account(|name| {
entries.push(AccountEntry { name });
ControlFlow::Continue(())
})?;
Ok(entries)
}
pub async fn get_account(
&self,
account_name: AcmeAccountName,
) -> Result<AccountInfo, Error> {
let account_data = self.load_account_config(&account_name).await?;
Ok(AccountInfo {
location: account_data.location.clone(),
tos: account_data.tos.clone(),
directory: account_data.directory_url.clone(),
account: AcmeAccountData {
only_return_existing: false, // don't actually write this out in case it's set
..account_data.account.clone()
},
})
}
pub async fn get_tos(&self, directory: Option<String>) -> Result<Option<String>, Error> {
let directory = directory.unwrap_or_else(|| DEFAULT_ACME_DIRECTORY_ENTRY.url.to_string());
Ok(AcmeClient::new(directory)
.terms_of_service_url()
.await?
.map(str::to_owned))
}
pub async fn register_account(
&self,
name: &AcmeAccountName,
contact: String,
tos_url: Option<String>,
directory_url: String,
eab_creds: Option<(String, String)>,
) -> Result<String, Error> {
let mut client = AcmeClient::new(directory_url.clone());
let contact = account_contact_from_string(&contact);
let account = client
.new_account(tos_url.is_some(), contact, None, eab_creds)
.await?;
let account = AccountData::from_account_dir_tos(account, directory_url, tos_url);
self.create_account_config(&name, &account)?;
Ok(account.location)
}
pub async fn deactivate_account(
&self,
worker: &WorkerTask,
name: &AcmeAccountName,
force: bool,
) -> Result<(), Error> {
let mut account_data = self.load_account_config(name).await?;
let mut client = account_data.client();
match client
.update_account(&json!({"status": "deactivated"}))
.await
{
Ok(account) => {
account_data.account = account.data.clone();
self.save_account_config(&name, &account_data)?;
}
Err(err) if !force => return Err(err),
Err(err) => {
task_warn!(
worker,
"error deactivating account {}, proceedeing anyway - {}",
name,
err,
);
}
}
self.mark_account_deactivated(&name)?;
Ok(())
}
pub async fn update_account(
&self,
name: &AcmeAccountName,
contact: Option<String>,
) -> Result<(), Error> {
let mut account_data = self.load_account_config(name).await?;
let mut client = account_data.client();
let data = match contact {
Some(contact) => json!({
"contact": account_contact_from_string(&contact),
}),
None => json!({}),
};
let account = client.update_account(&data).await?;
account_data.account = account.data.clone();
self.save_account_config(&name, &account_data)?;
Ok(())
}
}

View File

@ -0,0 +1,230 @@
//! ACME account configuration helpers (load/save config)
use std::ops::ControlFlow;
use std::fs::OpenOptions;
use std::path::Path;
use std::os::unix::fs::OpenOptionsExt;
use anyhow::{bail, format_err, Error};
use serde::{Deserialize, Serialize};
use proxmox_sys::error::SysError;
use proxmox_sys::fs::{replace_file, CreateOptions};
use proxmox_schema::api_types::SAFE_ID_REGEX;
use proxmox_acme::async_client::AcmeClient;
use proxmox_acme::types::AccountData as AcmeAccountData;
use proxmox_acme::Account;
use crate::config::AcmeApiConfig;
use crate::types::AcmeAccountName;
#[inline]
fn is_false(b: &bool) -> bool {
!*b
}
// Our on-disk format inherited from PVE's proxmox-acme code.
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountData {
/// The account's location URL.
pub location: String,
/// The account data.
pub account: AcmeAccountData,
/// The private key as PEM formatted string.
pub key: String,
/// ToS URL the user agreed to.
#[serde(skip_serializing_if = "Option::is_none")]
pub tos: Option<String>,
#[serde(skip_serializing_if = "is_false", default)]
pub debug: bool,
/// The directory's URL.
pub directory_url: String,
}
impl AccountData {
pub fn from_account_dir_tos(
account: &Account,
directory_url: String,
tos: Option<String>,
) -> Self {
AccountData {
location: account.location.clone(),
key: account.private_key.clone(),
account: AcmeAccountData {
only_return_existing: false, // don't actually write this out in case it's set
..account.data.clone()
},
debug: false,
tos,
directory_url,
}
}
pub fn client(&self) -> AcmeClient {
let mut client = AcmeClient::new(self.directory_url.clone());
client.set_account(Account {
location: self.location.clone(),
private_key: self.key.clone(),
data: self.account.clone(),
});
client
}
}
impl AcmeApiConfig {
fn acme_account_dir(&self) -> String {
format!("{}/{}", self.config_dir, "accounts")
}
/// Returns the path to the account configuration file (`$config_dir/accounts/$name`).
pub fn account_cfg_filename(&self, name: &str) -> String {
format!("{}/{}", self.acme_account_dir(), name)
}
fn make_acme_account_dir(&self) -> nix::Result<()> {
self.make_acme_dir()?;
Self::create_acme_subdir(&self.acme_account_dir())
}
pub(crate) fn foreach_acme_account<F>(&self, mut func: F) -> Result<(), Error>
where
F: FnMut(AcmeAccountName) -> ControlFlow<Result<(), Error>>,
{
match proxmox_sys::fs::scan_subdir(-1, self.acme_account_dir().as_str(), &SAFE_ID_REGEX) {
Ok(files) => {
for file in files {
let file = file?;
let file_name = unsafe { file.file_name_utf8_unchecked() };
if file_name.starts_with('_') {
continue;
}
let account_name = match AcmeAccountName::from_string(file_name.to_owned()) {
Ok(account_name) => account_name,
Err(_) => continue,
};
if let ControlFlow::Break(result) = func(account_name) {
return result;
}
}
Ok(())
}
Err(err) if err.not_found() => Ok(()),
Err(err) => Err(err.into()),
}
}
// Mark account as deactivated
pub(crate) fn mark_account_deactivated(&self, account_name: &str) -> Result<(), Error> {
let from = self.account_cfg_filename(account_name);
for i in 0..100 {
let to = self.account_cfg_filename(&format!("_deactivated_{}_{}", account_name, i));
if !Path::new(&to).exists() {
return std::fs::rename(&from, &to).map_err(|err| {
format_err!(
"failed to move account path {:?} to {:?} - {}",
from,
to,
err
)
});
}
}
bail!(
"No free slot to rename deactivated account {:?}, please cleanup {:?}",
from,
self.acme_account_dir()
);
}
// Load an existing ACME account by name.
pub(crate) async fn load_account_config(&self, account_name: &str) -> Result<AccountData, Error> {
let account_cfg_filename = self.account_cfg_filename(account_name);
let data = match tokio::fs::read(&account_cfg_filename).await {
Ok(data) => data,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
bail!("acme account '{}' does not exist", account_name)
}
Err(err) => bail!(
"failed to load acme account from '{}' - {}",
account_cfg_filename,
err
),
};
let data: AccountData = serde_json::from_slice(&data).map_err(|err| {
format_err!(
"failed to parse acme account from '{}' - {}",
account_cfg_filename,
err
)
})?;
Ok(data)
}
// Save an new ACME account (fails if the file already exists).
pub(crate) fn create_account_config(
&self,
account_name: &AcmeAccountName,
account: &AccountData,
) -> Result<(), Error> {
self.make_acme_account_dir()?;
let account_cfg_filename = self.account_cfg_filename(account_name.as_ref());
let file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&account_cfg_filename)
.map_err(|err| format_err!("failed to open {:?} for writing: {}", account_cfg_filename, err))?;
serde_json::to_writer_pretty(file, account).map_err(|err| {
format_err!(
"failed to write acme account to {:?}: {}",
account_cfg_filename,
err
)
})?;
Ok(())
}
// Save ACME account data (overtwrite existing data).
pub(crate) fn save_account_config(
&self,
account_name: &AcmeAccountName,
account: &AccountData,
) -> Result<(), Error> {
let account_cfg_filename = self.account_cfg_filename(account_name.as_ref());
let mut data = Vec::<u8>::new();
serde_json::to_writer_pretty(&mut data, account).map_err(|err| {
format_err!(
"failed to serialize acme account to {:?}: {}",
account_cfg_filename,
err
)
})?;
self.make_acme_account_dir()?;
replace_file(
account_cfg_filename,
&data,
CreateOptions::new()
.perm(nix::sys::stat::Mode::from_bits_truncate(0o600))
.owner(nix::unistd::ROOT)
.group(nix::unistd::Gid::from_raw(0)),
true,
)
}
}

View File

@ -0,0 +1,315 @@
//! Plugin type definitions.
use std::future::Future;
use std::pin::Pin;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{bail, format_err, Error};
use hyper::{Body, Request, Response};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader};
use tokio::process::Command;
use proxmox_acme::async_client::AcmeClient;
use proxmox_acme::{Authorization, Challenge};
use proxmox_rest_server::WorkerTask;
use crate::types::{AcmeDomain, DnsPlugin};
use crate::plugin_config::PluginData;
const PROXMOX_ACME_SH_PATH: &str = "/usr/share/proxmox-acme/proxmox-acme";
pub(crate) fn get_acme_plugin(
plugin_data: &PluginData,
name: &str,
) -> Result<Option<Box<dyn AcmePlugin + Send + Sync + 'static>>, Error> {
let (ty, data) = match plugin_data.get(name) {
Some(plugin) => plugin,
None => return Ok(None),
};
Ok(Some(match ty.as_str() {
"dns" => {
let plugin: DnsPlugin = serde::Deserialize::deserialize(data)?;
Box::new(plugin)
}
"standalone" => {
// this one has no config
Box::<StandaloneServer>::default()
}
other => bail!("missing implementation for plugin type '{}'", other),
}))
}
pub(crate) trait AcmePlugin {
/// Setup everything required to trigger the validation and return the corresponding validation
/// URL.
fn setup<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>(
&'a mut self,
client: &'b mut AcmeClient,
authorization: &'c Authorization,
domain: &'d AcmeDomain,
task: Arc<WorkerTask>,
) -> Pin<Box<dyn Future<Output = Result<&'c str, Error>> + Send + 'fut>>;
fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>(
&'a mut self,
client: &'b mut AcmeClient,
authorization: &'c Authorization,
domain: &'d AcmeDomain,
task: Arc<WorkerTask>,
) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'fut>>;
}
fn extract_challenge<'a>(
authorization: &'a Authorization,
ty: &str,
) -> Result<&'a Challenge, Error> {
authorization
.challenges
.iter()
.find(|ch| ch.ty == ty)
.ok_or_else(|| format_err!("no supported challenge type ({}) found", ty))
}
async fn pipe_to_tasklog<T: AsyncRead + Unpin>(
pipe: T,
task: Arc<WorkerTask>,
) -> Result<(), std::io::Error> {
let mut pipe = BufReader::new(pipe);
let mut line = String::new();
loop {
line.clear();
match pipe.read_line(&mut line).await {
Ok(0) => return Ok(()),
Ok(_) => task.log_message(line.as_str()),
Err(err) => return Err(err),
}
}
}
impl DnsPlugin {
async fn action<'a>(
&self,
client: &mut AcmeClient,
authorization: &'a Authorization,
domain: &AcmeDomain,
task: Arc<WorkerTask>,
action: &str,
) -> Result<&'a str, Error> {
let challenge = extract_challenge(authorization, "dns-01")?;
let mut stdin_data = client
.dns_01_txt_value(
challenge
.token()
.ok_or_else(|| format_err!("missing token in challenge"))?,
)?
.into_bytes();
stdin_data.push(b'\n');
stdin_data.extend(self.data.as_bytes());
if stdin_data.last() != Some(&b'\n') {
stdin_data.push(b'\n');
}
let mut command = Command::new("/usr/bin/setpriv");
#[rustfmt::skip]
command.args([
"--reuid", "nobody",
"--regid", "nogroup",
"--clear-groups",
"--reset-env",
"--",
"/bin/bash",
PROXMOX_ACME_SH_PATH,
action,
&self.core.api,
domain.alias.as_deref().unwrap_or(&domain.domain),
]);
// We could use 1 socketpair, but tokio wraps them all in `File` internally causing `close`
// to be called separately on all of them without exception, so we need 3 pipes :-(
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut stdin = child.stdin.take().expect("Stdio::piped()");
let stdout = child.stdout.take().expect("Stdio::piped() failed?");
let stdout = pipe_to_tasklog(stdout, Arc::clone(&task));
let stderr = child.stderr.take().expect("Stdio::piped() failed?");
let stderr = pipe_to_tasklog(stderr, Arc::clone(&task));
let stdin = async move {
stdin.write_all(&stdin_data).await?;
stdin.flush().await?;
Ok::<_, std::io::Error>(())
};
match futures::try_join!(stdin, stdout, stderr) {
Ok(((), (), ())) => (),
Err(err) => {
if let Err(err) = child.kill().await {
task.log_message(format!(
"failed to kill '{} {}' command: {}",
PROXMOX_ACME_SH_PATH, action, err
));
}
bail!("'{}' failed: {}", PROXMOX_ACME_SH_PATH, err);
}
}
let status = child.wait().await?;
if !status.success() {
bail!(
"'{} {}' exited with error ({})",
PROXMOX_ACME_SH_PATH,
action,
status.code().unwrap_or(-1)
);
}
Ok(&challenge.url)
}
}
impl AcmePlugin for DnsPlugin {
fn setup<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>(
&'a mut self,
client: &'b mut AcmeClient,
authorization: &'c Authorization,
domain: &'d AcmeDomain,
task: Arc<WorkerTask>,
) -> Pin<Box<dyn Future<Output = Result<&'c str, Error>> + Send + 'fut>> {
Box::pin(async move {
let result = self
.action(client, authorization, domain, task.clone(), "setup")
.await;
let validation_delay = self.core.validation_delay.unwrap_or(30) as u64;
if validation_delay > 0 {
task.log_message(format!(
"Sleeping {} seconds to wait for TXT record propagation",
validation_delay
));
tokio::time::sleep(Duration::from_secs(validation_delay)).await;
}
result
})
}
fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>(
&'a mut self,
client: &'b mut AcmeClient,
authorization: &'c Authorization,
domain: &'d AcmeDomain,
task: Arc<WorkerTask>,
) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'fut>> {
Box::pin(async move {
self.action(client, authorization, domain, task, "teardown")
.await
.map(drop)
})
}
}
#[derive(Default)]
struct StandaloneServer {
abort_handle: Option<futures::future::AbortHandle>,
}
// In case the "order_certificates" future gets dropped between setup & teardown, let's also cancel
// the HTTP listener on Drop:
impl Drop for StandaloneServer {
fn drop(&mut self) {
self.stop();
}
}
impl StandaloneServer {
fn stop(&mut self) {
if let Some(abort) = self.abort_handle.take() {
abort.abort();
}
}
}
async fn standalone_respond(
req: Request<Body>,
path: Arc<String>,
key_auth: Arc<String>,
) -> Result<Response<Body>, hyper::Error> {
if req.method() == hyper::Method::GET && req.uri().path() == path.as_str() {
Ok(Response::builder()
.status(http::StatusCode::OK)
.body(key_auth.as_bytes().to_vec().into())
.unwrap())
} else {
Ok(Response::builder()
.status(http::StatusCode::NOT_FOUND)
.body("Not found.".into())
.unwrap())
}
}
impl AcmePlugin for StandaloneServer {
fn setup<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>(
&'a mut self,
client: &'b mut AcmeClient,
authorization: &'c Authorization,
_domain: &'d AcmeDomain,
_task: Arc<WorkerTask>,
) -> Pin<Box<dyn Future<Output = Result<&'c str, Error>> + Send + 'fut>> {
use hyper::server::conn::AddrIncoming;
use hyper::service::{make_service_fn, service_fn};
Box::pin(async move {
self.stop();
let challenge = extract_challenge(authorization, "http-01")?;
let token = challenge
.token()
.ok_or_else(|| format_err!("missing token in challenge"))?;
let key_auth = Arc::new(client.key_authorization(token)?);
let path = Arc::new(format!("/.well-known/acme-challenge/{}", token));
let service = make_service_fn(move |_| {
let path = Arc::clone(&path);
let key_auth = Arc::clone(&key_auth);
async move {
Ok::<_, hyper::Error>(service_fn(move |request| {
standalone_respond(request, Arc::clone(&path), Arc::clone(&key_auth))
}))
}
});
// `[::]:80` first, then `*:80`
let incoming = AddrIncoming::bind(&(([0u16; 8], 80).into()))
.or_else(|_| AddrIncoming::bind(&(([0u8; 4], 80).into())))?;
let server = hyper::Server::builder(incoming).serve(service);
let (future, abort) = futures::future::abortable(server);
self.abort_handle = Some(abort);
tokio::spawn(future);
Ok(challenge.url.as_str())
})
}
fn teardown<'fut, 'a: 'fut, 'b: 'fut, 'c: 'fut, 'd: 'fut>(
&'a mut self,
_client: &'b mut AcmeClient,
_authorization: &'c Authorization,
_domain: &'d AcmeDomain,
_task: Arc<WorkerTask>,
) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'fut>> {
Box::pin(async move {
if let Some(abort) = self.abort_handle.take() {
abort.abort();
}
Ok(())
})
}
}

View File

@ -0,0 +1,206 @@
use std::sync::Arc;
use std::time::Duration;
use anyhow::{bail, format_err, Error};
use proxmox_acme::async_client::AcmeClient;
use proxmox_rest_server::WorkerTask;
use proxmox_sys::{task_log, task_warn};
use crate::config::AcmeApiConfig;
use crate::types::{AcmeConfig, AcmeDomain};
pub struct OrderedCertificate {
pub certificate: Vec<u8>,
pub private_key_pem: Vec<u8>,
}
impl AcmeApiConfig {
pub async fn order_certificate(
&self,
worker: Arc<WorkerTask>,
acme_config: AcmeConfig,
domains: Vec<AcmeDomain>,
) -> Result<Option<OrderedCertificate>, Error> {
use proxmox_acme::authorization::Status;
use proxmox_acme::order::Identifier;
let get_domain_config = |domain: &str| {
domains
.iter()
.find(|d| d.domain == domain)
.ok_or_else(|| format_err!("no config for domain '{}'", domain))
};
if domains.is_empty() {
task_log!(
worker,
"No domains configured to be ordered from an ACME server."
);
return Ok(None);
}
let mut acme = self.load_account_config(&acme_config.account).await?.client();
let (plugins, _) = self.plugin_config()?;
task_log!(worker, "Placing ACME order");
let order = acme
.new_order(domains.iter().map(|d| d.domain.to_ascii_lowercase()))
.await?;
task_log!(worker, "Order URL: {}", order.location);
let identifiers: Vec<String> = order
.data
.identifiers
.iter()
.map(|identifier| match identifier {
Identifier::Dns(domain) => domain.clone(),
})
.collect();
for auth_url in &order.data.authorizations {
task_log!(worker, "Getting authorization details from '{}'", auth_url);
let mut auth = acme.get_authorization(auth_url).await?;
let domain = match &mut auth.identifier {
Identifier::Dns(domain) => domain.to_ascii_lowercase(),
};
if auth.status == Status::Valid {
task_log!(worker, "{} is already validated!", domain);
continue;
}
task_log!(worker, "The validation for {} is pending", domain);
let domain_config: &AcmeDomain = get_domain_config(&domain)?;
let plugin_id = domain_config.plugin.as_deref().unwrap_or("standalone");
let mut plugin_cfg = crate::acme_plugin::get_acme_plugin(&plugins, plugin_id)?
.ok_or_else(|| {
format_err!("plugin '{}' for domain '{}' not found!", plugin_id, domain)
})?;
task_log!(worker, "Setting up validation plugin");
let validation_url = plugin_cfg
.setup(&mut acme, &auth, domain_config, Arc::clone(&worker))
.await?;
let result = Self::request_validation(&worker, &mut acme, auth_url, validation_url).await;
if let Err(err) = plugin_cfg
.teardown(&mut acme, &auth, domain_config, Arc::clone(&worker))
.await
{
task_warn!(
worker,
"Failed to teardown plugin '{}' for domain '{}' - {}",
plugin_id,
domain,
err
);
}
result?;
}
task_log!(worker, "All domains validated");
task_log!(worker, "Creating CSR");
let csr = proxmox_acme::util::Csr::generate(&identifiers, &Default::default())?;
let mut finalize_error_cnt = 0u8;
let order_url = &order.location;
let mut order;
loop {
use proxmox_acme::order::Status;
order = acme.get_order(order_url).await?;
match order.status {
Status::Pending => {
task_log!(worker, "still pending, trying to finalize anyway");
let finalize = order
.finalize
.as_deref()
.ok_or_else(|| format_err!("missing 'finalize' URL in order"))?;
if let Err(err) = acme.finalize(finalize, &csr.data).await {
if finalize_error_cnt >= 5 {
return Err(err);
}
finalize_error_cnt += 1;
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
Status::Ready => {
task_log!(worker, "order is ready, finalizing");
let finalize = order
.finalize
.as_deref()
.ok_or_else(|| format_err!("missing 'finalize' URL in order"))?;
acme.finalize(finalize, &csr.data).await?;
tokio::time::sleep(Duration::from_secs(5)).await;
}
Status::Processing => {
task_log!(worker, "still processing, trying again in 30 seconds");
tokio::time::sleep(Duration::from_secs(30)).await;
}
Status::Valid => {
task_log!(worker, "valid");
break;
}
other => bail!("order status: {:?}", other),
}
}
task_log!(worker, "Downloading certificate");
let certificate = acme
.get_certificate(
order
.certificate
.as_deref()
.ok_or_else(|| format_err!("missing certificate url in finalized order"))?,
)
.await?;
Ok(Some(OrderedCertificate {
certificate: certificate.to_vec(),
private_key_pem: csr.private_key_pem,
}))
}
async fn request_validation(
worker: &WorkerTask,
acme: &mut AcmeClient,
auth_url: &str,
validation_url: &str,
) -> Result<(), Error> {
task_log!(worker, "Triggering validation");
acme.request_challenge_validation(validation_url).await?;
task_log!(worker, "Sleeping for 5 seconds");
tokio::time::sleep(Duration::from_secs(5)).await;
loop {
use proxmox_acme::authorization::Status;
let auth = acme.get_authorization(auth_url).await?;
match auth.status {
Status::Pending => {
task_log!(
worker,
"Status is still 'pending', trying again in 10 seconds"
);
tokio::time::sleep(Duration::from_secs(10)).await;
}
Status::Valid => return Ok(()),
other => bail!(
"validating challenge '{}' failed - status: {:?}",
validation_url,
other
),
}
}
}
}

View File

@ -0,0 +1,74 @@
//! Read DNS Challenge schemas.
//!
//! Those schemas are provided by debian package "libproxmox-acme-plugins".
use std::sync::Arc;
use std::sync::Mutex;
use std::time::SystemTime;
use anyhow::Error;
use lazy_static::lazy_static;
use serde::Serialize;
use serde_json::Value;
use proxmox_sys::fs::file_read_string;
use crate::types::AcmeChallengeSchema;
const ACME_DNS_SCHEMA_FN: &str = "/usr/share/proxmox-acme/dns-challenge-schema.json";
/// Wrapper for efficient Arc use when returning the ACME challenge-plugin schema for serializing.
pub struct ChallengeSchemaWrapper {
inner: Arc<Vec<AcmeChallengeSchema>>,
}
impl Serialize for ChallengeSchemaWrapper {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.inner.serialize(serializer)
}
}
fn load_dns_challenge_schema() -> Result<Vec<AcmeChallengeSchema>, Error> {
let raw = file_read_string(ACME_DNS_SCHEMA_FN)?;
let schemas: serde_json::Map<String, Value> = serde_json::from_str(&raw)?;
Ok(schemas
.iter()
.map(|(id, schema)| AcmeChallengeSchema {
id: id.to_owned(),
name: schema
.get("name")
.and_then(Value::as_str)
.unwrap_or(id)
.to_owned(),
ty: "dns".into(),
schema: schema.to_owned(),
})
.collect())
}
pub fn get_cached_challenge_schemas() -> Result<ChallengeSchemaWrapper, Error> {
lazy_static! {
static ref CACHE: Mutex<Option<(Arc<Vec<AcmeChallengeSchema>>, SystemTime)>> =
Mutex::new(None);
}
// the actual loading code
let mut last = CACHE.lock().unwrap();
let actual_mtime = std::fs::metadata(ACME_DNS_SCHEMA_FN)?.modified()?;
let schema = match &*last {
Some((schema, cached_mtime)) if *cached_mtime >= actual_mtime => schema.clone(),
_ => {
let new_schema = Arc::new(load_dns_challenge_schema()?);
*last = Some((Arc::clone(&new_schema), actual_mtime));
new_schema
}
};
Ok(ChallengeSchemaWrapper { inner: schema })
}

View File

@ -0,0 +1,68 @@
//! ACME API Configuration.
use std::borrow::Cow;
use proxmox_sys::error::SysError;
use proxmox_sys::fs::CreateOptions;
use crate::types::KnownAcmeDirectory;
/// ACME API Configuration.
///
/// This struct provides access to the server side configuration, like the
/// configuration directory. All ACME API functions are implemented as member
/// fuction, so they all have access to this configuration.
///
pub struct AcmeApiConfig {
/// Path to the ACME configuration directory.
pub config_dir: &'static str,
/// Configuration file owner.
pub file_owner: fn() -> nix::unistd::User,
}
/// List of known ACME directorties.
pub const KNOWN_ACME_DIRECTORIES: &[KnownAcmeDirectory] = &[
KnownAcmeDirectory {
name: Cow::Borrowed("Let's Encrypt V2"),
url: Cow::Borrowed("https://acme-v02.api.letsencrypt.org/directory"),
},
KnownAcmeDirectory {
name: Cow::Borrowed("Let's Encrypt V2 Staging"),
url: Cow::Borrowed("https://acme-staging-v02.api.letsencrypt.org/directory"),
},
];
/// Default ACME directorties.
pub const DEFAULT_ACME_DIRECTORY_ENTRY: &KnownAcmeDirectory = &KNOWN_ACME_DIRECTORIES[0];
// local helpers to read/write acme configuration
impl AcmeApiConfig {
pub(crate) fn acme_config_dir(&self) -> &'static str {
self.config_dir
}
pub(crate) fn plugin_cfg_filename(&self) -> String {
format!("{}/plugins.cfg", self.acme_config_dir())
}
pub(crate) fn plugin_cfg_lockfile(&self) -> String {
format!("{}/.plugins.lck", self.acme_config_dir())
}
pub(crate) fn create_acme_subdir(dir: &str) -> nix::Result<()> {
let root_only = CreateOptions::new()
.owner(nix::unistd::ROOT)
.group(nix::unistd::Gid::from_raw(0))
.perm(nix::sys::stat::Mode::from_bits_truncate(0o700));
match proxmox_sys::fs::create_dir(dir, root_only) {
Ok(()) => Ok(()),
Err(err) if err.already_exists() => Ok(()),
Err(err) => Err(err),
}
}
pub(crate) fn make_acme_dir(&self) -> nix::Result<()> {
Self::create_acme_subdir(&self.acme_config_dir())
}
}

View File

@ -0,0 +1,28 @@
//! ACME API crate (API types and API implementation)
#[cfg(feature = "api-types")]
pub mod types;
#[cfg(feature = "impl")]
pub mod challenge_schemas;
#[cfg(feature = "impl")]
pub mod config;
#[cfg(feature = "impl")]
pub(crate) mod account_config;
#[cfg(feature = "impl")]
pub(crate) mod plugin_config;
#[cfg(feature = "impl")]
pub(crate) mod account_api_impl;
#[cfg(feature = "impl")]
pub(crate) mod plugin_api_impl;
#[cfg(feature = "impl")]
pub(crate) mod acme_plugin;
#[cfg(feature = "impl")]
pub(crate) mod certificate_helpers;

View File

@ -0,0 +1,188 @@
//! ACME plugin configuration API implementation
use anyhow::{bail, format_err, Error};
use hex::FromHex;
use serde::Deserialize;
use serde_json::Value;
use proxmox_schema::param_bail;
use crate::config::AcmeApiConfig;
use crate::types::{DeletablePluginProperty, PluginConfig, DnsPlugin, DnsPluginCore, DnsPluginCoreUpdater};
use proxmox_router::{http_bail, RpcEnvironment};
impl AcmeApiConfig {
pub fn list_plugins(
&self,
rpcenv: &mut dyn RpcEnvironment,
) -> Result<Vec<PluginConfig>, Error> {
let (plugins, digest) = self.plugin_config()?;
rpcenv["digest"] = hex::encode(digest).into();
Ok(plugins
.iter()
.map(|(id, (ty, data))| modify_cfg_for_api(id, ty, data))
.collect())
}
pub fn get_plugin(
&self,
id: String,
rpcenv: &mut dyn RpcEnvironment,
) -> Result<PluginConfig, Error> {
let (plugins, digest) = self.plugin_config()?;
rpcenv["digest"] = hex::encode(digest).into();
match plugins.get(&id) {
Some((ty, data)) => Ok(modify_cfg_for_api(&id, ty, data)),
None => http_bail!(NOT_FOUND, "no such plugin"),
}
}
pub fn add_plugin(
&self,
r#type: String,
core: DnsPluginCore,
data: String,
) -> Result<(), Error> {
// Currently we only support DNS plugins and the standalone plugin is "fixed":
if r#type != "dns" {
param_bail!("type", "invalid ACME plugin type: {:?}", r#type);
}
let data = String::from_utf8(base64::decode(data)?)
.map_err(|_| format_err!("data must be valid UTF-8"))?;
let id = core.id.clone();
let _lock = self.lock_plugin_config()?;
let (mut plugins, _digest) = self.plugin_config()?;
if plugins.contains_key(&id) {
param_bail!("id", "ACME plugin ID {:?} already exists", id);
}
let plugin = serde_json::to_value(DnsPlugin { core, data })?;
plugins.insert(id, r#type, plugin);
self.save_plugin_config(&plugins)?;
Ok(())
}
pub fn update_plugin(
&self,
id: String,
update: DnsPluginCoreUpdater,
data: Option<String>,
delete: Option<Vec<DeletablePluginProperty>>,
digest: Option<String>,
) -> Result<(), Error> {
let data = data
.as_deref()
.map(base64::decode)
.transpose()?
.map(String::from_utf8)
.transpose()
.map_err(|_| format_err!("data must be valid UTF-8"))?;
let _lock = self.lock_plugin_config()?;
let (mut plugins, expected_digest) = self.plugin_config()?;
if let Some(digest) = digest {
let digest = <[u8; 32]>::from_hex(digest)?;
if digest != expected_digest {
bail!("detected modified configuration - file changed by other user? Try again.");
}
}
match plugins.get_mut(&id) {
Some((ty, ref mut entry)) => {
if ty != "dns" {
bail!("cannot update plugin of type {:?}", ty);
}
let mut plugin = DnsPlugin::deserialize(&*entry)?;
if let Some(delete) = delete {
for delete_prop in delete {
match delete_prop {
DeletablePluginProperty::ValidationDelay => {
plugin.core.validation_delay = None;
}
DeletablePluginProperty::Disable => {
plugin.core.disable = None;
}
}
}
}
if let Some(data) = data {
plugin.data = data;
}
if let Some(api) = update.api {
plugin.core.api = api;
}
if update.validation_delay.is_some() {
plugin.core.validation_delay = update.validation_delay;
}
if update.disable.is_some() {
plugin.core.disable = update.disable;
}
*entry = serde_json::to_value(plugin)?;
}
None => http_bail!(NOT_FOUND, "no such plugin"),
}
self.save_plugin_config(&plugins)?;
Ok(())
}
pub fn delete_plugin(&self, id: String) -> Result<(), Error> {
let _lock = self.lock_plugin_config()?;
let (mut plugins, _digest) = self.plugin_config()?;
if plugins.remove(&id).is_none() {
http_bail!(NOT_FOUND, "no such plugin");
}
self.save_plugin_config(&plugins)?;
Ok(())
}
}
// See PMG/PVE's $modify_cfg_for_api sub
fn modify_cfg_for_api(id: &str, ty: &str, data: &Value) -> PluginConfig {
let mut entry = data.clone();
let obj = entry.as_object_mut().unwrap();
obj.remove("id");
obj.insert("plugin".to_string(), Value::String(id.to_owned()));
obj.insert("type".to_string(), Value::String(ty.to_owned()));
// FIXME: This needs to go once the `Updater` is fixed.
// None of these should be able to fail unless the user changed the files by hand, in which
// case we leave the unmodified string in the Value for now. This will be handled with an error
// later.
if let Some(Value::String(ref mut data)) = obj.get_mut("data") {
if let Ok(new) = base64::decode_config(&data, base64::URL_SAFE_NO_PAD) {
if let Ok(utf8) = String::from_utf8(new) {
*data = utf8;
}
}
}
// PVE/PMG do this explicitly for ACME plugins...
// obj.insert("digest".to_string(), Value::String(digest.clone()));
serde_json::from_value(entry).unwrap_or_else(|_| PluginConfig {
plugin: "*Error*".to_string(),
ty: "*Error*".to_string(),
..Default::default()
})
}

View File

@ -0,0 +1,142 @@
//! ACME plugin configuration helpers (SectionConfig implementation)
use anyhow::Error;
use lazy_static::lazy_static;
use serde_json::Value;
use proxmox_schema::{ApiType, Schema};
use proxmox_section_config::{SectionConfig, SectionConfigData, SectionConfigPlugin};
use crate::config::AcmeApiConfig;
use crate::types::{PLUGIN_ID_SCHEMA, DnsPlugin, StandalonePlugin};
lazy_static! {
static ref CONFIG: SectionConfig = init();
}
impl DnsPlugin {
pub fn decode_data(&self, output: &mut Vec<u8>) -> Result<(), Error> {
Ok(base64::decode_config_buf(
&self.data,
base64::URL_SAFE_NO_PAD,
output,
)?)
}
}
fn init() -> SectionConfig {
let mut config = SectionConfig::new(&PLUGIN_ID_SCHEMA);
let standalone_schema = match &StandalonePlugin::API_SCHEMA {
Schema::Object(schema) => schema,
_ => unreachable!(),
};
let standalone_plugin = SectionConfigPlugin::new(
"standalone".to_string(),
Some("id".to_string()),
standalone_schema,
);
config.register_plugin(standalone_plugin);
let dns_challenge_schema = match DnsPlugin::API_SCHEMA {
Schema::AllOf(ref schema) => schema,
_ => unreachable!(),
};
let dns_challenge_plugin = SectionConfigPlugin::new(
"dns".to_string(),
Some("id".to_string()),
dns_challenge_schema,
);
config.register_plugin(dns_challenge_plugin);
config
}
// LockGuard for the plugin configuration
pub(crate) struct AcmePluginConfigLockGuard {
_file: Option<std::fs::File>,
}
impl AcmeApiConfig {
pub(crate) fn lock_plugin_config(&self) -> Result<AcmePluginConfigLockGuard, Error> {
self.make_acme_dir()?;
let file_owner = (self.file_owner)();
let mode = nix::sys::stat::Mode::from_bits_truncate(0o0660);
let options = proxmox_sys::fs::CreateOptions::new()
.perm(mode)
.owner(file_owner.uid)
.group(file_owner.gid);
let timeout = std::time::Duration::new(10, 0);
let plugin_cfg_lockfile = self.plugin_cfg_lockfile();
let file = proxmox_sys::fs::open_file_locked(&plugin_cfg_lockfile, timeout, true, options)?;
Ok(AcmePluginConfigLockGuard { _file: Some(file) })
}
pub(crate) fn plugin_config(&self) -> Result<(PluginData, [u8; 32]), Error> {
let plugin_cfg_filename = self.plugin_cfg_filename();
let content =
proxmox_sys::fs::file_read_optional_string(&plugin_cfg_filename)?.unwrap_or_default();
let digest = openssl::sha::sha256(content.as_bytes());
let mut data = CONFIG.parse(&plugin_cfg_filename, &content)?;
if data.sections.get("standalone").is_none() {
let standalone = StandalonePlugin::default();
data.set_data("standalone", "standalone", &standalone)
.unwrap();
}
Ok((PluginData { data }, digest))
}
pub(crate) fn save_plugin_config(&self, config: &PluginData) -> Result<(), Error> {
self.make_acme_dir()?;
let plugin_cfg_filename = self.plugin_cfg_filename();
let raw = CONFIG.write(&plugin_cfg_filename, &config.data)?;
let file_owner = (self.file_owner)();
let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640);
// set the correct owner/group/permissions while saving file
let options = proxmox_sys::fs::CreateOptions::new()
.perm(mode)
.owner(file_owner.uid)
.group(file_owner.gid);
proxmox_sys::fs::replace_file(plugin_cfg_filename, raw.as_bytes(), options, true)
}
}
pub(crate) struct PluginData {
data: SectionConfigData,
}
// And some convenience helpers.
impl PluginData {
pub fn remove(&mut self, name: &str) -> Option<(String, Value)> {
self.data.sections.remove(name)
}
pub fn contains_key(&mut self, name: &str) -> bool {
self.data.sections.contains_key(name)
}
pub fn get(&self, name: &str) -> Option<&(String, Value)> {
self.data.sections.get(name)
}
pub fn get_mut(&mut self, name: &str) -> Option<&mut (String, Value)> {
self.data.sections.get_mut(name)
}
pub fn insert(&mut self, id: String, ty: String, plugin: Value) {
self.data.sections.insert(id, (ty, plugin));
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &(String, Value))> + Send {
self.data.sections.iter()
}
}

View File

@ -0,0 +1,355 @@
//! ACME API type definitions.
use std::borrow::Cow;
use anyhow::Error;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use proxmox_schema::{api, ApiStringFormat, ApiType, Schema, StringSchema, Updater};
use proxmox_schema::api_types::{DNS_ALIAS_FORMAT, DNS_NAME_FORMAT, SAFE_ID_FORMAT};
use proxmox_acme::types::AccountData as AcmeAccountData;
#[api(
properties: {
san: {
type: Array,
items: {
description: "A SubjectAlternateName entry.",
type: String,
},
},
},
)]
/// Certificate information.
#[derive(PartialEq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct CertificateInfo {
/// Certificate file name.
pub filename: String,
/// Certificate subject name.
pub subject: String,
/// List of certificate's SubjectAlternativeName entries.
pub san: Vec<String>,
/// Certificate issuer name.
pub issuer: String,
/// Certificate's notBefore timestamp (UNIX epoch).
#[serde(skip_serializing_if = "Option::is_none")]
pub notbefore: Option<i64>,
/// Certificate's notAfter timestamp (UNIX epoch).
#[serde(skip_serializing_if = "Option::is_none")]
pub notafter: Option<i64>,
/// Certificate in PEM format.
#[serde(skip_serializing_if = "Option::is_none")]
pub pem: Option<String>,
/// Certificate's public key algorithm.
pub public_key_type: String,
/// Certificate's public key size if available.
#[serde(skip_serializing_if = "Option::is_none")]
pub public_key_bits: Option<u32>,
/// The SSL Fingerprint.
#[serde(skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
}
proxmox_schema::api_string_type! {
#[api(format: &SAFE_ID_FORMAT)]
/// ACME account name.
#[derive(Clone, Eq, PartialEq, Hash, Deserialize, Serialize)]
#[serde(transparent)]
pub struct AcmeAccountName(String);
}
#[api(
properties: {
name: { type: String },
url: { type: String },
},
)]
/// An ACME directory endpoint with a name and URL.
#[derive(Clone, Deserialize, Serialize, PartialEq)]
pub struct KnownAcmeDirectory {
/// The ACME directory's name.
pub name: Cow<'static, str>,
/// The ACME directory's endpoint URL.
pub url: Cow<'static, str>,
}
#[api(
properties: {
schema: {
type: Object,
additional_properties: true,
properties: {},
},
type: {
type: String,
},
},
)]
#[derive(Clone, Deserialize, Serialize, PartialEq)]
/// Schema for an ACME challenge plugin.
pub struct AcmeChallengeSchema {
/// Plugin ID.
pub id: String,
/// Human readable name, falls back to id.
pub name: String,
/// Plugin Type.
#[serde(rename = "type")]
pub ty: String,
/// The plugin's parameter schema.
pub schema: Value,
}
#[api(
properties: {
"domain": { format: &DNS_NAME_FORMAT },
"alias": {
optional: true,
format: &DNS_ALIAS_FORMAT,
},
"plugin": {
optional: true,
format: &SAFE_ID_FORMAT,
},
},
default_key: "domain",
)]
#[derive(Clone, PartialEq, Deserialize, Serialize)]
/// A domain entry for an ACME certificate.
pub struct AcmeDomain {
/// The domain to certify for.
pub domain: String,
/// The domain to use for challenges instead of the default acme challenge domain.
///
/// This is useful if you use CNAME entries to redirect `_acme-challenge.*` domains to a
/// different DNS server.
#[serde(skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
/// The plugin to use to validate this domain.
///
/// Empty means standalone HTTP validation is used.
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin: Option<String>,
}
/// ACME domain configuration string [Schema].
pub const ACME_DOMAIN_PROPERTY_SCHEMA: Schema =
StringSchema::new("ACME domain configuration string")
.format(&ApiStringFormat::PropertyString(&AcmeDomain::API_SCHEMA))
.schema();
/// Parse [AcmeDomain] from property string.
pub fn parse_acme_domain_string(value_str: &str) -> Result<AcmeDomain, Error> {
let value = AcmeDomain::API_SCHEMA.parse_property_string(value_str)?;
let value: AcmeDomain = serde_json::from_value(value)?;
Ok(value)
}
/// Format [AcmeDomain] as property string.
pub fn create_acme_domain_string(config: &AcmeDomain) -> String {
proxmox_schema::property_string::print::<AcmeDomain>(config).unwrap()
}
#[api()]
#[derive(Clone, PartialEq, Deserialize, Serialize)]
/// ACME Account information.
///
/// This is what we return via the API.
pub struct AccountInfo {
/// Raw account data.
pub account: AcmeAccountData,
/// The ACME directory URL the account was created at.
pub directory: String,
/// The account's own URL within the ACME directory.
pub location: String,
/// The ToS URL, if the user agreed to one.
#[serde(skip_serializing_if = "Option::is_none")]
pub tos: Option<String>,
}
/// An ACME Account entry.
///
/// Currently only contains a 'name' property.
#[api()]
#[derive(Clone, PartialEq, Deserialize, Serialize)]
pub struct AcmeAccountEntry {
pub name: AcmeAccountName,
}
#[api()]
#[derive(Clone, PartialEq, Deserialize, Serialize)]
/// The ACME configuration.
///
/// Currently only contains the name of the account use.
pub struct AcmeConfig {
/// Account to use to acquire ACME certificates.
pub account: String,
}
/// Parse [AcmeConfig] from property string.
pub fn parse_acme_config_string(value_str: &str) -> Result<AcmeConfig, Error> {
let value = AcmeConfig::API_SCHEMA.parse_property_string(value_str)?;
let value: AcmeConfig = serde_json::from_value(value)?;
Ok(value)
}
/// Format [AcmeConfig] as property string.
pub fn create_acme_config_string(config: &AcmeConfig) -> String {
proxmox_schema::property_string::print::<AcmeConfig>(config).unwrap()
}
/// [Schema] for ACME Challenge Plugin ID.
pub const PLUGIN_ID_SCHEMA: Schema = StringSchema::new("ACME Challenge Plugin ID.")
.format(&SAFE_ID_FORMAT)
.min_length(1)
.max_length(32)
.schema();
#[api]
#[derive(Clone, Default, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
/// ACME plugin config. The API's format is inherited from PVE/PMG:
pub struct PluginConfig {
/// Plugin ID.
pub plugin: String,
/// Plugin type.
#[serde(rename = "type")]
pub ty: String,
/// DNS Api name.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub api: Option<String>,
/// Plugin configuration data.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub data: Option<String>,
/// Extra delay in seconds to wait before requesting validation.
///
/// Allows to cope with long TTL of DNS records.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub validation_delay: Option<u32>,
/// Flag to disable the config.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub disable: Option<bool>,
}
#[api(
properties: {
id: { schema: PLUGIN_ID_SCHEMA },
},
)]
#[derive(Deserialize, Serialize)]
/// Standalone ACME Plugin for the http-1 challenge.
pub struct StandalonePlugin {
/// Plugin ID.
id: String,
}
impl Default for StandalonePlugin {
fn default() -> Self {
Self {
id: "standalone".to_string(),
}
}
}
#[api(
properties: {
id: { schema: PLUGIN_ID_SCHEMA },
disable: {
optional: true,
default: false,
},
"validation-delay": {
default: 30,
optional: true,
minimum: 0,
maximum: 2 * 24 * 60 * 60,
},
},
)]
/// DNS ACME Challenge Plugin core data.
#[derive(Deserialize, Serialize, Updater)]
#[serde(rename_all = "kebab-case")]
pub struct DnsPluginCore {
/// Plugin ID.
#[updater(skip)]
pub id: String,
/// DNS API Plugin Id.
pub api: String,
/// Extra delay in seconds to wait before requesting validation.
///
/// Allows to cope with long TTL of DNS records.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub validation_delay: Option<u32>,
/// Flag to disable the config.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub disable: Option<bool>,
}
#[api(
properties: {
core: { type: DnsPluginCore },
},
)]
/// DNS ACME Challenge Plugin.
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct DnsPlugin {
#[serde(flatten)]
pub core: DnsPluginCore,
// We handle this property separately in the API calls.
/// DNS plugin data (base64url encoded without padding).
#[serde(with = "proxmox_serde::string_as_base64url_nopad")]
pub data: String,
}
#[api()]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
/// Deletable plugin property names.
pub enum DeletablePluginProperty {
/// Delete the disable property
Disable,
/// Delete the validation-delay property
ValidationDelay,
}
#[api(
properties: {
name: { type: AcmeAccountName },
},
)]
/// An ACME Account entry.
///
/// Currently only contains a 'name' property.
#[derive(Clone, PartialEq, Deserialize, Serialize)]
pub struct AccountEntry {
pub name: AcmeAccountName,
}