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
, AutoInstSettings
,
20 FetchAnswerFrom
, HttpOptions
,
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 SystemInfo(CommandSystemInfo
),
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' 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}'.
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}'.
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 '--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'
117 /// * get requested via an HTTP Post request ('http').
118 #[derive(Args, Debug)]
119 struct CommandPrepareISO
{
120 /// Path to the source ISO to prepare
123 /// Path to store the final ISO to, defaults to auto-generated depending on mode.
125 output
: Option
<PathBuf
>,
127 /// Where the automatic installer should fetch the answer file from.
128 #[arg(long, value_enum)]
129 fetch_from
: FetchAnswerFrom
,
131 /// Include the specified answer file in the ISO. Requires the '--fetch-from' parameter
132 /// to be set to 'iso'.
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 /// Staging directory to use for preparing the new ISO file. Defaults to the directory of the
150 /// Show the system information that can be used to identify a host.
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 {}
158 #[derive(Args, Debug)]
161 #[arg(long, short, value_enum)]
162 format
: OutputFormat
,
165 #[derive(Clone, Debug, ValueEnum, PartialEq)]
166 enum AllDeviceTypes
{
172 #[derive(Clone, Debug, ValueEnum)]
178 #[derive(Clone, Debug, ValueEnum)]
186 disks
: Option
<BTreeMap
<String
, BTreeMap
<String
, String
>>>,
187 nics
: Option
<BTreeMap
<String
, BTreeMap
<String
, String
>>>,
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
),
199 if let Err(err
) = res
{
201 std
::process
::exit(1);
205 fn info(args
: &CommandDeviceInfo
) -> Result
<()> {
206 let mut devs
= Devs
{
211 if args
.device
== AllDeviceTypes
::Network
|| args
.device
== AllDeviceTypes
::All
{
213 Ok(res
) => devs
.nics
= Some(res
),
214 Err(err
) => bail
!("Error getting NIC data: {err}"),
217 if args
.device
== AllDeviceTypes
::Disk
|| args
.device
== AllDeviceTypes
::All
{
219 Ok(res
) => devs
.disks
= Some(res
),
220 Err(err
) => bail
!("Error getting disk data: {err}"),
223 println
!("{}", serde_json
::to_string_pretty(&devs
).unwrap());
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(),
234 let mut filters
: BTreeMap
<String
, String
> = BTreeMap
::new();
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}'");
242 filters
.insert(String
::from(key
), String
::from(value
));
245 bail
!("Could not find separator '=' in filter: '{f}'");
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
)
255 Devicetype
::Network
=> get_single_udev_index(&filters
, &devs
).map(|r
| vec
![r
]),
259 Ok(result
) => println
!("{}", serde_json
::to_string_pretty(&result
).unwrap()),
260 Err(err
) => bail
!("Error matching filters: {err}"),
265 fn validate_answer(args
: &CommandValidateAnswer
) -> Result
<()> {
266 let answer
= parse_answer(&args
.path
)?
;
268 println
!("Parsed data from answer file:\n{:#?}", answer
);
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}"),
281 fn prepare_iso(args
: &CommandPrepareISO
) -> Result
<()> {
282 check_prepare_requirements(args
)?
;
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.");
288 if args
.cert_fingerprint
.is_some() {
289 bail
!("No certificate fingerprint needed for direct install mode. Drop the parameter!");
291 if args
.url
.is_some() {
292 bail
!("No URL needed for direct install mode. Drop the parameter!");
294 } else if args
.fetch_from
== FetchAnswerFrom
::Partition
{
295 if args
.cert_fingerprint
.is_some() {
297 "No certificate fingerprint needed for partition install mode. Drop the parameter!"
300 if args
.url
.is_some() {
301 bail
!("No URL needed for partition install mode. Drop the parameter!");
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.");
308 if let Some(file
) = &args
.answer_file
{
309 println
!("Checking provided answer file...");
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(),
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()),
325 let mut tmp_iso
= tmp_base
.clone();
326 tmp_iso
.push(format
!("{iso_target_file_name}.tmp",));
328 println
!("Copying source ISO to temporary location...");
329 fs
::copy(&args
.input
, &tmp_iso
)?
;
331 println
!("Preparing ISO...");
332 let config
= AutoInstSettings
{
333 mode
: args
.fetch_from
.clone(),
335 url
: args
.url
.clone(),
336 cert_fingerprint
: args
.cert_fingerprint
.clone(),
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
)?
)?
;
343 inject_file_to_iso(&tmp_iso
, &instmode_file_tmp
, "/auto-installer-mode.toml")?
;
345 if let Some(answer_file
) = &args
.answer_file
{
346 inject_file_to_iso(&tmp_iso
, answer_file
, "/answer.toml")?
;
349 println
!("Moving prepared ISO to target location...");
350 fs
::rename(&tmp_iso
, &iso_target
)?
;
351 println
!("Final ISO is available at {iso_target:?}.");
356 fn final_iso_location(args
: &CommandPrepareISO
) -> PathBuf
{
357 if let Some(specified
) = args
.output
.clone() {
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",
367 if args
.url
.is_some() {
368 suffix
.push_str("-url");
370 if args
.cert_fingerprint
.is_some() {
371 suffix
.push_str("-fp");
374 let base
= args
.input
.parent().unwrap();
375 let iso
= args
.input
.file_stem().unwrap();
377 let mut target
= base
.to_path_buf();
378 target
.push(format
!("{}-{}.iso", iso
.to_str().unwrap(), suffix
));
383 fn inject_file_to_iso(iso
: &PathBuf
, file
: &PathBuf
, location
: &str) -> Result
<()> {
384 let result
= Command
::new("xorriso")
394 if !result
.status
.success() {
396 "Error injecting {file:?} into {iso:?}: {}",
397 String
::from_utf8_lossy(&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:?}");
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 {path:?} failed: {err}"),
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}");
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
.input
) {
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(err
) if err
.kind() == io
::ErrorKind
::NotFound
=> {
558 bail
!("Could not find the 'xorriso' binary. Please install it.")
560 Err(err
) => bail
!("unexpected error when trying to execute 'xorriso' - {err}"),