forked from Proxmox/proxmox
acme-api: reusable ACME api implementation.
Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
This commit is contained in:
parent
870948f1d7
commit
cfc155a06b
@ -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" }
|
||||
|
58
proxmox-acme-api/Cargo.toml
Normal file
58
proxmox-acme-api/Cargo.toml
Normal 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",
|
||||
]
|
134
proxmox-acme-api/src/account_api_impl.rs
Normal file
134
proxmox-acme-api/src/account_api_impl.rs
Normal 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(())
|
||||
}
|
||||
}
|
230
proxmox-acme-api/src/account_config.rs
Normal file
230
proxmox-acme-api/src/account_config.rs
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
315
proxmox-acme-api/src/acme_plugin.rs
Normal file
315
proxmox-acme-api/src/acme_plugin.rs
Normal 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(())
|
||||
})
|
||||
}
|
||||
}
|
206
proxmox-acme-api/src/certificate_helpers.rs
Normal file
206
proxmox-acme-api/src/certificate_helpers.rs
Normal 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
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
74
proxmox-acme-api/src/challenge_schemas.rs
Normal file
74
proxmox-acme-api/src/challenge_schemas.rs
Normal 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 })
|
||||
}
|
68
proxmox-acme-api/src/config.rs
Normal file
68
proxmox-acme-api/src/config.rs
Normal 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())
|
||||
}
|
||||
}
|
28
proxmox-acme-api/src/lib.rs
Normal file
28
proxmox-acme-api/src/lib.rs
Normal 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;
|
188
proxmox-acme-api/src/plugin_api_impl.rs
Normal file
188
proxmox-acme-api/src/plugin_api_impl.rs
Normal 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()
|
||||
})
|
||||
}
|
142
proxmox-acme-api/src/plugin_config.rs
Normal file
142
proxmox-acme-api/src/plugin_config.rs
Normal 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()
|
||||
}
|
||||
}
|
355
proxmox-acme-api/src/types.rs
Normal file
355
proxmox-acme-api/src/types.rs
Normal 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,
|
||||
}
|
Loading…
Reference in New Issue
Block a user