diff --git a/Makefile b/Makefile index 9a9a001b7..c0492ba55 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ PATCH_DB_CLIENT_SRC := $(shell git ls-files --recurse-submodules patch-db/client GZIP_BIN := $(shell which pigz || which gzip) TAR_BIN := $(shell which gtar || which tar) COMPILED_TARGETS := $(BINS) system-images/compat/docker-images/$(ARCH).tar system-images/utils/docker-images/$(ARCH).tar system-images/binfmt/docker-images/$(ARCH).tar container-runtime/rootfs.$(ARCH).squashfs -ALL_TARGETS := $(STARTD_SRC) $(ENVIRONMENT_FILE) $(GIT_HASH_FILE) $(VERSION_FILE) $(COMPILED_TARGETS) $(shell if [ "$(PLATFORM)" = "raspberrypi" ]; then echo cargo-deps/aarch64-unknown-linux-musl/release/pi-beep; fi) $(shell /bin/bash -c 'if [[ "${ENVIRONMENT}" =~ (^|-)unstable($$|-) ]]; then echo cargo-deps/$(ARCH)-unknown-linux-musl/release/tokio-console; fi') $(PLATFORM_FILE) +ALL_TARGETS := $(STARTD_SRC) $(ENVIRONMENT_FILE) $(GIT_HASH_FILE) $(VERSION_FILE) $(COMPILED_TARGETS) cargo-deps/$(ARCH)-unknown-linux-musl/release/startos-backup-fs $(shell if [ "$(PLATFORM)" = "raspberrypi" ]; then echo cargo-deps/aarch64-unknown-linux-musl/release/pi-beep; fi) $(shell /bin/bash -c 'if [[ "${ENVIRONMENT}" =~ (^|-)unstable($$|-) ]]; then echo cargo-deps/$(ARCH)-unknown-linux-musl/release/tokio-console; fi') $(PLATFORM_FILE) ifeq ($(REMOTE),) mkdir = mkdir -p $1 @@ -115,12 +115,15 @@ results/$(BASENAME).$(IMAGE_TYPE) results/$(BASENAME).squashfs: $(IMAGE_RECIPE_S # For creating os images. DO NOT USE install: $(ALL_TARGETS) $(call mkdir,$(DESTDIR)/usr/bin) + $(call mkdir,$(DESTDIR)/usr/sbin) $(call cp,core/target/$(ARCH)-unknown-linux-musl/release/startbox,$(DESTDIR)/usr/bin/startbox) $(call ln,/usr/bin/startbox,$(DESTDIR)/usr/bin/startd) $(call ln,/usr/bin/startbox,$(DESTDIR)/usr/bin/start-cli) $(call ln,/usr/bin/startbox,$(DESTDIR)/usr/bin/start-sdk) if [ "$(PLATFORM)" = "raspberrypi" ]; then $(call cp,cargo-deps/aarch64-unknown-linux-musl/release/pi-beep,$(DESTDIR)/usr/bin/pi-beep); fi if /bin/bash -c '[[ "${ENVIRONMENT}" =~ (^|-)unstable($$|-) ]]'; then $(call cp,cargo-deps/$(ARCH)-unknown-linux-musl/release/tokio-console,$(DESTDIR)/usr/bin/tokio-console); fi + $(call cp,cargo-deps/$(ARCH)-unknown-linux-musl/release/startos-backup-fs,$(DESTDIR)/usr/bin/startos-backup-fs) + $(call ln,/usr/bin/startos-backup-fs,$(DESTDIR)/usr/sbin/mount.backup-fs) $(call mkdir,$(DESTDIR)/lib/systemd/system) $(call cp,core/startos/startd.service,$(DESTDIR)/lib/systemd/system/startd.service) @@ -310,4 +313,7 @@ cargo-deps/aarch64-unknown-linux-musl/release/pi-beep: ARCH=aarch64 ./build-cargo-dep.sh pi-beep cargo-deps/$(ARCH)-unknown-linux-musl/release/tokio-console: - ARCH=$(ARCH) ./build-cargo-dep.sh tokio-console \ No newline at end of file + ARCH=$(ARCH) PREINSTALL="apk add musl-dev pkgconfig" ./build-cargo-dep.sh tokio-console + +cargo-deps/$(ARCH)-unknown-linux-musl/release/startos-backup-fs: + ARCH=$(ARCH) PREINSTALL="apk add fuse3 fuse3-dev fuse3-static musl-dev pkgconfig" ./build-cargo-dep.sh --git https://github.com/Start9Labs/start-fs.git startos-backup-fs \ No newline at end of file diff --git a/build-cargo-dep.sh b/build-cargo-dep.sh index 9e20f0caf..c32e4f8ae 100755 --- a/build-cargo-dep.sh +++ b/build-cargo-dep.sh @@ -17,9 +17,18 @@ if [ -z "$ARCH" ]; then ARCH=$(uname -m) fi -mkdir -p cargo-deps -alias 'rust-musl-builder'='docker run $USE_TTY --rm -e "RUSTFLAGS=$RUSTFLAGS" -v "$HOME/.cargo/registry":/root/.cargo/registry -v "$(pwd)"/cargo-deps:/home/rust/src -w /home/rust/src -P messense/rust-musl-cross:$ARCH-musl' +DOCKER_PLATFORM="linux/${ARCH}" +if [ "$ARCH" = aarch64 ]; then + DOCKER_PLATFORM="linux/arm64" +elif [ "$ARCH" = x86_64 ]; then + DOCKER_PLATFORM="linux/amd64" +fi -rust-musl-builder cargo install "$1" --target-dir /home/rust/src --target=$ARCH-unknown-linux-musl +mkdir -p cargo-deps +alias 'rust-musl-builder'='docker run $USE_TTY --platform=${DOCKER_PLATFORM} --rm -e "RUSTFLAGS=$RUSTFLAGS" -v "$HOME/.cargo/registry":/root/.cargo/registry -v "$(pwd)"/cargo-deps:/home/rust/src -w /home/rust/src -P rust:alpine' + +PREINSTALL=${PREINSTALL:-true} + +rust-musl-builder sh -c "$PREINSTALL && cargo install $* --target-dir /home/rust/src --target=$ARCH-unknown-linux-musl" sudo chown -R $USER cargo-deps sudo chown -R $USER ~/.cargo \ No newline at end of file diff --git a/build/dpkg-deps/depends b/build/dpkg-deps/depends index 7519cff1b..33b7be2ad 100644 --- a/build/dpkg-deps/depends +++ b/build/dpkg-deps/depends @@ -14,6 +14,7 @@ e2fsprogs ecryptfs-utils exfatprogs flashrom +fuse3 grub-common htop httpdirfs diff --git a/core/startos/src/backup/backup_bulk.rs b/core/startos/src/backup/backup_bulk.rs index 79cb3b98a..a52c97f9b 100644 --- a/core/startos/src/backup/backup_bulk.rs +++ b/core/startos/src/backup/backup_bulk.rs @@ -164,7 +164,7 @@ pub async fn backup_all( .decrypt(&ctx)?; let password = password.decrypt(&ctx)?; - let ((fs, package_ids), status_guard) = ( + let ((fs, package_ids, server_id), status_guard) = ( ctx.db .mutate(|db| { check_password_against_db(db, &password)?; @@ -181,7 +181,11 @@ pub async fn backup_all( .collect() }; assure_backing_up(db, &package_ids)?; - Ok((fs, package_ids)) + Ok(( + fs, + package_ids, + db.as_public().as_server_info().as_id().de()?, + )) }) .await?, BackupStatusGuard::new(ctx.db.clone()), @@ -189,6 +193,7 @@ pub async fn backup_all( let mut backup_guard = BackupMountGuard::mount( TmpMountGuard::mount(&fs, ReadWrite).await?, + &server_id, &old_password_decrypted, ) .await?; @@ -298,11 +303,11 @@ async fn perform_backup( let ui = ctx.db.peek().await.into_public().into_ui().de()?; let mut os_backup_file = - AtomicFile::new(backup_guard.path().join("os-backup.cbor"), None::) + AtomicFile::new(backup_guard.path().join("os-backup.json"), None::) .await .with_kind(ErrorKind::Filesystem)?; os_backup_file - .write_all(&IoFormat::Cbor.to_vec(&OsBackup { + .write_all(&IoFormat::Json.to_vec(&OsBackup { account: ctx.account.read().await.clone(), ui, })?) @@ -325,22 +330,23 @@ async fn perform_backup( dir_copy(luks_folder, &luks_folder_bak, None).await?; } - let timestamp = Some(Utc::now()); + let timestamp = Utc::now(); backup_guard.unencrypted_metadata.version = crate::version::Current::new().semver().into(); - backup_guard.unencrypted_metadata.full = true; + backup_guard.unencrypted_metadata.hostname = ctx.account.read().await.hostname.clone(); + backup_guard.unencrypted_metadata.timestamp = timestamp.clone(); backup_guard.metadata.version = crate::version::Current::new().semver().into(); - backup_guard.metadata.timestamp = timestamp; + backup_guard.metadata.timestamp = Some(timestamp); backup_guard.metadata.package_backups = package_backups; - backup_guard.save().await?; + backup_guard.save_and_unmount().await?; ctx.db .mutate(|v| { v.as_public_mut() .as_server_info_mut() .as_last_backup_mut() - .ser(×tamp) + .ser(&Some(timestamp)) }) .await?; diff --git a/core/startos/src/backup/restore.rs b/core/startos/src/backup/restore.rs index 3f0aacbe8..23e0c8ac1 100644 --- a/core/startos/src/backup/restore.rs +++ b/core/startos/src/backup/restore.rs @@ -44,9 +44,14 @@ pub async fn restore_packages_rpc( password, }: RestorePackageParams, ) -> Result<(), Error> { - let fs = target_id.load(&ctx.db.peek().await)?; - let backup_guard = - BackupMountGuard::mount(TmpMountGuard::mount(&fs, ReadWrite).await?, &password).await?; + let peek = ctx.db.peek().await; + let fs = target_id.load(&peek)?; + let backup_guard = BackupMountGuard::mount( + TmpMountGuard::mount(&fs, ReadWrite).await?, + &peek.as_public().as_server_info().as_id().de()?, + &password, + ) + .await?; let tasks = restore_packages(&ctx, backup_guard, ids).await?; @@ -73,7 +78,8 @@ pub async fn recover_full_embassy( disk_guid: Arc, start_os_password: String, recovery_source: TmpMountGuard, - recovery_password: Option, + server_id: &str, + recovery_password: &str, SetupExecuteProgress { init_phases, restore_phase, @@ -82,14 +88,11 @@ pub async fn recover_full_embassy( ) -> Result<(SetupResult, RpcContext), Error> { let mut restore_phase = restore_phase.or_not_found("restore progress")?; - let backup_guard = BackupMountGuard::mount( - recovery_source, - recovery_password.as_deref().unwrap_or_default(), - ) - .await?; + let backup_guard = + BackupMountGuard::mount(recovery_source, server_id, recovery_password).await?; - let os_backup_path = backup_guard.path().join("os-backup.cbor"); - let mut os_backup: OsBackup = IoFormat::Cbor.from_slice( + let os_backup_path = backup_guard.path().join("os-backup.json"); + let mut os_backup: OsBackup = IoFormat::Json.from_slice( &tokio::fs::read(&os_backup_path) .await .with_ctx(|_| (ErrorKind::Filesystem, os_backup_path.display().to_string()))?, diff --git a/core/startos/src/backup/target/cifs.rs b/core/startos/src/backup/target/cifs.rs index ad4f81aa0..e83f4e981 100644 --- a/core/startos/src/backup/target/cifs.rs +++ b/core/startos/src/backup/target/cifs.rs @@ -14,7 +14,7 @@ use crate::db::model::DatabaseModel; use crate::disk::mount::filesystem::cifs::Cifs; use crate::disk::mount::filesystem::ReadOnly; use crate::disk::mount::guard::{GenericMountGuard, TmpMountGuard}; -use crate::disk::util::{recovery_info, EmbassyOsRecoveryInfo}; +use crate::disk::util::{recovery_info, StartOsRecoveryInfo}; use crate::prelude::*; use crate::util::serde::KeyVal; @@ -43,7 +43,7 @@ pub struct CifsBackupTarget { path: PathBuf, username: String, mountable: bool, - start_os: Option, + start_os: BTreeMap, } pub fn cifs() -> ParentHandler { @@ -239,7 +239,7 @@ pub async fn list(db: &DatabaseModel) -> Result, Er path: mount_info.path, username: mount_info.username, mountable: start_os.is_ok(), - start_os: start_os.ok().and_then(|a| a), + start_os: start_os.ok().unwrap_or_default(), }, )); } diff --git a/core/startos/src/backup/target/mod.rs b/core/startos/src/backup/target/mod.rs index f1273aa6a..f3b6b5f5c 100644 --- a/core/startos/src/backup/target/mod.rs +++ b/core/startos/src/backup/target/mod.rs @@ -157,6 +157,16 @@ pub fn target() -> ParentHandler { }) .with_call_remote::(), ) + .subcommand( + "mount", + from_fn_async(mount).with_call_remote::(), + ) + .subcommand( + "umount", + from_fn_async(umount) + .no_display() + .with_call_remote::(), + ) } // #[command(display(display_serializable))] @@ -250,6 +260,7 @@ fn display_backup_info(params: WithIoFormat, info: BackupInfo) { #[command(rename_all = "kebab-case")] pub struct InfoParams { target_id: BackupTargetId, + server_id: String, password: String, } @@ -258,11 +269,13 @@ pub async fn info( ctx: RpcContext, InfoParams { target_id, + server_id, password, }: InfoParams, ) -> Result { let guard = BackupMountGuard::mount( TmpMountGuard::mount(&target_id.load(&ctx.db.peek().await)?, ReadWrite).await?, + &server_id, &password, ) .await?; @@ -284,6 +297,7 @@ lazy_static::lazy_static! { #[command(rename_all = "kebab-case")] pub struct MountParams { target_id: BackupTargetId, + server_id: String, password: String, } @@ -292,6 +306,7 @@ pub async fn mount( ctx: RpcContext, MountParams { target_id, + server_id, password, }: MountParams, ) -> Result { @@ -303,6 +318,7 @@ pub async fn mount( let guard = BackupMountGuard::mount( TmpMountGuard::mount(&target_id.clone().load(&ctx.db.peek().await)?, ReadWrite).await?, + &server_id, &password, ) .await?; diff --git a/core/startos/src/disk/mod.rs b/core/startos/src/disk/mod.rs index d7ce5f766..c0a701fc9 100644 --- a/core/startos/src/disk/mod.rs +++ b/core/startos/src/disk/mod.rs @@ -1,5 +1,7 @@ use std::path::{Path, PathBuf}; +use itertools::Itertools; +use lazy_format::lazy_format; use rpc_toolkit::{from_fn_async, CallRemoteHandler, Context, Empty, HandlerExt, ParentHandler}; use serde::{Deserialize, Serialize}; @@ -102,10 +104,18 @@ fn display_disk_info(params: WithIoFormat, args: Vec) { } else { "N/A" }, - &if let Some(eos) = part.start_os.as_ref() { - eos.version.to_string() - } else { + &if part.start_os.is_empty() { "N/A".to_owned() + } else if part.start_os.len() == 1 { + part.start_os + .first_key_value() + .map(|(_, info)| info.version.to_string()) + .unwrap() + } else { + part.start_os + .iter() + .map(|(id, info)| lazy_format!("{} ({})", info.version, id)) + .join(", ") }, ]; table.add_row(row); diff --git a/core/startos/src/disk/mount/backup.rs b/core/startos/src/disk/mount/backup.rs index ad9f5090b..67f88d21f 100644 --- a/core/startos/src/disk/mount/backup.rs +++ b/core/startos/src/disk/mount/backup.rs @@ -11,9 +11,10 @@ use super::filesystem::ecryptfs::EcryptFS; use super::guard::{GenericMountGuard, TmpMountGuard}; use crate::auth::check_password; use crate::backup::target::BackupInfo; +use crate::disk::mount::filesystem::backupfs::BackupFS; use crate::disk::mount::filesystem::ReadWrite; use crate::disk::mount::guard::SubPath; -use crate::disk::util::EmbassyOsRecoveryInfo; +use crate::disk::util::StartOsRecoveryInfo; use crate::util::crypto::{decrypt_slice, encrypt_slice}; use crate::util::serde::IoFormat; use crate::{Error, ErrorKind, ResultExt}; @@ -23,29 +24,27 @@ pub struct BackupMountGuard { backup_disk_mount_guard: Option, encrypted_guard: Option, enc_key: String, - pub unencrypted_metadata: EmbassyOsRecoveryInfo, + unencrypted_metadata_path: PathBuf, + pub unencrypted_metadata: StartOsRecoveryInfo, pub metadata: BackupInfo, } impl BackupMountGuard { - fn backup_disk_path(&self) -> &Path { - if let Some(guard) = &self.backup_disk_mount_guard { - guard.path() - } else { - unreachable!() - } - } - #[instrument(skip_all)] - pub async fn mount(backup_disk_mount_guard: G, password: &str) -> Result { + pub async fn mount( + backup_disk_mount_guard: G, + server_id: &str, + password: &str, + ) -> Result { let backup_disk_path = backup_disk_mount_guard.path(); - let unencrypted_metadata_path = - backup_disk_path.join("EmbassyBackups/unencrypted-metadata.cbor"); - let mut unencrypted_metadata: EmbassyOsRecoveryInfo = + let backup_dir = backup_disk_path.join("StartOSBackups").join(server_id); + let unencrypted_metadata_path = backup_dir.join("unencrypted-metadata.json"); + let crypt_path = backup_dir.join("crypt"); + let mut unencrypted_metadata: StartOsRecoveryInfo = if tokio::fs::metadata(&unencrypted_metadata_path) .await .is_ok() { - IoFormat::Cbor.from_slice( + IoFormat::Json.from_slice( &tokio::fs::read(&unencrypted_metadata_path) .await .with_ctx(|_| { @@ -56,6 +55,9 @@ impl BackupMountGuard { })?, )? } else { + if tokio::fs::metadata(&crypt_path).await.is_ok() { + tokio::fs::remove_dir_all(&crypt_path).await?; + } Default::default() }; let enc_key = if let (Some(hash), Some(wrapped_key)) = ( @@ -96,7 +98,6 @@ impl BackupMountGuard { )); } - let crypt_path = backup_disk_path.join("EmbassyBackups/crypt"); if tokio::fs::metadata(&crypt_path).await.is_err() { tokio::fs::create_dir_all(&crypt_path).await.with_ctx(|_| { ( @@ -106,11 +107,11 @@ impl BackupMountGuard { })?; } let encrypted_guard = - TmpMountGuard::mount(&EcryptFS::new(&crypt_path, &enc_key), ReadWrite).await?; + TmpMountGuard::mount(&BackupFS::new(&crypt_path, &enc_key), ReadWrite).await?; - let metadata_path = encrypted_guard.path().join("metadata.cbor"); + let metadata_path = encrypted_guard.path().join("metadata.json"); let metadata: BackupInfo = if tokio::fs::metadata(&metadata_path).await.is_ok() { - IoFormat::Cbor.from_slice(&tokio::fs::read(&metadata_path).await.with_ctx(|_| { + IoFormat::Json.from_slice(&tokio::fs::read(&metadata_path).await.with_ctx(|_| { ( crate::ErrorKind::Filesystem, metadata_path.display().to_string(), @@ -124,6 +125,7 @@ impl BackupMountGuard { backup_disk_mount_guard: Some(backup_disk_mount_guard), encrypted_guard: Some(encrypted_guard), enc_key, + unencrypted_metadata_path, unencrypted_metadata, metadata, }) @@ -152,20 +154,17 @@ impl BackupMountGuard { #[instrument(skip_all)] pub async fn save(&self) -> Result<(), Error> { - let metadata_path = self.path().join("metadata.cbor"); - let backup_disk_path = self.backup_disk_path(); + let metadata_path = self.path().join("metadata.json"); let mut file = AtomicFile::new(&metadata_path, None::) .await .with_kind(ErrorKind::Filesystem)?; - file.write_all(&IoFormat::Cbor.to_vec(&self.metadata)?) + file.write_all(&IoFormat::Json.to_vec(&self.metadata)?) .await?; file.save().await.with_kind(ErrorKind::Filesystem)?; - let unencrypted_metadata_path = - backup_disk_path.join("EmbassyBackups/unencrypted-metadata.cbor"); - let mut file = AtomicFile::new(&unencrypted_metadata_path, None::) + let mut file = AtomicFile::new(&self.unencrypted_metadata_path, None::) .await .with_kind(ErrorKind::Filesystem)?; - file.write_all(&IoFormat::Cbor.to_vec(&self.unencrypted_metadata)?) + file.write_all(&IoFormat::Json.to_vec(&self.unencrypted_metadata)?) .await?; file.save().await.with_kind(ErrorKind::Filesystem)?; Ok(()) diff --git a/core/startos/src/disk/mount/filesystem/backupfs.rs b/core/startos/src/disk/mount/filesystem/backupfs.rs new file mode 100644 index 000000000..9ef258f34 --- /dev/null +++ b/core/startos/src/disk/mount/filesystem/backupfs.rs @@ -0,0 +1,55 @@ +use std::fmt::{self, Display}; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +use digest::generic_array::GenericArray; +use digest::{Digest, OutputSizeUser}; +use sha2::Sha256; + +use super::FileSystem; +use crate::prelude::*; + +pub struct BackupFS, Password: fmt::Display> { + data_dir: DataDir, + password: Password, +} +impl, Password: fmt::Display> BackupFS { + pub fn new(data_dir: DataDir, password: Password) -> Self { + BackupFS { data_dir, password } + } +} +impl + Send + Sync, Password: fmt::Display + Send + Sync> FileSystem + for BackupFS +{ + fn mount_type(&self) -> Option> { + Some("backup-fs") + } + fn mount_options(&self) -> impl IntoIterator { + [ + format!("password={}", self.password), + format!("file-size-padding=0.05"), + ] + } + async fn source(&self) -> Result>, Error> { + Ok(Some(&self.data_dir)) + } + async fn source_hash( + &self, + ) -> Result::OutputSize>, Error> { + let mut sha = Sha256::new(); + sha.update("BackupFS"); + sha.update( + tokio::fs::canonicalize(self.data_dir.as_ref()) + .await + .with_ctx(|_| { + ( + crate::ErrorKind::Filesystem, + self.data_dir.as_ref().display().to_string(), + ) + })? + .as_os_str() + .as_bytes(), + ); + Ok(sha.finalize()) + } +} diff --git a/core/startos/src/disk/mount/filesystem/mod.rs b/core/startos/src/disk/mount/filesystem/mod.rs index 53157937c..818549a0a 100644 --- a/core/startos/src/disk/mount/filesystem/mod.rs +++ b/core/startos/src/disk/mount/filesystem/mod.rs @@ -1,6 +1,7 @@ use std::ffi::OsStr; use std::fmt::{Display, Write}; use std::path::Path; +use std::time::Duration; use digest::generic_array::GenericArray; use digest::OutputSizeUser; @@ -11,6 +12,7 @@ use tokio::process::Command; use crate::prelude::*; use crate::util::Invoke; +pub mod backupfs; pub mod bind; pub mod block_dev; pub mod cifs; @@ -71,6 +73,7 @@ pub(self) async fn default_mount_impl( fs.pre_mount().await?; tokio::fs::create_dir_all(mountpoint.as_ref()).await?; Command::from(default_mount_command(fs, mountpoint, mount_type).await?) + .capture(false) .invoke(ErrorKind::Filesystem) .await?; diff --git a/core/startos/src/disk/util.rs b/core/startos/src/disk/util.rs index d3f2a8d27..c64ea40ae 100644 --- a/core/startos/src/disk/util.rs +++ b/core/startos/src/disk/util.rs @@ -1,6 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; +use chrono::{DateTime, Utc}; use color_eyre::eyre::{self, eyre}; use futures::TryStreamExt; use nom::bytes::complete::{tag, take_till1}; @@ -19,6 +20,7 @@ use super::mount::filesystem::ReadOnly; use super::mount::guard::TmpMountGuard; use crate::disk::mount::guard::GenericMountGuard; use crate::disk::OsPartitionInfo; +use crate::hostname::Hostname; use crate::util::serde::IoFormat; use crate::util::Invoke; use crate::{Error, ResultExt as _}; @@ -49,15 +51,16 @@ pub struct PartitionInfo { pub label: Option, pub capacity: u64, pub used: Option, - pub start_os: Option, + pub start_os: BTreeMap, pub guid: Option, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] -pub struct EmbassyOsRecoveryInfo { +pub struct StartOsRecoveryInfo { + pub hostname: Hostname, pub version: exver::Version, - pub full: bool, + pub timestamp: DateTime, pub password_hash: Option, pub wrapped_key: Option, } @@ -223,29 +226,38 @@ pub async fn pvscan() -> Result>, Error> { pub async fn recovery_info( mountpoint: impl AsRef, -) -> Result, Error> { - let backup_unencrypted_metadata_path = mountpoint - .as_ref() - .join("EmbassyBackups/unencrypted-metadata.cbor"); - if tokio::fs::metadata(&backup_unencrypted_metadata_path) - .await - .is_ok() - { - return Ok(Some( - IoFormat::Cbor.from_slice( - &tokio::fs::read(&backup_unencrypted_metadata_path) - .await - .with_ctx(|_| { - ( - crate::ErrorKind::Filesystem, - backup_unencrypted_metadata_path.display().to_string(), - ) - })?, - )?, - )); +) -> Result, Error> { + let backup_root = mountpoint.as_ref().join("StartOSBackups"); + let mut res = BTreeMap::new(); + if tokio::fs::metadata(&backup_root).await.is_ok() { + let mut dir = tokio::fs::read_dir(&backup_root).await?; + while let Some(entry) = dir.next_entry().await? { + let server_id = entry.file_name().to_string_lossy().into_owned(); + let backup_unencrypted_metadata_path = backup_root + .join(&server_id) + .join("unencrypted-metadata.json"); + if tokio::fs::metadata(&backup_unencrypted_metadata_path) + .await + .is_ok() + { + res.insert( + server_id, + IoFormat::Json.from_slice( + &tokio::fs::read(&backup_unencrypted_metadata_path) + .await + .with_ctx(|_| { + ( + crate::ErrorKind::Filesystem, + backup_unencrypted_metadata_path.display().to_string(), + ) + })?, + )?, + ); + } + } } - Ok(None) + Ok(res) } #[instrument(skip_all)] @@ -390,7 +402,7 @@ async fn disk_info(disk: PathBuf) -> DiskInfo { } async fn part_info(part: PathBuf) -> PartitionInfo { - let mut start_os = None; + let mut start_os = BTreeMap::new(); let label = get_label(&part) .await .map_err(|e| tracing::warn!("Could not get label of {}: {}", part.display(), e.source)) @@ -410,14 +422,13 @@ async fn part_info(part: PathBuf) -> PartitionInfo { tracing::warn!("Could not get usage of {}: {}", part.display(), e.source) }) .ok(); - if let Some(recovery_info) = match recovery_info(mount_guard.path()).await { - Ok(a) => a, + match recovery_info(mount_guard.path()).await { + Ok(a) => { + start_os = a; + } Err(e) => { tracing::error!("Error fetching unencrypted backup metadata: {}", e); - None } - } { - start_os = Some(recovery_info) } if let Err(e) = mount_guard.unmount().await { tracing::error!("Error unmounting partition {}: {}", part.display(), e); diff --git a/core/startos/src/hostname.rs b/core/startos/src/hostname.rs index f68d5c9d8..c4332354c 100644 --- a/core/startos/src/hostname.rs +++ b/core/startos/src/hostname.rs @@ -4,7 +4,7 @@ use tracing::instrument; use crate::util::Invoke; use crate::{Error, ErrorKind}; -#[derive(Clone, serde::Deserialize, serde::Serialize, Debug)] +#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)] pub struct Hostname(pub String); lazy_static::lazy_static! { diff --git a/core/startos/src/setup.rs b/core/startos/src/setup.rs index 8fb7cf7c1..642dd5476 100644 --- a/core/startos/src/setup.rs +++ b/core/startos/src/setup.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -25,7 +26,7 @@ use crate::disk::main::DEFAULT_PASSWORD; use crate::disk::mount::filesystem::cifs::Cifs; use crate::disk::mount::filesystem::ReadWrite; use crate::disk::mount::guard::{GenericMountGuard, TmpMountGuard}; -use crate::disk::util::{pvscan, recovery_info, DiskInfo, EmbassyOsRecoveryInfo}; +use crate::disk::util::{pvscan, recovery_info, DiskInfo, StartOsRecoveryInfo}; use crate::disk::REPAIR_DISK_PATH; use crate::init::{init, InitPhases, InitResult}; use crate::net::net_controller::PreInitNetController; @@ -237,7 +238,7 @@ pub async fn verify_cifs( username, password, }: VerifyCifsParams, -) -> Result { +) -> Result, Error> { let password: Option = password.map(|x| x.decrypt(&ctx)).flatten(); let guard = TmpMountGuard::mount( &Cifs { @@ -251,15 +252,28 @@ pub async fn verify_cifs( .await?; let start_os = recovery_info(guard.path()).await?; guard.unmount().await?; - start_os.ok_or_else(|| Error::new(eyre!("No Backup Found"), crate::ErrorKind::NotFound)) + if start_os.is_empty() { + return Err(Error::new( + eyre!("No Backup Found"), + crate::ErrorKind::NotFound, + )); + } + Ok(start_os) } #[derive(Debug, Deserialize, Serialize, TS)] #[serde(tag = "type")] #[serde(rename_all = "camelCase")] -pub enum RecoverySource { - Migrate { guid: String }, - Backup { target: BackupTargetFS }, +#[serde(rename_all_fields = "camelCase")] +pub enum RecoverySource { + Migrate { + guid: String, + }, + Backup { + target: BackupTargetFS, + password: Password, + server_id: String, + }, } #[derive(Deserialize, Serialize, TS)] @@ -268,8 +282,7 @@ pub enum RecoverySource { pub struct SetupExecuteParams { start_os_logicalname: PathBuf, start_os_password: EncryptedWire, - recovery_source: Option, - recovery_password: Option, + recovery_source: Option>, } // #[command(rpc_only)] @@ -279,7 +292,6 @@ pub async fn execute( start_os_logicalname, start_os_password, recovery_source, - recovery_password, }: SetupExecuteParams, ) -> Result { let start_os_password = match start_os_password.decrypt(&ctx) { @@ -291,29 +303,27 @@ pub async fn execute( )) } }; - let recovery_password: Option = match recovery_password { - Some(a) => match a.decrypt(&ctx) { - Some(a) => Some(a), - None => { - return Err(Error::new( + let recovery = match recovery_source { + Some(RecoverySource::Backup { + target, + password, + server_id, + }) => Some(RecoverySource::Backup { + target, + password: password.decrypt(&ctx).ok_or_else(|| { + Error::new( color_eyre::eyre::eyre!("Couldn't decode recoveryPassword"), crate::ErrorKind::Unknown, - )) - } - }, + ) + })?, + server_id, + }), + Some(RecoverySource::Migrate { guid }) => Some(RecoverySource::Migrate { guid }), None => None, }; let setup_ctx = ctx.clone(); - ctx.run_setup(|| { - execute_inner( - setup_ctx, - start_os_logicalname, - start_os_password, - recovery_source, - recovery_password, - ) - })?; + ctx.run_setup(|| execute_inner(setup_ctx, start_os_logicalname, start_os_password, recovery))?; Ok(ctx.progress().await) } @@ -348,12 +358,11 @@ pub async fn execute_inner( ctx: SetupContext, start_os_logicalname: PathBuf, start_os_password: String, - recovery_source: Option, - recovery_password: Option, + recovery_source: Option>, ) -> Result<(SetupResult, RpcContext), Error> { let progress = &ctx.progress; let mut disk_phase = progress.add_phase("Formatting data drive".into(), Some(10)); - let restore_phase = match &recovery_source { + let restore_phase = match recovery_source.as_ref() { Some(RecoverySource::Backup { .. }) => { Some(progress.add_phase("Restoring backup".into(), Some(100))) } @@ -396,13 +405,18 @@ pub async fn execute_inner( }; match recovery_source { - Some(RecoverySource::Backup { target }) => { + Some(RecoverySource::Backup { + target, + password, + server_id, + }) => { recover( &ctx, guid, start_os_password, target, - recovery_password, + server_id, + password, progress, ) .await @@ -448,7 +462,8 @@ async fn recover( guid: Arc, start_os_password: String, recovery_source: BackupTargetFS, - recovery_password: Option, + server_id: String, + recovery_password: String, progress: SetupExecuteProgress, ) -> Result<(SetupResult, RpcContext), Error> { let recovery_source = TmpMountGuard::mount(&recovery_source, ReadWrite).await?; @@ -457,7 +472,8 @@ async fn recover( guid.clone(), start_os_password, recovery_source, - recovery_password, + &server_id, + &recovery_password, progress, ) .await diff --git a/core/startos/src/util/serde.rs b/core/startos/src/util/serde.rs index b3b2dea6a..049fad2d2 100644 --- a/core/startos/src/util/serde.rs +++ b/core/startos/src/util/serde.rs @@ -107,64 +107,22 @@ pub fn serialize_display_opt( Option::::serialize(&t.as_ref().map(|t| t.to_string()), serializer) } -pub mod ed25519_pubkey { - use ed25519_dalek::VerifyingKey; - use serde::de::{Error, Unexpected, Visitor}; - use serde::{Deserializer, Serializer}; - - pub fn serialize( - pubkey: &VerifyingKey, - serializer: S, - ) -> Result { - serializer.serialize_str(&base32::encode( - base32::Alphabet::RFC4648 { padding: true }, - pubkey.as_bytes(), - )) - } - pub fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result { - struct PubkeyVisitor; - impl<'de> Visitor<'de> for PubkeyVisitor { - type Value = ed25519_dalek::VerifyingKey; - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "an RFC4648 encoded string") - } - fn visit_str(self, v: &str) -> Result - where - E: Error, - { - VerifyingKey::from_bytes( - &<[u8; 32]>::try_from( - base32::decode(base32::Alphabet::RFC4648 { padding: true }, v).ok_or( - Error::invalid_value(Unexpected::Str(v), &"an RFC4648 encoded string"), - )?, - ) - .map_err(|e| Error::invalid_length(e.len(), &"32 bytes"))?, - ) - .map_err(Error::custom) - } - } - deserializer.deserialize_str(PubkeyVisitor) - } -} - #[derive(Debug, Serialize)] #[serde(untagged)] -pub enum ValuePrimative { +pub enum ValuePrimitive { Null, Boolean(bool), String(String), Number(serde_json::Number), } -impl<'de> serde::de::Deserialize<'de> for ValuePrimative { +impl<'de> serde::de::Deserialize<'de> for ValuePrimitive { fn deserialize(deserializer: D) -> Result where D: serde::de::Deserializer<'de>, { struct Visitor; impl<'de> serde::de::Visitor<'de> for Visitor { - type Value = ValuePrimative; + type Value = ValuePrimitive; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { write!(formatter, "a JSON primative value") } @@ -172,37 +130,37 @@ impl<'de> serde::de::Deserialize<'de> for ValuePrimative { where E: serde::de::Error, { - Ok(ValuePrimative::Null) + Ok(ValuePrimitive::Null) } fn visit_none(self) -> Result where E: serde::de::Error, { - Ok(ValuePrimative::Null) + Ok(ValuePrimitive::Null) } fn visit_bool(self, v: bool) -> Result where E: serde::de::Error, { - Ok(ValuePrimative::Boolean(v)) + Ok(ValuePrimitive::Boolean(v)) } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { - Ok(ValuePrimative::String(v.to_owned())) + Ok(ValuePrimitive::String(v.to_owned())) } fn visit_string(self, v: String) -> Result where E: serde::de::Error, { - Ok(ValuePrimative::String(v)) + Ok(ValuePrimitive::String(v)) } fn visit_f32(self, v: f32) -> Result where E: serde::de::Error, { - Ok(ValuePrimative::Number( + Ok(ValuePrimitive::Number( serde_json::Number::from_f64(v as f64).ok_or_else(|| { serde::de::Error::invalid_value( serde::de::Unexpected::Float(v as f64), @@ -215,7 +173,7 @@ impl<'de> serde::de::Deserialize<'de> for ValuePrimative { where E: serde::de::Error, { - Ok(ValuePrimative::Number( + Ok(ValuePrimitive::Number( serde_json::Number::from_f64(v).ok_or_else(|| { serde::de::Error::invalid_value( serde::de::Unexpected::Float(v), @@ -228,49 +186,49 @@ impl<'de> serde::de::Deserialize<'de> for ValuePrimative { where E: serde::de::Error, { - Ok(ValuePrimative::Number(v.into())) + Ok(ValuePrimitive::Number(v.into())) } fn visit_u16(self, v: u16) -> Result where E: serde::de::Error, { - Ok(ValuePrimative::Number(v.into())) + Ok(ValuePrimitive::Number(v.into())) } fn visit_u32(self, v: u32) -> Result where E: serde::de::Error, { - Ok(ValuePrimative::Number(v.into())) + Ok(ValuePrimitive::Number(v.into())) } fn visit_u64(self, v: u64) -> Result where E: serde::de::Error, { - Ok(ValuePrimative::Number(v.into())) + Ok(ValuePrimitive::Number(v.into())) } fn visit_i8(self, v: i8) -> Result where E: serde::de::Error, { - Ok(ValuePrimative::Number(v.into())) + Ok(ValuePrimitive::Number(v.into())) } fn visit_i16(self, v: i16) -> Result where E: serde::de::Error, { - Ok(ValuePrimative::Number(v.into())) + Ok(ValuePrimitive::Number(v.into())) } fn visit_i32(self, v: i32) -> Result where E: serde::de::Error, { - Ok(ValuePrimative::Number(v.into())) + Ok(ValuePrimitive::Number(v.into())) } fn visit_i64(self, v: i64) -> Result where E: serde::de::Error, { - Ok(ValuePrimative::Number(v.into())) + Ok(ValuePrimitive::Number(v.into())) } } deserializer.deserialize_any(Visitor) diff --git a/foo b/foo deleted file mode 120000 index 5cea28a80..000000000 --- a/foo +++ /dev/null @@ -1 +0,0 @@ -firmware/x86_64/foo.romr.gz \ No newline at end of file diff --git a/sdk/lib/osBindings/RecoverySource.ts b/sdk/lib/osBindings/RecoverySource.ts index c40ec5132..40061f215 100644 --- a/sdk/lib/osBindings/RecoverySource.ts +++ b/sdk/lib/osBindings/RecoverySource.ts @@ -1,6 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { BackupTargetFS } from "./BackupTargetFS" -export type RecoverySource = +export type RecoverySource = | { type: "migrate"; guid: string } - | { type: "backup"; target: BackupTargetFS } + | { + type: "backup" + target: BackupTargetFS + password: Password + serverId: string + } diff --git a/sdk/lib/osBindings/SetupExecuteParams.ts b/sdk/lib/osBindings/SetupExecuteParams.ts index 4593e7667..17c35c346 100644 --- a/sdk/lib/osBindings/SetupExecuteParams.ts +++ b/sdk/lib/osBindings/SetupExecuteParams.ts @@ -5,6 +5,5 @@ import type { RecoverySource } from "./RecoverySource" export type SetupExecuteParams = { startOsLogicalname: string startOsPassword: EncryptedWire - recoverySource: RecoverySource | null - recoveryPassword: EncryptedWire | null + recoverySource: RecoverySource | null } diff --git a/web/projects/install-wizard/src/app/services/api/mock-api.service.ts b/web/projects/install-wizard/src/app/services/api/mock-api.service.ts index 5be394c67..6b94f18db 100644 --- a/web/projects/install-wizard/src/app/services/api/mock-api.service.ts +++ b/web/projects/install-wizard/src/app/services/api/mock-api.service.ts @@ -18,11 +18,14 @@ export class MockApiService implements ApiService { capacity: 73264762332, used: null, startOs: { - version: '0.2.17', - full: true, - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, + '1234-5678-9876-5432': { + hostname: 'adjective-noun', + timestamp: new Date().toISOString(), + version: '0.2.17', + passwordHash: + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: null, + }, }, guid: null, }, @@ -41,11 +44,14 @@ export class MockApiService implements ApiService { capacity: 73264762332, used: null, startOs: { - version: '0.3.3', - full: true, - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, + '1234-5678-9876-5432': { + hostname: 'adjective-noun', + timestamp: new Date().toISOString(), + version: '0.2.17', + passwordHash: + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: null, + }, }, guid: null, }, @@ -64,11 +70,14 @@ export class MockApiService implements ApiService { capacity: 73264762332, used: null, startOs: { - version: '0.3.2', - full: true, - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, + '1234-5678-9876-5432': { + hostname: 'adjective-noun', + timestamp: new Date().toISOString(), + version: '0.2.17', + passwordHash: + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: null, + }, }, guid: 'guid-guid-guid-guid', }, diff --git a/web/projects/setup-wizard/src/app/modals/cifs-modal/cifs-modal.module.ts b/web/projects/setup-wizard/src/app/modals/cifs-modal/cifs-modal.module.ts index b5c07d37c..78c09de0e 100644 --- a/web/projects/setup-wizard/src/app/modals/cifs-modal/cifs-modal.module.ts +++ b/web/projects/setup-wizard/src/app/modals/cifs-modal/cifs-modal.module.ts @@ -3,10 +3,11 @@ import { CommonModule } from '@angular/common' import { IonicModule } from '@ionic/angular' import { FormsModule } from '@angular/forms' import { CifsModal } from './cifs-modal.page' +import { ServerBackupSelectModule } from '../server-backup-select/server-backup-select.module' @NgModule({ declarations: [CifsModal], - imports: [CommonModule, FormsModule, IonicModule], + imports: [CommonModule, FormsModule, IonicModule, ServerBackupSelectModule], exports: [CifsModal], }) export class CifsModalModule {} diff --git a/web/projects/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.ts b/web/projects/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.ts index 0664be38f..f6ce0e5bc 100644 --- a/web/projects/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.ts +++ b/web/projects/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.ts @@ -4,9 +4,9 @@ import { LoadingController, ModalController, } from '@ionic/angular' -import { ApiService, CifsBackupTarget } from 'src/app/services/api/api.service' +import { ApiService } from 'src/app/services/api/api.service' import { StartOSDiskInfo } from '@start9labs/shared' -import { PasswordPage } from '../password/password.page' +import { ServerBackupSelectModal } from '../server-backup-select/server-backup-select.page' @Component({ selector: 'cifs-modal', @@ -50,30 +50,29 @@ export class CifsModal { await loader.dismiss() - this.presentModalPassword(diskInfo) + this.presentModalSelectServer(diskInfo) } catch (e) { await loader.dismiss() this.presentAlertFailed() } } - private async presentModalPassword(diskInfo: StartOSDiskInfo): Promise { - const target: CifsBackupTarget = { - ...this.cifs, - mountable: true, - startOs: diskInfo, - } - + private async presentModalSelectServer( + servers: Record, + ): Promise { const modal = await this.modalController.create({ - component: PasswordPage, - componentProps: { target }, + component: ServerBackupSelectModal, + componentProps: { + servers: Object.keys(servers).map(id => ({ id, ...servers[id] })), + }, }) modal.onDidDismiss().then(res => { if (res.role === 'success') { this.modalController.dismiss( { cifs: this.cifs, - recoveryPassword: res.data.password, + serverId: res.data.serverId, + recoveryPassword: res.data.recoveryPassword, }, 'success', ) diff --git a/web/projects/setup-wizard/src/app/modals/password/password.page.ts b/web/projects/setup-wizard/src/app/modals/password/password.page.ts index 8ea00ce0c..f99607002 100644 --- a/web/projects/setup-wizard/src/app/modals/password/password.page.ts +++ b/web/projects/setup-wizard/src/app/modals/password/password.page.ts @@ -1,9 +1,5 @@ import { Component, Input, ViewChild } from '@angular/core' import { IonInput, ModalController } from '@ionic/angular' -import { - CifsBackupTarget, - DiskBackupTarget, -} from 'src/app/services/api/api.service' import * as argon2 from '@start9labs/argon2' @Component({ @@ -13,7 +9,7 @@ import * as argon2 from '@start9labs/argon2' }) export class PasswordPage { @ViewChild('focusInput') elem?: IonInput - @Input() target?: CifsBackupTarget | DiskBackupTarget + @Input() passwordHash = '' @Input() storageDrive = false pwError = '' @@ -31,13 +27,8 @@ export class PasswordPage { } async verifyPw() { - if (!this.target || !this.target.startOs) - this.pwError = 'No recovery target' // unreachable - try { - const passwordHash = this.target!.startOs?.passwordHash || '' - - argon2.verify(passwordHash, this.password) + argon2.verify(this.passwordHash, this.password) this.modalController.dismiss({ password: this.password }, 'success') } catch (e) { this.pwError = 'Incorrect password provided' @@ -55,7 +46,7 @@ export class PasswordPage { } validate() { - if (!!this.target) return (this.pwError = '') + if (!!this.passwordHash) return (this.pwError = '') if (this.passwordVer) { this.checkVer() diff --git a/web/projects/setup-wizard/src/app/modals/server-backup-select/server-backup-select.module.ts b/web/projects/setup-wizard/src/app/modals/server-backup-select/server-backup-select.module.ts new file mode 100644 index 000000000..8305cd2a6 --- /dev/null +++ b/web/projects/setup-wizard/src/app/modals/server-backup-select/server-backup-select.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core' +import { CommonModule } from '@angular/common' +import { IonicModule } from '@ionic/angular' +import { FormsModule } from '@angular/forms' +import { ServerBackupSelectModal } from './server-backup-select.page' +import { PasswordPageModule } from '../password/password.module' + +@NgModule({ + declarations: [ServerBackupSelectModal], + imports: [CommonModule, FormsModule, IonicModule, PasswordPageModule], + exports: [ServerBackupSelectModal], +}) +export class ServerBackupSelectModule {} diff --git a/web/projects/setup-wizard/src/app/modals/server-backup-select/server-backup-select.page.html b/web/projects/setup-wizard/src/app/modals/server-backup-select/server-backup-select.page.html new file mode 100644 index 000000000..37736d78e --- /dev/null +++ b/web/projects/setup-wizard/src/app/modals/server-backup-select/server-backup-select.page.html @@ -0,0 +1,24 @@ + + + Select Server to Restore + + + + + + +

+ Local Hostname + : {{ server.hostname }}.local +

+

+ StartOS Version + : {{ server.version }} +

+

+ Created + : {{ server.timestamp | date : 'medium' }} +

+
+
+
diff --git a/web/projects/setup-wizard/src/app/modals/server-backup-select/server-backup-select.page.scss b/web/projects/setup-wizard/src/app/modals/server-backup-select/server-backup-select.page.scss new file mode 100644 index 000000000..e69de29bb diff --git a/web/projects/setup-wizard/src/app/modals/server-backup-select/server-backup-select.page.ts b/web/projects/setup-wizard/src/app/modals/server-backup-select/server-backup-select.page.ts new file mode 100644 index 000000000..9b0d8cf8b --- /dev/null +++ b/web/projects/setup-wizard/src/app/modals/server-backup-select/server-backup-select.page.ts @@ -0,0 +1,44 @@ +import { Component, Input } from '@angular/core' +import { ModalController } from '@ionic/angular' +import { StartOSDiskInfoWithId } from 'src/app/services/api/api.service' +import { PasswordPage } from '../password/password.page' + +@Component({ + selector: 'server-backup-select', + templateUrl: 'server-backup-select.page.html', + styleUrls: ['server-backup-select.page.scss'], +}) +export class ServerBackupSelectModal { + @Input() servers: StartOSDiskInfoWithId[] = [] + + constructor(private readonly modalController: ModalController) {} + + cancel() { + this.modalController.dismiss() + } + + async select(server: StartOSDiskInfoWithId): Promise { + this.presentModalPassword(server) + } + + private async presentModalPassword( + server: StartOSDiskInfoWithId, + ): Promise { + const modal = await this.modalController.create({ + component: PasswordPage, + componentProps: { passwordHash: server.passwordHash }, + }) + modal.onDidDismiss().then(res => { + if (res.role === 'success') { + this.modalController.dismiss( + { + serverId: server.id, + recoveryPassword: res.data.password, + }, + 'success', + ) + } + }) + await modal.present() + } +} diff --git a/web/projects/setup-wizard/src/app/pages/embassy/embassy.page.ts b/web/projects/setup-wizard/src/app/pages/embassy/embassy.page.ts index 110768be5..2961daf63 100644 --- a/web/projects/setup-wizard/src/app/pages/embassy/embassy.page.ts +++ b/web/projects/setup-wizard/src/app/pages/embassy/embassy.page.ts @@ -9,6 +9,7 @@ import { DiskInfo, ErrorService, GuidPipe } from '@start9labs/shared' import { ApiService } from 'src/app/services/api/api.service' import { StateService } from 'src/app/services/state.service' import { PasswordPage } from '../../modals/password/password.page' +import { T } from '@start9labs/start-sdk' @Component({ selector: 'app-embassy', @@ -50,15 +51,19 @@ export class EmbassyPage { const disks = await this.apiService.getDrives() if (this.stateService.setupType === 'fresh') { this.storageDrives = disks - } else if (this.stateService.setupType === 'restore') { - this.storageDrives = disks.filter( - d => - this.stateService.recoverySource?.type === 'backup' && - this.stateService.recoverySource.target?.type === 'disk' && - !d.partitions - .map(p => p.logicalname) - .includes(this.stateService.recoverySource.target.logicalname), - ) + } else if ( + this.stateService.setupType === 'restore' && + this.stateService.recoverySource?.type === 'backup' + ) { + if (this.stateService.recoverySource.target.type === 'disk') { + const logicalname = + this.stateService.recoverySource.target.logicalname + this.storageDrives = disks.filter( + d => !d.partitions.map(p => p.logicalname).includes(logicalname), + ) + } else { + this.storageDrives = disks + } } else if ( this.stateService.setupType === 'transfer' && this.stateService.recoverySource?.type === 'migrate' @@ -95,10 +100,10 @@ export class EmbassyPage { text: 'Continue', handler: () => { // for backup recoveries - if (this.stateService.recoveryPassword) { + if (this.stateService.recoverySource?.type === 'backup') { this.setupEmbassy( drive.logicalname, - this.stateService.recoveryPassword, + this.stateService.recoverySource.password, ) } else { // for migrations and fresh setups @@ -111,8 +116,11 @@ export class EmbassyPage { await alert.present() } else { // for backup recoveries - if (this.stateService.recoveryPassword) { - this.setupEmbassy(drive.logicalname, this.stateService.recoveryPassword) + if (this.stateService.recoverySource?.type === 'backup') { + this.setupEmbassy( + drive.logicalname, + this.stateService.recoverySource.password, + ) } else { // for migrations and fresh setups this.presentModalPassword(drive.logicalname) @@ -154,3 +162,7 @@ export class EmbassyPage { } } } + +function isDiskRecovery(source: T.RecoverySource): source is any { + return source.type === 'backup' && source.target.type === 'disk' +} diff --git a/web/projects/setup-wizard/src/app/pages/recover/drive-status.component.html b/web/projects/setup-wizard/src/app/pages/recover/drive-status.component.html deleted file mode 100644 index 7f4a4e5bd..000000000 --- a/web/projects/setup-wizard/src/app/pages/recover/drive-status.component.html +++ /dev/null @@ -1,14 +0,0 @@ -
- -

- - StartOS backup detected -

- - -

- - No StartOS backup -

-
-
diff --git a/web/projects/setup-wizard/src/app/pages/recover/recover.module.ts b/web/projects/setup-wizard/src/app/pages/recover/recover.module.ts index eaf04f506..7ecda048a 100644 --- a/web/projects/setup-wizard/src/app/pages/recover/recover.module.ts +++ b/web/projects/setup-wizard/src/app/pages/recover/recover.module.ts @@ -3,13 +3,13 @@ import { CommonModule } from '@angular/common' import { IonicModule } from '@ionic/angular' import { FormsModule } from '@angular/forms' import { UnitConversionPipesModule } from '@start9labs/shared' -import { DriveStatusComponent, RecoverPage } from './recover.page' +import { RecoverPage } from './recover.page' import { PasswordPageModule } from '../../modals/password/password.module' import { RecoverPageRoutingModule } from './recover-routing.module' import { CifsModalModule } from 'src/app/modals/cifs-modal/cifs-modal.module' @NgModule({ - declarations: [RecoverPage, DriveStatusComponent], + declarations: [RecoverPage], imports: [ CommonModule, FormsModule, diff --git a/web/projects/setup-wizard/src/app/pages/recover/recover.page.html b/web/projects/setup-wizard/src/app/pages/recover/recover.page.html index 32f71a3ad..8b92b36d3 100644 --- a/web/projects/setup-wizard/src/app/pages/recover/recover.page.html +++ b/web/projects/setup-wizard/src/app/pages/recover/recover.page.html @@ -54,29 +54,21 @@ - - - + + -

{{ drive.label || drive.logicalname }}

- -

- {{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model || - 'Unknown Model' }} -

-

Capacity: {{ drive.capacity | convertBytes }}

+

+ Local Hostname + : {{ server.hostname }}.local +

+

+ StartOS Version + : {{ server.version }} +

+

+ Created + : {{ server.timestamp | date : 'medium' }} +

diff --git a/web/projects/setup-wizard/src/app/pages/recover/recover.page.ts b/web/projects/setup-wizard/src/app/pages/recover/recover.page.ts index d47105f24..dd1dae9ce 100644 --- a/web/projects/setup-wizard/src/app/pages/recover/recover.page.ts +++ b/web/projects/setup-wizard/src/app/pages/recover/recover.page.ts @@ -1,8 +1,11 @@ -import { Component, Input } from '@angular/core' +import { Component } from '@angular/core' import { ModalController, NavController } from '@ionic/angular' import { ErrorService } from '@start9labs/shared' import { CifsModal } from 'src/app/modals/cifs-modal/cifs-modal.page' -import { ApiService, DiskBackupTarget } from 'src/app/services/api/api.service' +import { + ApiService, + StartOSDiskInfoWithId, +} from 'src/app/services/api/api.service' import { StateService } from 'src/app/services/state.service' import { PasswordPage } from '../../modals/password/password.page' @@ -13,7 +16,7 @@ import { PasswordPage } from '../../modals/password/password.page' }) export class RecoverPage { loading = true - mappedDrives: MappedDisk[] = [] + servers: StartOSDiskInfoWithId[] = [] constructor( private readonly apiService: ApiService, @@ -34,33 +37,19 @@ export class RecoverPage { await this.getDrives() } - driveClickable(mapped: MappedDisk) { - return mapped.drive.startOs?.full - } - async getDrives() { - this.mappedDrives = [] try { - const disks = await this.apiService.getDrives() - disks - .filter(d => d.partitions.length) - .forEach(d => { - d.partitions.forEach(p => { - const drive: DiskBackupTarget = { - vendor: d.vendor, - model: d.model, - logicalname: p.logicalname, - label: p.label, - capacity: p.capacity, - used: p.used, - startOs: p.startOs, - } - this.mappedDrives.push({ - hasValidBackup: !!p.startOs?.full, - drive, - }) - }) - }) + const drives = await this.apiService.getDrives() + this.servers = drives.flatMap(drive => + drive.partitions.flatMap(partition => + Object.entries(partition.startOs).map(([id, val]) => ({ + id, + ...val, + partition, + drive, + })), + ), + ) } catch (e: any) { this.errorService.handleError(e) } finally { @@ -74,65 +63,41 @@ export class RecoverPage { }) modal.onDidDismiss().then(res => { if (res.role === 'success') { - const { hostname, path, username, password } = res.data.cifs this.stateService.recoverySource = { type: 'backup', target: { type: 'cifs', - hostname, - path, - username, - password, + ...res.data.cifs, }, + serverId: res.data.serverId, + password: res.data.recoveryPassword, } - this.stateService.recoveryPassword = res.data.recoveryPassword this.navCtrl.navigateForward('/storage') } }) await modal.present() } - async select(target: DiskBackupTarget) { - const { logicalname } = target - - if (!logicalname) return - + async select(server: StartOSDiskInfoWithId) { const modal = await this.modalController.create({ component: PasswordPage, - componentProps: { target }, + componentProps: { passwordHash: server.passwordHash }, cssClass: 'alertlike-modal', }) modal.onDidDismiss().then(res => { - if (res.data?.password) { - this.selectRecoverySource(logicalname, res.data.password) + if (res.role === 'success') { + this.stateService.recoverySource = { + type: 'backup', + target: { + type: 'disk', + logicalname: res.data.logicalname, + }, + serverId: server.id, + password: res.data.password, + } + this.navCtrl.navigateForward(`/storage`) } }) await modal.present() } - - private async selectRecoverySource(logicalname: string, password?: string) { - this.stateService.recoverySource = { - type: 'backup', - target: { - type: 'disk', - logicalname, - }, - } - this.stateService.recoveryPassword = password - this.navCtrl.navigateForward(`/storage`) - } -} - -@Component({ - selector: 'drive-status', - templateUrl: './drive-status.component.html', - styleUrls: ['./recover.page.scss'], -}) -export class DriveStatusComponent { - @Input() hasValidBackup!: boolean -} - -interface MappedDisk { - hasValidBackup: boolean - drive: DiskBackupTarget } diff --git a/web/projects/setup-wizard/src/app/pages/success/success.module.ts b/web/projects/setup-wizard/src/app/pages/success/success.module.ts index c0a7a0ec2..7abb5d2d8 100644 --- a/web/projects/setup-wizard/src/app/pages/success/success.module.ts +++ b/web/projects/setup-wizard/src/app/pages/success/success.module.ts @@ -3,7 +3,6 @@ import { CommonModule } from '@angular/common' import { IonicModule } from '@ionic/angular' import { FormsModule } from '@angular/forms' import { ResponsiveColModule } from '@start9labs/shared' - import { SuccessPage } from './success.page' import { PasswordPageModule } from '../../modals/password/password.module' import { SuccessPageRoutingModule } from './success-routing.module' diff --git a/web/projects/setup-wizard/src/app/pages/success/success.page.ts b/web/projects/setup-wizard/src/app/pages/success/success.page.ts index 88262bdf2..dab0b44a6 100644 --- a/web/projects/setup-wizard/src/app/pages/success/success.page.ts +++ b/web/projects/setup-wizard/src/app/pages/success/success.page.ts @@ -8,7 +8,6 @@ import { StateService } from 'src/app/services/state.service' selector: 'success', templateUrl: 'success.page.html', styleUrls: ['success.page.scss'], - providers: [DownloadHTMLService], }) export class SuccessPage { @ViewChild('canvas', { static: true }) diff --git a/web/projects/setup-wizard/src/app/services/api/api.service.ts b/web/projects/setup-wizard/src/app/services/api/api.service.ts index 6719ce859..882d656ae 100644 --- a/web/projects/setup-wizard/src/app/services/api/api.service.ts +++ b/web/projects/setup-wizard/src/app/services/api/api.service.ts @@ -1,5 +1,10 @@ import * as jose from 'node-jose' -import { DiskListResponse, StartOSDiskInfo } from '@start9labs/shared' +import { + DiskInfo, + DiskListResponse, + PartitionInfo, + StartOSDiskInfo, +} from '@start9labs/shared' import { T } from '@start9labs/start-sdk' import { WebSocketSubjectConfig } from 'rxjs/webSocket' import { Observable } from 'rxjs' @@ -10,14 +15,16 @@ export abstract class ApiService { abstract getStatus(): Promise // setup.status abstract getPubKey(): Promise // setup.get-pubkey abstract getDrives(): Promise // setup.disk.list - abstract verifyCifs(cifs: T.VerifyCifsParams): Promise // setup.cifs.verify + abstract verifyCifs( + cifs: T.VerifyCifsParams, + ): Promise> // setup.cifs.verify abstract attach(importInfo: T.AttachParams): Promise // setup.attach abstract execute(setupInfo: T.SetupExecuteParams): Promise // setup.execute abstract complete(): Promise // setup.complete abstract exit(): Promise // setup.exit abstract openProgressWebsocket$(guid: string): Observable - async encrypt(toEncrypt: string): Promise { + async encrypt(toEncrypt: string): Promise { if (!this.pubkey) throw new Error('No pubkey found!') const encrypted = await jose.JWE.createEncrypt(this.pubkey!) .update(toEncrypt) @@ -28,26 +35,13 @@ export abstract class ApiService { } } -type Encrypted = { - encrypted: string -} - export type WebsocketConfig = Omit, 'url'> -export type DiskBackupTarget = { - vendor: string | null - model: string | null - logicalname: string | null - label: string | null - capacity: number - used: number | null - startOs: StartOSDiskInfo | null +export type StartOSDiskInfoWithId = StartOSDiskInfo & { + id: string } -export type CifsBackupTarget = { - hostname: string - path: string - username: string - mountable: boolean - startOs: StartOSDiskInfo | null +export type StartOSDiskInfoFull = StartOSDiskInfoWithId & { + partition: PartitionInfo + drive: DiskInfo } diff --git a/web/projects/setup-wizard/src/app/services/api/live-api.service.ts b/web/projects/setup-wizard/src/app/services/api/live-api.service.ts index f431f5151..0245bf554 100644 --- a/web/projects/setup-wizard/src/app/services/api/live-api.service.ts +++ b/web/projects/setup-wizard/src/app/services/api/live-api.service.ts @@ -9,7 +9,7 @@ import { RPCOptions, } from '@start9labs/shared' import { T } from '@start9labs/start-sdk' -import { ApiService, WebsocketConfig } from './api.service' +import { ApiService } from './api.service' import * as jose from 'node-jose' import { Observable } from 'rxjs' import { DOCUMENT } from '@angular/common' @@ -65,9 +65,11 @@ export class LiveApiService extends ApiService { }) } - async verifyCifs(source: T.VerifyCifsParams): Promise { + async verifyCifs( + source: T.VerifyCifsParams, + ): Promise> { source.path = source.path.replace('/\\/g', '/') - return this.rpcRequest({ + return this.rpcRequest>({ method: 'setup.cifs.verify', params: source, }) diff --git a/web/projects/setup-wizard/src/app/services/api/mock-api.service.ts b/web/projects/setup-wizard/src/app/services/api/mock-api.service.ts index 0a1c221f7..a5757b010 100644 --- a/web/projects/setup-wizard/src/app/services/api/mock-api.service.ts +++ b/web/projects/setup-wizard/src/app/services/api/mock-api.service.ts @@ -175,11 +175,14 @@ export class MockApiService extends ApiService { capacity: 1979120929996, used: null, startOs: { - version: '0.2.17', - full: true, - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, + '1234-5678-9876-5432': { + hostname: 'adjective-noun', + version: '0.2.17', + timestamp: new Date().toISOString(), + passwordHash: + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: null, + }, }, guid: null, }, @@ -198,11 +201,14 @@ export class MockApiService extends ApiService { capacity: 73264762332, used: null, startOs: { - version: '0.3.3', - full: true, - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, + '1234-5678-9876-5432': { + hostname: 'adjective-noun', + version: '0.2.17', + timestamp: new Date().toISOString(), + passwordHash: + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: null, + }, }, guid: null, }, @@ -221,11 +227,14 @@ export class MockApiService extends ApiService { capacity: 73264762332, used: null, startOs: { - version: '0.3.2', - full: true, - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: null, + '1234-5678-9876-5432': { + hostname: 'adjective-noun', + version: '0.2.17', + timestamp: new Date().toISOString(), + passwordHash: + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: null, + }, }, guid: 'guid-guid-guid-guid', }, @@ -236,14 +245,19 @@ export class MockApiService extends ApiService { ] } - async verifyCifs(params: T.VerifyCifsParams): Promise { + async verifyCifs( + params: T.VerifyCifsParams, + ): Promise> { await pauseFor(1000) return { - version: '0.3.0', - full: true, - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: '', + '9876-5432-1234-5678': { + hostname: 'adjective-noun', + version: '0.3.6', + timestamp: new Date().toISOString(), + passwordHash: + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: '', + }, } } diff --git a/web/projects/setup-wizard/src/app/services/state.service.ts b/web/projects/setup-wizard/src/app/services/state.service.ts index 8c653a088..8f4290ac0 100644 --- a/web/projects/setup-wizard/src/app/services/state.service.ts +++ b/web/projects/setup-wizard/src/app/services/state.service.ts @@ -7,8 +7,7 @@ import { T } from '@start9labs/start-sdk' }) export class StateService { setupType?: 'fresh' | 'restore' | 'attach' | 'transfer' - recoverySource?: T.RecoverySource - recoveryPassword?: string + recoverySource?: T.RecoverySource constructor(private readonly api: ApiService) {} @@ -26,9 +25,13 @@ export class StateService { await this.api.execute({ startOsLogicalname: storageLogicalname, startOsPassword: await this.api.encrypt(password), - recoverySource: this.recoverySource || null, - recoveryPassword: this.recoveryPassword - ? await this.api.encrypt(this.recoveryPassword) + recoverySource: this.recoverySource + ? this.recoverySource.type === 'migrate' + ? this.recoverySource + : { + ...this.recoverySource, + password: await this.api.encrypt(this.recoverySource.password), + } : null, }) } diff --git a/web/projects/shared/src/services/download-html.service.ts b/web/projects/shared/src/services/download-html.service.ts index 81f7b945b..13a146186 100644 --- a/web/projects/shared/src/services/download-html.service.ts +++ b/web/projects/shared/src/services/download-html.service.ts @@ -1,7 +1,9 @@ import { DOCUMENT } from '@angular/common' import { Inject, Injectable } from '@angular/core' -@Injectable() +@Injectable({ + providedIn: 'root', +}) export class DownloadHTMLService { constructor(@Inject(DOCUMENT) private readonly document: Document) {} diff --git a/web/projects/shared/src/types/api.ts b/web/projects/shared/src/types/api.ts index 743ea6ac8..c53ed5e33 100644 --- a/web/projects/shared/src/types/api.ts +++ b/web/projects/shared/src/types/api.ts @@ -32,13 +32,14 @@ export interface PartitionInfo { label: string | null capacity: number used: number | null - startOs: StartOSDiskInfo | null + startOs: Record guid: string | null } export type StartOSDiskInfo = { + hostname: string version: string - full: boolean + timestamp: string passwordHash: string | null wrappedKey: string | null } diff --git a/web/projects/ui/src/app/app/preloader/preloader.component.html b/web/projects/ui/src/app/app/preloader/preloader.component.html index 82923250e..b94b3fa0c 100644 --- a/web/projects/ui/src/app/app/preloader/preloader.component.html +++ b/web/projects/ui/src/app/app/preloader/preloader.component.html @@ -3,7 +3,7 @@ - + @@ -58,21 +58,21 @@ - - - - - - - - - + + + + + + + + + + + - - - - + + diff --git a/web/projects/ui/src/app/app/preloader/preloader.component.ts b/web/projects/ui/src/app/app/preloader/preloader.component.ts index f7184008e..177362222 100644 --- a/web/projects/ui/src/app/app/preloader/preloader.component.ts +++ b/web/projects/ui/src/app/app/preloader/preloader.component.ts @@ -1,4 +1,5 @@ import { ChangeDetectionStrategy, Component } from '@angular/core' +import { FormControl } from '@angular/forms' import { ActionSheetController, AlertController, @@ -122,6 +123,7 @@ const TAIGA = [ export class PreloaderComponent { readonly icons = ICONS readonly taiga = TAIGA + readonly control = new FormControl() constructor( _modals: ModalController, diff --git a/web/projects/ui/src/app/app/preloader/preloader.module.ts b/web/projects/ui/src/app/app/preloader/preloader.module.ts index 380b70f3a..0f22efb25 100644 --- a/web/projects/ui/src/app/app/preloader/preloader.module.ts +++ b/web/projects/ui/src/app/app/preloader/preloader.module.ts @@ -1,5 +1,6 @@ import { CommonModule } from '@angular/common' import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core' +import { ReactiveFormsModule } from '@angular/forms' import { IonicModule } from '@ionic/angular' import { TuiErrorModule, @@ -26,7 +27,7 @@ import { TuiProgressModule, TuiRadioListModule, TuiSelectModule, - TuiTextAreaModule, + TuiTextareaModule, TuiToggleModule, } from '@taiga-ui/kit' import { QrCodeModule } from 'ng-qrcode' @@ -35,6 +36,7 @@ import { PreloaderComponent } from './preloader.component' @NgModule({ imports: [ CommonModule, + ReactiveFormsModule, IonicModule, QrCodeModule, TuiTooltipModule, @@ -52,7 +54,7 @@ import { PreloaderComponent } from './preloader.component' TuiInputNumberModule, TuiExpandModule, TuiSelectModule, - TuiTextAreaModule, + TuiTextareaModule, TuiToggleModule, TuiElasticContainerModule, TuiCellModule, diff --git a/web/projects/ui/src/app/components/backup-drives/backup-drives-status.component.html b/web/projects/ui/src/app/components/backup-drives/backup-drives-status.component.html index e0437cd1d..ad422a8f8 100644 --- a/web/projects/ui/src/app/components/backup-drives/backup-drives-status.component.html +++ b/web/projects/ui/src/app/components/backup-drives/backup-drives-status.component.html @@ -1,20 +1,16 @@

- {{ - hasValidBackup - ? 'Available, contains existing backup' - : 'Available for fresh backup' - }} + Available for backup

-

+

- StartOS backup detected + StartOS backups detected

-

+

- No StartOS backup + No StartOS backups

diff --git a/web/projects/ui/src/app/components/backup-drives/backup-drives.component.html b/web/projects/ui/src/app/components/backup-drives/backup-drives.component.html index 141872084..82e508420 100644 --- a/web/projects/ui/src/app/components/backup-drives/backup-drives.component.html +++ b/web/projects/ui/src/app/components/backup-drives/backup-drives.component.html @@ -73,7 +73,7 @@

@@ -155,7 +155,7 @@

{{ drive.label || drive.logicalname }}

{{ drive.vendor || 'Unknown Vendor' }} - diff --git a/web/projects/ui/src/app/components/backup-drives/backup-drives.component.ts b/web/projects/ui/src/app/components/backup-drives/backup-drives.component.ts index 5613ae153..a4b272c46 100644 --- a/web/projects/ui/src/app/components/backup-drives/backup-drives.component.ts +++ b/web/projects/ui/src/app/components/backup-drives/backup-drives.component.ts @@ -72,10 +72,10 @@ export class BackupDrivesComponent { return } - if (this.type === 'restore' && !target.hasValidBackup) { + if (this.type === 'restore' && !target.hasAnyBackup) { const message = `${ target.entry.type === 'cifs' ? 'Network Folder' : 'Drive partition' - } does not contain a valid Start9 Server backup.` + } does not contain a valid backup.` this.presentAlertError(message) return } @@ -153,7 +153,7 @@ export class BackupDrivesComponent { const [id, entry] = Object.entries(res)[0] this.backupService.cifs.unshift({ id, - hasValidBackup: this.backupService.hasValidBackup(entry), + hasAnyBackup: this.backupService.hasAnyBackup(entry), entry, }) return true @@ -258,7 +258,7 @@ export class BackupDrivesHeaderComponent { }) export class BackupDrivesStatusComponent { @Input() type!: BackupType - @Input() hasValidBackup!: boolean + @Input() hasAnyBackup!: boolean } const cifsSpec = CB.Config.of({ diff --git a/web/projects/ui/src/app/components/backup-drives/backup.service.ts b/web/projects/ui/src/app/components/backup-drives/backup.service.ts index 6beec7e2d..cc1898e7e 100644 --- a/web/projects/ui/src/app/components/backup-drives/backup.service.ts +++ b/web/projects/ui/src/app/components/backup-drives/backup.service.ts @@ -34,7 +34,7 @@ export class BackupService { .map(([id, cifs]) => { return { id, - hasValidBackup: this.hasValidBackup(cifs), + hasAnyBackup: this.hasAnyBackup(cifs), entry: cifs as CifsBackupTarget, } }) @@ -44,7 +44,7 @@ export class BackupService { .map(([id, drive]) => { return { id, - hasValidBackup: this.hasValidBackup(drive), + hasAnyBackup: this.hasAnyBackup(drive), entry: drive as DiskBackupTarget, } }) @@ -55,8 +55,16 @@ export class BackupService { } } - hasValidBackup(target: BackupTarget): boolean { - const backup = target.startOs - return !!backup && this.emver.compare(backup.version, '0.3.0') !== -1 + hasAnyBackup(target: BackupTarget): boolean { + return Object.values(target.startOs).some( + s => this.emver.compare(s.version, '0.3.6') !== -1, + ) + } + + async hasThisBackup(target: BackupTarget, id: string): Promise { + return ( + target.startOs[id] && + this.emver.compare(target.startOs[id].version, '0.3.6') !== -1 + ) } } diff --git a/web/projects/ui/src/app/components/form/form-group/form-group.component.html b/web/projects/ui/src/app/components/form/form-group/form-group.component.html index d3c769b98..1c4f8301a 100644 --- a/web/projects/ui/src/app/components/form/form-group/form-group.component.html +++ b/web/projects/ui/src/app/components/form/form-group/form-group.component.html @@ -1,5 +1,5 @@ * - + diff --git a/web/projects/ui/src/app/components/form/form.module.ts b/web/projects/ui/src/app/components/form/form.module.ts index fe16b229f..b7a36cd1f 100644 --- a/web/projects/ui/src/app/components/form/form.module.ts +++ b/web/projects/ui/src/app/components/form/form.module.ts @@ -30,7 +30,7 @@ import { TuiPromptModule, TuiSelectModule, TuiTagModule, - TuiTextAreaModule, + TuiTextareaModule, TuiToggleModule, } from '@taiga-ui/kit' @@ -60,7 +60,7 @@ import { HintPipe } from './hint.pipe' TuiInputModule, TuiInputNumberModule, TuiInputFilesModule, - TuiTextAreaModule, + TuiTextareaModule, TuiSelectModule, TuiMultiSelectModule, TuiToggleModule, diff --git a/web/projects/ui/src/app/components/logs/logs.component.ts b/web/projects/ui/src/app/components/logs/logs.component.ts index c1ed61f09..59d78dac7 100644 --- a/web/projects/ui/src/app/components/logs/logs.component.ts +++ b/web/projects/ui/src/app/components/logs/logs.component.ts @@ -39,7 +39,7 @@ var convert = new Convert({ selector: 'logs', templateUrl: './logs.component.html', styleUrls: ['./logs.component.scss'], - providers: [TuiDestroyService, DownloadHTMLService], + providers: [TuiDestroyService], }) export class LogsComponent { @ViewChild(IonContent) diff --git a/web/projects/ui/src/app/modals/app-recover-select/app-recover-select.page.html b/web/projects/ui/src/app/modals/app-recover-select/app-recover-select.page.html index 9dfb72da9..f1372f468 100644 --- a/web/projects/ui/src/app/modals/app-recover-select/app-recover-select.page.html +++ b/web/projects/ui/src/app/modals/app-recover-select/app-recover-select.page.html @@ -18,8 +18,8 @@

{{ option.title }}

Version {{ option.version }}

-

Backup made: {{ option.timestamp | date : 'medium' }}

-

+

Created: {{ option.timestamp | date : 'medium' }}

+

Ready to restore

@@ -27,7 +27,7 @@ Unavailable. {{ option.title }} is already installed.

-

+

Unavailable. Backup was made on a newer version of StartOS. @@ -36,7 +36,7 @@ diff --git a/web/projects/ui/src/app/modals/app-recover-select/app-recover-select.page.ts b/web/projects/ui/src/app/modals/app-recover-select/app-recover-select.page.ts index e1d637ecf..c9869bce6 100644 --- a/web/projects/ui/src/app/modals/app-recover-select/app-recover-select.page.ts +++ b/web/projects/ui/src/app/modals/app-recover-select/app-recover-select.page.ts @@ -14,10 +14,10 @@ import { AppRecoverOption } from './to-options.pipe' styleUrls: ['./app-recover-select.page.scss'], }) export class AppRecoverSelectPage { - @Input() id!: string + @Input() targetId!: string + @Input() serverId!: string @Input() backupInfo!: BackupInfo @Input() password!: string - @Input() oldPassword?: string readonly packageData$ = this.patch.watch$('packageData').pipe(take(1)) @@ -46,8 +46,8 @@ export class AppRecoverSelectPage { try { await this.embassyApi.restorePackages({ ids, - targetId: this.id, - oldPassword: this.oldPassword || null, + targetId: this.targetId, + serverId: this.serverId, password: this.password, }) this.modalCtrl.dismiss(undefined, 'success') diff --git a/web/projects/ui/src/app/modals/app-recover-select/to-options.pipe.ts b/web/projects/ui/src/app/modals/app-recover-select/to-options.pipe.ts index 6a81df4bc..8a607896e 100644 --- a/web/projects/ui/src/app/modals/app-recover-select/to-options.pipe.ts +++ b/web/projects/ui/src/app/modals/app-recover-select/to-options.pipe.ts @@ -10,7 +10,7 @@ export interface AppRecoverOption extends PackageBackupInfo { id: string checked: boolean installed: boolean - 'newer-eos': boolean + newerOS: boolean } @Pipe({ @@ -34,7 +34,7 @@ export class ToOptionsPipe implements PipeTransform { id, installed: !!packageData[id], checked: false, - 'newer-eos': this.compare(packageBackups[id].osVersion), + newerOS: this.compare(packageBackups[id].osVersion), })) .sort((a, b) => b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1, diff --git a/web/projects/ui/src/app/modals/backup-server-select/backup-server-select.module.ts b/web/projects/ui/src/app/modals/backup-server-select/backup-server-select.module.ts new file mode 100644 index 000000000..958b98dff --- /dev/null +++ b/web/projects/ui/src/app/modals/backup-server-select/backup-server-select.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core' +import { CommonModule } from '@angular/common' +import { IonicModule } from '@ionic/angular' +import { FormsModule } from '@angular/forms' +import { BackupServerSelectModal } from './backup-server-select.page' +import { AppRecoverSelectPageModule } from 'src/app/modals/app-recover-select/app-recover-select.module' + +@NgModule({ + declarations: [BackupServerSelectModal], + imports: [CommonModule, FormsModule, IonicModule, AppRecoverSelectPageModule], + exports: [BackupServerSelectModal], +}) +export class BackupServerSelectModule {} diff --git a/web/projects/ui/src/app/modals/backup-server-select/backup-server-select.page.html b/web/projects/ui/src/app/modals/backup-server-select/backup-server-select.page.html new file mode 100644 index 000000000..e5b2369e5 --- /dev/null +++ b/web/projects/ui/src/app/modals/backup-server-select/backup-server-select.page.html @@ -0,0 +1,35 @@ + + + Select Server Backup + + + + + + + + + + + + +

+ Local Hostname + : {{ server.value.hostname }}.local +

+

+ StartOS Version + : {{ server.value.version }} +

+

+ Created + : {{ server.value.timestamp | date : 'medium' }} +

+ +
+ + diff --git a/web/projects/ui/src/app/modals/backup-server-select/backup-server-select.page.scss b/web/projects/ui/src/app/modals/backup-server-select/backup-server-select.page.scss new file mode 100644 index 000000000..e69de29bb diff --git a/web/projects/ui/src/app/modals/backup-server-select/backup-server-select.page.ts b/web/projects/ui/src/app/modals/backup-server-select/backup-server-select.page.ts new file mode 100644 index 000000000..e1f4f8961 --- /dev/null +++ b/web/projects/ui/src/app/modals/backup-server-select/backup-server-select.page.ts @@ -0,0 +1,103 @@ +import { Component, Input } from '@angular/core' +import { ModalController, NavController } from '@ionic/angular' +import * as argon2 from '@start9labs/argon2' +import { + ErrorService, + LoadingService, + StartOSDiskInfo, +} from '@start9labs/shared' +import { + BackupInfo, + CifsBackupTarget, + DiskBackupTarget, +} from 'src/app/services/api/api.types' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { MappedBackupTarget } from 'src/app/types/mapped-backup-target' +import { AppRecoverSelectPage } from '../app-recover-select/app-recover-select.page' +import { PasswordPromptModal } from './password-prompt.modal' + +@Component({ + selector: 'backup-server-select', + templateUrl: 'backup-server-select.page.html', + styleUrls: ['backup-server-select.page.scss'], +}) +export class BackupServerSelectModal { + @Input() target!: MappedBackupTarget + + constructor( + private readonly modalCtrl: ModalController, + private readonly loader: LoadingService, + private readonly api: ApiService, + private readonly navCtrl: NavController, + private readonly errorService: ErrorService, + ) {} + + dismiss() { + this.modalCtrl.dismiss() + } + + async presentModalPassword( + serverId: string, + server: StartOSDiskInfo, + ): Promise { + const modal = await this.modalCtrl.create({ + component: PasswordPromptModal, + }) + modal.present() + + const { data, role } = await modal.onWillDismiss() + + if (role === 'confirm') { + try { + argon2.verify(server.passwordHash!, data) + await this.restoreFromBackup(serverId, data) + } catch (e: any) { + this.errorService.handleError(e) + } + } + } + + private async restoreFromBackup( + serverId: string, + password: string, + ): Promise { + const loader = this.loader.open('Decrypting drive...').subscribe() + + try { + const backupInfo = await this.api.getBackupInfo({ + targetId: this.target.id, + serverId, + password, + }) + this.presentModalSelect(serverId, backupInfo, password) + } finally { + loader.unsubscribe() + } + } + + private async presentModalSelect( + serverId: string, + backupInfo: BackupInfo, + password: string, + ): Promise { + const modal = await this.modalCtrl.create({ + componentProps: { + targetId: this.target.id, + serverId, + backupInfo, + password, + }, + presentingElement: await this.modalCtrl.getTop(), + component: AppRecoverSelectPage, + }) + + modal.onDidDismiss().then(res => { + if (res.role === 'success') { + this.modalCtrl.dismiss(undefined, 'success') + this.navCtrl.navigateRoot('/services') + } + }) + + await modal.present() + } +} diff --git a/web/projects/ui/src/app/modals/backup-server-select/password-prompt.modal.ts b/web/projects/ui/src/app/modals/backup-server-select/password-prompt.modal.ts new file mode 100644 index 000000000..2af89e3d0 --- /dev/null +++ b/web/projects/ui/src/app/modals/backup-server-select/password-prompt.modal.ts @@ -0,0 +1,69 @@ +import { Component } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { IonicModule, ModalController } from '@ionic/angular' +import { TuiInputPasswordModule } from '@taiga-ui/kit' + +@Component({ + standalone: true, + template: ` + + + Decrypt Backup + + + + + + + + + +

+ Enter the password that was used to encrypt this backup. On the next + screen, you will select the individual services you want to restore. +

+

+ + Enter password + +

+
+ + + + + Cancel + + + Next + + + + `, + imports: [IonicModule, FormsModule, TuiInputPasswordModule], +}) +export class PasswordPromptModal { + password = '' + + constructor(private modalCtrl: ModalController) {} + + cancel() { + return this.modalCtrl.dismiss(null, 'cancel') + } + + confirm() { + return this.modalCtrl.dismiss(this.password, 'confirm') + } +} diff --git a/web/projects/ui/src/app/pages/server-routes/restore/restore.component.html b/web/projects/ui/src/app/pages/server-routes/restore/restore.component.html index 7440c9a77..ef22a587b 100644 --- a/web/projects/ui/src/app/pages/server-routes/restore/restore.component.html +++ b/web/projects/ui/src/app/pages/server-routes/restore/restore.component.html @@ -1,5 +1,5 @@ diff --git a/web/projects/ui/src/app/pages/server-routes/restore/restore.component.module.ts b/web/projects/ui/src/app/pages/server-routes/restore/restore.component.module.ts index 8cb4f6916..da99e66a2 100644 --- a/web/projects/ui/src/app/pages/server-routes/restore/restore.component.module.ts +++ b/web/projects/ui/src/app/pages/server-routes/restore/restore.component.module.ts @@ -5,7 +5,7 @@ import { IonicModule } from '@ionic/angular' import { RestorePage } from './restore.component' import { SharedPipesModule } from '@start9labs/shared' import { BackupDrivesComponentModule } from 'src/app/components/backup-drives/backup-drives.component.module' -import { AppRecoverSelectPageModule } from 'src/app/modals/app-recover-select/app-recover-select.module' +import { BackupServerSelectModule } from 'src/app/modals/backup-server-select/backup-server-select.module' const routes: Routes = [ { @@ -21,7 +21,7 @@ const routes: Routes = [ RouterModule.forChild(routes), SharedPipesModule, BackupDrivesComponentModule, - AppRecoverSelectPageModule, + BackupServerSelectModule, ], declarations: [RestorePage], }) diff --git a/web/projects/ui/src/app/pages/server-routes/restore/restore.component.ts b/web/projects/ui/src/app/pages/server-routes/restore/restore.component.ts index ae644399f..58f679908 100644 --- a/web/projects/ui/src/app/pages/server-routes/restore/restore.component.ts +++ b/web/projects/ui/src/app/pages/server-routes/restore/restore.component.ts @@ -1,18 +1,11 @@ import { Component } from '@angular/core' -import { ModalController, NavController } from '@ionic/angular' -import { LoadingService } from '@start9labs/shared' -import { TuiDialogService } from '@taiga-ui/core' -import { take } from 'rxjs/operators' -import { PROMPT, PromptOptions } from 'src/app/modals/prompt.component' -import { ApiService } from 'src/app/services/api/embassy-api.service' +import { ModalController } from '@ionic/angular' import { MappedBackupTarget } from 'src/app/types/mapped-backup-target' import { - BackupInfo, CifsBackupTarget, DiskBackupTarget, } from 'src/app/services/api/api.types' -import { AppRecoverSelectPage } from 'src/app/modals/app-recover-select/app-recover-select.page' -import * as argon2 from '@start9labs/argon2' +import { BackupServerSelectModal } from 'src/app/modals/backup-server-select/backup-server-select.page' @Component({ selector: 'restore', @@ -20,78 +13,15 @@ import * as argon2 from '@start9labs/argon2' styleUrls: ['./restore.component.scss'], }) export class RestorePage { - constructor( - private readonly modalCtrl: ModalController, - private readonly dialogs: TuiDialogService, - private readonly navCtrl: NavController, - private readonly embassyApi: ApiService, - private readonly loader: LoadingService, - ) {} + constructor(private readonly modalCtrl: ModalController) {} - async presentModalPassword( + async presentModalSelectServer( target: MappedBackupTarget, - ): Promise { - const options: PromptOptions = { - message: - 'Enter the master password that was used to encrypt this backup. On the next screen, you will select the individual services you want to restore.', - label: 'Master Password', - placeholder: 'Enter master password', - useMask: true, - buttonText: 'Next', - } - - this.dialogs - .open(PROMPT, { - label: 'Password Required', - data: options, - }) - .pipe(take(1)) - .subscribe(async (password: string) => { - const passwordHash = target.entry.startOs?.passwordHash || '' - argon2.verify(passwordHash, password) - await this.restoreFromBackup(target, password) - }) - } - - private async restoreFromBackup( - target: MappedBackupTarget, - password: string, - oldPassword?: string, - ): Promise { - const loader = this.loader.open('Decrypting drive...').subscribe() - - try { - const backupInfo = await this.embassyApi.getBackupInfo({ - targetId: target.id, - password, - }) - this.presentModalSelect(target.id, backupInfo, password, oldPassword) - } finally { - loader.unsubscribe() - } - } - - private async presentModalSelect( - id: string, - backupInfo: BackupInfo, - password: string, - oldPassword?: string, ): Promise { const modal = await this.modalCtrl.create({ - componentProps: { - id, - backupInfo, - password, - oldPassword, - }, + componentProps: { target }, presentingElement: await this.modalCtrl.getTop(), - component: AppRecoverSelectPage, - }) - - modal.onWillDismiss().then(res => { - if (res.role === 'success') { - this.navCtrl.navigateRoot('/services') - } + component: BackupServerSelectModal, }) await modal.present() diff --git a/web/projects/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts b/web/projects/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts index 804a88fab..b570b1603 100644 --- a/web/projects/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts +++ b/web/projects/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts @@ -17,6 +17,7 @@ import { BackupSelectPage } from 'src/app/modals/backup-select/backup-select.pag import { EOSService } from 'src/app/services/eos.service' import { getServerInfo } from 'src/app/util/get-server-info' import { DataModel } from 'src/app/services/patch-db/data-model' +import { BackupService } from 'src/app/components/backup-drives/backup.service' @Component({ selector: 'server-backup', @@ -38,6 +39,7 @@ export class ServerBackupPage { private readonly destroy$: TuiDestroyService, private readonly eosService: EOSService, private readonly patch: PatchDB, + private readonly backupService: BackupService, ) {} ngOnInit() { @@ -86,19 +88,18 @@ export class ServerBackupPage { }) .pipe(take(1)) .subscribe(async (password: string) => { + const { passwordHash, id } = await getServerInfo(this.patch) + // confirm password matches current master password - const { passwordHash } = await getServerInfo(this.patch) argon2.verify(passwordHash, password) // first time backup - if (!target.hasValidBackup) { + if (!this.backupService.hasThisBackup(target.entry, id)) { await this.createBackup(target, password) // existing backup } else { try { - const passwordHash = target.entry.startOs?.passwordHash || '' - - argon2.verify(passwordHash, password) + argon2.verify(target.entry.startOs[id].passwordHash!, password) } catch { setTimeout( () => this.presentModalOldPassword(target, password), @@ -124,6 +125,8 @@ export class ServerBackupPage { buttonText: 'Create Backup', } + const { id } = await getServerInfo(this.patch) + this.dialogs .open(PROMPT, { label: 'Original Password Needed', @@ -131,8 +134,7 @@ export class ServerBackupPage { }) .pipe(take(1)) .subscribe(async (oldPassword: string) => { - const passwordHash = target.entry.startOs?.passwordHash || '' - + const passwordHash = target.entry.startOs[id].passwordHash! argon2.verify(passwordHash, oldPassword) await this.createBackup(target, password, oldPassword) }) diff --git a/web/projects/ui/src/app/services/api/api.fixures.ts b/web/projects/ui/src/app/services/api/api.fixures.ts index abddbcf90..a1f81fd95 100644 --- a/web/projects/ui/src/app/services/api/api.fixures.ts +++ b/web/projects/ui/src/app/services/api/api.fixures.ts @@ -600,12 +600,15 @@ export module Mock { username: 'TestUser', mountable: false, startOs: { - version: '0.3.0', - full: true, - passwordHash: - // password is asdfasdf - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: '', + '1234-5678-9876-5432': { + hostname: 'adjective-noun', + timestamp: new Date().toISOString(), + version: '0.3.6', + passwordHash: + // password is asdfasdf + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: '', + }, }, }, // 'ftcvewdnkemfksdm': { @@ -616,7 +619,7 @@ export module Mock { // used: 0, // model: 'Evo SATA 2.5', // vendor: 'Samsung', - // startOs: null, + // startOs: {}, // }, csgashbdjkasnd: { type: 'cifs', @@ -624,7 +627,7 @@ export module Mock { path: '/Desktop/startos-backups-2', username: 'TestUser', mountable: true, - startOs: null, + startOs: {}, }, powjefhjbnwhdva: { type: 'disk', @@ -635,30 +638,33 @@ export module Mock { model: null, vendor: 'SSK', startOs: { - version: '0.3.0', - full: true, - // password is asdfasdf - passwordHash: - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - wrappedKey: '', + '1234-5678-9876-5432': { + hostname: 'adjective-noun', + timestamp: new Date().toISOString(), + version: '0.3.6', + passwordHash: + // password is asdfasdf + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: '', + }, }, }, } export const BackupInfo: RR.GetBackupInfoRes = { - version: '0.3.0', + version: '0.3.6', timestamp: new Date().toISOString(), packageBackups: { bitcoind: { title: 'Bitcoin Core', version: '0.21.0', - osVersion: '0.3.0', + osVersion: '0.3.6', timestamp: new Date().toISOString(), }, 'btc-rpc-proxy': { title: 'Bitcoin Proxy', version: '0.2.2', - osVersion: '0.3.0', + osVersion: '0.3.6', timestamp: new Date().toISOString(), }, }, diff --git a/web/projects/ui/src/app/services/api/api.types.ts b/web/projects/ui/src/app/services/api/api.types.ts index 7ca97db53..5ada03cad 100644 --- a/web/projects/ui/src/app/services/api/api.types.ts +++ b/web/projects/ui/src/app/services/api/api.types.ts @@ -191,7 +191,12 @@ export module RR { export type RemoveBackupTargetReq = { id: string } // backup.target.cifs.remove export type RemoveBackupTargetRes = null - export type GetBackupInfoReq = { targetId: string; password: string } // backup.target.info + export type GetBackupInfoReq = { + // backup.target.info + targetId: string + serverId: string + password: string + } export type GetBackupInfoRes = BackupInfo export type CreateBackupReq = { @@ -239,7 +244,7 @@ export module RR { // package.backup.restore ids: string[] targetId: string - oldPassword: string | null + serverId: string password: string } export type RestorePackagesRes = null @@ -403,7 +408,7 @@ export interface DiskBackupTarget { label: string | null capacity: number used: number | null - startOs: StartOSDiskInfo | null + startOs: Record } export interface CifsBackupTarget { @@ -412,7 +417,7 @@ export interface CifsBackupTarget { path: string username: string mountable: boolean - startOs: StartOSDiskInfo | null + startOs: Record } export type RecoverySource = DiskRecoverySource | CifsRecoverySource diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 284e3bab8..21491dc52 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -582,7 +582,7 @@ export class MockApiService extends ApiService { path: path.replace(/\\/g, '/'), username, mountable: true, - startOs: null, + startOs: {}, }, } } diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index 92fd979ef..8411b7b7c 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -59,7 +59,7 @@ export const mockPatchData: DataModel = { // password is asdfasdf passwordHash: '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - eosVersionCompat: '>=0.3.0 <=0.3.0.1', + eosVersionCompat: '>=0.3.0 <=0.3.6', statusInfo: { backupProgress: null, updated: false, diff --git a/web/projects/ui/src/app/types/mapped-backup-target.ts b/web/projects/ui/src/app/types/mapped-backup-target.ts index 13b51d4b5..4b3610bec 100644 --- a/web/projects/ui/src/app/types/mapped-backup-target.ts +++ b/web/projects/ui/src/app/types/mapped-backup-target.ts @@ -1,5 +1,5 @@ export interface MappedBackupTarget { id: string - hasValidBackup: boolean + hasAnyBackup: boolean entry: T }