acme-api: reusable ACME api implementation.
Signed-off-by: Dietmar Maurer <dietmar@proxmox.com>
This commit is contained in:
@ -1,6 +1,7 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"proxmox-acme",
|
"proxmox-acme",
|
||||||
|
"proxmox-acme-api",
|
||||||
"proxmox-api-macro",
|
"proxmox-api-macro",
|
||||||
"proxmox-apt",
|
"proxmox-apt",
|
||||||
"proxmox-async",
|
"proxmox-async",
|
||||||
@ -100,6 +101,7 @@ webauthn-rs = "0.3"
|
|||||||
zstd = { version = "0.12", features = [ "bindgen" ] }
|
zstd = { version = "0.12", features = [ "bindgen" ] }
|
||||||
|
|
||||||
# workspace dependencies
|
# 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-api-macro = { version = "1.0.8", path = "proxmox-api-macro" }
|
||||||
proxmox-async = { version = "0.4.1", path = "proxmox-async" }
|
proxmox-async = { version = "0.4.1", path = "proxmox-async" }
|
||||||
proxmox-compression = { version = "0.2.0", path = "proxmox-compression" }
|
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,
|
||||||
|
}
|
Reference in New Issue
Block a user