From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 17A051FF0ED for ; Fri, 31 Jul 2026 16:40:38 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id DB4F921586; Fri, 31 Jul 2026 16:40:37 +0200 (CEST) From: Christoph Heiss To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager 14/16] api: auto-installer: add option for adding new remotes to PDM Date: Fri, 31 Jul 2026 16:35:37 +0200 Message-ID: <20260731143910.936881-15-c.heiss@proxmox.com> X-Mailer: git-send-email 2.54.0 In-Reply-To: <20260731143910.936881-1-c.heiss@proxmox.com> References: <20260731143910.936881-1-c.heiss@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1785508823908 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.044 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) PROLO_LEO1 0.1 Meta Catches all Leo drug variations so far RCVD_IN_DNSWL_LOW -0.7 Sender listed at https://www.dnswl.org/, low trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: HE2FLWC6YHU5FMS4BITSKHI4L2GUJVL6 X-Message-ID-Hash: HE2FLWC6YHU5FMS4BITSKHI4L2GUJVL6 X-MailFrom: c.heiss@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: .. after the installation was successful. Requires the post-hook machinery to be set up correctly. If `post-hook-add-as-remote` is set on the prepared answer and the target installer supports creating API tokens after the installation, set `post-installation-webhook.api-token-name` in the answer file before sending it. The post-hook will then detect if an API token was created and add the machine as a new remote. Signed-off-by: Christoph Heiss --- Depends on the `proxmox-installer-types` changes and an accompanying dependency bump. lib/pdm-api-types/src/auto_installer.rs | 10 ++ server/src/api/auto_installer/mod.rs | 157 ++++++++++++++++++++---- 2 files changed, 146 insertions(+), 21 deletions(-) diff --git a/lib/pdm-api-types/src/auto_installer.rs b/lib/pdm-api-types/src/auto_installer.rs index 4b5027fb..bd9540f6 100644 --- a/lib/pdm-api-types/src/auto_installer.rs +++ b/lib/pdm-api-types/src/auto_installer.rs @@ -168,6 +168,10 @@ pub const PREPARED_INSTALL_CONFIG_ID_SCHEMA: proxmox_schema::Schema = schema: CERT_FINGERPRINT_SHA256_SCHEMA, optional: true, }, + "post-hook-add-as-remote": { + default: true, + optional: true, + }, "template-counters": { type: Object, properties: {}, @@ -329,6 +333,12 @@ pub struct PreparedInstallationConfig { #[serde(default, skip_serializing_if = "Option::is_none")] #[updater(serde(default, skip_serializing_if = "Option::is_none"))] pub post_hook_cert_fp: Option, + /// Whether to add the target host as new remote to PDM after the installation. + /// + /// Only available with PVE and PBS. + #[serde(default)] + #[updater(serde(default, skip_serializing_if = "Option::is_none"))] + pub post_hook_add_as_remote: bool, /// Key-value pairs of (auto-incrementing) counters. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] diff --git a/server/src/api/auto_installer/mod.rs b/server/src/api/auto_installer/mod.rs index 470f31b6..4b0a946c 100644 --- a/server/src/api/auto_installer/mod.rs +++ b/server/src/api/auto_installer/mod.rs @@ -1,12 +1,13 @@ //! Implements all the methods under `/api2/json/auto-install/`. -use anyhow::{Context, Result, anyhow}; -use http::StatusCode; +use anyhow::{Context, Result, anyhow, bail}; +use http::{StatusCode, Uri}; +use log::warn; use std::collections::{BTreeMap, HashMap}; -use pdm_api_types::PROXMOX_TOKEN_NAME_SCHEMA; use pdm_api_types::{ - Authid, ConfigDigest, PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA, + ConfigDigest, PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA, + PROXMOX_TOKEN_NAME_SCHEMA, auto_installer::{ AnswerToken, AnswerTokenCreateResult, AnswerTokenUpdateResult, AnswerTokenUpdater, DeletableAnswerTokenProperty, DeletablePreparedInstallationConfigProperty, @@ -15,12 +16,14 @@ use pdm_api_types::{ PreparedInstallationConfigCreateResult, PreparedInstallationConfigUpdateResult, PreparedInstallationConfigUpdater, TEMPLATE_COUNTER_NAME_REGEX, UDEV_FILTER_KEY_REGEX, }, + remotes::{NodeUrl, Remote, RemoteType}, }; use pdm_config::auto_install::types::PreparedInstallationSectionConfigWrapper; +use proxmox_auth_api::types::Authid; use proxmox_installer_types::{ - SystemInfo, + ProxmoxProduct, SystemInfo, answer::{ - self, AutoInstallerConfig, PostNotificationHookInfo, ROOT_PASSWORD_SCHEMA, + self, AutoInstallerConfig, PostNotificationHookInfo, ROOT_PASSWORD_SCHEMA, SchemaVersion, fetch::AnswerFetchData, }, post_hook::PostHookInfo, @@ -36,6 +39,11 @@ use proxmox_schema::{ use proxmox_sortable_macro::sortable; use proxmox_uuid::Uuid; +use crate::api; + +const ANSWER_FETCH_SUPPORTED_SCHEMA: SchemaVersion = SchemaVersion(1, 0); +const POST_HOOK_SUPPORTED_SCHEMA: SchemaVersion = SchemaVersion(1, 3); + #[sortable] const SUBDIR_INSTALLATION_PER_ID: SubdirMap = &sorted!([( "post-hook", @@ -139,6 +147,8 @@ fn api_function_new_installation( } }; + check_compatible_fetch_answer_schema(¶m)?; + let response = serde_json::from_value::(param) .map_err(|err| anyhow!("failed to deserialize body: {err:?}")) .and_then(|data| new_installation(&token_id, data)) @@ -161,6 +171,21 @@ fn api_function_new_installation( }) } +fn check_compatible_fetch_answer_schema(data: &serde_json::Value) -> Result<()> { + let version: SchemaVersion = data + .get("$schema") + .and_then(|schema| schema.get("version")) + .and_then(|val| val.as_str()) + .ok_or_else(|| anyhow!("answer fetch schema version not found")) + .and_then(|s| s.parse())?; + + if version <= ANSWER_FETCH_SUPPORTED_SCHEMA { + Ok(()) + } else { + bail!("unsupported answer fetch version") + } +} + /// Verifies the given `Authorization` HTTP header value whether /// a) It matches the required format, i.e. Bearer : /// b) The token secret is known and verifies successfully. @@ -227,6 +252,30 @@ fn new_installation(token_id: &String, payload: AnswerFetchData) -> Result Result { Ok(hex::encode(proxmox_sys::linux::random_data(16)?)) }) .transpose()?; + // Inject our custom post hook if the user defined a base url + if let Some(base_url) = config.post_hook_base_url { + // The target needs to support creating an API token, as well as the user requesting it + // to add it as a remote. + let should_create_token = + payload.sysinfo.supports_api_token_creation() && config.post_hook_add_as_remote; + + answer.post_installation_webhook = Some(PostNotificationHookInfo { + url: format!( + "{}/api2/json/auto-install/installations/{uuid}/post-hook", + base_url.trim_end_matches('/') + ), + cert_fingerprint: config.post_hook_cert_fp.clone(), + auth_token: post_hook_token.clone(), + // Anything below was introduced with API token support, so gate it on that to avoid + // breaking older ISOs in combination with newer PDM versions + max_schema_version: payload + .sysinfo + .supports_api_token_creation() + .then_some(POST_HOOK_SUPPORTED_SCHEMA), + api_token_name: should_create_token.then(|| "pdm-admin".to_owned()), + }); + } + let installation = Installation { uuid: uuid.clone(), received_at: timestamp_now, @@ -234,21 +283,9 @@ fn new_installation(token_id: &String, payload: AnswerFetchData) -> Result) -> Result<()> { /// POST /auto-install/installations/{uuid}/post-hook /// /// Handles the post-installation hook for all installations. -async fn handle_post_hook(uuid: Uuid, token: String, info: PostHookInfo) -> Result<()> { +async fn handle_post_hook(uuid: Uuid, token: String, mut info: PostHookInfo) -> Result<()> { let _lock = pdm_config::auto_install::installations_write_lock(); let (mut installations, _) = pdm_config::auto_install::read_installations()?; @@ -840,14 +882,87 @@ async fn handle_post_hook(uuid: Uuid, token: String, info: PostHookInfo) -> Resu return not_found(); } + let token_secret = if let Some(token) = &mut info.api_token { + Some(std::mem::take(&mut token.secret)) + } else { + None + }; + install.status = InstallationStatus::Finished; - install.post_hook_data = Some(info); + install.post_hook_data = Some(info.clone()); install.post_hook_token = None; pdm_config::auto_install::save_installation(install)?; + if let (Some(token), Some(secret)) = (&info.api_token, token_secret) { + add_host_as_remote(install, &info, &token.id, secret).await? + } + Ok(()) } +async fn add_host_as_remote( + install: &Installation, + info: &PostHookInfo, + authid: &Authid, + token: String, +) -> Result<()> { + let ty = match info.product.short { + ProxmoxProduct::Pve => RemoteType::Pve, + ProxmoxProduct::Pbs => RemoteType::Pbs, + _ => { + let answer = install + .answer_id + .as_ref() + .map(|s| format!(" (answer: '{s}')")) + .unwrap_or_default(); + + warn!( + "auto-installer: cannot add {} target as remote{answer}!", + info.product.fullname, + ); + return Ok(()); + } + }; + + // Prefer the management interface address if available, otherwise fall back to the first + // interface with an address. As a last fallback, use the FQDN. + let hostname = info + .network_interfaces + .iter() + .find(|iface| iface.is_management) + .and_then(|iface| iface.address) + .or_else(|| { + info.network_interfaces + .iter() + .find(|iface| iface.address.is_some()) + .and_then(|iface| iface.address) + }) + .map_or_else(|| info.fqdn.to_owned(), |cidr| cidr.address().to_string()); + + let web_url = Uri::builder() + .scheme("https") + .authority(format!("{}:{}", info.fqdn, ty.default_port())) + .build() + .ok(); + + let remote = Remote { + ty, + id: info.fqdn.clone(), + nodes: vec![ + NodeUrl { + hostname, + fingerprint: info.cert_fingerprint.clone(), + } + .into(), + ], + authid: authid.clone(), + token: token.to_owned(), + web_url, + }; + + api::remotes::add_remote(remote, None).await +} + #[api( returns: { description: "List of tokens for authenticating automated installations requests.", -- 2.54.0