From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 545BB91DBA for ; Thu, 4 Apr 2024 16:49:51 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id C3D7F46DE for ; Thu, 4 Apr 2024 16:49:19 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS for ; Thu, 4 Apr 2024 16:49:14 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 206DA4514C for ; Thu, 4 Apr 2024 16:49:13 +0200 (CEST) From: Aaron Lauterer To: pve-devel@lists.proxmox.com Date: Thu, 4 Apr 2024 16:48:56 +0200 Message-Id: <20240404144902.273800-25-a.lauterer@proxmox.com> X-Mailer: git-send-email 2.39.2 In-Reply-To: <20240404144902.273800-1-a.lauterer@proxmox.com> References: <20240404144902.273800-1-a.lauterer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.056 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 SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pve-devel] [PATCH installer v4 24/30] auto-installer: fetch: add http plugin to fetch answer X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Thu, 04 Apr 2024 14:49:51 -0000 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. Signed-off-by: Aaron Lauterer --- .../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 { 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 { + info!("Checking for certificate fingerprint in file."); + let mut fingerprint: Option = 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 { + 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 { + 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 { + 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) -> Result<(String, Option)> { + 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) -> Result<(String, Option)> { + let leases = fs::read_to_string(DHCP_LEASE_FILE)?; + + let mut answer_url: Option = 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 { + // 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 <