From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 3D4611FF0DF for ; Fri, 28 Aug 2026 15:34:15 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 59A9E21610; Fri, 28 Aug 2026 15:31:26 +0200 (CEST) From: Shannon Sterz To: pve-devel@lists.proxmox.com Subject: [PATCH installer 18/21] installer-common/tui-installer: implement restore tui Date: Fri, 28 Aug 2026 15:30:27 +0200 Message-ID: <20260828133030.351140-19-s.sterz@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260828133030.351140-1-s.sterz@proxmox.com> References: <20260828133030.351140-1-s.sterz@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1787923825144 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.193 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) KAM_MAILER 2 Automated Mailer Tag Left in Email RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: TWTQNKS3LLE4QEQ2JPFAKXXSZJGLTUSS X-Message-ID-Hash: TWTQNKS3LLE4QEQ2JPFAKXXSZJGLTUSS X-MailFrom: s.sterz@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: to allow restoring a backed up host, gather information in the tui installer, check it and then load the appropriate information into the installer's state. the tui installer is chosen for restoration as it is more likely to be properly supported regardless of hardware. for example, this will allow restoring even when only a serial interface is available. Signed-off-by: Shannon Sterz --- Cargo.toml | 2 + debian/control | 4 + proxmox-auto-installer/src/utils.rs | 3 + proxmox-installer-common/Cargo.toml | 3 +- proxmox-installer-common/src/http.rs | 51 ++ proxmox-installer-common/src/lib.rs | 1 + proxmox-installer-common/src/options.rs | 132 ++++- proxmox-installer-common/src/restore.rs | 119 +++++ proxmox-installer-common/src/setup.rs | 3 + proxmox-tui-installer/Cargo.toml | 6 +- proxmox-tui-installer/src/main.rs | 112 ++++- proxmox-tui-installer/src/options.rs | 3 + proxmox-tui-installer/src/setup.rs | 13 +- proxmox-tui-installer/src/views/bootdisk.rs | 31 +- proxmox-tui-installer/src/views/mod.rs | 3 + proxmox-tui-installer/src/views/restore.rs | 522 ++++++++++++++++++++ 16 files changed, 967 insertions(+), 41 deletions(-) create mode 100644 proxmox-installer-common/src/restore.rs create mode 100644 proxmox-tui-installer/src/views/restore.rs diff --git a/Cargo.toml b/Cargo.toml index 335fb58..7c5a7a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,9 @@ regex = "1.7" serde = "1.0" serde_json = "1.0" serde_plain = "1.0" +sha2 = { version = "0.10" } toml = "0.8" +proxmox-auth-api = { version = "1.0", features = ["api-types"] } proxmox-auto-installer.path = "./proxmox-auto-installer" proxmox-installer-common.path = "./proxmox-installer-common" proxmox-network-types = "1.1" diff --git a/debian/control b/debian/control index 3512404..7995e9f 100644 --- a/debian/control +++ b/debian/control @@ -19,9 +19,11 @@ Build-Depends: cargo:native, librust-native-tls-dev, librust-pico-args-0.5-dev, librust-pretty-assertions-1.4-dev, + librust-proxmox-auth-api-1+api-types-dev (>= 1.0-~~), librust-proxmox-installer-types-0.2+legacy-dev (>= 0.2-~~), librust-proxmox-network-types-1-dev (>= 1.1-~~), librust-proxmox-sys+crypt-dev, + librust-proxmox-time-2+default-dev, librust-regex-1+default-dev (>= 1.7~~), librust-rustls-0.23-dev, librust-rustls-native-certs-dev, @@ -32,6 +34,7 @@ Build-Depends: cargo:native, librust-tempfile-3-dev, librust-toml-0.8-dev, librust-ureq-3-dev, + librust-url-2+default-dev (>= 2.1-~~), librust-zstd-0.13-dev, libtest-mockmodule-perl, patchelf, @@ -52,6 +55,7 @@ Depends: chrony, libgtk3-webkit2-perl, libjson-perl, ndisc6, + proxmox-backup-client, proxmox-kernel-helper, rdnssd, squashfs-tools, diff --git a/proxmox-auto-installer/src/utils.rs b/proxmox-auto-installer/src/utils.rs index db47c60..ec9048c 100644 --- a/proxmox-auto-installer/src/utils.rs +++ b/proxmox-auto-installer/src/utils.rs @@ -564,6 +564,9 @@ pub fn parse_answer( dns: network_settings.dns_server, first_boot: InstallFirstBootSetup::default(), + + // TODO: add support for restoring backups via auto installer + restore_mount_point: None, }; set_disks(answer, udev_info, runtime_info, &mut config)?; diff --git a/proxmox-installer-common/Cargo.toml b/proxmox-installer-common/Cargo.toml index 7682680..6cc354f 100644 --- a/proxmox-installer-common/Cargo.toml +++ b/proxmox-installer-common/Cargo.toml @@ -13,6 +13,7 @@ regex.workspace = true serde = { workspace = true, features = [ "derive" ] } serde_json.workspace = true serde_plain.workspace = true +proxmox-auth-api.workspace = true proxmox-network-types.workspace = true proxmox-installer-types.workspace = true @@ -21,7 +22,7 @@ hex = { version = "0.4", optional = true } native-tls = { version = "0.2", optional = true } rustls = { version = "0.23", optional = true } rustls-native-certs = { version = "0.6", optional = true } -sha2 = { version = "0.10", optional = true } +sha2 = { workspace = true, optional = true } ureq = { version = "3", features = [ "platform-verifier" ], optional = true } # `cli` feature diff --git a/proxmox-installer-common/src/http.rs b/proxmox-installer-common/src/http.rs index 2fcc4f1..9b4204d 100644 --- a/proxmox-installer-common/src/http.rs +++ b/proxmox-installer-common/src/http.rs @@ -197,6 +197,57 @@ pub fn post( } } +/// Issues a GET request. TLS verification can either be done via a fingerprint, that needs to match +/// the server certificate's fingerprint, a callback, that can implement custom verification logic, +/// or can be delegated to the system's trust store. +/// +/// To gather the sha256 fingerprint you can use the following command: +/// ```no_compile +/// openssl s_client -connect :443 < /dev/null 2>/dev/null | openssl x509 -fingerprint -sha256 -noout -in /dev/stdin +/// ``` +/// +/// # Arguments +/// * `url` - URL to call +/// * `verification_option` - Defines how the connection is verified. +/// * `headers` - Additional headers to add to the request. +/// +/// # Returns +/// +/// If the request was successful, a [Response] holding the body contents and the `Content-Type` +/// header, if present. +pub fn get( + url: &str, + verification_option: VerificationOption, + headers: header::HeaderMap, +) -> Result { + let mut request = build_agent(verification_option)? + .get(url) + .config() + // don't treat 4xx and 5xx statuses as error, so we can extract the + // error message from the body + .http_status_as_error(false) + .build(); + + for (name, value) in headers.iter() { + request = request.header(name, value); + } + + let mut response = request.call()?; + + let body = response.body_mut().read_to_string()?; + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .map(ContentType::from_str) + .transpose()?; + + if response.status().is_success() { + Ok(Response { body, content_type }) + } else { + bail!("http error: {}: {body}", response.status()) + } +} /// A callback used to validate a TLS connection via rustls. See /// [rustls::client::danger::ServerCertVerifier::verify_server_cert] for an explanation of the diff --git a/proxmox-installer-common/src/lib.rs b/proxmox-installer-common/src/lib.rs index ee34096..5f81e29 100644 --- a/proxmox-installer-common/src/lib.rs +++ b/proxmox-installer-common/src/lib.rs @@ -1,6 +1,7 @@ pub mod disk_checks; pub mod dmi; pub mod options; +pub mod restore; pub mod setup; #[cfg(feature = "http")] diff --git a/proxmox-installer-common/src/options.rs b/proxmox-installer-common/src/options.rs index c7f0baf..76f1fe2 100644 --- a/proxmox-installer-common/src/options.rs +++ b/proxmox-installer-common/src/options.rs @@ -1,13 +1,14 @@ +use std::cmp; +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::OnceLock; + use anyhow::{Result, bail}; use regex::{Regex, RegexBuilder}; use serde::Deserialize; -use std::{ - cmp, - collections::HashMap, - fmt, - net::{IpAddr, Ipv4Addr}, - sync::OnceLock, -}; + +use proxmox_installer_types::answer::BtrfsOptions; use crate::disk_checks::check_raid_min_disks; use crate::net::{MAX_IFNAME_LEN, MIN_IFNAME_LEN}; @@ -15,8 +16,9 @@ use crate::setup::{LocaleInfo, NetworkInfo, RuntimeInfo, SetupInfo}; use proxmox_installer_types::{ EMAIL_DEFAULT_PLACEHOLDER, answer::{ - BtrfsCompressOption, BtrfsRaidLevel, FilesystemType, NetworkInterfacePinningOptionsAnswer, - ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel, + BtrfsCompressOption, BtrfsRaidLevel, FilesystemOptions, FilesystemType, LvmOptions, + NetworkInterfacePinningOptionsAnswer, ZfsChecksumOption, ZfsCompressOption, ZfsOptions, + ZfsRaidLevel, }, }; use proxmox_network_types::{fqdn::Fqdn, ip_address::Cidr}; @@ -127,6 +129,16 @@ impl LvmBootdiskOptions { min_lvm_free: None, } } + + pub fn from_disk_and_options(disk: &Disk, opt: LvmOptions) -> LvmBootdiskOptions { + LvmBootdiskOptions { + total_size: opt.hdsize.map(|e| e.min(disk.size)).unwrap_or(disk.size), + swap_size: opt.swapsize, + max_root_size: opt.maxroot, + max_data_size: opt.maxvz, + min_lvm_free: opt.minfree, + } + } } pub trait FilesystemDiskInfo { @@ -162,6 +174,23 @@ impl BtrfsBootdiskOptions { compress: BtrfsCompressOption::default(), } } + + pub fn from_runtime_info_disks_and_options( + runinfo: &RuntimeInfo, + selected_disks: Vec, + opt: BtrfsOptions, + ) -> Self { + let min_size = selected_disks + .iter() + .filter_map(|i| runinfo.disks.get(*i).map(|d| d.size)) + .fold(f64::INFINITY, |a, b| a.min(b)); + + Self { + disk_size: opt.hdsize.map(|s| s.min(min_size)).unwrap_or(min_size), + selected_disks, + compress: opt.compress.unwrap_or_default(), + } + } } #[derive(Clone, Debug)] @@ -189,6 +218,30 @@ impl ZfsBootdiskOptions { selected_disks: (0..runinfo.disks.len()).collect(), } } + + pub fn from_runtime_info_disks_and_options( + runinfo: &RuntimeInfo, + selected_disks: Vec, + opt: ZfsOptions, + ) -> Self { + let min_size = selected_disks + .iter() + .filter_map(|i| runinfo.disks.get(*i).map(|d| d.size)) + .fold(f64::INFINITY, |a, b| a.min(b)); + + Self { + ashift: opt.ashift.unwrap_or(12) as usize, + compress: opt.compress.unwrap_or_default(), + checksum: opt.checksum.unwrap_or_default(), + copies: opt.copies.unwrap_or(1) as usize, + arc_max: opt + .arc_max + .map(|a| a as usize) + .unwrap_or(runinfo.default_zfs_arc_max), + disk_size: opt.hdsize.map(|s| s.min(min_size)).unwrap_or(min_size), + selected_disks, + } + } } #[derive(Clone, Debug)] @@ -268,6 +321,67 @@ impl BootdiskOptions { advanced: AdvancedBootdiskOptions::Lvm(LvmBootdiskOptions::defaults_from(disk)), } } + + pub fn from_runtime_info_disks_and_options( + runinfo: &RuntimeInfo, + disks: Vec, + options: FilesystemOptions, + ) -> std::result::Result { + let fs_type = options.to_type(); + let selected_indices: HashSet<&String> = disks.iter().map(|i| &i.index).collect(); + let selected_disks: Vec = runinfo + .disks + .iter() + .enumerate() + .filter_map(|(i, d)| selected_indices.contains(&d.index).then_some(i)) + .collect(); + + match options { + FilesystemOptions::Ext4(opt) | FilesystemOptions::Xfs(opt) if disks.len() == 1 => { + let lvm_opt = LvmBootdiskOptions::from_disk_and_options( + &runinfo.disks[selected_disks[0]], + opt, + ); + Ok(BootdiskOptions { + disks, + fstype: options.to_type(), + advanced: AdvancedBootdiskOptions::Lvm(lvm_opt), + }) + } + FilesystemOptions::Zfs(opt) + if disks.len() >= opt.raid.unwrap_or_default().min_disks() => + { + let zfs_opt = ZfsBootdiskOptions::from_runtime_info_disks_and_options( + runinfo, + selected_disks, + opt, + ); + Ok(BootdiskOptions { + disks, + fstype: options.to_type(), + advanced: AdvancedBootdiskOptions::Zfs(zfs_opt), + }) + } + FilesystemOptions::Btrfs(opt) + if disks.len() >= opt.raid.unwrap_or_default().min_disks() => + { + let btrfs_opt = BtrfsBootdiskOptions::from_runtime_info_disks_and_options( + runinfo, + selected_disks, + opt, + ); + Ok(BootdiskOptions { + disks, + fstype: options.to_type(), + advanced: AdvancedBootdiskOptions::Btrfs(btrfs_opt), + }) + } + _ => bail!( + "File system unknown or wrong amount of disks {fs_type} {}.", + disks.len() + ), + } + } } #[derive(Clone, Debug)] diff --git a/proxmox-installer-common/src/restore.rs b/proxmox-installer-common/src/restore.rs new file mode 100644 index 0000000..3a7a3aa --- /dev/null +++ b/proxmox-installer-common/src/restore.rs @@ -0,0 +1,119 @@ +use std::collections::HashMap; + +use serde::Deserialize; + +use proxmox_auth_api::types::Authid; +use proxmox_installer_types::answer::AutoInstallerConfig; + +#[derive(Clone)] +pub struct RestoreInfo { + pub authid: Authid, + pub secret: String, + pub fingerprint: Option, + pub server: String, + pub datastore: String, + pub namespace: Option, + pub backup_id: String, +} + +impl RestoreInfo { + pub fn repository(&self) -> String { + format!("{}@{}:{}", self.authid, self.server, self.datastore) + } +} + +impl Default for RestoreInfo { + fn default() -> Self { + RestoreInfo { + authid: "root@pam!pve-backup".parse().unwrap(), + secret: String::default(), + fingerprint: Option::default(), + server: String::default(), + datastore: String::default(), + namespace: Option::default(), + backup_id: String::default(), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +/// A Proxmox VE backup index, which can be used to restore a Proxmox VE host. +pub struct PveBackupIndex { + /// The name of this backup. + pub backup_name: String, + /// Information used by the installer to reconstruct the original installation settings. + pub installer_info: AutoInstallerConfig, + /// Version information of the original Proxmox VE. + pub version: BackupVersionInformation, + /// Information on manually and automatically installed packages. + pub package_information: BackupPackageInformation, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +/// Version information of a Proxmox VE host. Including os-release and Proxmox VE specific +/// information. +pub struct BackupVersionInformation { + /// Information provided via the os-release file (see `man 5 os-release`). + pub os_release: OsRelease, + /// Version information retrieved via the Proxmox VE API. + pub proxmox_ve: PveVersionInformation, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +/// Information parsed from an os release file (see `man 5 os-release`). Note that in the case of +/// Proxmox VE this file is filled as if it was running its base Debian release only. The exact keys +/// present depend on the os vendor. This struct only covers fields commonly available in +/// Debian-based Proxmox products, all other fields can be accessed via the `additional` field. +pub struct OsRelease { + /// The name of this OS. + pub name: String, + /// The OS' ID. + pub id: String, + /// The pretty name of this OS. + pub pretty_name: String, + + /// The version of the OS. + pub version: Option, + /// The OS' version ID. + pub version_id: Option, + /// The codename of this version. + pub version_codename: Option, + /// The full Debian version, Debian specific. + pub debian_version_full: Option, + + /// The home URL. + pub home_url: Option, + /// The support URL. + pub support_url: Option, + /// The bug report URL. + pub bug_report_url: Option, + + /// Additional fields, os-release files can have any number of vendor specific fields. + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub additional: Option>, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +/// Version information provided by the Proxmox VE API. +pub struct PveVersionInformation { + /// The current installed pve-manager package version. + pub version: String, + /// The short git commit hash ID from which this version was build. + pub repoid: String, + /// The current installed Proxmox VE Release. + pub release: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +/// Packages installed through dpkg, marked as manually or automatically installed. +pub struct BackupPackageInformation { + /// Automatically installed packages. + pub automatically_installed: Vec, + /// Manually installed packages. + pub manually_installed: Vec, +} diff --git a/proxmox-installer-common/src/setup.rs b/proxmox-installer-common/src/setup.rs index b8af4e3..2abac6c 100644 --- a/proxmox-installer-common/src/setup.rs +++ b/proxmox-installer-common/src/setup.rs @@ -546,6 +546,9 @@ pub struct InstallConfig { pub dns: IpAddr, pub first_boot: InstallFirstBootSetup, + + #[serde(skip_serializing_if = "Option::is_none")] + pub restore_mount_point: Option, } #[derive(Clone, Debug, Deserialize, PartialEq)] diff --git a/proxmox-tui-installer/Cargo.toml b/proxmox-tui-installer/Cargo.toml index 56395a4..433c76d 100644 --- a/proxmox-tui-installer/Cargo.toml +++ b/proxmox-tui-installer/Cargo.toml @@ -8,10 +8,14 @@ exclude = [ "build", "debian" ] homepage = "https://www.proxmox.com" [dependencies] -proxmox-installer-common.workspace = true +proxmox-auth-api.workspace = true +proxmox-installer-common = { workspace = true, features = ["http"] } proxmox-network-types.workspace = true proxmox-installer-types.workspace = true +proxmox-time = "2" anyhow.workspace = true serde_json.workspace = true +sha2.workspace = true +url = "2.1" cursive = { version = "0.21", default-features = false, features = ["crossterm-backend"] } diff --git a/proxmox-tui-installer/src/main.rs b/proxmox-tui-installer/src/main.rs index e9f47a9..1f3e8fe 100644 --- a/proxmox-tui-installer/src/main.rs +++ b/proxmox-tui-installer/src/main.rs @@ -13,13 +13,14 @@ use cursive::{ }, }; +use proxmox_installer_common::restore::RestoreInfo; +use proxmox_installer_common::setup::{LocaleInfo, RuntimeInfo, SetupInfo, installer_setup}; use proxmox_installer_common::{ ROOT_PASSWORD_MIN_LENGTH, options::{ BootdiskOptions, NetworkInterfacePinningOptions, NetworkOptions, TimezoneOptions, email_validate, }, - setup::{LocaleInfo, RuntimeInfo, SetupInfo, installer_setup}, }; use proxmox_installer_types::ProxmoxProduct; @@ -32,10 +33,12 @@ mod system; mod views; use views::{ - BootdiskOptionsView, FormView, InstallProgressView, NetworkOptionsView, TableView, - TableViewItem, TimezoneOptionsView, + BootdiskOptionsView, FormView, InstallProgressView, NetworkOptionsView, RestoreOptionsView, + TableView, TableViewItem, TimezoneOptionsView, }; +use crate::views::{check_connection_cb, restore_installer_state}; + // TextView::center() seems to garble the first two lines, so fix it manually here. const PROXMOX_LOGO: &str = r" ____ @@ -131,6 +134,7 @@ impl ViewWrapper for InstallerBackgroundView { #[derive(Clone, Eq, Hash, PartialEq)] enum InstallerStep { Licence, + Restore, Bootdisk, Timezone, Password, @@ -152,12 +156,17 @@ struct InstallerState { fn main() { let mut siv = cursive::crossterm(); - let in_test_mode = match env::args().nth(1).as_deref() { - Some("-t") => true, + // Always force the test directory in debug builds + let mut in_test_mode = cfg!(debug_assertions); + let mut restore = false; - // Always force the test directory in debug builds - _ => cfg!(debug_assertions), - }; + for arg in env::args() { + match arg.as_str() { + "-t" => in_test_mode = true, + "-r" => restore = true, + _ => {} + }; + } let (setup_info, locales, runtime_info) = match installer_setup(in_test_mode) { Ok(result) => result, @@ -167,6 +176,13 @@ fn main() { siv.clear_global_callbacks(Event::CtrlChar('c')); siv.set_on_pre_event(Event::CtrlChar('c'), trigger_abort_install_dialog); + if restore && setup_info.config.product != ProxmoxProduct::Pve { + initial_setup_error( + &mut siv, + "Only Proxmox VE installers currently support restore mode.", + ); + } + siv.set_user_data(InstallerState { options: InstallerOptions { bootdisk: BootdiskOptions::defaults_from(&runtime_info.disks[0]), @@ -180,6 +196,8 @@ fn main() { Some(&NetworkInterfacePinningOptions::default()), ), autoreboot: true, + restore: restore.then_some(RestoreInfo::default()), + restore_mounted: false, }, setup_info, runtime_info, @@ -254,15 +272,21 @@ fn switch_to_next_screen( let state = siv.user_data::().cloned().unwrap(); let is_first_screen = state.steps.is_empty(); + let restore = siv + .with_user_data(|state: &mut InstallerState| state.options.restore.is_some()) + .unwrap_or_default(); + // Check if the screen already exists; if yes, then simply switch to it. if let Some(screen_id) = state.steps.get(&step) { siv.set_screen(*screen_id); - // The summary view cannot be cached (otherwise it would display stale values). Thus - // replace it if the screen is switched to. + // Caching views does not work if the contents of the view depend on the selection in + // previous views. This always applies to the summary view and, when in restore mode, the + // boot disk view. So don't cache them here. + // // TODO: Could be done by e.g. having all the main dialog views implement some sort of // .refresh(), which can be called if the view is switched to. - if step == InstallerStep::Summary { + if step == InstallerStep::Summary || (restore && step == InstallerStep::Bootdisk) { let view = constructor(siv); siv.screen_mut().pop_layer(); siv.screen_mut().add_layer(view); @@ -351,12 +375,19 @@ fn get_eula(setup: &SetupInfo) -> String { fn license_dialog(siv: &mut Cursive) -> InstallerView { let state = siv.user_data::().unwrap(); + let restore = state.options.restore.is_some(); let mut bbar = LinearLayout::horizontal() .child(abort_install_button()) .child(DummyView.full_width()) - .child(Button::new("I agree", |siv| { - switch_to_next_screen(siv, InstallerStep::Bootdisk, &bootdisk_dialog) + .child(Button::new("I agree", move |siv| { + if restore { + // if in restore mode, move to timezone dialog first. it will set the keyboard + // layout, which will make entering restore information easier. + switch_to_next_screen(siv, InstallerStep::Timezone, &timezone_dialog) + } else { + switch_to_next_screen(siv, InstallerStep::Bootdisk, &bootdisk_dialog) + } })); let _ = bbar.set_focus_index(2); // ignore errors @@ -378,14 +409,52 @@ fn license_dialog(siv: &mut Cursive) -> InstallerView { InstallerView::with_raw(state, inner) } +fn restore_dialog(siv: &mut Cursive) -> InstallerView { + let state = siv.user_data::().cloned().unwrap(); + + InstallerView::new( + &state, + RestoreOptionsView::new(siv, &state).with_name("restore-options"), + Box::new(|siv| { + check_connection_cb( + siv, + Box::new(|siv| { + let Some(Ok(opts)) = + siv.call_on_name("restore-options", RestoreOptionsView::get_values) + else { + display_setup_warning(siv, "Could not get restore options."); + return; + }; + + let res = siv.with_user_data(|state: &mut InstallerState| { + state.options.restore = Some(opts); + restore_installer_state(state) + }); + + if let Some(Err(e)) = res { + display_setup_warning( + siv, + &format!("Could not get information from backup - {e}"), + ); + } else { + switch_to_next_screen(siv, InstallerStep::Bootdisk, &bootdisk_dialog); + } + }), + ) + }), + true, + ) +} + fn bootdisk_dialog(siv: &mut Cursive) -> InstallerView { let state = siv.user_data::().cloned().unwrap(); + let restore = state.options.restore.is_some(); InstallerView::new( &state, BootdiskOptionsView::new(siv, &state.runtime_info, &state.options.bootdisk) .with_name("bootdisk-options"), - Box::new(|siv| { + Box::new(move |siv| { let options = siv.call_on_name("bootdisk-options", BootdiskOptionsView::get_values); match options { @@ -394,7 +463,11 @@ fn bootdisk_dialog(siv: &mut Cursive) -> InstallerView { state.options.bootdisk = options; }); - switch_to_next_screen(siv, InstallerStep::Timezone, &timezone_dialog); + if restore { + switch_to_next_screen(siv, InstallerStep::Summary, &summary_dialog); + } else { + switch_to_next_screen(siv, InstallerStep::Timezone, &timezone_dialog); + } } Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))), @@ -408,11 +481,12 @@ fn bootdisk_dialog(siv: &mut Cursive) -> InstallerView { fn timezone_dialog(siv: &mut Cursive) -> InstallerView { let state = siv.user_data::().unwrap(); let options = &state.options.timezone; + let restore = state.options.restore.is_some(); InstallerView::new( state, TimezoneOptionsView::new(&state.locales, options).with_name("timezone-options"), - Box::new(|siv| { + Box::new(move |siv| { let options = siv.call_on_name("timezone-options", TimezoneOptionsView::get_values); match options { @@ -421,7 +495,11 @@ fn timezone_dialog(siv: &mut Cursive) -> InstallerView { state.options.timezone = options; }); - switch_to_next_screen(siv, InstallerStep::Password, &password_dialog); + if restore { + switch_to_next_screen(siv, InstallerStep::Restore, &restore_dialog); + } else { + switch_to_next_screen(siv, InstallerStep::Password, &password_dialog); + } } Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))), _ => siv.add_layer(Dialog::info("Invalid values")), diff --git a/proxmox-tui-installer/src/options.rs b/proxmox-tui-installer/src/options.rs index 2c156e8..9b22115 100644 --- a/proxmox-tui-installer/src/options.rs +++ b/proxmox-tui-installer/src/options.rs @@ -1,5 +1,6 @@ use crate::SummaryOption; +use proxmox_installer_common::restore::RestoreInfo; use proxmox_installer_common::{ options::{BootdiskOptions, NetworkOptions, TimezoneOptions}, setup::LocaleInfo, @@ -28,6 +29,8 @@ pub struct InstallerOptions { pub password: PasswordOptions, pub network: NetworkOptions, pub autoreboot: bool, + pub restore: Option, + pub restore_mounted: bool, } impl InstallerOptions { diff --git a/proxmox-tui-installer/src/setup.rs b/proxmox-tui-installer/src/setup.rs index ae4b717..31a2396 100644 --- a/proxmox-tui-installer/src/setup.rs +++ b/proxmox-tui-installer/src/setup.rs @@ -1,4 +1,5 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; use crate::options::InstallerOptions; use proxmox_installer_common::{ @@ -44,8 +45,18 @@ impl From for InstallConfig { dns: options.network.dns_server, first_boot: InstallFirstBootSetup::default(), + restore_mount_point: options + .restore_mounted + .then_some(PathBuf::from("/run/proxmox-installer/restore-mp")), }; + // If we are restoring, do not pin any interfaces. Either they have been pinned by the + // backup already or they shouldn't be pinned to avoid breaking compatibility with the + // backed up `/etc/network/interfaces` file. + if options.restore_mounted { + config.network_interface_pin_map = HashMap::new(); + } + match &options.bootdisk.advanced { AdvancedBootdiskOptions::Lvm(lvm) => { config.hdsize = lvm.total_size; diff --git a/proxmox-tui-installer/src/views/bootdisk.rs b/proxmox-tui-installer/src/views/bootdisk.rs index a0267f1..c6ed619 100644 --- a/proxmox-tui-installer/src/views/bootdisk.rs +++ b/proxmox-tui-installer/src/views/bootdisk.rs @@ -53,18 +53,25 @@ impl BootdiskOptionsView { pub fn new(siv: &mut Cursive, runinfo: &RuntimeInfo, options: &BootdiskOptions) -> Self { let advanced_options = Arc::new(Mutex::new(options.clone())); - let bootdisk_form = FormView::<()>::new() - .child( - "Target harddisk", - target_bootdisk_selectview( - &runinfo.disks, - advanced_options.clone(), - // At least one disk must always exist to even get to this point, - // see proxmox_installer_common::setup::installer_setup() - &options.disks[0], - ), - ) - .with_name("bootdisk-options-target-disk"); + let mut bootdisk_form = FormView::<()>::new(); + + match options.fstype { + FilesystemType::Ext4 | FilesystemType::Xfs => { + bootdisk_form.add_child( + "Target harddisk", + target_bootdisk_selectview( + &runinfo.disks, + advanced_options.clone(), + // At least one disk must always exist to even get to this point, + // see proxmox_installer_common::setup::installer_setup() + &options.disks[0], + ), + ) + } + other => bootdisk_form.add_child("Target harddisk", TextView::new(other.to_string())), + } + + let bootdisk_form = bootdisk_form.with_name("bootdisk-options-target-disk"); let product_conf = siv .user_data::() diff --git a/proxmox-tui-installer/src/views/mod.rs b/proxmox-tui-installer/src/views/mod.rs index a343e60..9b57911 100644 --- a/proxmox-tui-installer/src/views/mod.rs +++ b/proxmox-tui-installer/src/views/mod.rs @@ -8,6 +8,9 @@ use cursive::{ views::{EditView, LinearLayout, NamedView, ResizedView, SelectView, TextView}, }; +mod restore; +pub use restore::*; + mod bootdisk; pub use bootdisk::*; diff --git a/proxmox-tui-installer/src/views/restore.rs b/proxmox-tui-installer/src/views/restore.rs new file mode 100644 index 0000000..7c9036c --- /dev/null +++ b/proxmox-tui-installer/src/views/restore.rs @@ -0,0 +1,522 @@ +use std::fmt::Write; +use std::net::Ipv6Addr; +use std::path::PathBuf; +use std::process::Command; +use std::sync::{Arc, Mutex}; +use std::thread; + +use cursive::Cursive; +use cursive::view::ViewWrapper; +use cursive::views::{Button, Dialog, DummyView, EditView, LinearLayout}; +use serde_json::Value; + +use proxmox_auth_api::types::Authid; +use proxmox_installer_common::http; +use proxmox_installer_common::options::{BootdiskOptions, Disk}; +use proxmox_installer_common::restore::{PveBackupIndex, RestoreInfo}; +use proxmox_installer_common::setup::read_json; +use proxmox_installer_types::UdevInfo; +use proxmox_installer_types::answer::Filesystem; + +use crate::InstallerState; +use crate::views::FormView; +use crate::{display_setup_warning, prompt_dialog}; + +pub struct RestoreOptionsView { + view: LinearLayout, +} + +impl RestoreOptionsView { + pub fn new(_siv: &mut Cursive, state: &InstallerState) -> Self { + let restore_info = state.options.restore.as_ref().unwrap(); + + let form: FormView = FormView::<()>::new() + .child("Server", EditView::new().content(&restore_info.server)) + .child( + "Token", + EditView::new().content(restore_info.authid.to_string()), + ) + .child( + "Secret", + EditView::new().content(&restore_info.secret).secret(), + ) + .child( + "Fingerprint [Optional]", + EditView::new().content(restore_info.fingerprint.as_deref().unwrap_or("")), + ) + .child( + "Datastore", + EditView::new().content(&restore_info.datastore), + ) + .child( + "Namespace [Optional]", + EditView::new().content(restore_info.namespace.as_deref().unwrap_or("")), + ) + .child( + "Backup ID", + EditView::new().content(&restore_info.backup_id), + ); + + let view = LinearLayout::vertical() + .child(form) + .child(DummyView::new()) + .child(Button::new( + "Test Settings", + Box::new(|siv: &mut Cursive| { + check_connection_cb( + siv, + Box::new(|siv| { + siv.add_layer( + Dialog::info("Restore settings are OK.").title("Restore Settings"), + ) + }), + ); + }), + )); + + Self { view } + } + + pub fn set_fingerprint(&mut self, fingerprint: &str) -> Result<(), String> { + let form: &mut FormView = self + .view + .get_child_mut(0) + .and_then(|f| f.downcast_mut::()) + .ok_or("Could not get form view.")?; + + form.get_child_mut::(3) + .ok_or("Could not get fingerprint field.")? + .set_content(fingerprint); + + Ok(()) + } + + pub fn get_values(&mut self) -> Result { + let form = self + .view + .get_child(0) + .and_then(|f| f.downcast_ref::()) + .ok_or("Could not get restore information form.")?; + + let server = form + .get_value::(0) + .ok_or("Failed to retrieve backup server address.")?; + + let authid = form + .get_value::(1) + .ok_or("Failed to retrieve authid for restore.")? + .parse::() + .map_err(|err| format!("Authid is not valid - {err:#}"))?; + + if !authid.is_token() { + return Err("Currently only tokens are supported for restoring backups!".to_owned()); + } + + let secret = form + .get_value::(2) + .ok_or("Failed to retrieve secret for restore.")?; + + let fingerprint = form + .get_value::(3) + .ok_or("Failed to retrieve fingerprint for restore.") + .map(|fp| if fp.is_empty() { None } else { Some(fp) })?; + + let datastore = form + .get_value::(4) + .ok_or("Failed to retrieve datastore for restore.")?; + + let namespace = form + .get_value::(5) + .ok_or("Failed to retrieve namespace for restore.") + .map(|ns| if ns.is_empty() { None } else { Some(ns) })?; + + let backup_id = form + .get_value::(6) + .ok_or("Failed to retrieve backup_id for restore.")?; + + Ok(RestoreInfo { + authid, + secret, + fingerprint, + server, + datastore, + namespace, + backup_id, + }) + } +} + +impl ViewWrapper for RestoreOptionsView { + cursive::wrap_impl!(self.view: LinearLayout); +} + +fn list_snapshot_file_url(options: &RestoreInfo) -> Result { + let backup_parts: Vec<&str> = options.backup_id.splitn(3, '/').collect(); + + let (id, time) = match backup_parts.len() { + 2 => (backup_parts[0], backup_parts[1]), + 3 if backup_parts[0] == "host" => (backup_parts[1], backup_parts[2]), + _ => return Err( + "could not parse backup id, invalid format ([host/]/)" + .to_owned(), + ), + }; + + // backup ids can contain characters that should be url encoded + let id: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect(); + let time = proxmox_time::parse_rfc3339(time) + .map_err(|e| format!("could not parse backup time stamp - {e}"))?; + let mut params = format!("?backup-type=host&backup-id={id}&backup-time={time}"); + + if let Some(namespace) = options.namespace.as_ref() { + let namespace: String = + url::form_urlencoded::byte_serialize(namespace.as_bytes()).collect(); + params = format!("{params}&ns={namespace}"); + } + + let store: String = + url::form_urlencoded::byte_serialize(options.datastore.as_bytes()).collect(); + + let (host, has_port) = if options.server.parse::().is_ok() { + (format!("[{}]", options.server), false) + } else if let Some(rest) = options.server.strip_prefix('[') { + let has_port = rest + .rsplit_once(']') + .is_some_and(|(_, tail)| tail.starts_with(':')); + (options.server.clone(), has_port) + } else { + (options.server.clone(), options.server.contains(':')) + }; + + let port = if has_port { "" } else { ":8007" }; + + Ok(format!( + "https://{host}{port}/api2/json/admin/datastore/{store}/files{params}", + )) +} + +fn get_major_version(release: &str) -> Option { + release.split('.').next().and_then(|r| r.parse().ok()) +} + +fn check_information(options: RestoreInfo) -> Result, String> { + let mut headers = http::header::HeaderMap::new(); + + headers.insert( + http::header::ACCEPT, + http::header::HeaderValue::from_str("application/json") + .map_err(|e| format!("Could not construct header value - {e}"))?, + ); + + headers.insert( + http::header::AUTHORIZATION, + http::header::HeaderValue::from_str(&format!( + "PBSAPIToken={}:{}", + options.authid, options.secret + )) + .map_err(|e| format!("Could not construct AUTHORIZATION header - {e}"))?, + ); + + let get_url = list_snapshot_file_url(&options)?; + let fp: Arc>> = Arc::new(Mutex::new(Option::default())); + + let verification_option = if let Some(fp) = options.fingerprint.as_ref() { + fp.as_str() + .try_into() + .map_err(|e| format!("Could not parse PBS fingerprint - {e:#}"))? + } else { + let inner_fp = fp.clone(); + + http::VerificationOption::DangerousCallback(Box::new(move |cert, _, _, _, _, valid| { + if valid { + // The server uses a valid certificate, we don't need to query its fingerprint. + return true; + } + + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(cert); + let result = hasher.finalize(); + + // 256 bits in bytes -> 256/8; + // each byte needs 2 chars of encoding + ":"-> *3; + // no ":" at the end of the last byte -> -1 + let mut remote_fp = String::with_capacity((256 / 8) * 3 - 1); + + let _ = write!(remote_fp, "{:02x}", result[0]); + for byte in &result[1..] { + let _ = write!(remote_fp, ":{byte:02x}"); + } + + if let Ok(mut inner_fp) = inner_fp.lock() { + *inner_fp = Some(remote_fp); + } + + false + })) + }; + + let res = http::get(&get_url, verification_option, headers) + .map_err(|e| format!("Could not get backup files - {e}")); + + let locked_fp = fp + .lock() + .map_err(|e| format!("Could not lock fingerprint mutex - {e}"))? + .take(); + + if locked_fp.is_some() && options.fingerprint.is_none() { + return Ok(locked_fp); + } + + let files: Value = serde_json::from_str(&res?.body) + .map_err(|e| format!("Could not parse pbs response - {e}"))?; + + for file in files["data"] + .as_array() + .ok_or("could not get data".to_owned())? + { + if file["filename"] + .as_str() + .ok_or("could not get filename")? + .starts_with("pve-backup.pxar") + { + return Ok(locked_fp); + } + } + + Err("Not a PVE host backup.".to_owned()) +} + +/// Connects to a PBS with the restore information from the installer state and checks if the +/// specified backup is a PVE backup. +pub(crate) fn check_connection_cb( + siv: &mut Cursive, + success_cb: Box, +) { + let Some(Ok(opts)) = siv.call_on_name("restore-options", RestoreOptionsView::get_values) else { + display_setup_warning(siv, "Could not get restore options."); + return; + }; + + // set loading mask + siv.add_layer(Dialog::text("Checking restore information...").title("Restore Settings")); + + let sink = siv.cb_sink().clone(); + + // Actual check needs to happen on separate thread to avoid blocking Cursive's rendering loop; + // otherwise the loading mask above will not be shown properly. + thread::spawn(move || { + let res = check_information(opts); + + // remove loading mask + let _ = sink.send(Box::new(|s| { + let _ = s.pop_layer(); + })); + + let _ = match res { + Err(e) => sink.send(Box::new(move |siv| display_setup_warning(siv, &e))), + Ok(fp) => { + if let Some(got_fp) = fp.as_ref() { + let new_fp = got_fp.clone(); + sink.send(Box::new(move |siv| { + prompt_dialog( + siv, + "Confirm Fingerprint", + &format!( + "Got the fingerprint:\n\n{new_fp}\n\nIs this the correct TLS \ + fingerprint for the provided PBS server?" + ), + "Yes", + Box::new(move |s| { + s.call_on_name("restore-options", |v: &mut RestoreOptionsView| { + v.set_fingerprint(&new_fp) + }); + }), + "No", + // nothing to do + Box::new(|_| {}), + ) + })) + } else { + sink.send(success_cb) + } + } + }; + }); +} + +/// Mounts a backup and loads data from its index into the installer state. +pub(crate) fn restore_installer_state(state: &mut InstallerState) -> Result<(), String> { + let Some(opts) = state.options.restore.clone() else { + return Ok(()); // Not a restoring installer, nothing to do. + }; + + let mut mount_dir = if state.in_test_mode { + PathBuf::from("./testdir") + } else { + PathBuf::from("/") + }; + + mount_dir.push("run"); + mount_dir.push("proxmox-installer"); + mount_dir.push("restore-mp"); + + if let Err(e) = std::fs::create_dir_all(&mount_dir) + && e.kind() != std::io::ErrorKind::AlreadyExists + { + return Err(format!( + "Mount directory for restore could not be created. - {e}" + )); + } + + ensure_no_mounted_backup(state)?; + + let mut cmd = Command::new("proxmox-backup-client"); + cmd.env_clear() + .env("PBS_PASSWORD", &opts.secret) + .arg("mount") + .arg(&opts.backup_id) + .arg("pve-backup.pxar") + .arg(&mount_dir) + .args(["--repository", &opts.repository()]); + + if let Some(fingerprint) = opts.fingerprint.as_ref() { + cmd.env("PBS_FINGERPRINT", fingerprint); + } + + if let Some(namespace) = opts.namespace.as_ref() { + cmd.args(["--ns", namespace]); + } + + let output = cmd + .output() + .map_err(|e| format!("Could not spawn proxmox-backup-client - {e}"))?; + + if !output.status.success() { + return Err("Could not mount backup for restore.".to_owned()); + } + + // proxmox-backup-client doesn't always return 1 on error here it seems + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if stdout.to_lowercase().contains("error") || stderr.to_lowercase().contains("error") { + return Err("error occurred when trying to mount backup for restore".to_owned()); + } + + state.options.restore_mounted = true; + + let index: PveBackupIndex = read_json(mount_dir.join("backup-index.json")) + .map_err(|e| format!("could not read backup index - {e}"))?; + + let opts = index + .installer_info + .disks + .filesystem_details() + .map_err(|e| format!("could not get valid filesystem details from backup - {e:#}"))?; + + let backup_major_version = get_major_version(&index.version.proxmox_ve.release); + let installer_major_version = get_major_version(&state.setup_info.iso_info.release); + + if let Some(backup_version) = backup_major_version + && let Some(installer_version) = installer_major_version + { + // An installer is only able to restore backups from versions in the range + // `[installer_version-1, installer_version]`. Otherwise, incompatibilities are too likely + // to ensure consistency. If an older backup needs to be restored, it should be restored + // with an older installer and then upgraded via the usual upgrade procedure. + // + // `contains()` excludes the maximum of a range so +1 here. + if !(installer_version - 1..installer_version + 1).contains(&backup_version) { + return Err(format!( + "Installer version ({}) is not compatible with backup version ({}).", + index.version.proxmox_ve.release, state.setup_info.iso_info.release + )); + } + } else { + return Err(format!( + "Could not parse release versions to check compatibility.\n\n\ + Backup Proxmox VE version: {}\n\ + Installer version: {}", + index.version.proxmox_ve.release, state.setup_info.iso_info.release + )); + } + + // pop-off "restore-mp" + mount_dir.pop(); + + // Manually filter disks via the device links provided as disks in a backup. This should provide + // better stability than the kernel assigned device name. + // + // TODO: Switch to a more general approach once #5493 is implemented. + let udev_info: UdevInfo = read_json(mount_dir.join("run-env-udev.json")) + .map_err(|err| format!("Failed to retrieve udev info details: {err:#}"))?; + + let dev_links = &index.installer_info.disks.disk_list; + let disk_setup = &index.installer_info.disks; + let hdsize = match disk_setup.filesystem { + Filesystem::Ext4 | Filesystem::Xfs => disk_setup.lvm.and_then(|l| l.hdsize), + Filesystem::Zfs => disk_setup.zfs.and_then(|z| z.hdsize), + Filesystem::Btrfs => disk_setup.btrfs.and_then(|b| b.hdsize), + }; + + let disks: Vec = state + .runtime_info + .disks + .iter() + .filter(|d| { + for i in dev_links { + if udev_info + .disks + .get(&d.index) + .and_then(|u| u.get("DEVLINKS").map(|c| c.split(" ").any(|l| l == i))) + .unwrap_or_default() + { + return true; + } + } + + false + }) + .cloned() + .collect(); + + let disks = if disks.len() == dev_links.len() { + disks + } else { + // Couldn't find the exact disks that the original system used; fall back to the first n + // disks that are at least `hdsize` big (or just the first n disks if no `hdsize` was given). + state + .runtime_info + .disks + .iter() + .filter(|d| hdsize.map(|hd| hd < d.size).unwrap_or(true)) + .take(dev_links.len()) + .cloned() + .collect() + }; + + state.options.bootdisk = + BootdiskOptions::from_runtime_info_disks_and_options(&state.runtime_info, disks, opts) + .map_err(|e| format!("could not get boot disk information from backup - {e:#}"))?; + + Ok(()) +} + +/// Ensure that no backup is currently mounted. +pub(crate) fn ensure_no_mounted_backup(state: &mut InstallerState) -> Result<(), String> { + if state.options.restore_mounted { + let output = Command::new("umount") + .arg("/run/proxmox-installer/restore-mp") + .output() + .map_err(|e| format!("Could not run unmount command - {e}"))?; + + if !output.status.success() { + return Err("Could not unmount backup!".to_owned()); + } + } + + state.options.restore_mounted = false; + + Ok(()) +} -- 2.47.3