]> git.proxmox.com Git - pve-installer.git/commitdiff
auto-installer: fetch: add http plugin to fetch answer
authorAaron Lauterer <a.lauterer@proxmox.com>
Wed, 17 Apr 2024 12:30:55 +0000 (14:30 +0200)
committerThomas Lamprecht <t.lamprecht@proxmox.com>
Mon, 22 Apr 2024 12:31:37 +0000 (14:31 +0200)
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.
If no fingerprint is provided, we switch rustls to native-certs and
native-tls.

Tested-by: Christoph Heiss <c.heiss@proxmox.com>
Reviewed-by: Christoph Heiss <c.heiss@proxmox.com>
Signed-off-by: Aaron Lauterer <a.lauterer@proxmox.com>
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
proxmox-auto-installer/Cargo.toml
proxmox-auto-installer/src/bin/proxmox-fetch-answer.rs
proxmox-auto-installer/src/fetch_plugins/http.rs [new file with mode: 0644]
proxmox-auto-installer/src/fetch_plugins/mod.rs
proxmox-auto-installer/src/fetch_plugins/utils/mod.rs
proxmox-auto-installer/src/fetch_plugins/utils/post.rs [new file with mode: 0644]
unconfigured.sh

index 48be375760be9aee0590f334794cb3f25e8a0a7d..5f7653170c76eb2cc4a1a0a75485145611d86c87 100644 (file)
@@ -14,9 +14,15 @@ homepage = "https://www.proxmox.com"
 anyhow = "1.0"
 clap = { version = "4.0", features = ["derive"] }
 glob = "0.3"
+hex = "0.4"
 log = "0.4.20"
+native-tls = "0.2"
 proxmox-installer-common = { path = "../proxmox-installer-common" }
 regex = "1.7"
+rustls = { version = "0.20", features = [ "dangerous_configuration" ] }
+rustls-native-certs = "0.6"
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
+sha2 = "0.10"
 toml = "0.7"
+ureq = { version = "2.6", features = [ "native-certs", "native-tls" ] }
index a3681a2b2645c52002d454e69dd0dabeb065c872..6d42df2eda3092f2515500e9d2dab80cb53ac712 100644 (file)
@@ -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 (file)
index 0000000..4ac9afb
--- /dev/null
@@ -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]))
+    }
+}
index 6f1e8a240e6a1bd99ea6d7f70fa01586d2a8446f..354fa7ef46d8626ab48313123cf45f3d208e145a 100644 (file)
@@ -1,2 +1,3 @@
+pub mod http;
 pub mod partition;
 pub mod utils;
index b3e9dad0f304ace074df3d47bbd16b3dde698eef..6b4c7dba2db931903236d6d3544081e6c3e53cde 100644 (file)
@@ -12,6 +12,7 @@ static ANSWER_MP: &str = "/mnt/answer";
 static PARTLABEL: &str = "proxmoxinst";
 static SEARCH_PATH: &str = "/dev/disk/by-label";
 
+pub mod post;
 pub mod sysinfo;
 
 /// Searches for upper and lower case existence of the partlabel in the search_path
diff --git a/proxmox-auto-installer/src/fetch_plugins/utils/post.rs b/proxmox-auto-installer/src/fetch_plugins/utils/post.rs
new file mode 100644 (file)
index 0000000..193e920
--- /dev/null
@@ -0,0 +1,94 @@
+use anyhow::Result;
+use rustls::ClientConfig;
+use sha2::{Digest, Sha256};
+use std::sync::Arc;
+use ureq::{Agent, AgentBuilder};
+
+/// Issues a POST request with the payload (JSON). Optionally a SHA256 fingerprint can be used to
+/// check the cert against it, instead of the regular cert validation.
+/// To gather the sha256 fingerprint you can use the following command:
+/// ```no_compile
+/// openssl s_client -connect <host>:443 < /dev/null 2>/dev/null | openssl x509 -fingerprint -sha256  -noout -in /dev/stdin
+/// ```
+///
+/// # Arguemnts
+/// * `url` - URL to call
+/// * `fingerprint` - SHA256 cert fingerprint if certificate pinning should be used. Optional.
+/// * `payload` - The payload to send to the server. Expected to be a JSON formatted string.
+pub fn call(url: String, fingerprint: Option<&str>, payload: String) -> Result<String> {
+    let answer      ;
+
+    if let Some(fingerprint) = fingerprint {
+        let tls_config = ClientConfig::builder()
+            .with_safe_defaults()
+            .with_custom_certificate_verifier(VerifyCertFingerprint::new(fingerprint)?)
+            .with_no_client_auth();
+
+        let agent: Agent = AgentBuilder::new().tls_config(Arc::new(tls_config)).build();
+
+        answer = agent
+            .post(&url)
+            .set("Content-type", "application/json; charset=utf-")
+            .send_string(&payload)?
+            .into_string()?;
+    } else {
+        let mut roots = rustls::RootCertStore::empty();
+        for cert in rustls_native_certs::load_native_certs()? {
+            roots.add(&rustls::Certificate(cert.0)).unwrap();
+        }
+
+        let tls_config = rustls::ClientConfig::builder()
+            .with_safe_defaults()
+            .with_root_certificates(roots)
+            .with_no_client_auth();
+
+        let agent = AgentBuilder::new()
+            .tls_connector(Arc::new(native_tls::TlsConnector::new()?))
+            .tls_config(Arc::new(tls_config))
+            .build();
+        answer = agent
+            .post(&url)
+            .set("Content-type", "application/json; charset=utf-")
+            .timeout(std::time::Duration::from_secs(60))
+            .send_string(&payload)?
+            .into_string()?;
+    }
+    Ok(answer)
+}
+
+struct VerifyCertFingerprint {
+    cert_fingerprint: Vec<u8>,
+}
+
+impl VerifyCertFingerprint {
+    fn new<S: AsRef<str>>(cert_fingerprint: S) -> Result<std::sync::Arc<Self>> {
+        let cert_fingerprint = cert_fingerprint.as_ref();
+        let sanitized = cert_fingerprint.replace(':', "");
+        let decoded = hex::decode(sanitized)?;
+        Ok(std::sync::Arc::new(Self {
+            cert_fingerprint: decoded,
+        }))
+    }
+}
+
+impl rustls::client::ServerCertVerifier for VerifyCertFingerprint {
+    fn verify_server_cert(
+        &self,
+        end_entity: &rustls::Certificate,
+        _intermediates: &[rustls::Certificate],
+        _server_name: &rustls::ServerName,
+        _scts: &mut dyn Iterator<Item = &[u8]>,
+        _ocsp_response: &[u8],
+        _now: std::time::SystemTime,
+    ) -> Result<rustls::client::ServerCertVerified, rustls::Error> {
+        let mut hasher = Sha256::new();
+        hasher.update(end_entity);
+        let result = hasher.finalize();
+
+        if result.as_slice() == self.cert_fingerprint {
+            Ok(rustls::client::ServerCertVerified::assertion())
+        } else {
+            Err(rustls::Error::General("Fingerprint did not match!".into()))
+        }
+    }
+}
index 2b371f089fe3594a6acf2e3ea413e05fc324a4df..a1f942207e421d7fa2598afd63c7297f9cd77df4 100755 (executable)
@@ -208,6 +208,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