[pbs-devel] [REBASED v2 backup 4/9] add config/acme api path
Wolfgang Bumiller
w.bumiller at proxmox.com
Mon May 3 11:39:54 CEST 2021
Signed-off-by: Wolfgang Bumiller <w.bumiller at proxmox.com>
---
src/api2/config.rs | 2 +
src/api2/config/acme.rs | 725 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 727 insertions(+)
create mode 100644 src/api2/config/acme.rs
diff --git a/src/api2/config.rs b/src/api2/config.rs
index 996ec268..9befa0e5 100644
--- a/src/api2/config.rs
+++ b/src/api2/config.rs
@@ -4,6 +4,7 @@ use proxmox::api::router::{Router, SubdirMap};
use proxmox::list_subdirs_api_method;
pub mod access;
+pub mod acme;
pub mod datastore;
pub mod remote;
pub mod sync;
@@ -16,6 +17,7 @@ pub mod tape_backup_job;
const SUBDIRS: SubdirMap = &[
("access", &access::ROUTER),
+ ("acme", &acme::ROUTER),
("changer", &changer::ROUTER),
("datastore", &datastore::ROUTER),
("drive", &drive::ROUTER),
diff --git a/src/api2/config/acme.rs b/src/api2/config/acme.rs
new file mode 100644
index 00000000..14a749d1
--- /dev/null
+++ b/src/api2/config/acme.rs
@@ -0,0 +1,725 @@
+use std::path::Path;
+
+use anyhow::{bail, format_err, Error};
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+
+use proxmox::api::router::SubdirMap;
+use proxmox::api::schema::Updatable;
+use proxmox::api::{api, Permission, Router, RpcEnvironment};
+use proxmox::http_bail;
+use proxmox::list_subdirs_api_method;
+
+use proxmox_acme_rs::account::AccountData as AcmeAccountData;
+use proxmox_acme_rs::Account;
+
+use crate::acme::AcmeClient;
+use crate::api2::types::Authid;
+use crate::config::acl::PRIV_SYS_MODIFY;
+use crate::config::acme::plugin::{
+ DnsPlugin, DnsPluginCore, DnsPluginCoreUpdater, PLUGIN_ID_SCHEMA,
+};
+use crate::config::acme::{AccountName, KnownAcmeDirectory};
+use crate::server::WorkerTask;
+use crate::tools::ControlFlow;
+
+pub(crate) const ROUTER: Router = Router::new()
+ .get(&list_subdirs_api_method!(SUBDIRS))
+ .subdirs(SUBDIRS);
+
+const SUBDIRS: SubdirMap = &[
+ (
+ "account",
+ &Router::new()
+ .get(&API_METHOD_LIST_ACCOUNTS)
+ .post(&API_METHOD_REGISTER_ACCOUNT)
+ .match_all("name", &ACCOUNT_ITEM_ROUTER),
+ ),
+ (
+ "challenge-schema",
+ &Router::new().get(&API_METHOD_GET_CHALLENGE_SCHEMA),
+ ),
+ (
+ "directories",
+ &Router::new().get(&API_METHOD_GET_DIRECTORIES),
+ ),
+ (
+ "plugins",
+ &Router::new()
+ .get(&API_METHOD_LIST_PLUGINS)
+ .post(&API_METHOD_ADD_PLUGIN)
+ .match_all("id", &PLUGIN_ITEM_ROUTER),
+ ),
+ ("tos", &Router::new().get(&API_METHOD_GET_TOS)),
+];
+
+const ACCOUNT_ITEM_ROUTER: Router = Router::new()
+ .get(&API_METHOD_GET_ACCOUNT)
+ .put(&API_METHOD_UPDATE_ACCOUNT)
+ .delete(&API_METHOD_DEACTIVATE_ACCOUNT);
+
+const PLUGIN_ITEM_ROUTER: Router = Router::new()
+ .get(&API_METHOD_GET_PLUGIN)
+ .put(&API_METHOD_UPDATE_PLUGIN)
+ .delete(&API_METHOD_DELETE_PLUGIN);
+
+#[api(
+ properties: {
+ name: { type: AccountName },
+ },
+)]
+/// An ACME Account entry.
+///
+/// Currently only contains a 'name' property.
+#[derive(Serialize)]
+pub struct AccountEntry {
+ name: AccountName,
+}
+
+#[api(
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ returns: {
+ type: Array,
+ items: { type: AccountEntry },
+ description: "List of ACME accounts.",
+ },
+ protected: true,
+)]
+/// List ACME accounts.
+pub fn list_accounts() -> Result<Vec<AccountEntry>, Error> {
+ let mut entries = Vec::new();
+ crate::config::acme::foreach_acme_account(|name| {
+ entries.push(AccountEntry { name });
+ ControlFlow::Continue(())
+ })?;
+ Ok(entries)
+}
+
+#[api(
+ properties: {
+ account: { type: Object, properties: {}, additional_properties: true },
+ tos: {
+ type: String,
+ optional: true,
+ },
+ },
+)]
+/// ACME Account information.
+///
+/// This is what we return via the API.
+#[derive(Serialize)]
+pub struct AccountInfo {
+ /// Raw account data.
+ account: AcmeAccountData,
+
+ /// The ACME directory URL the account was created at.
+ directory: String,
+
+ /// The account's own URL within the ACME directory.
+ location: String,
+
+ /// The ToS URL, if the user agreed to one.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ tos: Option<String>,
+}
+
+#[api(
+ input: {
+ properties: {
+ name: { type: AccountName },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ returns: { type: AccountInfo },
+ protected: true,
+)]
+/// Return existing ACME account information.
+pub async fn get_account(name: AccountName) -> Result<AccountInfo, Error> {
+ let client = AcmeClient::load(&name).await?;
+ let account = client.account()?;
+ Ok(AccountInfo {
+ location: account.location.clone(),
+ tos: client.tos().map(str::to_owned),
+ directory: client.directory_url().to_owned(),
+ account: AcmeAccountData {
+ only_return_existing: false, // don't actually write this out in case it's set
+ ..account.data.clone()
+ },
+ })
+}
+
+fn account_contact_from_string(s: &str) -> Vec<String> {
+ s.split(&[' ', ';', ',', '\0'][..])
+ .map(|s| format!("mailto:{}", s))
+ .collect()
+}
+
+#[api(
+ input: {
+ properties: {
+ name: {
+ type: AccountName,
+ optional: true,
+ },
+ contact: {
+ description: "List of email addresses.",
+ },
+ tos_url: {
+ description: "URL of CA TermsOfService - setting this indicates agreement.",
+ optional: true,
+ },
+ directory: {
+ type: String,
+ description: "The ACME Directory.",
+ optional: true,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Register an ACME account.
+fn register_account(
+ name: Option<AccountName>,
+ // Todo: email & email-list schema
+ contact: String,
+ tos_url: Option<String>,
+ directory: Option<String>,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<String, Error> {
+ let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
+
+ let name = name
+ .unwrap_or_else(|| unsafe { AccountName::from_string_unchecked("default".to_string()) });
+
+ if Path::new(&crate::config::acme::account_path(&name)).exists() {
+ http_bail!(BAD_REQUEST, "account {:?} already exists", name);
+ }
+
+ let directory = directory.unwrap_or_else(|| {
+ crate::config::acme::DEFAULT_ACME_DIRECTORY_ENTRY
+ .url
+ .to_owned()
+ });
+
+ WorkerTask::spawn(
+ "acme-register",
+ None,
+ auth_id,
+ true,
+ move |worker| async move {
+ let mut client = AcmeClient::new(directory);
+
+ worker.log("Registering ACME account...");
+
+ let account =
+ do_register_account(&mut client, &name, tos_url.is_some(), contact, None).await?;
+
+ worker.log(format!(
+ "Registration successful, account URL: {}",
+ account.location
+ ));
+
+ Ok(())
+ },
+ )
+}
+
+pub async fn do_register_account<'a>(
+ client: &'a mut AcmeClient,
+ name: &AccountName,
+ agree_to_tos: bool,
+ contact: String,
+ rsa_bits: Option<u32>,
+) -> Result<&'a Account, Error> {
+ let contact = account_contact_from_string(&contact);
+ Ok(client
+ .new_account(name, agree_to_tos, contact, rsa_bits)
+ .await?)
+}
+
+#[api(
+ input: {
+ properties: {
+ name: { type: AccountName },
+ contact: {
+ description: "List of email addresses.",
+ optional: true,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Update an ACME account.
+pub fn update_account(
+ name: AccountName,
+ // Todo: email & email-list schema
+ contact: Option<String>,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<String, Error> {
+ let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
+
+ WorkerTask::spawn(
+ "acme-update",
+ None,
+ auth_id,
+ true,
+ move |_worker| async move {
+ let data = match contact {
+ Some(data) => json!({
+ "contact": account_contact_from_string(&data),
+ }),
+ None => json!({}),
+ };
+
+ AcmeClient::load(&name).await?.update_account(&data).await?;
+
+ Ok(())
+ },
+ )
+}
+
+#[api(
+ input: {
+ properties: {
+ name: { type: AccountName },
+ force: {
+ description:
+ "Delete account data even if the server refuses to deactivate the account.",
+ optional: true,
+ default: false,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Deactivate an ACME account.
+pub fn deactivate_account(
+ name: AccountName,
+ force: bool,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<String, Error> {
+ let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
+
+ WorkerTask::spawn(
+ "acme-deactivate",
+ None,
+ auth_id,
+ true,
+ move |worker| async move {
+ match AcmeClient::load(&name)
+ .await?
+ .update_account(&json!({"status": "deactivated"}))
+ .await
+ {
+ Ok(_account) => (),
+ Err(err) if !force => return Err(err),
+ Err(err) => {
+ worker.warn(format!(
+ "error deactivating account {:?}, proceedeing anyway - {}",
+ name, err,
+ ));
+ }
+ }
+ crate::config::acme::mark_account_deactivated(&name)?;
+ Ok(())
+ },
+ )
+}
+
+#[api(
+ input: {
+ properties: {
+ directory: {
+ type: String,
+ description: "The ACME Directory.",
+ optional: true,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Anybody,
+ },
+ returns: {
+ type: String,
+ optional: true,
+ description: "The ACME Directory's ToS URL, if any.",
+ },
+)]
+/// Get the Terms of Service URL for an ACME directory.
+async fn get_tos(directory: Option<String>) -> Result<Option<String>, Error> {
+ let directory = directory.unwrap_or_else(|| {
+ crate::config::acme::DEFAULT_ACME_DIRECTORY_ENTRY
+ .url
+ .to_owned()
+ });
+ Ok(AcmeClient::new(directory)
+ .terms_of_service_url()
+ .await?
+ .map(str::to_owned))
+}
+
+#[api(
+ access: {
+ permission: &Permission::Anybody,
+ },
+ returns: {
+ description: "List of known ACME directories.",
+ type: Array,
+ items: { type: KnownAcmeDirectory },
+ },
+)]
+/// Get named known ACME directory endpoints.
+fn get_directories() -> Result<&'static [KnownAcmeDirectory], Error> {
+ Ok(crate::config::acme::KNOWN_ACME_DIRECTORIES)
+}
+
+#[api(
+ properties: {
+ schema: {
+ type: Object,
+ additional_properties: true,
+ properties: {},
+ },
+ type: {
+ type: String,
+ },
+ },
+)]
+#[derive(Serialize)]
+/// Schema for an ACME challenge plugin.
+pub struct ChallengeSchema {
+ /// Plugin ID.
+ id: String,
+
+ /// Human readable name, falls back to id.
+ name: String,
+
+ /// Plugin Type.
+ #[serde(rename = "type")]
+ ty: &'static str,
+
+ /// The plugin's parameter schema.
+ schema: Value,
+}
+
+#[api(
+ access: {
+ permission: &Permission::Anybody,
+ },
+ returns: {
+ description: "ACME Challenge Plugin Shema.",
+ type: Array,
+ items: { type: ChallengeSchema },
+ },
+)]
+/// Get named known ACME directory endpoints.
+fn get_challenge_schema() -> Result<Vec<ChallengeSchema>, Error> {
+ let mut out = Vec::new();
+ crate::config::acme::foreach_dns_plugin(|id| {
+ out.push(ChallengeSchema {
+ id: id.to_owned(),
+ name: id.to_owned(),
+ ty: "dns",
+ schema: Value::Object(Default::default()),
+ });
+ ControlFlow::Continue(())
+ })?;
+ Ok(out)
+}
+
+#[api]
+#[derive(Default, Deserialize, Serialize)]
+#[serde(rename_all = "kebab-case")]
+/// The API's format is inherited from PVE/PMG:
+pub struct PluginConfig {
+ /// Plugin ID.
+ plugin: String,
+
+ /// Plugin type.
+ #[serde(rename = "type")]
+ ty: String,
+
+ /// DNS Api name.
+ api: Option<String>,
+
+ /// Plugin configuration data.
+ 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)]
+ validation_delay: Option<u32>,
+
+ /// Flag to disable the config.
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ disable: Option<bool>,
+}
+
+// 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()
+ })
+}
+
+#[api(
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+ returns: {
+ type: Array,
+ description: "List of ACME plugin configurations.",
+ items: { type: PluginConfig },
+ },
+)]
+/// List ACME challenge plugins.
+pub fn list_plugins(mut rpcenv: &mut dyn RpcEnvironment) -> Result<Vec<PluginConfig>, Error> {
+ use crate::config::acme::plugin;
+
+ let (plugins, digest) = plugin::config()?;
+ rpcenv["digest"] = proxmox::tools::digest_to_hex(&digest).into();
+ Ok(plugins
+ .iter()
+ .map(|(id, (ty, data))| modify_cfg_for_api(&id, &ty, data))
+ .collect())
+}
+
+#[api(
+ input: {
+ properties: {
+ id: { schema: PLUGIN_ID_SCHEMA },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+ returns: { type: PluginConfig },
+)]
+/// List ACME challenge plugins.
+pub fn get_plugin(id: String, mut rpcenv: &mut dyn RpcEnvironment) -> Result<PluginConfig, Error> {
+ use crate::config::acme::plugin;
+
+ let (plugins, digest) = plugin::config()?;
+ rpcenv["digest"] = proxmox::tools::digest_to_hex(&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"),
+ }
+}
+
+// Currently we only have "the" standalone plugin and DNS plugins so we can just flatten a
+// DnsPluginUpdater:
+//
+// FIXME: The 'id' parameter should not be "optional" in the schema.
+#[api(
+ input: {
+ properties: {
+ type: {
+ type: String,
+ description: "The ACME challenge plugin type.",
+ },
+ core: {
+ type: DnsPluginCoreUpdater,
+ flatten: true,
+ },
+ data: {
+ type: String,
+ // This is different in the API!
+ description: "DNS plugin data (base64 encoded with padding).",
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Add ACME plugin configuration.
+pub fn add_plugin(r#type: String, core: DnsPluginCoreUpdater, data: String) -> Result<(), Error> {
+ use crate::config::acme::plugin;
+
+ // Currently we only support DNS plugins and the standalone plugin is "fixed":
+ if r#type != "dns" {
+ bail!("invalid ACME plugin type: {:?}", r#type);
+ }
+
+ let data = String::from_utf8(base64::decode(&data)?)
+ .map_err(|_| format_err!("data must be valid UTF-8"))?;
+ //core.api_fixup()?;
+
+ // FIXME: Solve the Updater with non-optional fields thing...
+ let id = core
+ .id
+ .clone()
+ .ok_or_else(|| format_err!("missing required 'id' parameter"))?;
+
+ let _lock = plugin::lock()?;
+
+ let (mut plugins, _digest) = plugin::config()?;
+ if plugins.contains_key(&id) {
+ bail!("ACME plugin ID {:?} already exists", id);
+ }
+
+ let plugin = serde_json::to_value(DnsPlugin {
+ core: DnsPluginCore::try_build_from(core)?,
+ data,
+ })?;
+
+ plugins.insert(id, r#type, plugin);
+
+ plugin::save_config(&plugins)?;
+
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ id: { schema: PLUGIN_ID_SCHEMA },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Delete an ACME plugin configuration.
+pub fn delete_plugin(id: String) -> Result<(), Error> {
+ use crate::config::acme::plugin;
+
+ let _lock = plugin::lock()?;
+
+ let (mut plugins, _digest) = plugin::config()?;
+ if plugins.remove(&id).is_none() {
+ http_bail!(NOT_FOUND, "no such plugin");
+ }
+ plugin::save_config(&plugins)?;
+
+ Ok(())
+}
+
+#[api(
+ input: {
+ properties: {
+ core_update: {
+ type: DnsPluginCoreUpdater,
+ flatten: true,
+ },
+ data: {
+ type: String,
+ optional: true,
+ // This is different in the API!
+ description: "DNS plugin data (base64 encoded with padding).",
+ },
+ digest: {
+ description: "Digest to protect against concurrent updates",
+ optional: true,
+ },
+ delete: {
+ description: "Options to remove from the configuration",
+ optional: true,
+ },
+ },
+ },
+ access: {
+ permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false),
+ },
+ protected: true,
+)]
+/// Update an ACME plugin configuration.
+pub fn update_plugin(
+ core_update: DnsPluginCoreUpdater,
+ data: Option<String>,
+ delete: Option<String>,
+ digest: Option<String>,
+) -> Result<(), Error> {
+ use crate::config::acme::plugin;
+
+ let data = data
+ .as_deref()
+ .map(base64::decode)
+ .transpose()?
+ .map(String::from_utf8)
+ .transpose()
+ .map_err(|_| format_err!("data must be valid UTF-8"))?;
+ //core_update.api_fixup()?;
+
+ // unwrap: the id is matched by this method's API path
+ let id = core_update.id.clone().unwrap();
+
+ let delete: Vec<&str> = delete
+ .as_deref()
+ .unwrap_or("")
+ .split(&[' ', ',', ';', '\0'][..])
+ .collect();
+
+ let _lock = plugin::lock()?;
+
+ let (mut plugins, expected_digest) = plugin::config()?;
+
+ if let Some(digest) = digest {
+ let digest = proxmox::tools::hex_to_digest(&digest)?;
+ crate::tools::detect_modified_configuration_file(&digest, &expected_digest)?;
+ }
+
+ match plugins.get_mut(&id) {
+ Some((ty, ref mut entry)) => {
+ if ty != "dns" {
+ bail!("cannot update plugin of type {:?}", ty);
+ }
+
+ let mut plugin: DnsPlugin = serde_json::from_value(entry.clone())?;
+ plugin.core.update_from(core_update, &delete)?;
+ if let Some(data) = data {
+ plugin.data = data;
+ }
+ *entry = serde_json::to_value(plugin)?;
+ }
+ None => http_bail!(NOT_FOUND, "no such plugin"),
+ }
+
+ plugin::save_config(&plugins)?;
+
+ Ok(())
+}
--
2.20.1
More information about the pbs-devel
mailing list