From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id A987673BA0 for ; Fri, 16 Apr 2021 15:36:24 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id BFFB824FEE for ; Fri, 16 Apr 2021 15:35:37 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [212.186.127.180]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS id 0A6BF24EC8 for ; Fri, 16 Apr 2021 15:35:26 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id C73A245B11 for ; Fri, 16 Apr 2021 15:35:25 +0200 (CEST) From: Wolfgang Bumiller To: pbs-devel@lists.proxmox.com Date: Fri, 16 Apr 2021 15:35:10 +0200 Message-Id: <20210416133517.23349-18-w.bumiller@proxmox.com> X-Mailer: git-send-email 2.20.1 In-Reply-To: <20210416133517.23349-1-w.bumiller@proxmox.com> References: <20210416133517.23349-1-w.bumiller@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.030 Adjusted score from AWL reputation of From: address KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [config.rs, acme.rs, plugin.data] Subject: [pbs-devel] [RFC backup 17/23] add config/acme api path X-BeenThere: pbs-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Backup Server development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Fri, 16 Apr 2021 13:36:24 -0000 Signed-off-by: Wolfgang Bumiller --- src/api2/config.rs | 2 + src/api2/config/acme.rs | 719 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 721 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..4f72a94e --- /dev/null +++ b/src/api2/config/acme.rs @@ -0,0 +1,719 @@ +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, 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, +} + +#[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 { + 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 { + s.split(&[' ', ';', ',', '\0'][..]) + .map(|s| format!("mailto:{}", s)) + .collect() +} + +#[api( + input: { + properties: { + name: { type: AccountName }, + 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: AccountName, + // Todo: email & email-list schema + contact: String, + tos_url: Option, + directory: Option, + rpcenv: &mut dyn RpcEnvironment, +) -> Result { + let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?; + + 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, +) -> 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, + rpcenv: &mut dyn RpcEnvironment, +) -> Result { + 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 { + 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) -> Result, 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, 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, + + /// Plugin configuration data. + data: Option, + + /// 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, + + /// Flag to disable the config. + #[serde(skip_serializing_if = "Option::is_none", default)] + disable: Option, +} + +// 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, 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 { + 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::write_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::write_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, + delete: Option, + digest: Option, +) -> 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::write_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