]>
Commit | Line | Data |
---|---|---|
9143507d AL |
1 | use anyhow::{bail, Result}; |
2 | use clap::{Args, Parser, Subcommand, ValueEnum}; | |
3 | use glob::Pattern; | |
4 | use regex::Regex; | |
9b9754a5 | 5 | use serde::Serialize; |
01470aae AL |
6 | use std::{ |
7 | collections::BTreeMap, | |
8 | fs, | |
9 | io::Read, | |
10 | path::{Path, PathBuf}, | |
11 | process::{Command, Stdio}, | |
12 | }; | |
9143507d AL |
13 | |
14 | use proxmox_auto_installer::{ | |
15 | answer::Answer, | |
16 | answer::FilterMatch, | |
eedc6521 | 17 | sysinfo, |
01470aae AL |
18 | utils::{ |
19 | get_matched_udev_indexes, get_nic_list, get_single_udev_index, AutoInstModes, | |
20 | AutoInstSettings, | |
21 | }, | |
9143507d AL |
22 | }; |
23 | ||
01470aae AL |
24 | static PROXMOX_ISO_FLAG: &str = "/autoinst-capable"; |
25 | ||
26 | /// This tool can be used to prepare a Proxmox installation ISO for automated installations. | |
27 | /// Additional uses are to validate the format of an answer file or to test match filters and | |
9143507d AL |
28 | /// print information on the properties to match against for the current hardware. |
29 | #[derive(Parser, Debug)] | |
30 | #[command(author, version, about, long_about = None)] | |
31 | struct Cli { | |
32 | #[command(subcommand)] | |
33 | command: Commands, | |
34 | } | |
35 | ||
36 | #[derive(Subcommand, Debug)] | |
37 | enum Commands { | |
01470aae | 38 | PrepareIso(CommandPrepareISO), |
9143507d | 39 | ValidateAnswer(CommandValidateAnswer), |
9b9754a5 AL |
40 | DeviceMatch(CommandDeviceMatch), |
41 | DeviceInfo(CommandDeviceInfo), | |
42 | Identifiers(CommandIdentifiers), | |
9143507d AL |
43 | } |
44 | ||
45 | /// Show device information that can be used for filters | |
46 | #[derive(Args, Debug)] | |
9b9754a5 | 47 | struct CommandDeviceInfo { |
9143507d AL |
48 | /// For which device type information should be shown |
49 | #[arg(name="type", short, long, value_enum, default_value_t=AllDeviceTypes::All)] | |
50 | device: AllDeviceTypes, | |
51 | } | |
52 | ||
53 | /// Test which devices the given filter matches against | |
54 | /// | |
55 | /// Filters support the following syntax: | |
56 | /// ? Match a single character | |
57 | /// * Match any number of characters | |
58 | /// [a], [0-9] Specifc character or range of characters | |
59 | /// [!a] Negate a specific character of range | |
60 | /// | |
61 | /// To avoid globbing characters being interpreted by the shell, use single quotes. | |
62 | /// Multiple filters can be defined. | |
63 | /// | |
64 | /// Examples: | |
65 | /// Match disks against the serial number and device name, both must match: | |
66 | /// | |
67 | /// proxmox-autoinst-helper match --filter-match all disk 'ID_SERIAL_SHORT=*2222*' 'DEVNAME=*nvme*' | |
68 | #[derive(Args, Debug)] | |
69 | #[command(verbatim_doc_comment)] | |
9b9754a5 | 70 | struct CommandDeviceMatch { |
9143507d AL |
71 | /// Device type to match the filter against |
72 | r#type: Devicetype, | |
73 | ||
74 | /// Filter in the format KEY=VALUE where the key is the UDEV key and VALUE the filter string. | |
75 | /// Multiple filters are possible, separated by a space. | |
76 | filter: Vec<String>, | |
77 | ||
78 | /// Defines if any filter or all filters must match. | |
79 | #[arg(long, value_enum, default_value_t=FilterMatch::Any)] | |
80 | filter_match: FilterMatch, | |
81 | } | |
82 | ||
83 | /// Validate if an answer file is formatted correctly. | |
84 | #[derive(Args, Debug)] | |
85 | struct CommandValidateAnswer { | |
86 | /// Path to the answer file | |
87 | path: PathBuf, | |
88 | #[arg(short, long, default_value_t = false)] | |
89 | debug: bool, | |
90 | } | |
91 | ||
01470aae AL |
92 | /// Prepare an ISO for automated installation. |
93 | /// | |
94 | /// The final ISO will try to fetch an answer file automatically. It will first search for a | |
95 | /// partition / file-system called "PROXMOXINST" (or lowercase) and a file in the root named | |
96 | /// "answer.toml". | |
97 | /// | |
98 | /// If that is not found, it will try to fetch an answer file via an HTTP Post request. The URL for | |
99 | /// it can be defined for the ISO with the '--url', '-u' argument. If not present, it will try to | |
100 | /// get a URL from a DHCP option (250, TXT) or as a DNS TXT record at 'proxmoxinst.{search | |
101 | /// domain}'. | |
102 | /// | |
103 | /// The TLS certificate fingerprint can either be defined via the '--cert-fingerprint', '-c' | |
104 | /// argument or alternatively via the custom DHCP option (251, TXT) or in a DNS TXT record located | |
105 | /// at 'proxmoxinst-fp.{search domain}'. | |
106 | /// | |
107 | /// The latter options to provide the TLS fingerprint will only be used if the same method was used | |
108 | /// to retrieve the URL. For example, the DNS TXT record for the fingerprint will only be used, if | |
109 | /// no one was configured with the '--cert-fingerprint' parameter and if the URL was retrieved via | |
110 | /// the DNS TXT record. | |
111 | /// | |
112 | /// The behavior of how to fetch an answer file can be overridden with the '--install-mode', '-i' | |
113 | /// parameter. The answer file can be{n} | |
114 | /// * integrated into the ISO itself ('included'){n} | |
115 | /// * needs to be present in a partition / file-system called 'PROXMOXINST' ('partition'){n} | |
116 | /// * only be requested via an HTTP Post request ('http'). | |
117 | #[derive(Args, Debug)] | |
118 | struct CommandPrepareISO { | |
119 | /// Path to the source ISO | |
120 | source: PathBuf, | |
121 | ||
122 | /// Path to store the final ISO to. | |
123 | #[arg(short, long)] | |
124 | target: Option<PathBuf>, | |
125 | ||
126 | /// Where to fetch the answer file from. | |
127 | #[arg(short, long, value_enum, default_value_t=AutoInstModes::Auto)] | |
128 | install_mode: AutoInstModes, | |
129 | ||
130 | /// Include the specified answer file in the ISO. Requires the '--install-mode', '-i' parameter | |
131 | /// to be set to 'included'. | |
132 | #[arg(short, long)] | |
133 | answer_file: Option<PathBuf>, | |
134 | ||
135 | /// Specify URL for fetching the answer file via HTTP | |
136 | #[arg(short, long)] | |
137 | url: Option<String>, | |
138 | ||
139 | /// Pin the ISO to the specified SHA256 TLS certificate fingerprint. | |
140 | #[arg(short, long)] | |
141 | cert_fingerprint: Option<String>, | |
142 | ||
143 | /// Tmp directory to use. | |
144 | #[arg(long)] | |
145 | tmp: Option<String>, | |
146 | } | |
147 | ||
9b9754a5 AL |
148 | /// Show identifiers for the current machine. This information is part of the POST request to fetch |
149 | /// an answer file. | |
150 | #[derive(Args, Debug)] | |
151 | struct CommandIdentifiers {} | |
152 | ||
9143507d AL |
153 | #[derive(Args, Debug)] |
154 | struct GlobalOpts { | |
155 | /// Output format | |
156 | #[arg(long, short, value_enum)] | |
157 | format: OutputFormat, | |
158 | } | |
159 | ||
160 | #[derive(Clone, Debug, ValueEnum, PartialEq)] | |
161 | enum AllDeviceTypes { | |
162 | All, | |
163 | Network, | |
164 | Disk, | |
165 | } | |
166 | ||
167 | #[derive(Clone, Debug, ValueEnum)] | |
168 | enum Devicetype { | |
169 | Network, | |
170 | Disk, | |
171 | } | |
172 | ||
173 | #[derive(Clone, Debug, ValueEnum)] | |
174 | enum OutputFormat { | |
175 | Pretty, | |
176 | Json, | |
177 | } | |
178 | ||
179 | #[derive(Serialize)] | |
180 | struct Devs { | |
181 | disks: Option<BTreeMap<String, BTreeMap<String, String>>>, | |
182 | nics: Option<BTreeMap<String, BTreeMap<String, String>>>, | |
183 | } | |
184 | ||
185 | fn main() { | |
186 | let args = Cli::parse(); | |
187 | let res = match &args.command { | |
01470aae | 188 | Commands::PrepareIso(args) => prepare_iso(args), |
9143507d | 189 | Commands::ValidateAnswer(args) => validate_answer(args), |
9b9754a5 AL |
190 | Commands::DeviceInfo(args) => info(args), |
191 | Commands::DeviceMatch(args) => match_filter(args), | |
192 | Commands::Identifiers(args) => show_identifiers(args), | |
9143507d AL |
193 | }; |
194 | if let Err(err) = res { | |
195 | eprintln!("{err}"); | |
196 | std::process::exit(1); | |
197 | } | |
198 | } | |
199 | ||
9b9754a5 | 200 | fn info(args: &CommandDeviceInfo) -> Result<()> { |
9143507d AL |
201 | let mut devs = Devs { |
202 | disks: None, | |
203 | nics: None, | |
204 | }; | |
205 | ||
206 | if args.device == AllDeviceTypes::Network || args.device == AllDeviceTypes::All { | |
207 | match get_nics() { | |
208 | Ok(res) => devs.nics = Some(res), | |
209 | Err(err) => bail!("Error getting NIC data: {err}"), | |
210 | } | |
211 | } | |
212 | if args.device == AllDeviceTypes::Disk || args.device == AllDeviceTypes::All { | |
213 | match get_disks() { | |
214 | Ok(res) => devs.disks = Some(res), | |
215 | Err(err) => bail!("Error getting disk data: {err}"), | |
216 | } | |
217 | } | |
218 | println!("{}", serde_json::to_string_pretty(&devs).unwrap()); | |
219 | Ok(()) | |
220 | } | |
221 | ||
9b9754a5 | 222 | fn match_filter(args: &CommandDeviceMatch) -> Result<()> { |
9143507d AL |
223 | let devs: BTreeMap<String, BTreeMap<String, String>> = match args.r#type { |
224 | Devicetype::Disk => get_disks().unwrap(), | |
225 | Devicetype::Network => get_nics().unwrap(), | |
226 | }; | |
227 | // parse filters | |
228 | ||
229 | let mut filters: BTreeMap<String, String> = BTreeMap::new(); | |
230 | ||
231 | for f in &args.filter { | |
232 | match f.split_once('=') { | |
233 | Some((key, value)) => { | |
234 | if key.is_empty() || value.is_empty() { | |
235 | bail!("Filter key or value is empty in filter: '{f}'"); | |
236 | } | |
237 | filters.insert(String::from(key), String::from(value)); | |
238 | } | |
239 | None => { | |
240 | bail!("Could not find separator '=' in filter: '{f}'"); | |
241 | } | |
242 | } | |
243 | } | |
244 | ||
245 | // align return values | |
246 | let result = match args.r#type { | |
247 | Devicetype::Disk => { | |
248 | get_matched_udev_indexes(filters, &devs, args.filter_match == FilterMatch::All) | |
249 | } | |
250 | Devicetype::Network => get_single_udev_index(filters, &devs).map(|r| vec![r]), | |
251 | }; | |
252 | ||
253 | match result { | |
254 | Ok(result) => println!("{}", serde_json::to_string_pretty(&result).unwrap()), | |
255 | Err(err) => bail!("Error matching filters: {err}"), | |
256 | } | |
257 | Ok(()) | |
258 | } | |
259 | ||
260 | fn validate_answer(args: &CommandValidateAnswer) -> Result<()> { | |
01470aae | 261 | let answer = parse_answer(&args.path)?; |
9143507d AL |
262 | if args.debug { |
263 | println!("Parsed data from answer file:\n{:#?}", answer); | |
264 | } | |
265 | Ok(()) | |
266 | } | |
267 | ||
9b9754a5 AL |
268 | fn show_identifiers(_args: &CommandIdentifiers) -> Result<()> { |
269 | match sysinfo::get_sysinfo(true) { | |
270 | Ok(res) => println!("{res}"), | |
271 | Err(err) => eprintln!("Error fetching system identifiers: {err}"), | |
272 | } | |
273 | Ok(()) | |
274 | } | |
275 | ||
01470aae AL |
276 | fn prepare_iso(args: &CommandPrepareISO) -> Result<()> { |
277 | check_prepare_requirements(args)?; | |
278 | ||
279 | if args.install_mode == AutoInstModes::Included { | |
280 | if args.answer_file.is_none() { | |
281 | bail!("Missing path to answer file needed for 'direct' install mode."); | |
282 | } | |
283 | if args.cert_fingerprint.is_some() { | |
284 | bail!("No certificate fingerprint needed for direct install mode. Drop the parameter!"); | |
285 | } | |
286 | if args.url.is_some() { | |
287 | bail!("No URL needed for direct install mode. Drop the parameter!"); | |
288 | } | |
289 | } else if args.install_mode == AutoInstModes::Partition { | |
290 | if args.cert_fingerprint.is_some() { | |
291 | bail!( | |
292 | "No certificate fingerprint needed for partition install mode. Drop the parameter!" | |
293 | ); | |
294 | } | |
295 | if args.url.is_some() { | |
296 | bail!("No URL needed for partition install mode. Drop the parameter!"); | |
297 | } | |
298 | } | |
299 | if args.answer_file.is_some() && args.install_mode != AutoInstModes::Included { | |
300 | bail!("Set '-i', '--install-mode' to 'included' to place the answer file directly in the ISO."); | |
301 | } | |
302 | ||
303 | if let Some(file) = &args.answer_file { | |
304 | println!("Checking provided answer file..."); | |
305 | parse_answer(file)?; | |
306 | } | |
307 | ||
308 | let mut tmp_base = PathBuf::new(); | |
309 | if args.tmp.is_some() { | |
310 | tmp_base.push(args.tmp.as_ref().unwrap()); | |
311 | } else { | |
312 | tmp_base.push(args.source.parent().unwrap()); | |
313 | tmp_base.push(".proxmox-iso-prepare"); | |
314 | } | |
315 | fs::create_dir_all(&tmp_base)?; | |
316 | ||
317 | let mut tmp_iso = tmp_base.clone(); | |
318 | tmp_iso.push("proxmox.iso"); | |
319 | let mut tmp_answer = tmp_base.clone(); | |
320 | tmp_answer.push("answer.toml"); | |
321 | ||
322 | println!("Copying source ISO to temporary location..."); | |
323 | fs::copy(&args.source, &tmp_iso)?; | |
324 | println!("Done copying source ISO"); | |
325 | ||
326 | println!("Preparing ISO..."); | |
327 | let install_mode = AutoInstSettings { | |
328 | mode: args.install_mode.clone(), | |
329 | http_url: args.url.clone(), | |
330 | cert_fingerprint: args.cert_fingerprint.clone(), | |
331 | }; | |
332 | let mut instmode_file_tmp = tmp_base.clone(); | |
333 | instmode_file_tmp.push("autoinst-mode.toml"); | |
334 | fs::write(&instmode_file_tmp, toml::to_string_pretty(&install_mode)?)?; | |
335 | ||
336 | inject_file_to_iso(&tmp_iso, &instmode_file_tmp, "/autoinst-mode.toml")?; | |
337 | ||
338 | if let Some(answer) = &args.answer_file { | |
339 | fs::copy(answer, &tmp_answer)?; | |
340 | inject_file_to_iso(&tmp_iso, &tmp_answer, "/answer.toml")?; | |
341 | } | |
342 | ||
343 | println!("Done preparing iso."); | |
344 | println!("Move ISO to target location..."); | |
345 | let iso_target = final_iso_location(args); | |
346 | fs::rename(&tmp_iso, &iso_target)?; | |
347 | println!("Cleaning up..."); | |
348 | fs::remove_dir_all(&tmp_base)?; | |
349 | println!("Final ISO is available at {}.", &iso_target.display()); | |
350 | ||
351 | Ok(()) | |
352 | } | |
353 | ||
354 | fn final_iso_location(args: &CommandPrepareISO) -> PathBuf { | |
355 | if let Some(specified) = args.target.clone() { | |
356 | return specified; | |
357 | } | |
358 | let mut suffix: String = match args.install_mode { | |
359 | AutoInstModes::Auto => "auto".into(), | |
360 | AutoInstModes::Http => "auto-http".into(), | |
361 | AutoInstModes::Included => "auto-answer-included".into(), | |
362 | AutoInstModes::Partition => "auto-part".into(), | |
363 | }; | |
364 | ||
365 | if args.url.is_some() { | |
366 | suffix.push_str("-url"); | |
367 | } | |
368 | if args.cert_fingerprint.is_some() { | |
369 | suffix.push_str("-fp"); | |
370 | } | |
371 | ||
372 | let base = args.source.parent().unwrap(); | |
373 | let iso = args.source.file_stem().unwrap(); | |
374 | ||
375 | let mut target = base.to_path_buf(); | |
376 | target.push(format!("{}-{}.iso", iso.to_str().unwrap(), suffix)); | |
377 | ||
378 | target.to_path_buf() | |
379 | } | |
380 | ||
381 | fn inject_file_to_iso(iso: &PathBuf, file: &PathBuf, location: &str) -> Result<()> { | |
382 | let result = Command::new("xorriso") | |
383 | .arg("--boot_image") | |
384 | .arg("any") | |
385 | .arg("keep") | |
386 | .arg("-dev") | |
387 | .arg(iso) | |
388 | .arg("-map") | |
389 | .arg(file) | |
390 | .arg(location) | |
391 | .output()?; | |
392 | if !result.status.success() { | |
393 | bail!( | |
394 | "Error injecting {} into {}: {}", | |
395 | file.display(), | |
396 | iso.display(), | |
397 | String::from_utf8(result.stderr)? | |
398 | ); | |
399 | } | |
400 | Ok(()) | |
401 | } | |
402 | ||
9143507d AL |
403 | fn get_disks() -> Result<BTreeMap<String, BTreeMap<String, String>>> { |
404 | let unwantend_block_devs = vec![ | |
405 | "ram[0-9]*", | |
406 | "loop[0-9]*", | |
407 | "md[0-9]*", | |
408 | "dm-*", | |
409 | "fd[0-9]*", | |
410 | "sr[0-9]*", | |
411 | ]; | |
412 | ||
413 | // compile Regex here once and not inside the loop | |
414 | let re_disk = Regex::new(r"(?m)^E: DEVTYPE=disk")?; | |
415 | let re_cdrom = Regex::new(r"(?m)^E: ID_CDROM")?; | |
416 | let re_iso9660 = Regex::new(r"(?m)^E: ID_FS_TYPE=iso9660")?; | |
417 | ||
418 | let re_name = Regex::new(r"(?m)^N: (.*)$")?; | |
419 | let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?; | |
420 | ||
421 | let mut disks: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new(); | |
422 | ||
423 | 'outer: for entry in fs::read_dir("/sys/block")? { | |
424 | let entry = entry.unwrap(); | |
425 | let filename = entry.file_name().into_string().unwrap(); | |
426 | ||
427 | for p in &unwantend_block_devs { | |
428 | if Pattern::new(p)?.matches(&filename) { | |
429 | continue 'outer; | |
430 | } | |
431 | } | |
432 | ||
433 | let output = match get_udev_properties(&entry.path()) { | |
434 | Ok(output) => output, | |
435 | Err(err) => { | |
436 | eprint!("{err}"); | |
437 | continue 'outer; | |
438 | } | |
439 | }; | |
440 | ||
441 | if !re_disk.is_match(&output) { | |
442 | continue 'outer; | |
443 | }; | |
444 | if re_cdrom.is_match(&output) { | |
445 | continue 'outer; | |
446 | }; | |
447 | if re_iso9660.is_match(&output) { | |
448 | continue 'outer; | |
449 | }; | |
450 | ||
451 | let mut name = filename; | |
452 | if let Some(cap) = re_name.captures(&output) { | |
453 | if let Some(res) = cap.get(1) { | |
454 | name = String::from(res.as_str()); | |
455 | } | |
456 | } | |
457 | ||
458 | let mut udev_props: BTreeMap<String, String> = BTreeMap::new(); | |
459 | ||
460 | for line in output.lines() { | |
461 | if let Some(caps) = re_props.captures(line) { | |
462 | let key = String::from(caps.get(1).unwrap().as_str()); | |
463 | let value = String::from(caps.get(2).unwrap().as_str()); | |
464 | udev_props.insert(key, value); | |
465 | } | |
466 | } | |
467 | ||
468 | disks.insert(name, udev_props); | |
469 | } | |
470 | Ok(disks) | |
471 | } | |
472 | ||
9143507d AL |
473 | fn get_nics() -> Result<BTreeMap<String, BTreeMap<String, String>>> { |
474 | let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?; | |
475 | let mut nics: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new(); | |
476 | ||
9b9754a5 | 477 | let links = get_nic_list()?; |
9143507d AL |
478 | for link in links { |
479 | let path = format!("/sys/class/net/{link}"); | |
480 | ||
481 | let output = match get_udev_properties(&PathBuf::from(path)) { | |
482 | Ok(output) => output, | |
483 | Err(err) => { | |
484 | eprint!("{err}"); | |
485 | continue; | |
486 | } | |
487 | }; | |
488 | ||
489 | let mut udev_props: BTreeMap<String, String> = BTreeMap::new(); | |
490 | ||
491 | for line in output.lines() { | |
492 | if let Some(caps) = re_props.captures(line) { | |
493 | let key = String::from(caps.get(1).unwrap().as_str()); | |
494 | let value = String::from(caps.get(2).unwrap().as_str()); | |
495 | udev_props.insert(key, value); | |
496 | } | |
497 | } | |
498 | ||
499 | nics.insert(link, udev_props); | |
500 | } | |
501 | Ok(nics) | |
502 | } | |
503 | ||
504 | fn get_udev_properties(path: &PathBuf) -> Result<String> { | |
505 | let udev_output = Command::new("udevadm") | |
506 | .arg("info") | |
507 | .arg("--path") | |
508 | .arg(path) | |
509 | .arg("--query") | |
510 | .arg("all") | |
511 | .output()?; | |
512 | if !udev_output.status.success() { | |
513 | bail!("could not run udevadm successfully for {}", path.display()); | |
514 | } | |
515 | Ok(String::from_utf8(udev_output.stdout)?) | |
516 | } | |
01470aae AL |
517 | |
518 | fn parse_answer(path: &PathBuf) -> Result<Answer> { | |
519 | let mut file = match fs::File::open(path) { | |
520 | Ok(file) => file, | |
521 | Err(err) => bail!("Opening answer file '{}' failed: {err}", path.display()), | |
522 | }; | |
523 | let mut contents = String::new(); | |
524 | if let Err(err) = file.read_to_string(&mut contents) { | |
525 | bail!("Reading from file '{}' failed: {err}", path.display()); | |
526 | } | |
527 | match toml::from_str(&contents) { | |
528 | Ok(answer) => { | |
529 | println!("The file was parsed successfully, no syntax errors found!"); | |
530 | Ok(answer) | |
531 | } | |
532 | Err(err) => bail!("Error parsing answer file: {err}"), | |
533 | } | |
534 | } | |
535 | ||
536 | fn check_prepare_requirements(args: &CommandPrepareISO) -> Result<()> { | |
537 | match Path::try_exists(&args.source) { | |
538 | Ok(true) => (), | |
539 | Ok(false) => bail!("Source file does not exist."), | |
540 | Err(_) => bail!("Source file does not exist."), | |
541 | } | |
542 | ||
543 | match Command::new("xorriso") | |
544 | .arg("-dev") | |
545 | .arg(&args.source) | |
546 | .arg("-find") | |
547 | .arg(PROXMOX_ISO_FLAG) | |
548 | .stderr(Stdio::null()) | |
549 | .stdout(Stdio::null()) | |
550 | .status() | |
551 | { | |
552 | Ok(v) => { | |
553 | if !v.success() { | |
554 | bail!("The source ISO file is not able to be installed automatically. Please try a more current one."); | |
555 | } | |
556 | } | |
557 | Err(_) => bail!("Could not run 'xorriso'. Please install it."), | |
558 | }; | |
559 | ||
560 | Ok(()) | |
561 | } |