]> git.proxmox.com Git - pve-installer.git/blob - proxmox-auto-install-assistant/src/main.rs
da55cf6bd6bc8086f735a1171406d9dfecdcc0ac
[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::Read,
10 path::{Path, PathBuf},
11 process::{Command, Stdio},
12 };
13
14 use proxmox_auto_installer::{
15 answer::Answer,
16 answer::FilterMatch,
17 sysinfo,
18 utils::{
19 get_matched_udev_indexes, get_nic_list, get_single_udev_index, AutoInstModes,
20 AutoInstSettings,
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 Identifiers(CommandIdentifiers),
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', '-u' argument. If not present, it will try to
100 /// get a 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', '-c'
104 /// argument or alternatively via the custom DHCP option (251, TXT) or in a DNS TXT record located
105 /// at '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 '--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 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
121 source: PathBuf,
122
123 /// Path to store the final ISO to.
124 #[arg(short, long)]
125 target: Option<PathBuf>,
126
127 /// Where to fetch the answer file from.
128 #[arg(short, long, value_enum, default_value_t=AutoInstModes::Auto)]
129 install_mode: AutoInstModes,
130
131 /// Include the specified answer file in the ISO. Requires the '--install-mode', '-i' parameter
132 /// to be set to 'included'.
133 #[arg(short, long)]
134 answer_file: Option<PathBuf>,
135
136 /// Specify URL for fetching the answer file via HTTP
137 #[arg(short, long)]
138 url: Option<String>,
139
140 /// Pin the ISO to the specified SHA256 TLS certificate fingerprint.
141 #[arg(short, long)]
142 cert_fingerprint: Option<String>,
143
144 /// Tmp directory to use.
145 #[arg(long)]
146 tmp: Option<String>,
147 }
148
149 /// Show identifiers for the current machine. This information is part of the POST request to fetch
150 /// an answer file.
151 #[derive(Args, Debug)]
152 struct CommandIdentifiers {}
153
154 #[derive(Args, Debug)]
155 struct GlobalOpts {
156 /// Output format
157 #[arg(long, short, value_enum)]
158 format: OutputFormat,
159 }
160
161 #[derive(Clone, Debug, ValueEnum, PartialEq)]
162 enum AllDeviceTypes {
163 All,
164 Network,
165 Disk,
166 }
167
168 #[derive(Clone, Debug, ValueEnum)]
169 enum Devicetype {
170 Network,
171 Disk,
172 }
173
174 #[derive(Clone, Debug, ValueEnum)]
175 enum OutputFormat {
176 Pretty,
177 Json,
178 }
179
180 #[derive(Serialize)]
181 struct Devs {
182 disks: Option<BTreeMap<String, BTreeMap<String, String>>>,
183 nics: Option<BTreeMap<String, BTreeMap<String, String>>>,
184 }
185
186 fn main() {
187 let args = Cli::parse();
188 let res = match &args.command {
189 Commands::PrepareIso(args) => prepare_iso(args),
190 Commands::ValidateAnswer(args) => validate_answer(args),
191 Commands::DeviceInfo(args) => info(args),
192 Commands::DeviceMatch(args) => match_filter(args),
193 Commands::Identifiers(args) => show_identifiers(args),
194 };
195 if let Err(err) = res {
196 eprintln!("{err}");
197 std::process::exit(1);
198 }
199 }
200
201 fn info(args: &CommandDeviceInfo) -> Result<()> {
202 let mut devs = Devs {
203 disks: None,
204 nics: None,
205 };
206
207 if args.device == AllDeviceTypes::Network || args.device == AllDeviceTypes::All {
208 match get_nics() {
209 Ok(res) => devs.nics = Some(res),
210 Err(err) => bail!("Error getting NIC data: {err}"),
211 }
212 }
213 if args.device == AllDeviceTypes::Disk || args.device == AllDeviceTypes::All {
214 match get_disks() {
215 Ok(res) => devs.disks = Some(res),
216 Err(err) => bail!("Error getting disk data: {err}"),
217 }
218 }
219 println!("{}", serde_json::to_string_pretty(&devs).unwrap());
220 Ok(())
221 }
222
223 fn match_filter(args: &CommandDeviceMatch) -> Result<()> {
224 let devs: BTreeMap<String, BTreeMap<String, String>> = match args.r#type {
225 Devicetype::Disk => get_disks().unwrap(),
226 Devicetype::Network => get_nics().unwrap(),
227 };
228 // parse filters
229
230 let mut filters: BTreeMap<String, String> = BTreeMap::new();
231
232 for f in &args.filter {
233 match f.split_once('=') {
234 Some((key, value)) => {
235 if key.is_empty() || value.is_empty() {
236 bail!("Filter key or value is empty in filter: '{f}'");
237 }
238 filters.insert(String::from(key), String::from(value));
239 }
240 None => {
241 bail!("Could not find separator '=' in filter: '{f}'");
242 }
243 }
244 }
245
246 // align return values
247 let result = match args.r#type {
248 Devicetype::Disk => {
249 get_matched_udev_indexes(filters, &devs, args.filter_match == FilterMatch::All)
250 }
251 Devicetype::Network => get_single_udev_index(filters, &devs).map(|r| vec![r]),
252 };
253
254 match result {
255 Ok(result) => println!("{}", serde_json::to_string_pretty(&result).unwrap()),
256 Err(err) => bail!("Error matching filters: {err}"),
257 }
258 Ok(())
259 }
260
261 fn validate_answer(args: &CommandValidateAnswer) -> Result<()> {
262 let answer = parse_answer(&args.path)?;
263 if args.debug {
264 println!("Parsed data from answer file:\n{:#?}", answer);
265 }
266 Ok(())
267 }
268
269 fn show_identifiers(_args: &CommandIdentifiers) -> Result<()> {
270 match sysinfo::get_sysinfo(true) {
271 Ok(res) => println!("{res}"),
272 Err(err) => eprintln!("Error fetching system identifiers: {err}"),
273 }
274 Ok(())
275 }
276
277 fn prepare_iso(args: &CommandPrepareISO) -> Result<()> {
278 check_prepare_requirements(args)?;
279
280 if args.install_mode == AutoInstModes::Included {
281 if args.answer_file.is_none() {
282 bail!("Missing path to answer file needed for 'direct' install mode.");
283 }
284 if args.cert_fingerprint.is_some() {
285 bail!("No certificate fingerprint needed for direct install mode. Drop the parameter!");
286 }
287 if args.url.is_some() {
288 bail!("No URL needed for direct install mode. Drop the parameter!");
289 }
290 } else if args.install_mode == AutoInstModes::Partition {
291 if args.cert_fingerprint.is_some() {
292 bail!(
293 "No certificate fingerprint needed for partition install mode. Drop the parameter!"
294 );
295 }
296 if args.url.is_some() {
297 bail!("No URL needed for partition install mode. Drop the parameter!");
298 }
299 }
300 if args.answer_file.is_some() && args.install_mode != AutoInstModes::Included {
301 bail!("Set '-i', '--install-mode' to 'included' to place the answer file directly in the ISO.");
302 }
303
304 if let Some(file) = &args.answer_file {
305 println!("Checking provided answer file...");
306 parse_answer(file)?;
307 }
308
309 let mut tmp_base = PathBuf::new();
310 match args.tmp.as_ref() {
311 Some(tmp_dir) => tmp_base.push(tmp_dir),
312 None => tmp_base.push(args.source.parent().unwrap()),
313 }
314
315 let iso_target = final_iso_location(args);
316
317 let mut tmp_iso = tmp_base.clone();
318 let iso_target_file_name = match iso_target.file_name() {
319 None => bail!("no base filename in target ISO path found"),
320 Some(source_file_name) => source_file_name.to_string_lossy(),
321 };
322 tmp_iso.push(format!("{iso_target_file_name}.tmp",));
323
324 let mut tmp_answer = tmp_base.clone();
325 tmp_answer.push("answer.toml");
326
327 println!("Copying source ISO to temporary location...");
328 fs::copy(&args.source, &tmp_iso)?;
329
330 println!("Preparing ISO...");
331 let install_mode = AutoInstSettings {
332 mode: args.install_mode.clone(),
333 http_url: args.url.clone(),
334 cert_fingerprint: args.cert_fingerprint.clone(),
335 };
336 let mut instmode_file_tmp = tmp_base.clone();
337 instmode_file_tmp.push("auto-installer-mode.toml");
338 fs::write(&instmode_file_tmp, toml::to_string_pretty(&install_mode)?)?;
339
340 inject_file_to_iso(&tmp_iso, &instmode_file_tmp, "/auto-installer-mode.toml")?;
341
342 if let Some(answer) = &args.answer_file {
343 fs::copy(answer, &tmp_answer)?;
344 inject_file_to_iso(&tmp_iso, &tmp_answer, "/answer.toml")?;
345 }
346
347 println!("Moving prepared ISO to target location...");
348 fs::rename(&tmp_iso, &iso_target)?;
349 println!("Final ISO is available at {iso_target:?}.");
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",
360 AutoInstModes::Http => "auto-http",
361 AutoInstModes::Included => "auto-answer-included",
362 AutoInstModes::Partition => "auto-part",
363 }
364 .into();
365
366 if args.url.is_some() {
367 suffix.push_str("-url");
368 }
369 if args.cert_fingerprint.is_some() {
370 suffix.push_str("-fp");
371 }
372
373 let base = args.source.parent().unwrap();
374 let iso = args.source.file_stem().unwrap();
375
376 let mut target = base.to_path_buf();
377 target.push(format!("{}-{}.iso", iso.to_str().unwrap(), suffix));
378
379 target.to_path_buf()
380 }
381
382 fn inject_file_to_iso(iso: &PathBuf, file: &PathBuf, location: &str) -> Result<()> {
383 let result = Command::new("xorriso")
384 .arg("--boot_image")
385 .arg("any")
386 .arg("keep")
387 .arg("-dev")
388 .arg(iso)
389 .arg("-map")
390 .arg(file)
391 .arg(location)
392 .output()?;
393 if !result.status.success() {
394 bail!(
395 "Error injecting {file:?} into {iso:?}: {}",
396 String::from_utf8_lossy(&result.stderr)
397 );
398 }
399 Ok(())
400 }
401
402 fn get_disks() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
403 let unwantend_block_devs = vec![
404 "ram[0-9]*",
405 "loop[0-9]*",
406 "md[0-9]*",
407 "dm-*",
408 "fd[0-9]*",
409 "sr[0-9]*",
410 ];
411
412 // compile Regex here once and not inside the loop
413 let re_disk = Regex::new(r"(?m)^E: DEVTYPE=disk")?;
414 let re_cdrom = Regex::new(r"(?m)^E: ID_CDROM")?;
415 let re_iso9660 = Regex::new(r"(?m)^E: ID_FS_TYPE=iso9660")?;
416
417 let re_name = Regex::new(r"(?m)^N: (.*)$")?;
418 let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?;
419
420 let mut disks: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
421
422 'outer: for entry in fs::read_dir("/sys/block")? {
423 let entry = entry.unwrap();
424 let filename = entry.file_name().into_string().unwrap();
425
426 for p in &unwantend_block_devs {
427 if Pattern::new(p)?.matches(&filename) {
428 continue 'outer;
429 }
430 }
431
432 let output = match get_udev_properties(&entry.path()) {
433 Ok(output) => output,
434 Err(err) => {
435 eprint!("{err}");
436 continue 'outer;
437 }
438 };
439
440 if !re_disk.is_match(&output) {
441 continue 'outer;
442 };
443 if re_cdrom.is_match(&output) {
444 continue 'outer;
445 };
446 if re_iso9660.is_match(&output) {
447 continue 'outer;
448 };
449
450 let mut name = filename;
451 if let Some(cap) = re_name.captures(&output) {
452 if let Some(res) = cap.get(1) {
453 name = String::from(res.as_str());
454 }
455 }
456
457 let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
458
459 for line in output.lines() {
460 if let Some(caps) = re_props.captures(line) {
461 let key = String::from(caps.get(1).unwrap().as_str());
462 let value = String::from(caps.get(2).unwrap().as_str());
463 udev_props.insert(key, value);
464 }
465 }
466
467 disks.insert(name, udev_props);
468 }
469 Ok(disks)
470 }
471
472 fn get_nics() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
473 let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?;
474 let mut nics: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
475
476 let links = get_nic_list()?;
477 for link in links {
478 let path = format!("/sys/class/net/{link}");
479
480 let output = match get_udev_properties(&PathBuf::from(path)) {
481 Ok(output) => output,
482 Err(err) => {
483 eprint!("{err}");
484 continue;
485 }
486 };
487
488 let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
489
490 for line in output.lines() {
491 if let Some(caps) = re_props.captures(line) {
492 let key = String::from(caps.get(1).unwrap().as_str());
493 let value = String::from(caps.get(2).unwrap().as_str());
494 udev_props.insert(key, value);
495 }
496 }
497
498 nics.insert(link, udev_props);
499 }
500 Ok(nics)
501 }
502
503 fn get_udev_properties(path: &PathBuf) -> Result<String> {
504 let udev_output = Command::new("udevadm")
505 .arg("info")
506 .arg("--path")
507 .arg(path)
508 .arg("--query")
509 .arg("all")
510 .output()?;
511 if !udev_output.status.success() {
512 bail!("could not run udevadm successfully for {path:?}");
513 }
514 Ok(String::from_utf8(udev_output.stdout)?)
515 }
516
517 fn parse_answer(path: &PathBuf) -> Result<Answer> {
518 let mut file = match fs::File::open(path) {
519 Ok(file) => file,
520 Err(err) => bail!("Opening answer file {path:?} failed: {err}"),
521 };
522 let mut contents = String::new();
523 if let Err(err) = file.read_to_string(&mut contents) {
524 bail!("Reading from file {path:?} failed: {err}");
525 }
526 match toml::from_str(&contents) {
527 Ok(answer) => {
528 println!("The file was parsed successfully, no syntax errors found!");
529 Ok(answer)
530 }
531 Err(err) => bail!("Error parsing answer file: {err}"),
532 }
533 }
534
535 fn check_prepare_requirements(args: &CommandPrepareISO) -> Result<()> {
536 match Path::try_exists(&args.source) {
537 Ok(true) => (),
538 Ok(false) => bail!("Source file does not exist."),
539 Err(_) => bail!("Source file does not exist."),
540 }
541
542 match Command::new("xorriso")
543 .arg("-dev")
544 .arg(&args.source)
545 .arg("-find")
546 .arg(PROXMOX_ISO_FLAG)
547 .stderr(Stdio::null())
548 .stdout(Stdio::null())
549 .status()
550 {
551 Ok(v) => {
552 if !v.success() {
553 bail!("The source ISO file is not able to be installed automatically. Please try a more current one.");
554 }
555 }
556 Err(_) => bail!("Could not run 'xorriso'. Please install it."),
557 };
558
559 Ok(())
560 }