diff --git a/src/acme/client.rs b/src/acme/client.rs index 96d64770..e43a41b9 100644 --- a/src/acme/client.rs +++ b/src/acme/client.rs @@ -17,7 +17,8 @@ use proxmox_acme_rs::order::{Order, OrderData}; use proxmox_acme_rs::Request as AcmeRequest; use proxmox_acme_rs::{Account, Authorization, Challenge, Directory, Error, ErrorResponse}; -use crate::config::acme::{account_path, AccountName}; +use crate::api2::types::AcmeAccountName; +use crate::config::acme::account_path; use crate::tools::http::SimpleHttp; /// Our on-disk format inherited from PVE's proxmox-acme code. @@ -76,7 +77,7 @@ impl AcmeClient { } /// Load an existing ACME account by name. - pub async fn load(account_name: &AccountName) -> Result { + pub async fn load(account_name: &AcmeAccountName) -> Result { Self::load_path(account_path(account_name.as_ref())).await } @@ -98,7 +99,7 @@ impl AcmeClient { pub async fn new_account<'a>( &'a mut self, - account_name: &AccountName, + account_name: &AcmeAccountName, tos_agreed: bool, contact: Vec, rsa_bits: Option, diff --git a/src/acme/plugin.rs b/src/acme/plugin.rs index 860e7750..5e0e547a 100644 --- a/src/acme/plugin.rs +++ b/src/acme/plugin.rs @@ -11,7 +11,7 @@ use tokio::process::Command; use proxmox_acme_rs::{Authorization, Challenge}; use crate::acme::AcmeClient; -use crate::config::acme::AcmeDomain; +use crate::api2::types::AcmeDomain; use crate::server::WorkerTask; use crate::config::acme::plugin::{DnsPlugin, PluginData}; diff --git a/src/api2/config/acme.rs b/src/api2/config/acme.rs index 14a749d1..8ba38347 100644 --- a/src/api2/config/acme.rs +++ b/src/api2/config/acme.rs @@ -14,12 +14,11 @@ 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::api2::types::{Authid, KnownAcmeDirectory, AcmeAccountName}; use crate::server::WorkerTask; use crate::tools::ControlFlow; @@ -65,7 +64,7 @@ const PLUGIN_ITEM_ROUTER: Router = Router::new() #[api( properties: { - name: { type: AccountName }, + name: { type: AcmeAccountName }, }, )] /// An ACME Account entry. @@ -73,7 +72,7 @@ const PLUGIN_ITEM_ROUTER: Router = Router::new() /// Currently only contains a 'name' property. #[derive(Serialize)] pub struct AccountEntry { - name: AccountName, + name: AcmeAccountName, } #[api( @@ -128,7 +127,7 @@ pub struct AccountInfo { #[api( input: { properties: { - name: { type: AccountName }, + name: { type: AcmeAccountName }, }, }, access: { @@ -138,7 +137,7 @@ pub struct AccountInfo { protected: true, )] /// Return existing ACME account information. -pub async fn get_account(name: AccountName) -> Result { +pub async fn get_account(name: AcmeAccountName) -> Result { let client = AcmeClient::load(&name).await?; let account = client.account()?; Ok(AccountInfo { @@ -162,7 +161,7 @@ fn account_contact_from_string(s: &str) -> Vec { input: { properties: { name: { - type: AccountName, + type: AcmeAccountName, optional: true, }, contact: { @@ -186,7 +185,7 @@ fn account_contact_from_string(s: &str) -> Vec { )] /// Register an ACME account. fn register_account( - name: Option, + name: Option, // Todo: email & email-list schema contact: String, tos_url: Option, @@ -196,7 +195,7 @@ fn register_account( let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?; let name = name - .unwrap_or_else(|| unsafe { AccountName::from_string_unchecked("default".to_string()) }); + .unwrap_or_else(|| unsafe { AcmeAccountName::from_string_unchecked("default".to_string()) }); if Path::new(&crate::config::acme::account_path(&name)).exists() { http_bail!(BAD_REQUEST, "account {:?} already exists", name); @@ -233,7 +232,7 @@ fn register_account( pub async fn do_register_account<'a>( client: &'a mut AcmeClient, - name: &AccountName, + name: &AcmeAccountName, agree_to_tos: bool, contact: String, rsa_bits: Option, @@ -247,7 +246,7 @@ pub async fn do_register_account<'a>( #[api( input: { properties: { - name: { type: AccountName }, + name: { type: AcmeAccountName }, contact: { description: "List of email addresses.", optional: true, @@ -261,7 +260,7 @@ pub async fn do_register_account<'a>( )] /// Update an ACME account. pub fn update_account( - name: AccountName, + name: AcmeAccountName, // Todo: email & email-list schema contact: Option, rpcenv: &mut dyn RpcEnvironment, @@ -291,7 +290,7 @@ pub fn update_account( #[api( input: { properties: { - name: { type: AccountName }, + name: { type: AcmeAccountName }, force: { description: "Delete account data even if the server refuses to deactivate the account.", @@ -307,7 +306,7 @@ pub fn update_account( )] /// Deactivate an ACME account. pub fn deactivate_account( - name: AccountName, + name: AcmeAccountName, force: bool, rpcenv: &mut dyn RpcEnvironment, ) -> Result { diff --git a/src/api2/node/certificates.rs b/src/api2/node/certificates.rs index edc6e536..e6ad59b3 100644 --- a/src/api2/node/certificates.rs +++ b/src/api2/node/certificates.rs @@ -14,8 +14,8 @@ use proxmox::list_subdirs_api_method; use crate::acme::AcmeClient; use crate::api2::types::Authid; use crate::api2::types::NODE_SCHEMA; +use crate::api2::types::AcmeDomain; use crate::config::acl::PRIV_SYS_MODIFY; -use crate::config::acme::AcmeDomain; use crate::config::node::NodeConfig; use crate::server::WorkerTask; use crate::tools::cert; diff --git a/src/api2/types/acme.rs b/src/api2/types/acme.rs new file mode 100644 index 00000000..cc5df322 --- /dev/null +++ b/src/api2/types/acme.rs @@ -0,0 +1,126 @@ +use std::fmt; + +use anyhow::Error; +use serde::{Deserialize, Serialize}; + +use proxmox::api::{api, schema::{Schema, StringSchema, ApiStringFormat}}; + +use crate::api2::types::{ + DNS_ALIAS_FORMAT, DNS_NAME_FORMAT, PROXMOX_SAFE_ID_FORMAT, +}; + +#[api( + properties: { + "domain": { format: &DNS_NAME_FORMAT }, + "alias": { + optional: true, + format: &DNS_ALIAS_FORMAT, + }, + "plugin": { + optional: true, + format: &PROXMOX_SAFE_ID_FORMAT, + }, + }, + default_key: "domain", +)] +#[derive(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, + + /// 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, +} + +pub const ACME_DOMAIN_PROPERTY_SCHEMA: Schema = StringSchema::new( + "ACME domain configuration string") + .format(&ApiStringFormat::PropertyString(&AcmeDomain::API_SCHEMA)) + .schema(); + +#[api( + properties: { + name: { type: String }, + url: { type: String }, + }, +)] +/// An ACME directory endpoint with a name and URL. +#[derive(Serialize)] +pub struct KnownAcmeDirectory { + /// The ACME directory's name. + pub name: &'static str, + + /// The ACME directory's endpoint URL. + pub url: &'static str, +} + +#[api(format: &PROXMOX_SAFE_ID_FORMAT)] +/// ACME account name. +#[derive(Clone, Eq, PartialEq, Hash, Deserialize, Serialize)] +#[serde(transparent)] +pub struct AcmeAccountName(String); + +impl AcmeAccountName { + pub fn into_string(self) -> String { + self.0 + } + + pub fn from_string(name: String) -> Result { + match &Self::API_SCHEMA { + Schema::String(s) => s.check_constraints(&name)?, + _ => unreachable!(), + } + Ok(Self(name)) + } + + pub unsafe fn from_string_unchecked(name: String) -> Self { + Self(name) + } +} + +impl std::ops::Deref for AcmeAccountName { + type Target = str; + + #[inline] + fn deref(&self) -> &str { + &self.0 + } +} + +impl std::ops::DerefMut for AcmeAccountName { + #[inline] + fn deref_mut(&mut self) -> &mut str { + &mut self.0 + } +} + +impl AsRef for AcmeAccountName { + #[inline] + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +impl fmt::Debug for AcmeAccountName { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fmt::Debug::fmt(&self.0, f) + } +} + +impl fmt::Display for AcmeAccountName { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} diff --git a/src/api2/types/mod.rs b/src/api2/types/mod.rs index 0889bc98..e829f207 100644 --- a/src/api2/types/mod.rs +++ b/src/api2/types/mod.rs @@ -37,6 +37,9 @@ pub use tape::*; mod file_restore; pub use file_restore::*; +mod acme; +pub use acme::*; + // File names: may not contain slashes, may not start with "." pub const FILENAME_FORMAT: ApiStringFormat = ApiStringFormat::VerifyFn(|name| { if name.starts_with('.') { diff --git a/src/bin/proxmox_backup_manager/acme.rs b/src/bin/proxmox_backup_manager/acme.rs index 317473cb..5b1adcab 100644 --- a/src/bin/proxmox_backup_manager/acme.rs +++ b/src/bin/proxmox_backup_manager/acme.rs @@ -8,8 +8,9 @@ use proxmox::tools::fs::file_get_contents; use proxmox_backup::acme::AcmeClient; use proxmox_backup::api2; +use proxmox_backup::api2::types::AcmeAccountName; use proxmox_backup::config::acme::plugin::DnsPluginCoreUpdater; -use proxmox_backup::config::acme::{AccountName, KNOWN_ACME_DIRECTORIES}; +use proxmox_backup::config::acme::KNOWN_ACME_DIRECTORIES; pub fn acme_mgmt_cli() -> CommandLineInterface { let cmd_def = CliCommandMap::new() @@ -49,7 +50,7 @@ fn list_accounts(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Er #[api( input: { properties: { - name: { type: AccountName }, + name: { type: AcmeAccountName }, "output-format": { schema: OUTPUT_FORMAT, optional: true, @@ -83,7 +84,7 @@ async fn get_account(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<() #[api( input: { properties: { - name: { type: AccountName }, + name: { type: AcmeAccountName }, contact: { description: "List of email addresses.", }, @@ -97,7 +98,7 @@ async fn get_account(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<() )] /// Register an ACME account. async fn register_account( - name: AccountName, + name: AcmeAccountName, contact: String, directory: Option, ) -> Result<(), Error> { @@ -169,7 +170,7 @@ async fn register_account( #[api( input: { properties: { - name: { type: AccountName }, + name: { type: AcmeAccountName }, contact: { description: "List of email addresses.", type: String, @@ -194,7 +195,7 @@ async fn update_account(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result #[api( input: { properties: { - name: { type: AccountName }, + name: { type: AcmeAccountName }, force: { description: "Delete account data even if the server refuses to deactivate the account.", diff --git a/src/config/acme/mod.rs b/src/config/acme/mod.rs index 097e2de2..9350551d 100644 --- a/src/config/acme/mod.rs +++ b/src/config/acme/mod.rs @@ -1,16 +1,15 @@ use std::collections::HashMap; -use std::fmt; use std::path::Path; use anyhow::{bail, format_err, Error}; -use serde::{Deserialize, Serialize}; -use proxmox::api::{api, schema::{Schema, StringSchema, ApiStringFormat}}; use proxmox::sys::error::SysError; use proxmox::tools::fs::CreateOptions; use crate::api2::types::{ - DNS_ALIAS_FORMAT, DNS_NAME_FORMAT, PROXMOX_SAFE_ID_FORMAT, PROXMOX_SAFE_ID_REGEX, + PROXMOX_SAFE_ID_REGEX, + KnownAcmeDirectory, + AcmeAccountName, }; use crate::tools::ControlFlow; @@ -44,61 +43,6 @@ pub(crate) fn make_acme_account_dir() -> nix::Result<()> { create_acme_subdir(ACME_ACCOUNT_DIR) } -#[api( - properties: { - "domain": { format: &DNS_NAME_FORMAT }, - "alias": { - optional: true, - format: &DNS_ALIAS_FORMAT, - }, - "plugin": { - optional: true, - format: &PROXMOX_SAFE_ID_FORMAT, - }, - }, - default_key: "domain", -)] -#[derive(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, - - /// 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, -} - -pub const ACME_DOMAIN_PROPERTY_SCHEMA: Schema = StringSchema::new( - "ACME domain configuration string") - .format(&ApiStringFormat::PropertyString(&AcmeDomain::API_SCHEMA)) - .schema(); - -#[api( - properties: { - name: { type: String }, - url: { type: String }, - }, -)] -/// An ACME directory endpoint with a name and URL. -#[derive(Serialize)] -pub struct KnownAcmeDirectory { - /// The ACME directory's name. - pub name: &'static str, - - /// The ACME directory's endpoint URL. - pub url: &'static str, -} - pub const KNOWN_ACME_DIRECTORIES: &[KnownAcmeDirectory] = &[ KnownAcmeDirectory { name: "Let's Encrypt V2", @@ -116,70 +60,10 @@ pub fn account_path(name: &str) -> String { format!("{}/{}", ACME_ACCOUNT_DIR, name) } -#[api(format: &PROXMOX_SAFE_ID_FORMAT)] -/// ACME account name. -#[derive(Clone, Eq, PartialEq, Hash, Deserialize, Serialize)] -#[serde(transparent)] -pub struct AccountName(String); - -impl AccountName { - pub fn into_string(self) -> String { - self.0 - } - - pub fn from_string(name: String) -> Result { - match &Self::API_SCHEMA { - Schema::String(s) => s.check_constraints(&name)?, - _ => unreachable!(), - } - Ok(Self(name)) - } - - pub unsafe fn from_string_unchecked(name: String) -> Self { - Self(name) - } -} - -impl std::ops::Deref for AccountName { - type Target = str; - - #[inline] - fn deref(&self) -> &str { - &self.0 - } -} - -impl std::ops::DerefMut for AccountName { - #[inline] - fn deref_mut(&mut self) -> &mut str { - &mut self.0 - } -} - -impl AsRef for AccountName { - #[inline] - fn as_ref(&self) -> &str { - self.0.as_ref() - } -} - -impl fmt::Debug for AccountName { - #[inline] - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - fmt::Debug::fmt(&self.0, f) - } -} - -impl fmt::Display for AccountName { - #[inline] - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - fmt::Display::fmt(&self.0, f) - } -} pub fn foreach_acme_account(mut func: F) -> Result<(), Error> where - F: FnMut(AccountName) -> ControlFlow>, + F: FnMut(AcmeAccountName) -> ControlFlow>, { match crate::tools::fs::scan_subdir(-1, ACME_ACCOUNT_DIR, &PROXMOX_SAFE_ID_REGEX) { Ok(files) => { @@ -191,7 +75,10 @@ where continue; } - let account_name = AccountName(file_name.to_owned()); + 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; diff --git a/src/config/acme/plugin.rs b/src/config/acme/plugin.rs index 4d197604..759b9a33 100644 --- a/src/config/acme/plugin.rs +++ b/src/config/acme/plugin.rs @@ -15,6 +15,8 @@ use crate::api2::types::PROXMOX_SAFE_ID_FORMAT; pub const PLUGIN_ID_SCHEMA: Schema = StringSchema::new("ACME Challenge Plugin ID.") .format(&PROXMOX_SAFE_ID_FORMAT) + .min_length(1) + .max_length(32) .schema(); lazy_static! { diff --git a/src/config/node.rs b/src/config/node.rs index 0983697a..6f48409f 100644 --- a/src/config/node.rs +++ b/src/config/node.rs @@ -10,8 +10,8 @@ use proxmox::api::api; use proxmox::api::schema::{ApiStringFormat, Updater}; use proxmox::tools::fs::{replace_file, CreateOptions}; +use crate::api2::types::{AcmeDomain, AcmeAccountName, ACME_DOMAIN_PROPERTY_SCHEMA}; use crate::acme::AcmeClient; -use crate::config::acme::{AccountName, AcmeDomain, ACME_DOMAIN_PROPERTY_SCHEMA}; const CONF_FILE: &str = configdir!("/node.cfg"); const LOCK_FILE: &str = configdir!("/.node.lck"); @@ -49,7 +49,7 @@ pub fn save_config(config: &NodeConfig) -> Result<(), Error> { #[api( properties: { - account: { type: AccountName }, + account: { type: AcmeAccountName }, } )] #[derive(Deserialize, Serialize)] @@ -58,7 +58,7 @@ pub fn save_config(config: &NodeConfig) -> Result<(), Error> { /// Currently only contains the name of the account use. pub struct AcmeConfig { /// Account to use to acquire ACME certificates. - account: AccountName, + account: AcmeAccountName, } #[api(