]> git.proxmox.com Git - pve-installer.git/blob - proxmox-auto-install-assistant/src/main.rs
ec04523211e3a0e1edaad05eba1b0670121ff6bf
[pve-installer.git] / proxmox-auto-install-assistant / src / main.rs
1 use anyhow::{bail, Result};
2 use clap::{Args, Parser, Subcommand, ValueEnum};
3 use glob::Pattern;
4 use regex::Regex;
5 use serde::Serialize;
6 use std::{
7 collections::BTreeMap,
8 fs,
9 io::{self, Read},
10 path::{Path, PathBuf},
11 process::{Command, Stdio},
12 };
13
14 use proxmox_auto_installer::{
15 answer::Answer,
16 answer::FilterMatch,
17 sysinfo::SysInfo,
18 utils::{
19 get_matched_udev_indexes, get_nic_list, get_single_udev_index, AutoInstSettings,
20 FetchAnswerFrom, HttpOptions,
21 },
22 };
23
24 static PROXMOX_ISO_FLAG: &str = "/auto-installer-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
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 {
38 PrepareIso(CommandPrepareISO),
39 ValidateAnswer(CommandValidateAnswer),
40 DeviceMatch(CommandDeviceMatch),
41 DeviceInfo(CommandDeviceInfo),
42 SystemInfo(CommandSystemInfo),
43 }
44
45 /// Show device information that can be used for filters
46 #[derive(Args, Debug)]
47 struct CommandDeviceInfo {
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-auto-install-assistant match --filter-match all disk 'ID_SERIAL_SHORT=*2222*' 'DEVNAME=*nvme*'
68 #[derive(Args, Debug)]
69 #[command(verbatim_doc_comment)]
70 struct CommandDeviceMatch {
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
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 "PROXMOX-INST-SRC" (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' argument. If not present, it will try to get a
100 /// URL from a DHCP option (250, TXT) or by querying a DNS TXT record at
101 /// 'proxmox-auto-installer.{search domain}'.
102 ///
103 /// The TLS certificate fingerprint can either be defined via the '--cert-fingerprint' argument or
104 /// alternatively via the custom DHCP option (251, TXT) or in a DNS TXT record located at
105 /// 'proxmox-auto-installer-cert-fingerprint.{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 '--fetch-from',
113 /// parameter. The answer file can be{n}
114 /// * integrated into the ISO itself ('iso'){n}
115 /// * needs to be present in a partition / file-system with the label 'PROXMOX-INST-SRC'
116 /// ('partition'){n}
117 /// * get requested via an HTTP Post request ('http').
118 #[derive(Args, Debug)]
119 struct CommandPrepareISO {
120 /// Path to the source ISO to prepare
121 input: PathBuf,
122
123 /// Path to store the final ISO to, defaults to auto-generated depending on mode.
124 #[arg(short, long)]
125 output: Option<PathBuf>,
126
127 /// Where the automatic installer should fetch the answer file from.
128 #[arg(long, value_enum)]
129 fetch_from: FetchAnswerFrom,
130
131 /// Include the specified answer file in the ISO. Requires the '--fetch-from' parameter
132 /// to be set to 'iso'.
133 #[arg(long)]
134 answer_file: Option<PathBuf>,
135
136 /// Specify URL for fetching the answer file via HTTP
137 #[arg(long)]
138 url: Option<String>,
139
140 /// Pin the ISO to the specified SHA256 TLS certificate fingerprint.
141 #[arg(long)]
142 cert_fingerprint: Option<String>,
143
144 /// Staging directory to use for preparing the new ISO file. Defaults to the directory of the
145 /// input ISO file.
146 #[arg(long)]
147 tmp: Option<String>,
148 }
149
150 /// Show the system information that can be used to identify a host.
151 ///
152 /// The shown information is sent as POST HTTP request when fetching the answer file for the
153 /// automatic installation through HTTP, You can, for example, use this to return a dynamically
154 /// assembled answer file.
155 #[derive(Args, Debug)]
156 struct CommandSystemInfo {}
157
158 #[derive(Args, Debug)]
159 struct GlobalOpts {
160 /// Output format
161 #[arg(long, short, value_enum)]
162 format: OutputFormat,
163 }
164
165 #[derive(Clone, Debug, ValueEnum, PartialEq)]
166 enum AllDeviceTypes {
167 All,
168 Network,
169 Disk,
170 }
171
172 #[derive(Clone, Debug, ValueEnum)]
173 enum Devicetype {
174 Network,
175 Disk,
176 }
177
178 #[derive(Clone, Debug, ValueEnum)]
179 enum OutputFormat {
180 Pretty,
181 Json,
182 }
183
184 #[derive(Serialize)]
185 struct Devs {
186 disks: Option<BTreeMap<String, BTreeMap<String, String>>>,
187 nics: Option<BTreeMap<String, BTreeMap<String, String>>>,
188 }
189
190 fn main() {
191 let args = Cli::parse();
192 let res = match &args.command {
193 Commands::PrepareIso(args) => prepare_iso(args),
194 Commands::ValidateAnswer(args) => validate_answer(args),
195 Commands::DeviceInfo(args) => info(args),
196 Commands::DeviceMatch(args) => match_filter(args),
197 Commands::SystemInfo(args) => show_system_info(args),
198 };
199 if let Err(err) = res {
200 eprintln!("{err}");
201 std::process::exit(1);
202 }
203 }
204
205 fn info(args: &CommandDeviceInfo) -> Result<()> {
206 let mut devs = Devs {
207 disks: None,
208 nics: None,
209 };
210
211 if args.device == AllDeviceTypes::Network || args.device == AllDeviceTypes::All {
212 match get_nics() {
213 Ok(res) => devs.nics = Some(res),
214 Err(err) => bail!("Error getting NIC data: {err}"),
215 }
216 }
217 if args.device == AllDeviceTypes::Disk || args.device == AllDeviceTypes::All {
218 match get_disks() {
219 Ok(res) => devs.disks = Some(res),
220 Err(err) => bail!("Error getting disk data: {err}"),
221 }
222 }
223 println!("{}", serde_json::to_string_pretty(&devs).unwrap());
224 Ok(())
225 }
226
227 fn match_filter(args: &CommandDeviceMatch) -> Result<()> {
228 let devs: BTreeMap<String, BTreeMap<String, String>> = match args.r#type {
229 Devicetype::Disk => get_disks().unwrap(),
230 Devicetype::Network => get_nics().unwrap(),
231 };
232 // parse filters
233
234 let mut filters: BTreeMap<String, String> = BTreeMap::new();
235
236 for f in &args.filter {
237 match f.split_once('=') {
238 Some((key, value)) => {
239 if key.is_empty() || value.is_empty() {
240 bail!("Filter key or value is empty in filter: '{f}'");
241 }
242 filters.insert(String::from(key), String::from(value));
243 }
244 None => {
245 bail!("Could not find separator '=' in filter: '{f}'");
246 }
247 }
248 }
249
250 // align return values
251 let result = match args.r#type {
252 Devicetype::Disk => {
253 get_matched_udev_indexes(&filters, &devs, args.filter_match == FilterMatch::All)
254 }
255 Devicetype::Network => get_single_udev_index(&filters, &devs).map(|r| vec![r]),
256 };
257
258 match result {
259 Ok(result) => println!("{}", serde_json::to_string_pretty(&result).unwrap()),
260 Err(err) => bail!("Error matching filters: {err}"),
261 }
262 Ok(())
263 }
264
265 fn validate_answer(args: &CommandValidateAnswer) -> Result<()> {
266 let answer = parse_answer(&args.path)?;
267 if args.debug {
268 println!("Parsed data from answer file:\n{:#?}", answer);
269 }
270 Ok(())
271 }
272
273 fn show_system_info(_args: &CommandSystemInfo) -> Result<()> {
274 match SysInfo::as_json_pretty() {
275 Ok(res) => println!("{res}"),
276 Err(err) => eprintln!("Error fetching system info: {err}"),
277 }
278 Ok(())
279 }
280
281 fn prepare_iso(args: &CommandPrepareISO) -> Result<()> {
282 check_prepare_requirements(args)?;
283
284 if args.fetch_from == FetchAnswerFrom::Iso {
285 if args.answer_file.is_none() {
286 bail!("Missing path to answer file needed for 'direct' install mode.");
287 }
288 if args.cert_fingerprint.is_some() {
289 bail!("No certificate fingerprint needed for direct install mode. Drop the parameter!");
290 }
291 if args.url.is_some() {
292 bail!("No URL needed for direct install mode. Drop the parameter!");
293 }
294 } else if args.fetch_from == FetchAnswerFrom::Partition {
295 if args.cert_fingerprint.is_some() {
296 bail!(
297 "No certificate fingerprint needed for partition install mode. Drop the parameter!"
298 );
299 }
300 if args.url.is_some() {
301 bail!("No URL needed for partition install mode. Drop the parameter!");
302 }
303 }
304 if args.answer_file.is_some() && args.fetch_from != FetchAnswerFrom::Iso {
305 bail!("Set '-i', '--install-mode' to 'included' to place the answer file directly in the ISO.");
306 }
307
308 if let Some(file) = &args.answer_file {
309 println!("Checking provided answer file...");
310 parse_answer(file)?;
311 }
312
313 let iso_target = final_iso_location(args);
314 let iso_target_file_name = match iso_target.file_name() {
315 None => bail!("no base filename in target ISO path found"),
316 Some(source_file_name) => source_file_name.to_string_lossy(),
317 };
318
319 let mut tmp_base = PathBuf::new();
320 match args.tmp.as_ref() {
321 Some(tmp_dir) => tmp_base.push(tmp_dir),
322 None => tmp_base.push(iso_target.parent().unwrap()),
323 }
324
325 let mut tmp_iso = tmp_base.clone();
326 tmp_iso.push(format!("{iso_target_file_name}.tmp",));
327
328 println!("Copying source ISO to temporary location...");
329 fs::copy(&args.input, &tmp_iso)?;
330
331 println!("Preparing ISO...");
332 let config = AutoInstSettings {
333 mode: args.fetch_from.clone(),
334 http: HttpOptions {
335 url: args.url.clone(),
336 cert_fingerprint: args.cert_fingerprint.clone(),
337 },
338 };
339 let mut instmode_file_tmp = tmp_base.clone();
340 instmode_file_tmp.push("auto-installer-mode.toml");
341 fs::write(&instmode_file_tmp, toml::to_string_pretty(&config)?)?;
342
343 inject_file_to_iso(&tmp_iso, &instmode_file_tmp, "/auto-installer-mode.toml")?;
344
345 if let Some(answer_file) = &args.answer_file {
346 inject_file_to_iso(&tmp_iso, answer_file, "/answer.toml")?;
347 }
348
349 println!("Moving prepared ISO to target location...");
350 fs::rename(&tmp_iso, &iso_target)?;
351 println!("Final ISO is available at {iso_target:?}.");
352
353 Ok(())
354 }
355
356 fn final_iso_location(args: &CommandPrepareISO) -> PathBuf {
357 if let Some(specified) = args.output.clone() {
358 return specified;
359 }
360 let mut suffix: String = match args.fetch_from {
361 FetchAnswerFrom::Http => "auto-from-http",
362 FetchAnswerFrom::Iso => "auto-from-iso",
363 FetchAnswerFrom::Partition => "auto-from-partition",
364 }
365 .into();
366
367 if args.url.is_some() {
368 suffix.push_str("-url");
369 }
370 if args.cert_fingerprint.is_some() {
371 suffix.push_str("-fp");
372 }
373
374 let base = args.input.parent().unwrap();
375 let iso = args.input.file_stem().unwrap();
376
377 let mut target = base.to_path_buf();
378 target.push(format!("{}-{}.iso", iso.to_str().unwrap(), suffix));
379
380 target.to_path_buf()
381 }
382
383 fn inject_file_to_iso(iso: &PathBuf, file: &PathBuf, location: &str) -> Result<()> {
384 let result = Command::new("xorriso")
385 .arg("--boot_image")
386 .arg("any")
387 .arg("keep")
388 .arg("-dev")
389 .arg(iso)
390 .arg("-map")
391 .arg(file)
392 .arg(location)
393 .output()?;
394 if !result.status.success() {
395 bail!(
396 "Error injecting {file:?} into {iso:?}: {}",
397 String::from_utf8_lossy(&result.stderr)
398 );
399 }
400 Ok(())
401 }
402
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
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
477 let links = get_nic_list()?;
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:?}");
514 }
515 Ok(String::from_utf8(udev_output.stdout)?)
516 }
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 {path:?} failed: {err}"),
522 };
523 let mut contents = String::new();
524 if let Err(err) = file.read_to_string(&mut contents) {
525 bail!("Reading from file {path:?} failed: {err}");
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.input) {
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.input)
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(err) if err.kind() == io::ErrorKind::NotFound => {
558 bail!("Could not find the 'xorriso' binary. Please install it.")
559 }
560 Err(err) => bail!("unexpected error when trying to execute 'xorriso' - {err}"),
561 };
562
563 Ok(())
564 }