This plugin will send a HTTP POST request with identifying sysinfo to fetch an answer file. The provided sysinfo can be used to identify the system and generate a matching answer file on demand.
The URL to send the request to, can be defined in two ways. Via a custom DHCP option or a TXT record on a predefined subdomain, relative to the search domain received via DHCP. Additionally it is possible to specify a SHA256 SSL fingerprint. This can be useful if a self-signed certificate is used or the URL is using an IP address instead of an FQDN. Even with a trusted cert, it can be used to pin this specific certificate. The certificate fingerprint can either be placed on the `proxmoxinst` partition and needs to be called `cert_fingerprint.txt`, or it can be provided in a second custom DHCP option or a TXT record. Tested-by: Christoph Heiss <c.he...@proxmox.com> Reviewed-by: Christoph Heiss <c.he...@proxmox.com> Signed-off-by: Aaron Lauterer <a.laute...@proxmox.com> --- .../src/bin/proxmox-fetch-answer.rs | 11 +- .../src/fetch_plugins/http.rs | 190 ++++++++++++++++++ .../src/fetch_plugins/mod.rs | 1 + unconfigured.sh | 9 + 4 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 proxmox-auto-installer/src/fetch_plugins/http.rs diff --git a/proxmox-auto-installer/src/bin/proxmox-fetch-answer.rs b/proxmox-auto-installer/src/bin/proxmox-fetch-answer.rs index a3681a2..6d42df2 100644 --- a/proxmox-auto-installer/src/bin/proxmox-fetch-answer.rs +++ b/proxmox-auto-installer/src/bin/proxmox-fetch-answer.rs @@ -1,6 +1,9 @@ use anyhow::{anyhow, Error, Result}; use log::{error, info, LevelFilter}; -use proxmox_auto_installer::{fetch_plugins::partition::FetchFromPartition, log::AutoInstLogger}; +use proxmox_auto_installer::{ + fetch_plugins::{http::FetchFromHTTP, partition::FetchFromPartition}, + log::AutoInstLogger, +}; use std::io::Write; use std::process::{Command, ExitCode, Stdio}; @@ -18,8 +21,10 @@ fn fetch_answer() -> Result<String> { Ok(answer) => return Ok(answer), Err(err) => info!("Fetching answer file from partition failed: {err}"), } - // TODO: add more options to get an answer file, e.g. download from url where url could be - // fetched via txt records on predefined subdomain, kernel param, dhcp option, ... + match FetchFromHTTP::get_answer() { + Ok(answer) => return Ok(answer), + Err(err) => info!("Fetching answer file via HTTP failed: {err}"), + } Err(Error::msg("Could not find any answer file!")) } diff --git a/proxmox-auto-installer/src/fetch_plugins/http.rs b/proxmox-auto-installer/src/fetch_plugins/http.rs new file mode 100644 index 0000000..4ac9afb --- /dev/null +++ b/proxmox-auto-installer/src/fetch_plugins/http.rs @@ -0,0 +1,190 @@ +use anyhow::{bail, Error, Result}; +use log::info; +use std::{ + fs::{self, read_to_string}, + path::Path, + process::Command, +}; + +use crate::fetch_plugins::utils::{post, sysinfo}; + +use super::utils; + +static CERT_FINGERPRINT_FILE: &str = "cert_fingerprint.txt"; +static ANSWER_SUBDOMAIN: &str = "proxmoxinst"; +static ANSWER_SUBDOMAIN_FP: &str = "proxmoxinst-fp"; + +// It is possible to set custom DHPC options. Option numbers 224 to 254 [0]. +// To use them with dhclient, we need to configure it to request them and what they should be +// called. +// +// e.g. /etc/dhcp/dhclient.conf: +// ``` +// option proxmoxinst-url code 250 = text; +// option proxmoxinst-fp code 251 = text; +// also request proxmoxinst-url, proxmoxinst-fp; +// ``` +// +// The results will end up in the /var/lib/dhcp/dhclient.leases file from where we can fetch them +// +// [0] https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml +static DHCP_URL_OPTION: &str = "proxmoxinst-url"; +static DHCP_FP_OPTION: &str = "proxmoxinst-fp"; +static DHCP_LEASE_FILE: &str = "/var/lib/dhcp/dhclient.leases"; + +pub struct FetchFromHTTP; + +impl FetchFromHTTP { + /// Will try to fetch the answer.toml by sending a HTTP POST request. The URL can be configured + /// either via DHCP or DNS. + /// DHCP options are checked first. The SSL certificate need to be either trusted by the root + /// certs or a SHA256 fingerprint needs to be provided. The SHA256 SSL fingerprint can either + /// be placed in a `cert_fingerprint.txt` file in the `proxmoxinst` partition, as DHCP option, + /// or as DNS TXT record. If provided, the `cert_fingerprint.txt` file has preference. + pub fn get_answer() -> Result<String> { + info!("Checking for certificate fingerprint in file."); + let mut fingerprint: Option<String> = match Self::get_cert_fingerprint_from_file() { + Ok(fp) => Some(fp), + Err(err) => { + info!("{err}"); + None + } + }; + + let answer_url: String; + + (answer_url, fingerprint) = match Self::fetch_dhcp(fingerprint.clone()) { + Ok((url, fp)) => (url, fp), + Err(err) => { + info!("{err}"); + Self::fetch_dns(fingerprint.clone())? + } + }; + + if fingerprint.is_some() { + let fp = fingerprint.clone(); + fs::write("/tmp/cert_fingerprint", fp.unwrap()).ok(); + } + + info!("Gathering system information."); + let payload = sysinfo::get_sysinfo(false)?; + info!("Sending POST request to '{answer_url}'."); + let answer = post::call(answer_url, fingerprint.as_deref(), payload)?; + Ok(answer) + } + + /// Reads certificate fingerprint from file + pub fn get_cert_fingerprint_from_file() -> Result<String> { + let mount_path = utils::mount_proxmoxinst_part()?; + let cert_path = Path::new(mount_path.as_str()).join(CERT_FINGERPRINT_FILE); + match cert_path.try_exists() { + Ok(true) => { + info!("Found certifacte fingerprint file."); + Ok(fs::read_to_string(cert_path)?.trim().into()) + } + _ => Err(Error::msg(format!( + "could not find cert fingerprint file expected at: {}", + cert_path.display() + ))), + } + } + + /// Fetches search domain from resolv.conf file + fn get_search_domain() -> Result<String> { + info!("Retrieving default search domain."); + for line in read_to_string("/etc/resolv.conf")?.lines() { + if let Some((key, value)) = line.split_once(' ') { + if key == "search" { + return Ok(value.trim().into()); + } + } + } + Err(Error::msg("Could not find search domain in resolv.conf.")) + } + + /// Runs a TXT DNS query on the domain provided + fn query_txt_record(query: String) -> Result<String> { + info!("Querying TXT record for '{query}'"); + let url: String; + match Command::new("dig") + .args(["txt", "+short"]) + .arg(&query) + .output() + { + Ok(output) => { + if output.status.success() { + url = String::from_utf8(output.stdout)? + .replace('"', "") + .trim() + .into(); + if url.is_empty() { + bail!("Got empty response."); + } + } else { + bail!( + "Error querying DNS record '{query}' : {}", + String::from_utf8(output.stderr)? + ); + } + } + Err(err) => bail!("Error querying DNS record '{query}': {err}"), + } + info!("Found: '{url}'"); + Ok(url) + } + + /// Tries to fetch answer URL and SSL fingerprint info from DNS + fn fetch_dns(mut fingerprint: Option<String>) -> Result<(String, Option<String>)> { + let search_domain = Self::get_search_domain()?; + + let answer_url = match Self::query_txt_record(format!("{ANSWER_SUBDOMAIN}.{search_domain}")) + { + Ok(url) => url, + Err(err) => bail!("{err}"), + }; + + if fingerprint.is_none() { + fingerprint = + match Self::query_txt_record(format!("{ANSWER_SUBDOMAIN_FP}.{search_domain}")) { + Ok(fp) => Some(fp), + Err(err) => { + info!("{err}"); + None + } + }; + } + Ok((answer_url, fingerprint)) + } + + /// Tries to fetch answer URL and SSL fingerprint info from DHCP options + fn fetch_dhcp(mut fingerprint: Option<String>) -> Result<(String, Option<String>)> { + let leases = fs::read_to_string(DHCP_LEASE_FILE)?; + + let mut answer_url: Option<String> = None; + + let url_match = format!("option {DHCP_URL_OPTION}"); + let fp_match = format!("option {DHCP_FP_OPTION}"); + + for line in leases.lines() { + if answer_url.is_none() && line.trim().starts_with(url_match.as_str()) { + answer_url = Self::strip_dhcp_option(line.split(' ').nth_back(0)); + } + if fingerprint.is_none() && line.trim().starts_with(fp_match.as_str()) { + fingerprint = Self::strip_dhcp_option(line.split(' ').nth_back(0)); + } + } + + let answer_url = match answer_url { + None => bail!("No DHCP option found for fetch URL."), + Some(url) => url, + }; + + Ok((answer_url, fingerprint)) + } + + /// Clean DHCP option string + fn strip_dhcp_option(value: Option<&str>) -> Option<String> { + // value is expected to be in format: "value"; + value.map(|value| String::from(&value[1..value.len() - 2])) + } +} diff --git a/proxmox-auto-installer/src/fetch_plugins/mod.rs b/proxmox-auto-installer/src/fetch_plugins/mod.rs index 6f1e8a2..354fa7e 100644 --- a/proxmox-auto-installer/src/fetch_plugins/mod.rs +++ b/proxmox-auto-installer/src/fetch_plugins/mod.rs @@ -1,2 +1,3 @@ +pub mod http; pub mod partition; pub mod utils; diff --git a/unconfigured.sh b/unconfigured.sh index f02336a..dbdb027 100755 --- a/unconfigured.sh +++ b/unconfigured.sh @@ -212,6 +212,15 @@ if [ $proxdebug -ne 0 ]; then debugsh || true fi +# add custom DHCP options for auto installer +if [ $proxauto -ne 0 ]; then + cat >> /etc/dhcp/dhclient.conf <<EOF +option proxmoxinst-url code 250 = text; +option proxmoxinst-fp code 251 = text; +also request proxmoxinst-url, proxmoxinst-fp; +EOF +fi + # try to get ip config with dhcp echo -n "Attempting to get DHCP leases... " dhclient -v -- 2.39.2 _______________________________________________ pve-devel mailing list pve-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel