]> git.proxmox.com Git - pve-installer.git/blob - proxmox-fetch-answer/src/fetch_plugins/http.rs
simplify some code
[pve-installer.git] / proxmox-fetch-answer / src / fetch_plugins / http.rs
1 use anyhow::{bail, Error, Result};
2 use log::info;
3 use std::{
4 fs::{self, read_to_string},
5 process::Command,
6 };
7
8 use proxmox_auto_installer::{sysinfo::SysInfo, utils::AutoInstSettings};
9
10 static ANSWER_URL_SUBDOMAIN: &str = "proxmox-auto-installer";
11 static ANSWER_CERT_FP_SUBDOMAIN: &str = "proxmox-auto-installer-cert-fingerprint";
12
13 // It is possible to set custom DHPC options. Option numbers 224 to 254 [0].
14 // To use them with dhclient, we need to configure it to request them and what they should be
15 // called.
16 //
17 // e.g. /etc/dhcp/dhclient.conf:
18 // ```
19 // option proxmox-auto-installer-manifest-url code 250 = text;
20 // option proxmox-auto-installer-cert-fingerprint code 251 = text;
21 // also request proxmox-auto-installer-manifest-url, proxmox-auto-installer-cert-fingerprint;
22 // ```
23 //
24 // The results will end up in the /var/lib/dhcp/dhclient.leases file from where we can fetch them
25 //
26 // [0] https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml
27 static DHCP_URL_OPTION: &str = "proxmox-auto-installer-manifest-url";
28 static DHCP_CERT_FP_OPTION: &str = "proxmox-auto-installer-cert-fingerprint";
29 static DHCP_LEASE_FILE: &str = "/var/lib/dhcp/dhclient.leases";
30
31 pub struct FetchFromHTTP;
32
33 impl FetchFromHTTP {
34 /// Will try to fetch the answer.toml by sending a HTTP POST request. The URL can be configured
35 /// either via DHCP or DNS or preconfigured in the ISO.
36 /// If the URL is not defined in the ISO, it will first check DHCP options. The SSL certificate
37 /// needs to be either trusted by the root certs or a SHA256 fingerprint needs to be provided.
38 /// The SHA256 SSL fingerprint can either be defined in the ISO, as DHCP option, or as DNS TXT
39 /// record. If provided, the fingerprint provided in the ISO has preference.
40 pub fn get_answer(settings: &AutoInstSettings) -> Result<String> {
41 let mut fingerprint: Option<String> = match settings.cert_fingerprint.clone() {
42 Some(fp) => {
43 info!("SSL fingerprint provided through ISO.");
44 Some(fp)
45 }
46 None => None,
47 };
48
49 let answer_url: String;
50 if let Some(url) = settings.http_url.clone() {
51 info!("URL specified in ISO");
52 answer_url = url;
53 } else {
54 (answer_url, fingerprint) = match Self::fetch_dhcp(fingerprint.clone()) {
55 Ok((url, fp)) => (url, fp),
56 Err(err) => {
57 info!("{err}");
58 Self::fetch_dns(fingerprint.clone())?
59 }
60 };
61 }
62
63 if let Some(fingerprint) = &fingerprint {
64 let _ = fs::write("/tmp/cert_fingerprint", fingerprint);
65 }
66
67 info!("Gathering system information.");
68 let payload = SysInfo::as_json()?;
69 info!("Sending POST request to '{answer_url}'.");
70 let answer = http_post::call(answer_url, fingerprint.as_deref(), payload)?;
71 Ok(answer)
72 }
73
74 /// Fetches search domain from resolv.conf file
75 fn get_search_domain() -> Result<String> {
76 info!("Retrieving default search domain.");
77 for line in read_to_string("/etc/resolv.conf")?.lines() {
78 if let Some((key, value)) = line.split_once(' ') {
79 if key == "search" {
80 return Ok(value.trim().into());
81 }
82 }
83 }
84 Err(Error::msg("Could not find search domain in resolv.conf."))
85 }
86
87 /// Runs a TXT DNS query on the domain provided
88 fn query_txt_record(query: String) -> Result<String> {
89 info!("Querying TXT record for '{query}'");
90 let url: String;
91 match Command::new("dig")
92 .args(["txt", "+short"])
93 .arg(&query)
94 .output()
95 {
96 Ok(output) => {
97 if output.status.success() {
98 url = String::from_utf8(output.stdout)?
99 .replace('"', "")
100 .trim()
101 .into();
102 if url.is_empty() {
103 bail!("Got empty response.");
104 }
105 } else {
106 bail!(
107 "Error querying DNS record '{query}' : {}",
108 String::from_utf8(output.stderr)?
109 );
110 }
111 }
112 Err(err) => bail!("Error querying DNS record '{query}': {err}"),
113 }
114 info!("Found: '{url}'");
115 Ok(url)
116 }
117
118 /// Tries to fetch answer URL and SSL fingerprint info from DNS
119 fn fetch_dns(mut fingerprint: Option<String>) -> Result<(String, Option<String>)> {
120 let search_domain = Self::get_search_domain()?;
121
122 let answer_url =
123 match Self::query_txt_record(format!("{ANSWER_URL_SUBDOMAIN}.{search_domain}")) {
124 Ok(url) => url,
125 Err(err) => bail!("{err}"),
126 };
127
128 if fingerprint.is_none() {
129 fingerprint =
130 match Self::query_txt_record(format!("{ANSWER_CERT_FP_SUBDOMAIN}.{search_domain}"))
131 {
132 Ok(fp) => Some(fp),
133 Err(err) => {
134 info!("{err}");
135 None
136 }
137 };
138 }
139 Ok((answer_url, fingerprint))
140 }
141
142 /// Tries to fetch answer URL and SSL fingerprint info from DHCP options
143 fn fetch_dhcp(mut fingerprint: Option<String>) -> Result<(String, Option<String>)> {
144 info!("Checking DHCP options.");
145 let leases = fs::read_to_string(DHCP_LEASE_FILE)?;
146
147 let mut answer_url: Option<String> = None;
148
149 let url_match = format!("option {DHCP_URL_OPTION}");
150 let fp_match = format!("option {DHCP_CERT_FP_OPTION}");
151
152 for line in leases.lines() {
153 if answer_url.is_none() && line.trim().starts_with(url_match.as_str()) {
154 answer_url = Self::strip_dhcp_option(line.split(' ').nth_back(0));
155 }
156 if fingerprint.is_none() && line.trim().starts_with(fp_match.as_str()) {
157 fingerprint = Self::strip_dhcp_option(line.split(' ').nth_back(0));
158 }
159 }
160
161 let answer_url = match answer_url {
162 None => bail!("No DHCP option found for fetch URL."),
163 Some(url) => {
164 info!("Found URL for answer in DHCP option: '{url}'");
165 url
166 }
167 };
168
169 if let Some(fp) = fingerprint.clone() {
170 info!("Found SSL Fingerprint via DHCP: '{fp}'");
171 }
172
173 Ok((answer_url, fingerprint))
174 }
175
176 /// Clean DHCP option string
177 fn strip_dhcp_option(value: Option<&str>) -> Option<String> {
178 // value is expected to be in format: "value";
179 value.map(|value| String::from(&value[1..value.len() - 2]))
180 }
181 }
182
183 mod http_post {
184 use anyhow::Result;
185 use rustls::ClientConfig;
186 use sha2::{Digest, Sha256};
187 use std::sync::Arc;
188 use ureq::{Agent, AgentBuilder};
189
190 /// Issues a POST request with the payload (JSON). Optionally a SHA256 fingerprint can be used to
191 /// check the cert against it, instead of the regular cert validation.
192 /// To gather the sha256 fingerprint you can use the following command:
193 /// ```no_compile
194 /// openssl s_client -connect <host>:443 < /dev/null 2>/dev/null | openssl x509 -fingerprint -sha256 -noout -in /dev/stdin
195 /// ```
196 ///
197 /// # Arguemnts
198 /// * `url` - URL to call
199 /// * `fingerprint` - SHA256 cert fingerprint if certificate pinning should be used. Optional.
200 /// * `payload` - The payload to send to the server. Expected to be a JSON formatted string.
201 pub fn call(url: String, fingerprint: Option<&str>, payload: String) -> Result<String> {
202 let answer;
203
204 if let Some(fingerprint) = fingerprint {
205 let tls_config = ClientConfig::builder()
206 .with_safe_defaults()
207 .with_custom_certificate_verifier(VerifyCertFingerprint::new(fingerprint)?)
208 .with_no_client_auth();
209
210 let agent: Agent = AgentBuilder::new().tls_config(Arc::new(tls_config)).build();
211
212 answer = agent
213 .post(&url)
214 .set("Content-type", "application/json; charset=utf-")
215 .send_string(&payload)?
216 .into_string()?;
217 } else {
218 let mut roots = rustls::RootCertStore::empty();
219 for cert in rustls_native_certs::load_native_certs()? {
220 roots.add(&rustls::Certificate(cert.0)).unwrap();
221 }
222
223 let tls_config = rustls::ClientConfig::builder()
224 .with_safe_defaults()
225 .with_root_certificates(roots)
226 .with_no_client_auth();
227
228 let agent = AgentBuilder::new()
229 .tls_connector(Arc::new(native_tls::TlsConnector::new()?))
230 .tls_config(Arc::new(tls_config))
231 .build();
232 answer = agent
233 .post(&url)
234 .set("Content-type", "application/json; charset=utf-")
235 .timeout(std::time::Duration::from_secs(60))
236 .send_string(&payload)?
237 .into_string()?;
238 }
239 Ok(answer)
240 }
241
242 struct VerifyCertFingerprint {
243 cert_fingerprint: Vec<u8>,
244 }
245
246 impl VerifyCertFingerprint {
247 fn new<S: AsRef<str>>(cert_fingerprint: S) -> Result<std::sync::Arc<Self>> {
248 let cert_fingerprint = cert_fingerprint.as_ref();
249 let sanitized = cert_fingerprint.replace(':', "");
250 let decoded = hex::decode(sanitized)?;
251 Ok(std::sync::Arc::new(Self {
252 cert_fingerprint: decoded,
253 }))
254 }
255 }
256
257 impl rustls::client::ServerCertVerifier for VerifyCertFingerprint {
258 fn verify_server_cert(
259 &self,
260 end_entity: &rustls::Certificate,
261 _intermediates: &[rustls::Certificate],
262 _server_name: &rustls::ServerName,
263 _scts: &mut dyn Iterator<Item = &[u8]>,
264 _ocsp_response: &[u8],
265 _now: std::time::SystemTime,
266 ) -> Result<rustls::client::ServerCertVerified, rustls::Error> {
267 let mut hasher = Sha256::new();
268 hasher.update(end_entity);
269 let result = hasher.finalize();
270
271 if result.as_slice() == self.cert_fingerprint {
272 Ok(rustls::client::ServerCertVerified::assertion())
273 } else {
274 Err(rustls::Error::General("Fingerprint did not match!".into()))
275 }
276 }
277 }
278 }