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 = "/auto-installer-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-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
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 "PROXMOX-INST-SRC" (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 by querying a DNS TXT record at
101 /// 'proxmox-auto-installer.{search domain}'.
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}'.
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 with the label 'PROXMOX-INST-SRC'
117 /// * get requested via an HTTP Post request ('http').
118 #[derive(Args, Debug)]
119 struct CommandPrepareISO
{
120 /// Path to the source ISO
123 /// Path to store the final ISO to.
125 target
: Option
<PathBuf
>,
127 /// Where to fetch the answer file from.
128 #[arg(short, long, value_enum, default_value_t=AutoInstModes::Auto)]
129 install_mode
: AutoInstModes
,
131 /// Include the specified answer file in the ISO. Requires the '--install-mode', '-i' parameter
132 /// to be set to 'included'.
134 answer_file
: Option
<PathBuf
>,
136 /// Specify URL for fetching the answer file via HTTP
140 /// Pin the ISO to the specified SHA256 TLS certificate fingerprint.
142 cert_fingerprint
: Option
<String
>,
144 /// Tmp directory to use.
149 /// Show identifiers for the current machine. This information is part of the POST request to fetch
151 #[derive(Args, Debug)]
152 struct CommandIdentifiers {}
154 #[derive(Args, Debug)]
157 #[arg(long, short, value_enum)]
158 format
: OutputFormat
,
161 #[derive(Clone, Debug, ValueEnum, PartialEq)]
162 enum AllDeviceTypes
{
168 #[derive(Clone, Debug, ValueEnum)]
174 #[derive(Clone, Debug, ValueEnum)]
182 disks
: Option
<BTreeMap
<String
, BTreeMap
<String
, String
>>>,
183 nics
: Option
<BTreeMap
<String
, BTreeMap
<String
, String
>>>,
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
),
195 if let Err(err
) = res
{
197 std
::process
::exit(1);
201 fn info(args
: &CommandDeviceInfo
) -> Result
<()> {
202 let mut devs
= Devs
{
207 if args
.device
== AllDeviceTypes
::Network
|| args
.device
== AllDeviceTypes
::All
{
209 Ok(res
) => devs
.nics
= Some(res
),
210 Err(err
) => bail
!("Error getting NIC data: {err}"),
213 if args
.device
== AllDeviceTypes
::Disk
|| args
.device
== AllDeviceTypes
::All
{
215 Ok(res
) => devs
.disks
= Some(res
),
216 Err(err
) => bail
!("Error getting disk data: {err}"),
219 println
!("{}", serde_json
::to_string_pretty(&devs
).unwrap());
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(),
230 let mut filters
: BTreeMap
<String
, String
> = BTreeMap
::new();
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}'");
238 filters
.insert(String
::from(key
), String
::from(value
));
241 bail
!("Could not find separator '=' in filter: '{f}'");
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
)
251 Devicetype
::Network
=> get_single_udev_index(filters
, &devs
).map(|r
| vec
![r
]),
255 Ok(result
) => println
!("{}", serde_json
::to_string_pretty(&result
).unwrap()),
256 Err(err
) => bail
!("Error matching filters: {err}"),
261 fn validate_answer(args
: &CommandValidateAnswer
) -> Result
<()> {
262 let answer
= parse_answer(&args
.path
)?
;
264 println
!("Parsed data from answer file:\n{:#?}", answer
);
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}"),
277 fn prepare_iso(args
: &CommandPrepareISO
) -> Result
<()> {
278 check_prepare_requirements(args
)?
;
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.");
284 if args
.cert_fingerprint
.is_some() {
285 bail
!("No certificate fingerprint needed for direct install mode. Drop the parameter!");
287 if args
.url
.is_some() {
288 bail
!("No URL needed for direct install mode. Drop the parameter!");
290 } else if args
.install_mode
== AutoInstModes
::Partition
{
291 if args
.cert_fingerprint
.is_some() {
293 "No certificate fingerprint needed for partition install mode. Drop the parameter!"
296 if args
.url
.is_some() {
297 bail
!("No URL needed for partition install mode. Drop the parameter!");
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.");
304 if let Some(file
) = &args
.answer_file
{
305 println
!("Checking provided answer file...");
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()),
315 let iso_target
= final_iso_location(args
);
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(),
322 tmp_iso
.push(format
!("{iso_target_file_name}.tmp",));
324 let mut tmp_answer
= tmp_base
.clone();
325 tmp_answer
.push("answer.toml");
327 println
!("Copying source ISO to temporary location...");
328 fs
::copy(&args
.source
, &tmp_iso
)?
;
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(),
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
)?
)?
;
340 inject_file_to_iso(&tmp_iso
, &instmode_file_tmp
, "/auto-installer-mode.toml")?
;
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")?
;
347 println
!("Moving prepared ISO to target location...");
348 fs
::rename(&tmp_iso
, &iso_target
)?
;
349 println
!("Final ISO is available at {iso_target:?}.");
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",
360 AutoInstModes
::Http
=> "auto-http",
361 AutoInstModes
::Included
=> "auto-answer-included",
362 AutoInstModes
::Partition
=> "auto-part",
366 if args
.url
.is_some() {
367 suffix
.push_str("-url");
369 if args
.cert_fingerprint
.is_some() {
370 suffix
.push_str("-fp");
373 let base
= args
.source
.parent().unwrap();
374 let iso
= args
.source
.file_stem().unwrap();
376 let mut target
= base
.to_path_buf();
377 target
.push(format
!("{}-{}.iso", iso
.to_str().unwrap(), suffix
));
382 fn inject_file_to_iso(iso
: &PathBuf
, file
: &PathBuf
, location
: &str) -> Result
<()> {
383 let result
= Command
::new("xorriso")
393 if !result
.status
.success() {
395 "Error injecting {file:?} into {iso:?}: {}",
396 String
::from_utf8_lossy(&result
.stderr
)
402 fn get_disks() -> Result
<BTreeMap
<String
, BTreeMap
<String
, String
>>> {
403 let unwantend_block_devs
= vec
![
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")?
;
417 let re_name
= Regex
::new(r
"(?m)^N: (.*)$")?
;
418 let re_props
= Regex
::new(r
"(?m)^E: (.*)=(.*)$")?
;
420 let mut disks
: BTreeMap
<String
, BTreeMap
<String
, String
>> = BTreeMap
::new();
422 'outer
: for entry
in fs
::read_dir("/sys/block")?
{
423 let entry
= entry
.unwrap();
424 let filename
= entry
.file_name().into_string().unwrap();
426 for p
in &unwantend_block_devs
{
427 if Pattern
::new(p
)?
.matches(&filename
) {
432 let output
= match get_udev_properties(&entry
.path()) {
433 Ok(output
) => output
,
440 if !re_disk
.is_match(&output
) {
443 if re_cdrom
.is_match(&output
) {
446 if re_iso9660
.is_match(&output
) {
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());
457 let mut udev_props
: BTreeMap
<String
, String
> = BTreeMap
::new();
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
);
467 disks
.insert(name
, udev_props
);
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();
476 let links
= get_nic_list()?
;
478 let path
= format
!("/sys/class/net/{link}");
480 let output
= match get_udev_properties(&PathBuf
::from(path
)) {
481 Ok(output
) => output
,
488 let mut udev_props
: BTreeMap
<String
, String
> = BTreeMap
::new();
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
);
498 nics
.insert(link
, udev_props
);
503 fn get_udev_properties(path
: &PathBuf
) -> Result
<String
> {
504 let udev_output
= Command
::new("udevadm")
511 if !udev_output
.status
.success() {
512 bail
!("could not run udevadm successfully for {path:?}");
514 Ok(String
::from_utf8(udev_output
.stdout
)?
)
517 fn parse_answer(path
: &PathBuf
) -> Result
<Answer
> {
518 let mut file
= match fs
::File
::open(path
) {
520 Err(err
) => bail
!("Opening answer file {path:?} failed: {err}"),
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}");
526 match toml
::from_str(&contents
) {
528 println
!("The file was parsed successfully, no syntax errors found!");
531 Err(err
) => bail
!("Error parsing answer file: {err}"),
535 fn check_prepare_requirements(args
: &CommandPrepareISO
) -> Result
<()> {
536 match Path
::try_exists(&args
.source
) {
538 Ok(false) => bail
!("Source file does not exist."),
539 Err(_
) => bail
!("Source file does not exist."),
542 match Command
::new("xorriso")
546 .arg(PROXMOX_ISO_FLAG
)
547 .stderr(Stdio
::null())
548 .stdout(Stdio
::null())
553 bail
!("The source ISO file is not able to be installed automatically. Please try a more current one.");
556 Err(_
) => bail
!("Could not run 'xorriso'. Please install it."),