]> git.proxmox.com Git - pve-installer.git/blob - proxmox-auto-install-assistant/src/main.rs
44dd12ee5d090709b9b6899f6e66ec71c7e254a2
[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 behavior of how to fetch an answer file must be set with the '--fetch-from', parameter. The
95 /// answer file can be{n}:
96 /// * integrated into the ISO itself ('iso'){n}
97 /// * needs to be present in a partition / file-system with the label 'PROXMOX-INST-SRC'
98 /// ('partition'){n}
99 /// * get requested via an HTTP Post request ('http').
100 ///
101 /// The URL for the HTTP mode can be defined for the ISO with the '--url' argument. If not present,
102 /// it will try to get a URL from a DHCP option (250, TXT) or by querying a DNS TXT record at
103 /// 'proxmox-auto-installer.{search domain}'.
104 ///
105 /// The TLS certificate fingerprint can either be defined via the '--cert-fingerprint' argument or
106 /// alternatively via the custom DHCP option (251, TXT) or in a DNS TXT record located at
107 /// 'proxmox-auto-installer-cert-fingerprint.{search domain}'.
108 ///
109 /// The latter options to provide the TLS fingerprint will only be used if the same method was used
110 /// to retrieve the URL. For example, the DNS TXT record for the fingerprint will only be used, if
111 /// no one was configured with the '--cert-fingerprint' parameter and if the URL was retrieved via
112 /// the DNS TXT record.
113 #[derive(Args, Debug)]
114 struct CommandPrepareISO {
115 /// Path to the source ISO to prepare
116 input: PathBuf,
117
118 /// Path to store the final ISO to, defaults to an auto-generated file name depending on mode
119 /// and the same directory as the source file is located in.
120 #[arg(long)]
121 output: Option<PathBuf>,
122
123 /// Where the automatic installer should fetch the answer file from.
124 #[arg(long, value_enum)]
125 fetch_from: FetchAnswerFrom,
126
127 /// Include the specified answer file in the ISO. Requires the '--fetch-from' parameter
128 /// to be set to 'iso'.
129 #[arg(long)]
130 answer_file: Option<PathBuf>,
131
132 /// Specify URL for fetching the answer file via HTTP
133 #[arg(long)]
134 url: Option<String>,
135
136 /// Pin the ISO to the specified SHA256 TLS certificate fingerprint.
137 #[arg(long)]
138 cert_fingerprint: Option<String>,
139
140 /// Staging directory to use for preparing the new ISO file. Defaults to the directory of the
141 /// input ISO file.
142 #[arg(long)]
143 tmp: Option<String>,
144 }
145
146 /// Show the system information that can be used to identify a host.
147 ///
148 /// The shown information is sent as POST HTTP request when fetching the answer file for the
149 /// automatic installation through HTTP, You can, for example, use this to return a dynamically
150 /// assembled answer file.
151 #[derive(Args, Debug)]
152 struct CommandSystemInfo {}
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::SystemInfo(args) => show_system_info(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_system_info(_args: &CommandSystemInfo) -> Result<()> {
270 match SysInfo::as_json_pretty() {
271 Ok(res) => println!("{res}"),
272 Err(err) => eprintln!("Error fetching system info: {err}"),
273 }
274 Ok(())
275 }
276
277 fn prepare_iso(args: &CommandPrepareISO) -> Result<()> {
278 check_prepare_requirements(args)?;
279
280 if args.fetch_from == FetchAnswerFrom::Iso && args.answer_file.is_none() {
281 bail!("Missing path to the answer file required for the fetch-from 'iso' mode.");
282 }
283 if args.url.is_some() && args.fetch_from != FetchAnswerFrom::Http {
284 bail!(
285 "Setting a URL is incompatible with the fetch-from '{:?}' mode, only works with the 'http' mode",
286 args.fetch_from,
287 );
288 }
289 if args.cert_fingerprint.is_some() && args.fetch_from != FetchAnswerFrom::Http {
290 bail!(
291 "Setting a certificate fingerprint incompatible is fetch-from '{:?}' mode, only works for 'http' mode.",
292 args.fetch_from,
293 );
294 }
295 if args.answer_file.is_some() && args.fetch_from != FetchAnswerFrom::Iso {
296 bail!("Set '-i', '--install-mode' to 'included' to place the answer file directly in the ISO.");
297 }
298
299 if let Some(file) = &args.answer_file {
300 println!("Checking provided answer file...");
301 parse_answer(file)?;
302 }
303
304 let iso_target = final_iso_location(args);
305 let iso_target_file_name = match iso_target.file_name() {
306 None => bail!("no base filename in target ISO path found"),
307 Some(source_file_name) => source_file_name.to_string_lossy(),
308 };
309
310 let mut tmp_base = PathBuf::new();
311 match args.tmp.as_ref() {
312 Some(tmp_dir) => tmp_base.push(tmp_dir),
313 None => tmp_base.push(iso_target.parent().unwrap()),
314 }
315
316 let mut tmp_iso = tmp_base.clone();
317 tmp_iso.push(format!("{iso_target_file_name}.tmp",));
318
319 println!("Copying source ISO to temporary location...");
320 fs::copy(&args.input, &tmp_iso)?;
321
322 println!("Preparing ISO...");
323 let config = AutoInstSettings {
324 mode: args.fetch_from.clone(),
325 http: HttpOptions {
326 url: args.url.clone(),
327 cert_fingerprint: args.cert_fingerprint.clone(),
328 },
329 };
330 let mut instmode_file_tmp = tmp_base.clone();
331 instmode_file_tmp.push("auto-installer-mode.toml");
332 fs::write(&instmode_file_tmp, toml::to_string_pretty(&config)?)?;
333
334 inject_file_to_iso(&tmp_iso, &instmode_file_tmp, "/auto-installer-mode.toml")?;
335
336 if let Some(answer_file) = &args.answer_file {
337 inject_file_to_iso(&tmp_iso, answer_file, "/answer.toml")?;
338 }
339
340 println!("Moving prepared ISO to target location...");
341 fs::rename(&tmp_iso, &iso_target)?;
342 println!("Final ISO is available at {iso_target:?}.");
343
344 Ok(())
345 }
346
347 fn final_iso_location(args: &CommandPrepareISO) -> PathBuf {
348 if let Some(specified) = args.output.clone() {
349 return specified;
350 }
351 let mut suffix: String = match args.fetch_from {
352 FetchAnswerFrom::Http => "auto-from-http",
353 FetchAnswerFrom::Iso => "auto-from-iso",
354 FetchAnswerFrom::Partition => "auto-from-partition",
355 }
356 .into();
357
358 if args.url.is_some() {
359 suffix.push_str("-url");
360 }
361 if args.cert_fingerprint.is_some() {
362 suffix.push_str("-fp");
363 }
364
365 let base = args.input.parent().unwrap();
366 let iso = args.input.file_stem().unwrap();
367
368 let mut target = base.to_path_buf();
369 target.push(format!("{}-{}.iso", iso.to_str().unwrap(), suffix));
370
371 target.to_path_buf()
372 }
373
374 fn inject_file_to_iso(iso: &PathBuf, file: &PathBuf, location: &str) -> Result<()> {
375 let result = Command::new("xorriso")
376 .arg("--boot_image")
377 .arg("any")
378 .arg("keep")
379 .arg("-dev")
380 .arg(iso)
381 .arg("-map")
382 .arg(file)
383 .arg(location)
384 .output()?;
385 if !result.status.success() {
386 bail!(
387 "Error injecting {file:?} into {iso:?}: {}",
388 String::from_utf8_lossy(&result.stderr)
389 );
390 }
391 Ok(())
392 }
393
394 fn get_disks() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
395 let unwantend_block_devs = vec![
396 "ram[0-9]*",
397 "loop[0-9]*",
398 "md[0-9]*",
399 "dm-*",
400 "fd[0-9]*",
401 "sr[0-9]*",
402 ];
403
404 // compile Regex here once and not inside the loop
405 let re_disk = Regex::new(r"(?m)^E: DEVTYPE=disk")?;
406 let re_cdrom = Regex::new(r"(?m)^E: ID_CDROM")?;
407 let re_iso9660 = Regex::new(r"(?m)^E: ID_FS_TYPE=iso9660")?;
408
409 let re_name = Regex::new(r"(?m)^N: (.*)$")?;
410 let re_props = Regex::new(r"(?m)^E: ([^=]+)=(.*)$")?;
411
412 let mut disks: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
413
414 'outer: for entry in fs::read_dir("/sys/block")? {
415 let entry = entry.unwrap();
416 let filename = entry.file_name().into_string().unwrap();
417
418 for p in &unwantend_block_devs {
419 if Pattern::new(p)?.matches(&filename) {
420 continue 'outer;
421 }
422 }
423
424 let output = match get_udev_properties(&entry.path()) {
425 Ok(output) => output,
426 Err(err) => {
427 eprint!("{err}");
428 continue 'outer;
429 }
430 };
431
432 if !re_disk.is_match(&output) {
433 continue 'outer;
434 };
435 if re_cdrom.is_match(&output) {
436 continue 'outer;
437 };
438 if re_iso9660.is_match(&output) {
439 continue 'outer;
440 };
441
442 let mut name = filename;
443 if let Some(cap) = re_name.captures(&output) {
444 if let Some(res) = cap.get(1) {
445 name = String::from(res.as_str());
446 }
447 }
448
449 let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
450
451 for line in output.lines() {
452 if let Some(caps) = re_props.captures(line) {
453 let key = String::from(caps.get(1).unwrap().as_str());
454 let value = String::from(caps.get(2).unwrap().as_str());
455 udev_props.insert(key, value);
456 }
457 }
458
459 disks.insert(name, udev_props);
460 }
461 Ok(disks)
462 }
463
464 fn get_nics() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
465 let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?;
466 let mut nics: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
467
468 let links = get_nic_list()?;
469 for link in links {
470 let path = format!("/sys/class/net/{link}");
471
472 let output = match get_udev_properties(&PathBuf::from(path)) {
473 Ok(output) => output,
474 Err(err) => {
475 eprint!("{err}");
476 continue;
477 }
478 };
479
480 let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
481
482 for line in output.lines() {
483 if let Some(caps) = re_props.captures(line) {
484 let key = String::from(caps.get(1).unwrap().as_str());
485 let value = String::from(caps.get(2).unwrap().as_str());
486 udev_props.insert(key, value);
487 }
488 }
489
490 nics.insert(link, udev_props);
491 }
492 Ok(nics)
493 }
494
495 fn get_udev_properties(path: &PathBuf) -> Result<String> {
496 let udev_output = Command::new("udevadm")
497 .arg("info")
498 .arg("--path")
499 .arg(path)
500 .arg("--query")
501 .arg("all")
502 .output()?;
503 if !udev_output.status.success() {
504 bail!("could not run udevadm successfully for {path:?}");
505 }
506 Ok(String::from_utf8(udev_output.stdout)?)
507 }
508
509 fn parse_answer(path: &PathBuf) -> Result<Answer> {
510 let mut file = match fs::File::open(path) {
511 Ok(file) => file,
512 Err(err) => bail!("Opening answer file {path:?} failed: {err}"),
513 };
514 let mut contents = String::new();
515 if let Err(err) = file.read_to_string(&mut contents) {
516 bail!("Reading from file {path:?} failed: {err}");
517 }
518 match toml::from_str(&contents) {
519 Ok(answer) => {
520 println!("The file was parsed successfully, no syntax errors found!");
521 Ok(answer)
522 }
523 Err(err) => bail!("Error parsing answer file: {err}"),
524 }
525 }
526
527 fn check_prepare_requirements(args: &CommandPrepareISO) -> Result<()> {
528 match Path::try_exists(&args.input) {
529 Ok(true) => (),
530 Ok(false) => bail!("Source file does not exist."),
531 Err(_) => bail!("Source file does not exist."),
532 }
533
534 match Command::new("xorriso")
535 .arg("-dev")
536 .arg(&args.input)
537 .arg("-find")
538 .arg(PROXMOX_ISO_FLAG)
539 .stderr(Stdio::null())
540 .stdout(Stdio::null())
541 .status()
542 {
543 Ok(v) => {
544 if !v.success() {
545 bail!("The source ISO file is not able to be installed automatically. Please try a more current one.");
546 }
547 }
548 Err(err) if err.kind() == io::ErrorKind::NotFound => {
549 bail!("Could not find the 'xorriso' binary. Please install it.")
550 }
551 Err(err) => bail!("unexpected error when trying to execute 'xorriso' - {err}"),
552 };
553
554 Ok(())
555 }