From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id AECB21FF13E for ; Fri, 03 Apr 2026 18:56:07 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 785BD839E; Fri, 3 Apr 2026 18:56:38 +0200 (CEST) From: Christoph Heiss To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager v3 18/38] server: api: add auto-installer integration module Date: Fri, 3 Apr 2026 18:53:50 +0200 Message-ID: <20260403165437.2166551-19-c.heiss@proxmox.com> X-Mailer: git-send-email 2.53.0 In-Reply-To: <20260403165437.2166551-1-c.heiss@proxmox.com> References: <20260403165437.2166551-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: 1775235318900 X-SPAM-LEVEL: Spam detection results: 0 AWL -1.088 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 KAM_MAILER 2 Automated Mailer Tag Left in Email POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record T_FILL_THIS_FORM_SHORT 0.01 Fill in a short form with personal information Message-ID-Hash: HVN6SZKA44NPHHMF4IHFSCGXX5RU57EI X-Message-ID-Hash: HVN6SZKA44NPHHMF4IHFSCGXX5RU57EI 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: Adds the required API surface for managing prepared answer files and viewing past installations via the UI, as well as serving answer files to proxmox-auto-installer. Quick overview: POST /auto-install/answer serves answer files based on target filters, if any GET /auto-install/installations list all in-progress and past installations DELETE /auto-install/installations/{id} delete the giving past installation POST /auto-install/installations/{id}/post-hook endpoint for integrating the post-installation notification webhook GET /auto-install/prepared list all prepared answer file configurations POST /auto-install/prepared create a new prepared answer file configuration GET /auto-install/prepared/{id} get a specific prepared answer file configuration PUT /auto-install/prepared/{id} update a specific prepared answer file configuration DELETE /auto-install/prepared/{id} delete an existing prepared answer file configuration Signed-off-by: Christoph Heiss --- Changes v2 -> v3: * added authentication using custom token to /auto-install/answer endpoint (see also [0], esp. on why a separate token system) * added templating support for some answer fields * the /answer endpoint now lives under /api/json as normal * adapted as necessary to changed types from `proxmox-installer-types` * append full /api2/json/ path to post-hook url * destructure updater to future-proof against missing any fields in the future * replace manual `SystemTime::now()` with `proxmox_time::epoch_i64()` Changes v1 -> v2: * fixed compilation error due to leftover, unresolved type [0] https://lore.proxmox.com/pdm-devel/DETMUXY1Q877.32G593TWC52WW@proxmox.com/#:~:text=%20I%20think%20this%20is%20dangerous. Cargo.toml | 1 + debian/control | 2 + server/Cargo.toml | 4 + server/src/api/auto_installer/mod.rs | 945 +++++++++++++++++++++++++++ server/src/api/mod.rs | 2 + 5 files changed, 954 insertions(+) create mode 100644 server/src/api/auto_installer/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 77b10af..0f0bcf5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,6 +105,7 @@ async-trait = "0.1" bitflags = "2.4" const_format = "0.2" futures = "0.3" +glob = "0.3" h2 = { version = "0.4", features = [ "stream" ] } handlebars = "5.1" hex = "0.4.3" diff --git a/debian/control b/debian/control index 6c9ec38..5442c1c 100644 --- a/debian/control +++ b/debian/control @@ -17,7 +17,9 @@ Build-Depends: debhelper-compat (= 13), librust-async-trait-0.1+default-dev, librust-const-format-0.2+default-dev, librust-futures-0.3+default-dev, + librust-glob-0.3-dev, librust-hex-0.4+default-dev (>= 0.4.3-~~), + librust-handlebars-5+default-dev, librust-http-1+default-dev, librust-http-body-util-0.1+default-dev (>= 0.1.2-~~), librust-hyper-1+default-dev, diff --git a/server/Cargo.toml b/server/Cargo.toml index 6969549..e1ee697 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -14,6 +14,8 @@ async-stream.workspace = true async-trait.workspace = true const_format.workspace = true futures.workspace = true +glob.workspace = true +handlebars.workspace = true hex.workspace = true http.workspace = true http-body-util.workspace = true @@ -42,10 +44,12 @@ proxmox-base64.workspace = true proxmox-daemon.workspace = true proxmox-docgen.workspace = true proxmox-http = { workspace = true, features = [ "client-trait", "proxmox-async" ] } # pbs-client doesn't use these +proxmox-installer-types.workspace = true proxmox-lang.workspace = true proxmox-ldap.workspace = true proxmox-log.workspace = true proxmox-login.workspace = true +proxmox-network-types.workspace = true proxmox-openid.workspace = true proxmox-rest-server = { workspace = true, features = [ "templates" ] } proxmox-router = { workspace = true, features = [ "cli", "server"] } diff --git a/server/src/api/auto_installer/mod.rs b/server/src/api/auto_installer/mod.rs new file mode 100644 index 0000000..60eccd8 --- /dev/null +++ b/server/src/api/auto_installer/mod.rs @@ -0,0 +1,945 @@ +//! Implements all the methods under `/api2/json/auto-install/`. + +use anyhow::{anyhow, Result}; +use handlebars::Handlebars; +use http::StatusCode; +use std::collections::{BTreeMap, HashMap}; + +use pdm_api_types::{ + auto_installer::{ + DeletablePreparedInstallationConfigProperty, Installation, InstallationStatus, + PreparedInstallationConfig, PreparedInstallationConfigUpdater, INSTALLATION_UUID_SCHEMA, + PREPARED_INSTALL_CONFIG_ID_SCHEMA, + }, + ConfigDigest, PRIV_SYS_AUDIT, PRIV_SYS_MODIFY, PROXMOX_CONFIG_DIGEST_SCHEMA, +}; +use pdm_config::auto_install::types::PreparedInstallationSectionConfigWrapper; +use proxmox_installer_types::{ + answer::{ + self, fetch::AnswerFetchData, AutoInstallerConfig, PostNotificationHookInfo, + ROOT_PASSWORD_SCHEMA, + }, + post_hook::PostHookInfo, + SystemInfo, +}; +use proxmox_network_types::fqdn::Fqdn; +use proxmox_router::{ + http_bail, list_subdirs_api_method, ApiHandler, ApiMethod, ApiResponseFuture, Permission, + Router, RpcEnvironment, SubdirMap, +}; +use proxmox_schema::{api, AllOfSchema, ApiType, ParameterSchema, ReturnType, StringSchema}; +use proxmox_sortable_macro::sortable; +use proxmox_uuid::Uuid; + +#[sortable] +const SUBDIR_INSTALLATION_PER_ID: SubdirMap = &sorted!([( + "post-hook", + &Router::new().post(&API_METHOD_HANDLE_POST_HOOK) +)]); + +#[sortable] +const SUBDIRS: SubdirMap = &sorted!([ + ("answer", &Router::new().post(&API_METHOD_NEW_INSTALLATION)), + ( + "installations", + &Router::new().get(&API_METHOD_LIST_INSTALLATIONS).match_all( + "uuid", + &Router::new() + .delete(&API_METHOD_DELETE_INSTALLATION) + .subdirs(SUBDIR_INSTALLATION_PER_ID) + ) + ), + ( + "prepared", + &Router::new() + .get(&API_METHOD_LIST_PREPARED_ANSWERS) + .post(&API_METHOD_CREATE_PREPARED_ANSWER) + .match_all( + "id", + &Router::new() + .get(&API_METHOD_GET_PREPARED_ANSWER) + .put(&API_METHOD_UPDATE_PREPARED_ANSWER) + .delete(&API_METHOD_DELETE_PREPARED_ANSWER) + ) + ), +]); + +pub const ROUTER: Router = Router::new() + .get(&list_subdirs_api_method!(SUBDIRS)) + .subdirs(SUBDIRS); + +const API_METHOD_NEW_INSTALLATION: ApiMethod = ApiMethod::new_full( + &ApiHandler::AsyncHttpBodyParameters(&api_function_new_installation), + ParameterSchema::AllOf(&AllOfSchema::new( + r#"\ + Handles the system information of a new machine to install. + + See also + "#, + &[&::API_SCHEMA], + )), +) +.returns(ReturnType::new( + false, + &StringSchema::new( + "either a auto-installation configuration or a request to wait, in JSON format", + ) + .schema(), +)) +.access( + Some("Implemented through specialized secret tokens."), + &Permission::World, +) +.protected(false); + +/// Implements the "upper" API handling for /auto-install/answer, most importantly +/// the authentication through secret tokens. +fn api_function_new_installation( + parts: http::request::Parts, + param: serde_json::Value, + _info: &ApiMethod, + _rpcenv: Box, +) -> ApiResponseFuture { + Box::pin(async move { + let auth_header = parts + .headers + .get(http::header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .unwrap_or_default(); + + let token_id = match verify_answer_authorization_header(auth_header) { + Some(token_id) => token_id, + None => { + return Ok(http::Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(String::new().into())?) + } + }; + + let response = serde_json::from_value::(param) + .map_err(|err| anyhow!("failed to deserialize body: {err:?}")) + .and_then(|data| new_installation(&token_id, data)) + .map_err(|err| err.to_string()) + .and_then(|result| serde_json::to_string(&result).map_err(|err| err.to_string())); + + match response { + Ok(body) => Ok(http::Response::builder() + .status(StatusCode::OK) + .header( + http::header::CONTENT_TYPE, + "application/json; charset=utf-8", + ) + .body(body.into())?), + Err(err) => Ok(http::Response::builder() + .status(StatusCode::BAD_REQUEST) + .header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(format!("{err:#}").into())?), + } + }) +} + +/// Verifies the given `Authorization` HTTP header value whether +/// a) It matches the required format, i.e. PmxInstallerToken : +/// b) The token secret is known and verifies successfully. +/// +/// # Parameters +/// +/// * `header` - The value of the `Authorization` header sent by the client +fn verify_answer_authorization_header(header: &str) -> Option { + let (scheme, token) = header.split_once(' ').unwrap_or_default(); + if scheme.to_lowercase() != "proxmoxinstallertoken" { + return None; + } + + let _lock = pdm_config::auto_install::token_read_lock(); + + let (id, secret) = token.split_once(':').unwrap_or_default(); + pdm_config::auto_install::verify_token_secret(id, secret).ok()?; + + Some(id.to_owned()) +} + +/// POST /auto-install/answer +/// +/// Handles the system information of a new machine to install. +/// +/// See also +/// +/// +/// Returns a auto-installer configuration if a matching one is found, otherwise errors out. +/// +/// The system information data is saved in any case to make them easily inspectable. +fn new_installation(token_id: &String, payload: AnswerFetchData) -> Result { + let _lock = pdm_config::auto_install::installations_write_lock(); + + let uuid = Uuid::generate(); + let (mut installations, _) = pdm_config::auto_install::read_installations()?; + + if installations.iter().any(|p| p.uuid == uuid) { + http_bail!(CONFLICT, "already exists"); + } + + let timestamp_now = proxmox_time::epoch_i64(); + + if let Some(config) = find_config(token_id, &payload.sysinfo)? { + let status = if config.post_hook_base_url.is_some() { + InstallationStatus::InProgress + } else { + InstallationStatus::AnswerSent + }; + + let mut answer: AutoInstallerConfig = render_prepared_config(&config, &payload.sysinfo)?; + + installations.push(Installation { + uuid: uuid.clone(), + received_at: timestamp_now, + status, + info: payload.sysinfo, + answer_id: Some(config.id.clone()), + post_hook_data: None, + }); + + // "Inject" our custom post hook if possible + if let Some(base_url) = config.post_hook_base_url { + answer.post_installation_webhook = Some(PostNotificationHookInfo { + url: format!("{base_url}/api2/json/auto-install/installations/{uuid}/post-hook"), + cert_fingerprint: config.post_hook_cert_fp.clone(), + }); + } + + increment_template_counters(&config.id)?; + pdm_config::auto_install::save_installations(&installations)?; + Ok(answer) + } else { + installations.push(Installation { + uuid: uuid.clone(), + received_at: timestamp_now, + status: InstallationStatus::NoAnswerFound, + info: payload.sysinfo, + answer_id: None, + post_hook_data: None, + }); + + pdm_config::auto_install::save_installations(&installations)?; + http_bail!(NOT_FOUND, "no answer file found"); + } +} + +#[api( + returns: { + description: "List of all automated installations.", + type: Array, + items: { type: Installation }, + }, + access: { + permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_AUDIT, false), + }, +)] +/// GET /auto-install/installations +/// +/// Get all automated installations. +fn list_installations(rpcenv: &mut dyn RpcEnvironment) -> Result> { + let _lock = pdm_config::auto_install::installations_read_lock(); + + let (config, digest) = pdm_config::auto_install::read_installations()?; + + rpcenv["digest"] = hex::encode(digest).into(); + Ok(config) +} + +#[api( + input: { + properties: { + uuid: { + schema: INSTALLATION_UUID_SCHEMA, + } + }, + }, + access: { + permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_MODIFY, false), + }, +)] +/// DELETE /auto-install/installations/{uuid} +/// +/// Remove an installation entry. +fn delete_installation(uuid: Uuid) -> Result<()> { + let _lock = pdm_config::auto_install::installations_write_lock(); + + let (mut installations, _) = pdm_config::auto_install::read_installations()?; + if installations + .extract_if(.., |inst| inst.uuid == uuid) + .count() + == 0 + { + http_bail!(NOT_FOUND, "no such entry {uuid:?}"); + } + + pdm_config::auto_install::save_installations(&installations) +} + +#[api( + returns: { + description: "List of prepared auto-installer answer configurations.", + type: Array, + items: { type: PreparedInstallationConfig }, + }, + access: { + permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_AUDIT, false), + }, +)] +/// GET /auto-install/prepared +/// +/// Get all prepared auto-installer answer configurations. +async fn list_prepared_answers( + rpcenv: &mut dyn RpcEnvironment, +) -> Result> { + let (prepared, digest) = pdm_config::auto_install::read_prepared_answers()?; + + rpcenv["digest"] = hex::encode(digest).into(); + + prepared.values().try_fold( + Vec::with_capacity(prepared.len()), + |mut v, p| -> Result, anyhow::Error> { + let mut p: PreparedInstallationConfig = p.clone().try_into()?; + p.root_password_hashed = None; + v.push(p); + Ok(v) + }, + ) +} + +#[api( + input: { + properties: { + config: { + type: PreparedInstallationConfig, + flatten: true, + }, + "root-password": { + schema: ROOT_PASSWORD_SCHEMA, + optional: true, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_MODIFY, false), + }, +)] +/// POST /auto-install/prepared +/// +/// Creates a new prepared answer file. +async fn create_prepared_answer( + mut config: PreparedInstallationConfig, + root_password: Option, +) -> Result<()> { + let _lock = pdm_config::auto_install::prepared_answers_write_lock(); + let (mut prepared, _) = pdm_config::auto_install::read_prepared_answers()?; + + if prepared.contains_key(&config.id) { + http_bail!( + CONFLICT, + "configuration with ID {} already exists", + config.id + ); + } + + if config.is_default { + if let Some(PreparedInstallationSectionConfigWrapper::PreparedConfig(p)) = prepared + .values() + .find(|PreparedInstallationSectionConfigWrapper::PreparedConfig(p)| p.is_default) + { + http_bail!( + CONFLICT, + "configuration '{}' is already the default answer", + p.id + ); + } + } + + if let Some(password) = root_password { + config.root_password_hashed = Some(proxmox_sys::crypt::encrypt_pw(&password)?); + } else if config.root_password_hashed.is_none() { + http_bail!( + BAD_REQUEST, + "either `root-password` or `root-password-hashed` must be set" + ); + } + + prepared.insert(config.id.clone(), config.try_into()?); + pdm_config::auto_install::save_prepared_answers(&prepared) +} + +#[api( + input: { + properties: { + id: { + schema: PREPARED_INSTALL_CONFIG_ID_SCHEMA, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_AUDIT, false), + }, +)] +/// GET /auto-install/prepared/{id} +/// +/// Retrieves a prepared auto-installer answer configuration. +async fn get_prepared_answer(id: String) -> Result { + let (prepared, _) = pdm_config::auto_install::read_prepared_answers()?; + + if let Some(PreparedInstallationSectionConfigWrapper::PreparedConfig(mut p)) = + prepared.get(&id).cloned() + { + // Don't send the hashed password, the user cannot do anything with it anyway + p.root_password_hashed = None; + p.try_into() + } else { + http_bail!(NOT_FOUND, "no such prepared answer configuration: {id}"); + } +} + +#[api( + input: { + properties: { + id: { + schema: PREPARED_INSTALL_CONFIG_ID_SCHEMA, + }, + update: { + type: PreparedInstallationConfigUpdater, + flatten: true, + }, + "root-password": { + schema: ROOT_PASSWORD_SCHEMA, + optional: true, + }, + delete: { + description: "List of properties to delete.", + type: Array, + optional: true, + items: { + type: DeletablePreparedInstallationConfigProperty, + } + }, + digest: { + optional: true, + schema: PROXMOX_CONFIG_DIGEST_SCHEMA, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_MODIFY, false), + }, +)] +/// PUT /auto-install/prepared/{id} +/// +/// Updates a prepared auto-installer answer configuration. +async fn update_prepared_answer( + id: String, + update: PreparedInstallationConfigUpdater, + root_password: Option, + delete: Option>, + digest: Option, +) -> Result<()> { + let _lock = pdm_config::auto_install::prepared_answers_write_lock(); + + let (mut prepared, config_digest) = pdm_config::auto_install::read_prepared_answers()?; + config_digest.detect_modification(digest.as_ref())?; + + if update.is_default.unwrap_or(false) { + if let Some(PreparedInstallationSectionConfigWrapper::PreparedConfig(other)) = + prepared.values().find( + |PreparedInstallationSectionConfigWrapper::PreparedConfig(p)| { + p.is_default && p.id != id + }, + ) + { + http_bail!( + CONFLICT, + "configuration '{}' is already the default answer", + other.id + ); + } + } + + let p = match prepared.get_mut(&id) { + Some(PreparedInstallationSectionConfigWrapper::PreparedConfig(p)) => p, + None => http_bail!(NOT_FOUND, "no such prepared answer configuration: {id}"), + }; + + if let Some(delete) = delete { + for prop in delete { + match prop { + DeletablePreparedInstallationConfigProperty::TargetFilter => { + p.target_filter.clear(); + } + DeletablePreparedInstallationConfigProperty::NetdevFilter => { + p.netdev_filter.clear(); + } + DeletablePreparedInstallationConfigProperty::DiskFilter => { + p.disk_filter.clear(); + } + DeletablePreparedInstallationConfigProperty::RootSshKeys => { + p.root_ssh_keys.clear(); + } + DeletablePreparedInstallationConfigProperty::PostHookBaseUrl => { + p.post_hook_base_url = None; + } + DeletablePreparedInstallationConfigProperty::PostHookCertFp => { + p.post_hook_cert_fp = None; + } + DeletablePreparedInstallationConfigProperty::TemplateCounters => { + p.template_counters.clear(); + } + } + } + } + + // Destructuring makes sure we don't forget any member + let PreparedInstallationConfigUpdater { + authorized_tokens, + is_default, + target_filter, + country, + fqdn, + use_dhcp_fqdn, + keyboard, + mailto, + timezone, + root_password_hashed, + reboot_on_error, + reboot_mode, + root_ssh_keys, + use_dhcp_network, + cidr, + gateway, + dns, + netdev_filter, + netif_name_pinning_enabled, + filesystem, + disk_mode, + disk_list, + disk_filter, + disk_filter_match, + post_hook_base_url, + post_hook_cert_fp, + template_counters, + } = update; + + if let Some(tokens) = authorized_tokens { + p.authorized_tokens = tokens; + } + + if let Some(is_default) = is_default { + p.is_default = is_default; + } + + if let Some(target_filter) = target_filter { + **p.target_filter = target_filter; + } + + if let Some(country) = country { + p.country = country; + } + + if let Some(fqdn) = fqdn { + p.fqdn = fqdn; + } + + if let Some(use_dhcp) = use_dhcp_fqdn { + p.use_dhcp_fqdn = use_dhcp; + } + + if let Some(keyboard) = keyboard { + p.keyboard = keyboard; + } + + if let Some(mailto) = mailto { + p.mailto = mailto; + } + + if let Some(timezone) = timezone { + p.timezone = timezone; + } + + if let Some(password) = root_password { + p.root_password_hashed = Some(proxmox_sys::crypt::encrypt_pw(&password)?); + } else if let Some(password) = root_password_hashed { + p.root_password_hashed = Some(password); + } + + if let Some(reboot_on_error) = reboot_on_error { + p.reboot_on_error = reboot_on_error; + } + + if let Some(reboot_mode) = reboot_mode { + p.reboot_mode = reboot_mode; + } + + if let Some(ssh_keys) = root_ssh_keys { + p.root_ssh_keys = ssh_keys; + } + + if let Some(use_dhcp) = use_dhcp_network { + p.use_dhcp_network = use_dhcp; + } + + if let Some(cidr) = cidr { + p.cidr = Some(cidr); + } + + if let Some(gateway) = gateway { + p.gateway = Some(gateway); + } + + if let Some(dns) = dns { + p.dns = Some(dns); + } + + if let Some(filter) = netdev_filter { + **p.netdev_filter = filter; + } + + if let Some(enabled) = netif_name_pinning_enabled { + p.netif_name_pinning_enabled = enabled; + } + + if let Some(fs) = filesystem { + *p.filesystem = fs; + } + + if let Some(mode) = disk_mode { + p.disk_mode = mode; + } + + if let Some(list) = disk_list { + p.disk_list = list; + } + + if let Some(filter) = disk_filter { + **p.disk_filter = filter; + } + + if let Some(filter_match) = disk_filter_match { + p.disk_filter_match = Some(filter_match); + } + + if let Some(url) = post_hook_base_url { + p.post_hook_base_url = Some(url); + } + + if let Some(fp) = post_hook_cert_fp { + p.post_hook_cert_fp = Some(fp); + } + + if let Some(counters) = template_counters { + **p.template_counters = counters; + } + + pdm_config::auto_install::save_prepared_answers(&prepared) +} + +#[api( + input: { + properties: { + id: { + schema: PREPARED_INSTALL_CONFIG_ID_SCHEMA, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "auto-installation"], PRIV_SYS_MODIFY, false), + }, +)] +/// DELETE /auto-install/prepared/{id} +/// +/// Deletes a prepared auto-installer answer configuration. +async fn delete_prepared_answer(id: String) -> Result<()> { + let _lock = pdm_config::auto_install::prepared_answers_write_lock(); + + let (mut prepared, _) = pdm_config::auto_install::read_prepared_answers()?; + if prepared.remove(&id).is_none() { + http_bail!(NOT_FOUND, "no such entry '{id:?}'"); + } + + pdm_config::auto_install::save_prepared_answers(&prepared) +} + +#[api( + input: { + properties: { + uuid: { + schema: INSTALLATION_UUID_SCHEMA, + }, + info: { + type: PostHookInfo, + flatten: true, + } + }, + }, + access: { + permission: &Permission::World, + }, +)] +/// POST /auto-install/installations/{uuid}/post-hook +/// +/// Handles the post-installation hook for all installations. +async fn handle_post_hook(uuid: Uuid, info: PostHookInfo) -> Result<()> { + let _lock = pdm_config::auto_install::installations_write_lock(); + let (mut installations, _) = pdm_config::auto_install::read_installations()?; + + if let Some(install) = installations.iter_mut().find(|p| p.uuid == uuid) { + install.status = InstallationStatus::Finished; + install.post_hook_data = Some(info); + pdm_config::auto_install::save_installations(&installations)?; + } else { + http_bail!(NOT_FOUND, "installation {uuid} not found"); + } + + Ok(()) +} + +/// Tries to find a prepared answer configuration matching the given target node system +/// information. +/// +/// # Parameters +/// +/// * `token_id` - ID of the authorization token. +/// * `info` - System information of the machine to be installed. +/// +/// # Returns +/// +/// * `Ok(Some(answer))` if a matching answer was found, containing the most specified answer that +/// matched. +/// * `Ok(None)` if no answer was matched and no default one exists, either. +/// * `Err(..)` if some error occurred. +fn find_config( + token_id: &String, + info: &proxmox_installer_types::SystemInfo, +) -> Result> { + let info = serde_json::to_value(info)?; + let (prepared, _) = pdm_config::auto_install::read_prepared_answers()?; + + let mut default_answer = None; + for sc in prepared.values() { + let PreparedInstallationSectionConfigWrapper::PreparedConfig(p) = sc; + + if !p.authorized_tokens.contains(token_id) { + continue; + } + + if p.is_default { + // Save the default answer for later and use it if no other matched before that + default_answer = Some(p.clone()); + continue; + } + + if p.target_filter.is_empty() { + // Not default answer and empty target filter, can never match + continue; + } + + let matched_all = p.target_filter.iter().all(|filter| { + // Retrieve the value the key (aka. a JSON pointer) points to + if let Some(value) = info.pointer(filter.0).and_then(|v| v.as_str()) { + // .. and match it against the given value glob + match glob::Pattern::new(filter.1) { + Ok(pattern) => pattern.matches(value), + _ => false, + } + } else { + false + } + }); + + if matched_all { + return Ok(Some(p.clone().try_into()?)); + } + } + + // If no specific target filter(s) matched, return the default answer, if there is one + default_answer.map(|a| a.try_into()).transpose() +} + +/// Renders a given [`PreparedInstallationConfig`] into the target [`AutoInstallerConfig`] struct. +/// +/// Converts all types as needed and renders out Handlebar templates in applicable fields. +/// Currently, templating is supported for the following fields: +/// +/// * `fqdn` +/// * `mailto` +/// * `cidr` +/// * `dns` +/// * `gateway` +fn render_prepared_config( + conf: &PreparedInstallationConfig, + sysinfo: &SystemInfo, +) -> Result { + use pdm_api_types::auto_installer::DiskSelectionMode; + use proxmox_installer_types::answer::{Filesystem, FilesystemOptions}; + + let mut handlebars = Handlebars::new(); + handlebars.register_helper("zeropad", Box::new(handlebars_zeropad_int_helper)); + + let mut template_data = serde_json::to_value(sysinfo)?; + if let Some(obj) = template_data.as_object_mut() { + for (k, v) in conf.template_counters.iter() { + obj.insert(k.clone(), (*v).into()); + } + } + let hb_context = handlebars::Context::from(template_data); + + let fqdn = if conf.use_dhcp_fqdn { + answer::FqdnConfig::from_dhcp(None) + } else { + let fqdn = handlebars.render_template_with_context(&conf.fqdn.to_string(), &hb_context)?; + answer::FqdnConfig::Simple(Fqdn::from(&fqdn)?) + }; + + let mailto = handlebars.render_template_with_context(&conf.mailto, &hb_context)?; + + let global = answer::GlobalOptions { + country: conf.country.clone(), + fqdn, + keyboard: conf.keyboard, + mailto, + timezone: conf.timezone.clone(), + root_password: None, + root_password_hashed: conf.root_password_hashed.clone(), + reboot_on_error: conf.reboot_on_error, + reboot_mode: conf.reboot_mode, + root_ssh_keys: conf.root_ssh_keys.clone(), + }; + + let network = { + let interface_name_pinning = conf.netif_name_pinning_enabled.then_some( + answer::NetworkInterfacePinningOptionsAnswer { + enabled: true, + mapping: HashMap::new(), + }, + ); + + if conf.use_dhcp_network { + answer::NetworkConfig::FromDhcp(answer::NetworkConfigFromDhcp { + interface_name_pinning, + }) + } else { + let cidr = conf + .cidr + .ok_or_else(|| anyhow!("no host address")) + .and_then(|cidr| { + Ok(handlebars.render_template_with_context(&cidr.to_string(), &hb_context)?) + }) + .and_then(|s| Ok(s.parse()?))?; + + let dns = conf + .dns + .ok_or_else(|| anyhow!("no DNS server address")) + .and_then(|cidr| { + Ok(handlebars.render_template_with_context(&cidr.to_string(), &hb_context)?) + }) + .and_then(|s| Ok(s.parse()?))?; + + let gateway = conf + .gateway + .ok_or_else(|| anyhow!("no gateway address")) + .and_then(|cidr| { + Ok(handlebars.render_template_with_context(&cidr.to_string(), &hb_context)?) + }) + .and_then(|s| Ok(s.parse()?))?; + + answer::NetworkConfig::FromAnswer(answer::NetworkConfigFromAnswer { + cidr, + dns, + gateway, + filter: conf.netdev_filter.clone(), + interface_name_pinning, + }) + } + }; + + let (disk_list, filter) = if conf.disk_mode == DiskSelectionMode::Fixed { + (conf.disk_list.clone(), BTreeMap::new()) + } else { + (vec![], conf.disk_filter.clone()) + }; + + let disks = answer::DiskSetup { + filesystem: match conf.filesystem { + FilesystemOptions::Ext4(_) => Filesystem::Ext4, + FilesystemOptions::Xfs(_) => Filesystem::Xfs, + FilesystemOptions::Zfs(_) => Filesystem::Zfs, + FilesystemOptions::Btrfs(_) => Filesystem::Btrfs, + }, + disk_list, + filter, + filter_match: conf.disk_filter_match, + zfs: match conf.filesystem { + FilesystemOptions::Zfs(opts) => Some(opts), + _ => None, + }, + lvm: match conf.filesystem { + FilesystemOptions::Ext4(opts) | FilesystemOptions::Xfs(opts) => Some(opts), + _ => None, + }, + btrfs: match conf.filesystem { + FilesystemOptions::Btrfs(opts) => Some(opts), + _ => None, + }, + }; + + Ok(AutoInstallerConfig { + global, + network, + disks, + post_installation_webhook: None, + first_boot: None, + }) +} + +/// Increments all counters of a given template by one. +/// +/// # Parameters +/// +/// `id` - ID of the template to update. +fn increment_template_counters(id: &str) -> Result<()> { + let _lock = pdm_config::auto_install::prepared_answers_write_lock(); + let (mut prepared, _) = pdm_config::auto_install::read_prepared_answers()?; + + let conf = match prepared.get_mut(id) { + Some(PreparedInstallationSectionConfigWrapper::PreparedConfig(p)) => p, + None => http_bail!(NOT_FOUND, "no such prepared answer configuration: {id}"), + }; + + conf.template_counters + .values_mut() + .for_each(|v| *v = v.saturating_add(1)); + + pdm_config::auto_install::save_prepared_answers(&prepared)?; + Ok(()) +} + +/// Handlebars handler for the "zeropad" helper. +/// +/// Takes an integer as first argument and target width as second argument, and returns the integer +/// formatted as string padded with leading zeros, such that it is exactly as long as specified in +/// the target width. +fn handlebars_zeropad_int_helper( + h: &handlebars::Helper, + _: &Handlebars, + _: &handlebars::Context, + _rc: &mut handlebars::RenderContext, + out: &mut dyn handlebars::Output, +) -> handlebars::HelperResult { + let value = h.param(0).and_then(|v| v.value().as_i64()).ok_or_else(|| { + handlebars::RenderErrorReason::ParamNotFoundForIndex("integer to format", 0) + })?; + + let width: usize = h + .param(1) + .and_then(|v| v.value().as_u64()) + .and_then(|v| v.try_into().ok()) + .ok_or_else(|| handlebars::RenderErrorReason::ParamNotFoundForIndex("target width", 0))?; + + out.write(&format!("{value:00$}", width))?; + Ok(()) +} diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 5688871..0fa26da 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -9,6 +9,7 @@ use proxmox_schema::api; use proxmox_sortable_macro::sortable; pub mod access; +pub mod auto_installer; pub mod config; pub mod metric_collection; pub mod nodes; @@ -25,6 +26,7 @@ pub mod sdn; #[sortable] const SUBDIRS: SubdirMap = &sorted!([ ("access", &access::ROUTER), + ("auto-install", &auto_installer::ROUTER), ("config", &config::ROUTER), ("ping", &Router::new().get(&API_METHOD_PING)), ("pve", &pve::ROUTER), -- 2.53.0