[PATCH datacenter-manager v3 18/38] server: api: add auto-installer integration module

Christoph Heiss
2026-04-03T16:56:07Z

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 <c.heiss@proxmox.com>
---
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
+ <https://pve.proxmox.com/wiki/Automated_Installation#Answer_Fetched_via_HTTP>"#,
+ &[&<AnswerFetchData as ApiType>::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<dyn RpcEnvironment>,
+) -> 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::<AnswerFetchData>(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 <token-id>:<secret>
+/// 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<String> {
+ 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
+/// <https://pve.proxmox.com/wiki/Automated_Installation#Answer_Fetched_via_HTTP>
+///
+/// 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<AutoInstallerConfig> {
+ 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<Vec<Installation>> {
+ 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<Vec<PreparedInstallationConfig>> {
+ 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<Vec<PreparedInstallationConfig>, 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<String>,
+) -> 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<PreparedInstallationConfig> {
+ 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<String>,
+ delete: Option<Vec<DeletablePreparedInstallationConfigProperty>>,
+ digest: Option<ConfigDigest>,
+) -> 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<Option<PreparedInstallationConfig>> {
+ 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<AutoInstallerConfig> {
+ 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