From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id D34591FF13E for ; Fri, 23 Jan 2026 18:29:14 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id C5B5D12844; Fri, 23 Jan 2026 18:29:33 +0100 (CET) From: Shan Shaji To: pdm-devel@lists.proxmox.com Date: Fri, 23 Jan 2026 18:29:09 +0100 Message-ID: <20260123172910.244121-3-s.shaji@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260123172910.244121-1-s.shaji@proxmox.com> References: <20260123172910.244121-1-s.shaji@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1769189308964 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.107 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pdm-devel] [PATCH datacenter-manager 2/3] fix #7179: cli: admin: expose acme commands X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" Previously, ACME commands were not exposed through the admin CLI. Added the necessary functionality to manage ACME settings directly via the command line. Signed-off-by: Shan Shaji --- cli/admin/Cargo.toml | 4 +- cli/admin/src/acme.rs | 442 ++++++++++++++++++++++++++++++++++++++++++ cli/admin/src/main.rs | 5 + 3 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 cli/admin/src/acme.rs diff --git a/cli/admin/Cargo.toml b/cli/admin/Cargo.toml index e566b39..01afc88 100644 --- a/cli/admin/Cargo.toml +++ b/cli/admin/Cargo.toml @@ -22,7 +22,9 @@ proxmox-access-control.workspace = true proxmox-rest-server.workspace = true proxmox-sys.workspace = true proxmox-daemon.workspace = true - +proxmox-acme.workspace = true +proxmox-acme-api.workspace = true +proxmox-base64.workspace = true pdm-api-types.workspace = true pdm-config.workspace = true pdm-buildcfg.workspace = true diff --git a/cli/admin/src/acme.rs b/cli/admin/src/acme.rs new file mode 100644 index 0000000..81c63f9 --- /dev/null +++ b/cli/admin/src/acme.rs @@ -0,0 +1,442 @@ +use std::io::Write; + +use anyhow::{bail, Error}; +use serde_json::Value; + +use proxmox_acme::async_client::AcmeClient; +use proxmox_acme_api::{completion::*, AcmeAccountName, DnsPluginCore, KNOWN_ACME_DIRECTORIES}; +use proxmox_rest_server::wait_for_local_worker; +use proxmox_router::{cli::*, ApiHandler, RpcEnvironment}; +use proxmox_schema::api; +use proxmox_sys::fs::file_get_contents; + +use server::api as dc_api; + +pub fn acme_mgmt_cli() -> CommandLineInterface { + let cmd_def = CliCommandMap::new() + .insert("account", account_cli()) + .insert("plugin", plugin_cli()) + .insert("certificate", cert_cli()); + + cmd_def.into() +} + +#[api( + input: { + properties: { + "output-format": { + schema: OUTPUT_FORMAT, + optional: true, + } + } + } +)] +/// List ACME accounts. +fn list_accounts(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> { + let output_format = get_output_format(¶m); + let info = &dc_api::config::acme::API_METHOD_LIST_ACCOUNTS; + let mut data = match info.handler { + ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?, + _ => unreachable!(), + }; + + let options = default_table_format_options(); + format_and_print_result_full(&mut data, &info.returns, &output_format, &options); + + Ok(()) +} + +#[api( + input: { + properties: { + name: { type: AcmeAccountName }, + "output-format": { + schema: OUTPUT_FORMAT, + optional: true, + }, + } + } +)] +/// Show ACME account information. +async fn get_account(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> { + let output_format = get_output_format(¶m); + + let info = &dc_api::config::acme::API_METHOD_GET_ACCOUNT; + let mut data = match info.handler { + ApiHandler::Async(handler) => (handler)(param, info, rpcenv).await?, + _ => unreachable!(), + }; + + let options = default_table_format_options() + .column( + ColumnConfig::new("account") + .renderer(|value, _record| Ok(serde_json::to_string_pretty(value)?)), + ) + .column(ColumnConfig::new("directory")) + .column(ColumnConfig::new("location")) + .column(ColumnConfig::new("tos")); + format_and_print_result_full(&mut data, &info.returns, &output_format, &options); + + Ok(()) +} + +#[api( + input: { + properties: { + name: { type: AcmeAccountName }, + contact: { + description: "List of email addresses.", + }, + directory: { + type: String, + description: "The ACME Directory.", + optional: true, }, + } + } +)] +///Register an ACME account. +async fn register_account( + name: AcmeAccountName, + contact: String, + directory: Option, +) -> Result<(), Error> { + let (directory_url, custom_directory) = match directory { + Some(directory) => (directory, true), + None => { + println!("Directory endpoints:"); + for (i, dir) in KNOWN_ACME_DIRECTORIES.iter().enumerate() { + println!("{}) {}", i, dir.url); + } + + println!("{}) Custom", KNOWN_ACME_DIRECTORIES.len()); + let mut attempt = 0; + loop { + print!("Enter selection: "); + std::io::stdout().flush()?; + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + + match input.trim().parse::() { + Ok(n) if n < KNOWN_ACME_DIRECTORIES.len() => { + break (KNOWN_ACME_DIRECTORIES[n].url.to_string(), false); + } + Ok(n) if n == KNOWN_ACME_DIRECTORIES.len() => { + input.clear(); + print!("Enter custom directory URI: "); + std::io::stdout().flush()?; + std::io::stdin().read_line(&mut input)?; + break (input.trim().to_owned(), true); + } + _ => eprintln!("Invalid selection."), + } + + attempt += 1; + if attempt >= 3 { + bail!("Aborting."); + } + } + } + }; + + println!("Attempting to fetch Terms of Service from {directory_url:?}"); + let mut client = AcmeClient::new(directory_url.clone()); + let directory = client.directory().await?; + let tos_agreed = if let Some(tos_url) = directory.terms_of_service_url() { + println!("Terms of Service: {tos_url}"); + print!("Do you agree to the above terms? [y|N]: "); + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + input.trim().eq_ignore_ascii_case("y") + } else { + println!("No Terms of Service found, proceeding."); + true + }; + + let mut eab_enabled = directory.external_account_binding_required(); + if !eab_enabled && custom_directory { + print!("Do you want to use external account binding? [y|N]: "); + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + eab_enabled = input.trim().eq_ignore_ascii_case("y"); + } else if eab_enabled { + println!("The CA requires external account binding."); + } + + let eab_creds = if eab_enabled { + println!("You should have received a key id and a key from your CA."); + + print!("Enter EAB key id: "); + std::io::stdout().flush()?; + let mut eab_kid = String::new(); + std::io::stdin().read_line(&mut eab_kid)?; + + print!("Enter EAB key: "); + std::io::stdout().flush()?; + let mut eab_hmac_key = String::new(); + std::io::stdin().read_line(&mut eab_hmac_key)?; + + Some((eab_kid.trim().to_owned(), eab_hmac_key.trim().to_owned())) + } else { + None + }; + + let tos_url = tos_agreed + .then(|| directory.terms_of_service_url().map(str::to_owned)) + .flatten(); + + println!("Registering ACME account '{}'...", &name,); + let location = + proxmox_acme_api::register_account(&name, contact, tos_url, Some(directory_url), eab_creds) + .await?; + println!("Registration successful, account URL: {}", location); + + Ok(()) +} + +#[api( + input: { + properties: { + name: { type: AcmeAccountName }, + contact: { + description: "List of email addresses.", + type: String, + optional: true, + } + } + } +)] +/// Update an ACME Account. +async fn update_account(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> { + let info = &dc_api::config::acme::API_METHOD_UPDATE_ACCOUNT; + let result = match info.handler { + ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?, + _ => unreachable!(), + }; + + wait_for_local_worker(result.as_str().unwrap()).await?; + + Ok(()) +} + +#[api( + input: { + properties: { + name: { type: AcmeAccountName }, + force: { + description: "Delete account data even if the server refuses to deactivate the account.", + type: Boolean, + optional: true, + default: true, + } + } + } +)] +/// Deactivate an ACME account. +async fn deactivate_account(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> { + let info = &dc_api::config::acme::API_METHOD_DEACTIVATE_ACCOUNT; + let result = match info.handler { + ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?, + _ => unreachable!(), + }; + + wait_for_local_worker(result.as_str().unwrap()).await?; + + Ok(()) +} + +fn account_cli() -> CommandLineInterface { + let cmd_def = CliCommandMap::new() + .insert("list", CliCommand::new(&API_METHOD_LIST_ACCOUNTS)) + .insert( + "register", + CliCommand::new(&API_METHOD_REGISTER_ACCOUNT).arg_param(&["name", "contact"]), + ) + .insert( + "deactivate", + CliCommand::new(&API_METHOD_DEACTIVATE_ACCOUNT) + .arg_param(&["name"]) + .completion_cb("name", complete_acme_account), + ) + .insert( + "info", + CliCommand::new(&API_METHOD_GET_ACCOUNT) + .arg_param(&["name"]) + .completion_cb("name", complete_acme_account), + ) + .insert( + "update", + CliCommand::new(&API_METHOD_UPDATE_ACCOUNT) + .arg_param(&["name", "contact"]) + .completion_cb("name", complete_acme_account), + ); + + cmd_def.into() +} + +#[api( + input: { + properties: { + "output-format": { + schema: OUTPUT_FORMAT, + optional: true, + }, + } + } +)] +/// List ACME plugins. +fn list_plugins(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> { + let output_format = get_output_format(¶m); + + let info = &dc_api::config::acme::API_METHOD_LIST_PLUGINS; + let mut data = match info.handler { + ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?, + _ => unreachable!(), + }; + + let options = default_table_format_options(); + format_and_print_result_full(&mut data, &info.returns, &output_format, &options); + + Ok(()) +} + +#[api( + input: { + properties: { + id: { + type: String, + description: "Plugin ID", + }, + "output-format": { + schema: OUTPUT_FORMAT, + optional: true, + }, + } + } +)] +/// Show ACME plugin information. +fn get_plugin(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> { + let output_format = get_output_format(¶m); + + let info = &dc_api::config::acme::API_METHOD_GET_PLUGIN; + let mut data = match info.handler { + ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?, + _ => unreachable!(), + }; + + let options = default_table_format_options(); + format_and_print_result_full(&mut data, &info.returns, &output_format, &options); + + Ok(()) +} + +#[api(input: { + properties: { + type: { + type: String, + description: "The ACME challenge plugin type." + }, + core: { + type: DnsPluginCore, + flatten: true, + }, + data: { + type: String, + description: "File containing the plugin data." + } + } +})] +/// Add ACME plugin configuration. +fn add_plugin(r#type: String, core: DnsPluginCore, data: String) -> Result<(), Error> { + let data = proxmox_base64::encode(file_get_contents(data)?); + dc_api::config::acme::add_plugin(r#type, core, data)?; + Ok(()) +} + +pub fn plugin_cli() -> CommandLineInterface { + let cmd_def = CliCommandMap::new() + .insert("list", CliCommand::new(&API_METHOD_LIST_PLUGINS)) + .insert( + "config", + CliCommand::new(&API_METHOD_GET_PLUGIN) + .arg_param(&["id"]) + .completion_cb("id", complete_acme_plugin), + ) + .insert( + "add", + CliCommand::new(&API_METHOD_ADD_PLUGIN) + .arg_param(&["type", "id"]) + .completion_cb("api", complete_acme_api_challenge_type) + .completion_cb("type", complete_acme_plugin_type), + ) + .insert( + "remove", + CliCommand::new(&dc_api::config::acme::API_METHOD_DELETE_PLUGIN) + .arg_param(&["id"]) + .completion_cb("id", complete_acme_plugin), + ) + .insert( + "set", + CliCommand::new(&dc_api::config::acme::API_METHOD_UPDATE_PLUGIN) + .arg_param(&["id"]) + .completion_cb("id", complete_acme_plugin), + ); + + cmd_def.into() +} + +#[api( + input: { + properties: { + force: { + description: "Force renewal even if the certificate does not expire soon.", + type: Boolean, + optional: true, + default: false, + }, + }, + }, +)] +/// Order a new ACME certificate. +async fn order_acme_cert(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> { + if !param["force"].as_bool().unwrap_or(false) + && !dc_api::nodes::certificates::cert_expires_soon()? + { + println!("Certificate does not expire within the next 30 days, not renewing."); + return Ok(()); + } + + let info = &dc_api::nodes::certificates::API_METHOD_RENEW_ACME_CERT; + let result = match info.handler { + ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?, + _ => unreachable!(), + }; + + wait_for_local_worker(result.as_str().unwrap()).await?; + + Ok(()) +} + +#[api] +/// Revoke ACME certificate. +async fn revoke_acme_cert(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> { + let info = &dc_api::nodes::certificates::API_METHOD_REVOKE_ACME_CERT; + let result = match info.handler { + ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?, + _ => unreachable!(), + }; + + wait_for_local_worker(result.as_str().unwrap()).await?; + + Ok(()) +} + +pub fn cert_cli() -> CommandLineInterface { + let cmd_def = CliCommandMap::new() + .insert("order", CliCommand::new(&API_METHOD_ORDER_ACME_CERT)) + .insert("revoke", CliCommand::new(&API_METHOD_REVOKE_ACME_CERT)); + + cmd_def.into() +} diff --git a/cli/admin/src/main.rs b/cli/admin/src/main.rs index bcaa0a6..15114c9 100644 --- a/cli/admin/src/main.rs +++ b/cli/admin/src/main.rs @@ -9,6 +9,7 @@ use proxmox_router::RpcEnvironment; use proxmox_schema::api; use proxmox_sys::fs::CreateOptions; +mod acme; mod remotes; mod support_status; @@ -21,13 +22,17 @@ async fn run() -> Result<(), Error> { &pdm_api_types::AccessControlConfig, pdm_buildcfg::configdir!("/access"), )?; + proxmox_acme_api::init(pdm_buildcfg::configdir!("/acme"), false)?; + proxmox_log::Logger::from_env("PDM_LOG", proxmox_log::LevelFilter::INFO) + .stderr_on_no_workertask() .stderr() .init()?; server::context::init()?; let cmd_def = CliCommandMap::new() + .insert("acme", acme::acme_mgmt_cli()) .insert("remote", remotes::cli()) .insert( "report", -- 2.47.3 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel