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 6A4E51FF13C for ; Thu, 30 Apr 2026 14:49:17 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 48F3373D5; Thu, 30 Apr 2026 14:49:17 +0200 (CEST) From: Christoph Heiss To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager v4 18/40] server: api: add auto-installer integration module Date: Thu, 30 Apr 2026 14:46:47 +0200 Message-ID: <20260430124712.1614305-19-c.heiss@proxmox.com> X-Mailer: git-send-email 2.53.0 In-Reply-To: <20260430124712.1614305-1-c.heiss@proxmox.com> References: <20260430124712.1614305-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: 1777553233802 X-SPAM-LEVEL: Spam detection results: 0 AWL -1.080 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: KTYDTX3E2AQ646HQFFFSFPARUQWL4DBI X-Message-ID-Hash: KTYDTX3E2AQ646HQFFFSFPARUQWL4DBI 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 v3 -> v4: * switch templating engine from handlebars to minijinja * add validation for udev filters and template counters * add `proxmox-uuid` dependency on `server` * check whether tokens are actually enabled before verifying them * strip additional slashes from constructed post-hook url * use new create/update api result types * only allow post-hook to continue on in-progress installations * use `Bearer` instead of `ProxmoxInstallerToken` in HTTP Authorization header 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 | 2 + debian/control | 2 + server/Cargo.toml | 5 + server/src/api/auto_installer/mod.rs | 966 +++++++++++++++++++++++++++ server/src/api/mod.rs | 2 + 5 files changed, 977 insertions(+) create mode 100644 server/src/api/auto_installer/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 166e227..fc1df08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,6 +104,7 @@ async-stream = "0.3" async-trait = "0.1" const_format = "0.2" futures = "0.3" +glob = "0.3" hex = "0.4.3" http = "1" http-body-util = "0.1.2" @@ -111,6 +112,7 @@ hyper = { version = "1", features = [ "full" ] } hyper-util = "0.1" libc = "0.2" log = "0.4.17" +minijinja = "2.19" nix = "0.29" once_cell = "1.3.1" openssl = "0.10.40" diff --git a/debian/control b/debian/control index 3bdd611..0a38111 100644 --- a/debian/control +++ b/debian/control @@ -17,6 +17,7 @@ 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-http-1+default-dev, librust-http-body-util-0.1+default-dev (>= 0.1.2-~~), @@ -25,6 +26,7 @@ Build-Depends: debhelper-compat (= 13), librust-hyper-util-0.1+default-dev, librust-libc-0.2+default-dev, librust-log-0.4+default-dev (>= 0.4.17-~~), + librust-minijinja-2.19-dev, librust-nix-0.29+default-dev, librust-once-cell-1+default-dev (>= 1.3.1-~~), librust-openssl-0.10+default-dev (>= 0.10.40-~~), diff --git a/server/Cargo.toml b/server/Cargo.toml index 0d6371d..e46184c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -14,6 +14,7 @@ async-stream.workspace = true async-trait.workspace = true const_format.workspace = true futures.workspace = true +glob.workspace = true hex.workspace = true http.workspace = true http-body-util.workspace = true @@ -21,6 +22,7 @@ hyper.workspace = true hyper-util.workspace = true libc.workspace = true log.workspace = true +minijinja.workspace = true nix.workspace = true once_cell.workspace = true openssl.workspace = true @@ -39,10 +41,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"] } @@ -57,6 +61,7 @@ proxmox-sys = { workspace = true, features = [ "timer" ] } proxmox-systemd.workspace = true proxmox-tfa = { workspace = true, features = [ "api" ] } proxmox-time.workspace = true +proxmox-uuid.workspace = true proxmox-apt = { workspace = true, features = [ "cache" ] } proxmox-apt-api-types.workspace = true diff --git a/server/src/api/auto_installer/mod.rs b/server/src/api/auto_installer/mod.rs new file mode 100644 index 0000000..852a233 --- /dev/null +++ b/server/src/api/auto_installer/mod.rs @@ -0,0 +1,966 @@ +//! Implements all the methods under `/api2/json/auto-install/`. + +use anyhow::{anyhow, Result}; +use http::StatusCode; +use std::collections::{BTreeMap, HashMap}; + +use pdm_api_types::{ + auto_installer::{ + AnswerToken, DeletablePreparedInstallationConfigProperty, Installation, InstallationStatus, + PreparedInstallationConfig, PreparedInstallationConfigCreateResult, + PreparedInstallationConfigUpdateResult, PreparedInstallationConfigUpdater, + INSTALLATION_UUID_SCHEMA, PREPARED_INSTALL_CONFIG_ID_SCHEMA, TEMPLATE_COUNTER_NAME_REGEX, + UDEV_FILTER_KEY_REGEX, + }, + 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}; +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, + &::API_SCHEMA, +)) +.access( + Some("Implemented through specialized bearer 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. Bearer : +/// 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() != "bearer" { + return None; + } + + let _lock = pdm_config::auto_install::tokens_read_lock(); + let (tokens, _) = pdm_config::auto_install::read_tokens().ok()?; + + let (id, secret) = token.split_once(':').unwrap_or_default(); + + let token: AnswerToken = tokens.get(id)?.clone().into(); + if !token.enabled.unwrap_or(true) { + return None; + } + + pdm_config::auto_install::verify_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 = 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 the user defined a base url + if let Some(base_url) = config.post_hook_base_url { + 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(), + }); + } + + 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), + }, + returns: { + type: PreparedInstallationConfigCreateResult, + }, +)] +/// 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" + ); + } + + validate_udev_filter_map(&config.netdev_filter)?; + validate_udev_filter_map(&config.disk_filter)?; + validate_template_map(&config.template_counters)?; + + prepared.insert(config.id.clone(), config.clone().try_into()?); + pdm_config::auto_install::save_prepared_answers(&prepared)?; + + Ok(PreparedInstallationConfigCreateResult { + config, + token: None, + }) +} + +#[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), + }, + returns: { + type: PreparedInstallationConfigCreateResult, + }, +)] +/// 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(filter) = target_filter { + **p.target_filter = 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 { + validate_udev_filter_map(&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 { + validate_udev_filter_map(&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 { + validate_template_map(&counters)?; + **p.template_counters = counters; + } + + let config = p.clone().try_into()?; + pdm_config::auto_install::save_prepared_answers(&prepared)?; + + Ok(PreparedInstallationConfigCreateResult { + config, + token: None, + }) +} + +#[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) +} + +fn validate_udev_filter_map(map: &BTreeMap) -> Result<()> { + for k in map.keys() { + if !UDEV_FILTER_KEY_REGEX.is_match(k) { + http_bail!(BAD_REQUEST, "invalid udev filter key: '{k}'"); + } + } + Ok(()) +} + +fn validate_template_map(map: &BTreeMap) -> Result<()> { + for k in map.keys() { + if !TEMPLATE_COUNTER_NAME_REGEX.is_match(k) { + http_bail!(BAD_REQUEST, "invalid template counter name: '{k}'"); + } + } + Ok(()) +} + +#[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(|inst| { + inst.uuid == uuid + && inst.status == InstallationStatus::InProgress + && inst.post_hook_data.is_none() + }) { + 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 jinja = minijinja::Environment::new(); + let mut context = serde_json::to_value(sysinfo)?; + if let Some(obj) = context.as_object_mut() { + for (k, v) in conf.template_counters.iter() { + obj.insert(k.clone(), (*v).into()); + } + } + + let fqdn = if conf.use_dhcp_fqdn { + answer::FqdnConfig::from_dhcp(None) + } else { + let fqdn = jinja.render_named_str("fqdn", &conf.fqdn.to_string(), &context)?; + answer::FqdnConfig::Simple(Fqdn::from(&fqdn)?) + }; + + let mailto = jinja.render_named_str("mailto", &conf.mailto, &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(jinja.render_named_str("cidr", &cidr.to_string(), &context)?)) + .and_then(|s| Ok(s.parse()?))?; + + let dns = conf + .dns + .ok_or_else(|| anyhow!("no DNS server address")) + .and_then(|cidr| Ok(jinja.render_named_str("dns", &cidr.to_string(), &context)?)) + .and_then(|s| Ok(s.parse()?))?; + + let gateway = conf + .gateway + .ok_or_else(|| anyhow!("no gateway address")) + .and_then(|cidr| { + Ok(jinja.render_named_str("gateway", &cidr.to_string(), &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(()) +} diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 9c1890a..110191b 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 nodes; pub mod pbs; @@ -21,6 +22,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