1 use anyhow
::{bail, Result}
;
2 use clap
::{Args, Parser, Subcommand, ValueEnum}
;
10 path
::{Path, PathBuf}
,
11 process
::{Command, Stdio}
,
14 use proxmox_auto_installer
::{
19 get_matched_udev_indexes
, get_nic_list
, get_single_udev_index
, AutoInstModes
,
24 static PROXMOX_ISO_FLAG
: &str = "/autoinst-capable";
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)]
32 #[command(subcommand)]
36 #[derive(Subcommand, Debug)]
38 PrepareIso(CommandPrepareISO
),
39 ValidateAnswer(CommandValidateAnswer
),
40 DeviceMatch(CommandDeviceMatch
),
41 DeviceInfo(CommandDeviceInfo
),
42 Identifiers(CommandIdentifiers
),
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
,
53 /// Test which devices the given filter matches against
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
61 /// To avoid globbing characters being interpreted by the shell, use single quotes.
62 /// Multiple filters can be defined.
65 /// Match disks against the serial number and device name, both must match:
67 /// proxmox-autoinst-helper 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
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.
78 /// Defines if any filter or all filters must match.
79 #[arg(long, value_enum, default_value_t=FilterMatch::Any)]
80 filter_match
: FilterMatch
,
83 /// Validate if an answer file is formatted correctly.
84 #[derive(Args, Debug)]
85 struct CommandValidateAnswer
{
86 /// Path to the answer file
88 #[arg(short, long, default_value_t = false)]
92 /// Prepare an ISO for automated installation.
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
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
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}'.
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.
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
122 /// Path to store the final ISO to.
124 target
: Option
<PathBuf
>,
126 /// Where to fetch the answer file from.
127 #[arg(short, long, value_enum, default_value_t=AutoInstModes::Auto)]
128 install_mode
: AutoInstModes
,
130 /// Include the specified answer file in the ISO. Requires the '--install-mode', '-i' parameter
131 /// to be set to 'included'.
133 answer_file
: Option
<PathBuf
>,
135 /// Specify URL for fetching the answer file via HTTP
139 /// Pin the ISO to the specified SHA256 TLS certificate fingerprint.
141 cert_fingerprint
: Option
<String
>,
143 /// Tmp directory to use.
148 /// Show identifiers for the current machine. This information is part of the POST request to fetch
150 #[derive(Args, Debug)]
151 struct CommandIdentifiers {}
153 #[derive(Args, Debug)]
156 #[arg(long, short, value_enum)]
157 format
: OutputFormat
,
160 #[derive(Clone, Debug, ValueEnum, PartialEq)]
161 enum AllDeviceTypes
{
167 #[derive(Clone, Debug, ValueEnum)]
173 #[derive(Clone, Debug, ValueEnum)]
181 disks
: Option
<BTreeMap
<String
, BTreeMap
<String
, String
>>>,
182 nics
: Option
<BTreeMap
<String
, BTreeMap
<String
, String
>>>,
186 let args
= Cli
::parse();
187 let res
= match &args
.command
{
188 Commands
::PrepareIso(args
) => prepare_iso(args
),
189 Commands
::ValidateAnswer(args
) => validate_answer(args
),
190 Commands
::DeviceInfo(args
) => info(args
),
191 Commands
::DeviceMatch(args
) => match_filter(args
),
192 Commands
::Identifiers(args
) => show_identifiers(args
),
194 if let Err(err
) = res
{
196 std
::process
::exit(1);
200 fn info(args
: &CommandDeviceInfo
) -> Result
<()> {
201 let mut devs
= Devs
{
206 if args
.device
== AllDeviceTypes
::Network
|| args
.device
== AllDeviceTypes
::All
{
208 Ok(res
) => devs
.nics
= Some(res
),
209 Err(err
) => bail
!("Error getting NIC data: {err}"),
212 if args
.device
== AllDeviceTypes
::Disk
|| args
.device
== AllDeviceTypes
::All
{
214 Ok(res
) => devs
.disks
= Some(res
),
215 Err(err
) => bail
!("Error getting disk data: {err}"),
218 println
!("{}", serde_json
::to_string_pretty(&devs
).unwrap());
222 fn match_filter(args
: &CommandDeviceMatch
) -> Result
<()> {
223 let devs
: BTreeMap
<String
, BTreeMap
<String
, String
>> = match args
.r
#type {
224 Devicetype
::Disk
=> get_disks().unwrap(),
225 Devicetype
::Network
=> get_nics().unwrap(),
229 let mut filters
: BTreeMap
<String
, String
> = BTreeMap
::new();
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}'");
237 filters
.insert(String
::from(key
), String
::from(value
));
240 bail
!("Could not find separator '=' in filter: '{f}'");
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
)
250 Devicetype
::Network
=> get_single_udev_index(filters
, &devs
).map(|r
| vec
![r
]),
254 Ok(result
) => println
!("{}", serde_json
::to_string_pretty(&result
).unwrap()),
255 Err(err
) => bail
!("Error matching filters: {err}"),
260 fn validate_answer(args
: &CommandValidateAnswer
) -> Result
<()> {
261 let answer
= parse_answer(&args
.path
)?
;
263 println
!("Parsed data from answer file:\n{:#?}", answer
);
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}"),
276 fn prepare_iso(args
: &CommandPrepareISO
) -> Result
<()> {
277 check_prepare_requirements(args
)?
;
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.");
283 if args
.cert_fingerprint
.is_some() {
284 bail
!("No certificate fingerprint needed for direct install mode. Drop the parameter!");
286 if args
.url
.is_some() {
287 bail
!("No URL needed for direct install mode. Drop the parameter!");
289 } else if args
.install_mode
== AutoInstModes
::Partition
{
290 if args
.cert_fingerprint
.is_some() {
292 "No certificate fingerprint needed for partition install mode. Drop the parameter!"
295 if args
.url
.is_some() {
296 bail
!("No URL needed for partition install mode. Drop the parameter!");
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.");
303 if let Some(file
) = &args
.answer_file
{
304 println
!("Checking provided answer file...");
308 let mut tmp_base
= PathBuf
::new();
309 if args
.tmp
.is_some() {
310 tmp_base
.push(args
.tmp
.as_ref().unwrap());
312 tmp_base
.push(args
.source
.parent().unwrap());
313 tmp_base
.push(".proxmox-iso-prepare");
315 fs
::create_dir_all(&tmp_base
)?
;
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");
322 println
!("Copying source ISO to temporary location...");
323 fs
::copy(&args
.source
, &tmp_iso
)?
;
324 println
!("Done copying source ISO");
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(),
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
)?
)?
;
336 inject_file_to_iso(&tmp_iso
, &instmode_file_tmp
, "/autoinst-mode.toml")?
;
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")?
;
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());
354 fn final_iso_location(args
: &CommandPrepareISO
) -> PathBuf
{
355 if let Some(specified
) = args
.target
.clone() {
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(),
365 if args
.url
.is_some() {
366 suffix
.push_str("-url");
368 if args
.cert_fingerprint
.is_some() {
369 suffix
.push_str("-fp");
372 let base
= args
.source
.parent().unwrap();
373 let iso
= args
.source
.file_stem().unwrap();
375 let mut target
= base
.to_path_buf();
376 target
.push(format
!("{}-{}.iso", iso
.to_str().unwrap(), suffix
));
381 fn inject_file_to_iso(iso
: &PathBuf
, file
: &PathBuf
, location
: &str) -> Result
<()> {
382 let result
= Command
::new("xorriso")
392 if !result
.status
.success() {
394 "Error injecting {} into {}: {}",
397 String
::from_utf8(result
.stderr
)?
403 fn get_disks() -> Result
<BTreeMap
<String
, BTreeMap
<String
, String
>>> {
404 let unwantend_block_devs
= vec
![
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")?
;
418 let re_name
= Regex
::new(r
"(?m)^N: (.*)$")?
;
419 let re_props
= Regex
::new(r
"(?m)^E: (.*)=(.*)$")?
;
421 let mut disks
: BTreeMap
<String
, BTreeMap
<String
, String
>> = BTreeMap
::new();
423 'outer
: for entry
in fs
::read_dir("/sys/block")?
{
424 let entry
= entry
.unwrap();
425 let filename
= entry
.file_name().into_string().unwrap();
427 for p
in &unwantend_block_devs
{
428 if Pattern
::new(p
)?
.matches(&filename
) {
433 let output
= match get_udev_properties(&entry
.path()) {
434 Ok(output
) => output
,
441 if !re_disk
.is_match(&output
) {
444 if re_cdrom
.is_match(&output
) {
447 if re_iso9660
.is_match(&output
) {
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());
458 let mut udev_props
: BTreeMap
<String
, String
> = BTreeMap
::new();
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
);
468 disks
.insert(name
, udev_props
);
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();
477 let links
= get_nic_list()?
;
479 let path
= format
!("/sys/class/net/{link}");
481 let output
= match get_udev_properties(&PathBuf
::from(path
)) {
482 Ok(output
) => output
,
489 let mut udev_props
: BTreeMap
<String
, String
> = BTreeMap
::new();
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
);
499 nics
.insert(link
, udev_props
);
504 fn get_udev_properties(path
: &PathBuf
) -> Result
<String
> {
505 let udev_output
= Command
::new("udevadm")
512 if !udev_output
.status
.success() {
513 bail
!("could not run udevadm successfully for {}", path
.display());
515 Ok(String
::from_utf8(udev_output
.stdout
)?
)
518 fn parse_answer(path
: &PathBuf
) -> Result
<Answer
> {
519 let mut file
= match fs
::File
::open(path
) {
521 Err(err
) => bail
!("Opening answer file '{}' failed: {err}", path
.display()),
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());
527 match toml
::from_str(&contents
) {
529 println
!("The file was parsed successfully, no syntax errors found!");
532 Err(err
) => bail
!("Error parsing answer file: {err}"),
536 fn check_prepare_requirements(args
: &CommandPrepareISO
) -> Result
<()> {
537 match Path
::try_exists(&args
.source
) {
539 Ok(false) => bail
!("Source file does not exist."),
540 Err(_
) => bail
!("Source file does not exist."),
543 match Command
::new("xorriso")
547 .arg(PROXMOX_ISO_FLAG
)
548 .stderr(Stdio
::null())
549 .stdout(Stdio
::null())
554 bail
!("The source ISO file is not able to be installed automatically. Please try a more current one.");
557 Err(_
) => bail
!("Could not run 'xorriso'. Please install it."),