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 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'
99 /// * get requested via an HTTP Post request ('http').
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}'.
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}'.
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
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.
121 output
: Option
<PathBuf
>,
123 /// Where the automatic installer should fetch the answer file from.
124 #[arg(long, value_enum)]
125 fetch_from
: FetchAnswerFrom
,
127 /// Include the specified answer file in the ISO. Requires the '--fetch-from' parameter
128 /// to be set to 'iso'.
130 answer_file
: Option
<PathBuf
>,
132 /// Specify URL for fetching the answer file via HTTP
136 /// Pin the ISO to the specified SHA256 TLS certificate fingerprint.
138 cert_fingerprint
: Option
<String
>,
140 /// Staging directory to use for preparing the new ISO file. Defaults to the directory of the
146 /// Show the system information that can be used to identify a host.
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 {}
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
::SystemInfo(args
) => show_system_info(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_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}"),
277 fn prepare_iso(args
: &CommandPrepareISO
) -> Result
<()> {
278 check_prepare_requirements(args
)?
;
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.");
283 if args
.url
.is_some() && args
.fetch_from
!= FetchAnswerFrom
::Http
{
285 "Setting a URL is incompatible with the fetch-from '{:?}' mode, only works with the 'http' mode",
289 if args
.cert_fingerprint
.is_some() && args
.fetch_from
!= FetchAnswerFrom
::Http
{
291 "Setting a certificate fingerprint incompatible is fetch-from '{:?}' mode, only works for 'http' mode.",
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.");
299 if let Some(file
) = &args
.answer_file
{
300 println
!("Checking provided answer file...");
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(),
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()),
316 let mut tmp_iso
= tmp_base
.clone();
317 tmp_iso
.push(format
!("{iso_target_file_name}.tmp",));
319 println
!("Copying source ISO to temporary location...");
320 fs
::copy(&args
.input
, &tmp_iso
)?
;
322 println
!("Preparing ISO...");
323 let config
= AutoInstSettings
{
324 mode
: args
.fetch_from
.clone(),
326 url
: args
.url
.clone(),
327 cert_fingerprint
: args
.cert_fingerprint
.clone(),
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
)?
)?
;
334 inject_file_to_iso(&tmp_iso
, &instmode_file_tmp
, "/auto-installer-mode.toml")?
;
336 if let Some(answer_file
) = &args
.answer_file
{
337 inject_file_to_iso(&tmp_iso
, answer_file
, "/answer.toml")?
;
340 println
!("Moving prepared ISO to target location...");
341 fs
::rename(&tmp_iso
, &iso_target
)?
;
342 println
!("Final ISO is available at {iso_target:?}.");
347 fn final_iso_location(args
: &CommandPrepareISO
) -> PathBuf
{
348 if let Some(specified
) = args
.output
.clone() {
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",
358 if args
.url
.is_some() {
359 suffix
.push_str("-url");
361 if args
.cert_fingerprint
.is_some() {
362 suffix
.push_str("-fp");
365 let base
= args
.input
.parent().unwrap();
366 let iso
= args
.input
.file_stem().unwrap();
368 let mut target
= base
.to_path_buf();
369 target
.push(format
!("{}-{}.iso", iso
.to_str().unwrap(), suffix
));
374 fn inject_file_to_iso(iso
: &PathBuf
, file
: &PathBuf
, location
: &str) -> Result
<()> {
375 let result
= Command
::new("xorriso")
385 if !result
.status
.success() {
387 "Error injecting {file:?} into {iso:?}: {}",
388 String
::from_utf8_lossy(&result
.stderr
)
394 fn get_disks() -> Result
<BTreeMap
<String
, BTreeMap
<String
, String
>>> {
395 let unwantend_block_devs
= vec
![
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")?
;
409 let re_name
= Regex
::new(r
"(?m)^N: (.*)$")?
;
410 let re_props
= Regex
::new(r
"(?m)^E: ([^=]+)=(.*)$")?
;
412 let mut disks
: BTreeMap
<String
, BTreeMap
<String
, String
>> = BTreeMap
::new();
414 'outer
: for entry
in fs
::read_dir("/sys/block")?
{
415 let entry
= entry
.unwrap();
416 let filename
= entry
.file_name().into_string().unwrap();
418 for p
in &unwantend_block_devs
{
419 if Pattern
::new(p
)?
.matches(&filename
) {
424 let output
= match get_udev_properties(&entry
.path()) {
425 Ok(output
) => output
,
432 if !re_disk
.is_match(&output
) {
435 if re_cdrom
.is_match(&output
) {
438 if re_iso9660
.is_match(&output
) {
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());
449 let mut udev_props
: BTreeMap
<String
, String
> = BTreeMap
::new();
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
);
459 disks
.insert(name
, udev_props
);
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();
468 let links
= get_nic_list()?
;
470 let path
= format
!("/sys/class/net/{link}");
472 let output
= match get_udev_properties(&PathBuf
::from(path
)) {
473 Ok(output
) => output
,
480 let mut udev_props
: BTreeMap
<String
, String
> = BTreeMap
::new();
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
);
490 nics
.insert(link
, udev_props
);
495 fn get_udev_properties(path
: &PathBuf
) -> Result
<String
> {
496 let udev_output
= Command
::new("udevadm")
503 if !udev_output
.status
.success() {
504 bail
!("could not run udevadm successfully for {path:?}");
506 Ok(String
::from_utf8(udev_output
.stdout
)?
)
509 fn parse_answer(path
: &PathBuf
) -> Result
<Answer
> {
510 let mut file
= match fs
::File
::open(path
) {
512 Err(err
) => bail
!("Opening answer file {path:?} failed: {err}"),
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}");
518 match toml
::from_str(&contents
) {
520 println
!("The file was parsed successfully, no syntax errors found!");
523 Err(err
) => bail
!("Error parsing answer file: {err}"),
527 fn check_prepare_requirements(args
: &CommandPrepareISO
) -> Result
<()> {
528 match Path
::try_exists(&args
.input
) {
530 Ok(false) => bail
!("Source file does not exist."),
531 Err(_
) => bail
!("Source file does not exist."),
534 match Command
::new("xorriso")
538 .arg(PROXMOX_ISO_FLAG
)
539 .stderr(Stdio
::null())
540 .stdout(Stdio
::null())
545 bail
!("The source ISO file is not able to be installed automatically. Please try a more current one.");
548 Err(err
) if err
.kind() == io
::ErrorKind
::NotFound
=> {
549 bail
!("Could not find the 'xorriso' binary. Please install it.")
551 Err(err
) => bail
!("unexpected error when trying to execute 'xorriso' - {err}"),