]>
Commit | Line | Data |
---|---|---|
1ca401af AL |
1 | use anyhow::{bail, Error, Result}; |
2 | use log::info; | |
3 | use std::{ | |
4 | fs::{self, read_to_string}, | |
1ca401af AL |
5 | process::Command, |
6 | }; | |
7 | ||
d4c43e9d | 8 | use proxmox_auto_installer::{sysinfo::SysInfo, utils::AutoInstSettings}; |
1ca401af | 9 | |
a101adee TL |
10 | static ANSWER_URL_SUBDOMAIN: &str = "proxmox-auto-installer"; |
11 | static ANSWER_CERT_FP_SUBDOMAIN: &str = "proxmox-auto-installer-cert-fingerprint"; | |
1ca401af AL |
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 | // ``` | |
a101adee TL |
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; | |
1ca401af AL |
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 | |
a101adee TL |
27 | static DHCP_URL_OPTION: &str = "proxmox-auto-installer-manifest-url"; |
28 | static DHCP_CERT_FP_OPTION: &str = "proxmox-auto-installer-cert-fingerprint"; | |
1ca401af AL |
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 | |
2a94c1a8 AL |
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) | |
1ca401af | 45 | } |
2a94c1a8 | 46 | None => None, |
1ca401af AL |
47 | }; |
48 | ||
49 | let answer_url: String; | |
2a94c1a8 AL |
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 | } | |
1ca401af | 62 | |
15ba8a15 WB |
63 | if let Some(fingerprint) = &fingerprint { |
64 | let _ = fs::write("/tmp/cert_fingerprint", fingerprint); | |
1ca401af AL |
65 | } |
66 | ||
67 | info!("Gathering system information."); | |
d4c43e9d | 68 | let payload = SysInfo::as_json()?; |
1ca401af | 69 | info!("Sending POST request to '{answer_url}'."); |
412871c9 | 70 | let answer = http_post::call(answer_url, fingerprint.as_deref(), payload)?; |
1ca401af AL |
71 | Ok(answer) |
72 | } | |
73 | ||
1ca401af AL |
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 | ||
a101adee TL |
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 | }; | |
1ca401af AL |
127 | |
128 | if fingerprint.is_none() { | |
129 | fingerprint = | |
a101adee TL |
130 | match Self::query_txt_record(format!("{ANSWER_CERT_FP_SUBDOMAIN}.{search_domain}")) |
131 | { | |
1ca401af AL |
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>)> { | |
856dc468 | 144 | info!("Checking DHCP options."); |
1ca401af AL |
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}"); | |
a101adee | 150 | let fp_match = format!("option {DHCP_CERT_FP_OPTION}"); |
1ca401af AL |
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."), | |
856dc468 AL |
163 | Some(url) => { |
164 | info!("Found URL for answer in DHCP option: '{url}'"); | |
165 | url | |
166 | } | |
1ca401af AL |
167 | }; |
168 | ||
856dc468 AL |
169 | if let Some(fp) = fingerprint.clone() { |
170 | info!("Found SSL Fingerprint via DHCP: '{fp}'"); | |
171 | } | |
172 | ||
1ca401af AL |
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 | } | |
412871c9 TL |
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 | } |