mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
Feature/backup fs (#2665)
* port 040 config, WIP * update fixtures * use taiga modal for backups too * fix: update Taiga UI and refactor everything to work * chore: package-lock * fix interfaces and mocks for interfaces * better mocks * function to transform old spec to new * delete unused fns * delete unused FE config utils * fix exports from sdk * reorganize exports * functions to translate config * rename unionSelectKey and unionValueKey * new backup fs * update sdk types * change types, include fuse module * fix casing * rework setup wiz * rework UI * only fuse3 * fix arm build * misc fixes * fix duplicate server select * fix: fix throwing inside dialog --------- Co-authored-by: Matt Hill <mattnine@protonmail.com> Co-authored-by: waterplea <alexander@inkin.ru> Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com>
This commit is contained in:
10
Makefile
10
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
|
||||
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
|
||||
@@ -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
|
||||
@@ -14,6 +14,7 @@ e2fsprogs
|
||||
ecryptfs-utils
|
||||
exfatprogs
|
||||
flashrom
|
||||
fuse3
|
||||
grub-common
|
||||
htop
|
||||
httpdirfs
|
||||
|
||||
@@ -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::<PathBuf>)
|
||||
AtomicFile::new(backup_guard.path().join("os-backup.json"), None::<PathBuf>)
|
||||
.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?;
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
start_os_password: String,
|
||||
recovery_source: TmpMountGuard,
|
||||
recovery_password: Option<String>,
|
||||
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()))?,
|
||||
|
||||
@@ -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<EmbassyOsRecoveryInfo>,
|
||||
start_os: BTreeMap<String, StartOsRecoveryInfo>,
|
||||
}
|
||||
|
||||
pub fn cifs<C: Context>() -> ParentHandler<C> {
|
||||
@@ -239,7 +239,7 @@ pub async fn list(db: &DatabaseModel) -> Result<Vec<(u32, CifsBackupTarget)>, 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(),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
@@ -157,6 +157,16 @@ pub fn target<C: Context>() -> ParentHandler<C> {
|
||||
})
|
||||
.with_call_remote::<CliContext>(),
|
||||
)
|
||||
.subcommand(
|
||||
"mount",
|
||||
from_fn_async(mount).with_call_remote::<CliContext>(),
|
||||
)
|
||||
.subcommand(
|
||||
"umount",
|
||||
from_fn_async(umount)
|
||||
.no_display()
|
||||
.with_call_remote::<CliContext>(),
|
||||
)
|
||||
}
|
||||
|
||||
// #[command(display(display_serializable))]
|
||||
@@ -250,6 +260,7 @@ fn display_backup_info(params: WithIoFormat<InfoParams>, 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<BackupInfo, Error> {
|
||||
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<String, Error> {
|
||||
@@ -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?;
|
||||
|
||||
@@ -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<Empty>, args: Vec<DiskInfo>) {
|
||||
} 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);
|
||||
|
||||
@@ -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<G: GenericMountGuard> {
|
||||
backup_disk_mount_guard: Option<G>,
|
||||
encrypted_guard: Option<TmpMountGuard>,
|
||||
enc_key: String,
|
||||
pub unencrypted_metadata: EmbassyOsRecoveryInfo,
|
||||
unencrypted_metadata_path: PathBuf,
|
||||
pub unencrypted_metadata: StartOsRecoveryInfo,
|
||||
pub metadata: BackupInfo,
|
||||
}
|
||||
impl<G: GenericMountGuard> BackupMountGuard<G> {
|
||||
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<Self, Error> {
|
||||
pub async fn mount(
|
||||
backup_disk_mount_guard: G,
|
||||
server_id: &str,
|
||||
password: &str,
|
||||
) -> Result<Self, Error> {
|
||||
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<G: GenericMountGuard> BackupMountGuard<G> {
|
||||
})?,
|
||||
)?
|
||||
} 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<G: GenericMountGuard> BackupMountGuard<G> {
|
||||
));
|
||||
}
|
||||
|
||||
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<G: GenericMountGuard> BackupMountGuard<G> {
|
||||
})?;
|
||||
}
|
||||
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<G: GenericMountGuard> BackupMountGuard<G> {
|
||||
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<G: GenericMountGuard> BackupMountGuard<G> {
|
||||
|
||||
#[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::<PathBuf>)
|
||||
.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::<PathBuf>)
|
||||
let mut file = AtomicFile::new(&self.unencrypted_metadata_path, None::<PathBuf>)
|
||||
.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(())
|
||||
|
||||
55
core/startos/src/disk/mount/filesystem/backupfs.rs
Normal file
55
core/startos/src/disk/mount/filesystem/backupfs.rs
Normal file
@@ -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<DataDir: AsRef<Path>, Password: fmt::Display> {
|
||||
data_dir: DataDir,
|
||||
password: Password,
|
||||
}
|
||||
impl<DataDir: AsRef<Path>, Password: fmt::Display> BackupFS<DataDir, Password> {
|
||||
pub fn new(data_dir: DataDir, password: Password) -> Self {
|
||||
BackupFS { data_dir, password }
|
||||
}
|
||||
}
|
||||
impl<DataDir: AsRef<Path> + Send + Sync, Password: fmt::Display + Send + Sync> FileSystem
|
||||
for BackupFS<DataDir, Password>
|
||||
{
|
||||
fn mount_type(&self) -> Option<impl AsRef<str>> {
|
||||
Some("backup-fs")
|
||||
}
|
||||
fn mount_options(&self) -> impl IntoIterator<Item = impl Display> {
|
||||
[
|
||||
format!("password={}", self.password),
|
||||
format!("file-size-padding=0.05"),
|
||||
]
|
||||
}
|
||||
async fn source(&self) -> Result<Option<impl AsRef<Path>>, Error> {
|
||||
Ok(Some(&self.data_dir))
|
||||
}
|
||||
async fn source_hash(
|
||||
&self,
|
||||
) -> Result<GenericArray<u8, <Sha256 as OutputSizeUser>::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())
|
||||
}
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
pub capacity: u64,
|
||||
pub used: Option<u64>,
|
||||
pub start_os: Option<EmbassyOsRecoveryInfo>,
|
||||
pub start_os: BTreeMap<String, StartOsRecoveryInfo>,
|
||||
pub guid: Option<String>,
|
||||
}
|
||||
|
||||
#[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<Utc>,
|
||||
pub password_hash: Option<String>,
|
||||
pub wrapped_key: Option<String>,
|
||||
}
|
||||
@@ -223,29 +226,38 @@ pub async fn pvscan() -> Result<BTreeMap<PathBuf, Option<String>>, Error> {
|
||||
|
||||
pub async fn recovery_info(
|
||||
mountpoint: impl AsRef<Path>,
|
||||
) -> Result<Option<EmbassyOsRecoveryInfo>, 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<BTreeMap<String, StartOsRecoveryInfo>, 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);
|
||||
|
||||
@@ -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! {
|
||||
|
||||
@@ -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<EmbassyOsRecoveryInfo, Error> {
|
||||
) -> Result<BTreeMap<String, StartOsRecoveryInfo>, Error> {
|
||||
let password: Option<String> = 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<Password> {
|
||||
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<RecoverySource>,
|
||||
recovery_password: Option<EncryptedWire>,
|
||||
recovery_source: Option<RecoverySource<EncryptedWire>>,
|
||||
}
|
||||
|
||||
// #[command(rpc_only)]
|
||||
@@ -279,7 +292,6 @@ pub async fn execute(
|
||||
start_os_logicalname,
|
||||
start_os_password,
|
||||
recovery_source,
|
||||
recovery_password,
|
||||
}: SetupExecuteParams,
|
||||
) -> Result<SetupProgress, Error> {
|
||||
let start_os_password = match start_os_password.decrypt(&ctx) {
|
||||
@@ -291,29 +303,27 @@ pub async fn execute(
|
||||
))
|
||||
}
|
||||
};
|
||||
let recovery_password: Option<String> = 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<RecoverySource>,
|
||||
recovery_password: Option<String>,
|
||||
recovery_source: Option<RecoverySource<String>>,
|
||||
) -> 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<String>,
|
||||
start_os_password: String,
|
||||
recovery_source: BackupTargetFS,
|
||||
recovery_password: Option<String>,
|
||||
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
|
||||
|
||||
@@ -107,64 +107,22 @@ pub fn serialize_display_opt<T: std::fmt::Display, S: Serializer>(
|
||||
Option::<String>::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<S: Serializer>(
|
||||
pubkey: &VerifyingKey,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&base32::encode(
|
||||
base32::Alphabet::RFC4648 { padding: true },
|
||||
pubkey.as_bytes(),
|
||||
))
|
||||
}
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<VerifyingKey, D::Error> {
|
||||
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<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
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<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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<E>(self) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(ValuePrimative::Null)
|
||||
Ok(ValuePrimitive::Null)
|
||||
}
|
||||
fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(ValuePrimative::Boolean(v))
|
||||
Ok(ValuePrimitive::Boolean(v))
|
||||
}
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(ValuePrimative::String(v.to_owned()))
|
||||
Ok(ValuePrimitive::String(v.to_owned()))
|
||||
}
|
||||
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(ValuePrimative::String(v))
|
||||
Ok(ValuePrimitive::String(v))
|
||||
}
|
||||
fn visit_f32<E>(self, v: f32) -> Result<Self::Value, E>
|
||||
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<E>(self, v: u16) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(ValuePrimative::Number(v.into()))
|
||||
Ok(ValuePrimitive::Number(v.into()))
|
||||
}
|
||||
fn visit_u32<E>(self, v: u32) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(ValuePrimative::Number(v.into()))
|
||||
Ok(ValuePrimitive::Number(v.into()))
|
||||
}
|
||||
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(ValuePrimative::Number(v.into()))
|
||||
Ok(ValuePrimitive::Number(v.into()))
|
||||
}
|
||||
fn visit_i8<E>(self, v: i8) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(ValuePrimative::Number(v.into()))
|
||||
Ok(ValuePrimitive::Number(v.into()))
|
||||
}
|
||||
fn visit_i16<E>(self, v: i16) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(ValuePrimative::Number(v.into()))
|
||||
Ok(ValuePrimitive::Number(v.into()))
|
||||
}
|
||||
fn visit_i32<E>(self, v: i32) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(ValuePrimative::Number(v.into()))
|
||||
Ok(ValuePrimitive::Number(v.into()))
|
||||
}
|
||||
fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(ValuePrimative::Number(v.into()))
|
||||
Ok(ValuePrimitive::Number(v.into()))
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_any(Visitor)
|
||||
|
||||
@@ -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<Password> =
|
||||
| { type: "migrate"; guid: string }
|
||||
| { type: "backup"; target: BackupTargetFS }
|
||||
| {
|
||||
type: "backup"
|
||||
target: BackupTargetFS
|
||||
password: Password
|
||||
serverId: string
|
||||
}
|
||||
|
||||
@@ -5,6 +5,5 @@ import type { RecoverySource } from "./RecoverySource"
|
||||
export type SetupExecuteParams = {
|
||||
startOsLogicalname: string
|
||||
startOsPassword: EncryptedWire
|
||||
recoverySource: RecoverySource | null
|
||||
recoveryPassword: EncryptedWire | null
|
||||
recoverySource: RecoverySource<EncryptedWire> | null
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<void> {
|
||||
const target: CifsBackupTarget = {
|
||||
...this.cifs,
|
||||
mountable: true,
|
||||
startOs: diskInfo,
|
||||
}
|
||||
|
||||
private async presentModalSelectServer(
|
||||
servers: Record<string, StartOSDiskInfo>,
|
||||
): Promise<void> {
|
||||
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',
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,24 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Select Server to Restore</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<ion-item *ngFor="let server of servers" button (click)="select(server)">
|
||||
<ion-label>
|
||||
<h2>
|
||||
<b>Local Hostname</b>
|
||||
: {{ server.hostname }}.local
|
||||
</h2>
|
||||
<h2>
|
||||
<b>StartOS Version</b>
|
||||
: {{ server.version }}
|
||||
</h2>
|
||||
<h2>
|
||||
<b>Created</b>
|
||||
: {{ server.timestamp | date : 'medium' }}
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-content>
|
||||
@@ -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<void> {
|
||||
this.presentModalPassword(server)
|
||||
}
|
||||
|
||||
private async presentModalPassword(
|
||||
server: StartOSDiskInfoWithId,
|
||||
): Promise<void> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<string>): source is any {
|
||||
return source.type === 'backup' && source.target.type === 'disk'
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<div class="inline">
|
||||
<!-- has backup -->
|
||||
<h2 *ngIf="hasValidBackup; else noBackup">
|
||||
<ion-icon name="cloud-done" color="success"></ion-icon>
|
||||
StartOS backup detected
|
||||
</h2>
|
||||
<!-- no backup -->
|
||||
<ng-template #noBackup>
|
||||
<h2>
|
||||
<ion-icon name="cloud-offline" color="danger"></ion-icon>
|
||||
No StartOS backup
|
||||
</h2>
|
||||
</ng-template>
|
||||
</div>
|
||||
@@ -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,
|
||||
|
||||
@@ -54,29 +54,21 @@
|
||||
</b>
|
||||
</div>
|
||||
|
||||
<ng-container *ngFor="let mapped of mappedDrives">
|
||||
<ion-item
|
||||
button
|
||||
*ngIf="mapped.drive as drive"
|
||||
[disabled]="!driveClickable(mapped)"
|
||||
(click)="select(drive)"
|
||||
lines="none"
|
||||
>
|
||||
<ion-icon
|
||||
slot="start"
|
||||
name="save-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ng-container *ngFor="let server of servers">
|
||||
<ion-item button (click)="select(server)" lines="none">
|
||||
<ion-label>
|
||||
<h1>{{ drive.label || drive.logicalname }}</h1>
|
||||
<drive-status
|
||||
[hasValidBackup]="mapped.hasValidBackup"
|
||||
></drive-status>
|
||||
<p>
|
||||
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model ||
|
||||
'Unknown Model' }}
|
||||
</p>
|
||||
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
|
||||
<h2>
|
||||
<b>Local Hostname</b>
|
||||
: {{ server.hostname }}.local
|
||||
</h2>
|
||||
<h2>
|
||||
<b>StartOS Version</b>
|
||||
: {{ server.version }}
|
||||
</h2>
|
||||
<h2>
|
||||
<b>Created</b>
|
||||
: {{ server.timestamp | date : 'medium' }}
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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<T.SetupStatusRes | null> // setup.status
|
||||
abstract getPubKey(): Promise<void> // setup.get-pubkey
|
||||
abstract getDrives(): Promise<DiskListResponse> // setup.disk.list
|
||||
abstract verifyCifs(cifs: T.VerifyCifsParams): Promise<StartOSDiskInfo> // setup.cifs.verify
|
||||
abstract verifyCifs(
|
||||
cifs: T.VerifyCifsParams,
|
||||
): Promise<Record<string, StartOSDiskInfo>> // setup.cifs.verify
|
||||
abstract attach(importInfo: T.AttachParams): Promise<T.SetupProgress> // setup.attach
|
||||
abstract execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
|
||||
abstract complete(): Promise<T.SetupResult> // setup.complete
|
||||
abstract exit(): Promise<void> // setup.exit
|
||||
abstract openProgressWebsocket$(guid: string): Observable<T.FullProgress>
|
||||
|
||||
async encrypt(toEncrypt: string): Promise<Encrypted> {
|
||||
async encrypt(toEncrypt: string): Promise<T.EncryptedWire> {
|
||||
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<T> = Omit<WebSocketSubjectConfig<T>, '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
|
||||
}
|
||||
|
||||
@@ -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<StartOSDiskInfo> {
|
||||
async verifyCifs(
|
||||
source: T.VerifyCifsParams,
|
||||
): Promise<Record<string, StartOSDiskInfo>> {
|
||||
source.path = source.path.replace('/\\/g', '/')
|
||||
return this.rpcRequest<StartOSDiskInfo>({
|
||||
return this.rpcRequest<Record<string, StartOSDiskInfo>>({
|
||||
method: 'setup.cifs.verify',
|
||||
params: source,
|
||||
})
|
||||
|
||||
@@ -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<StartOSDiskInfo> {
|
||||
async verifyCifs(
|
||||
params: T.VerifyCifsParams,
|
||||
): Promise<Record<string, StartOSDiskInfo>> {
|
||||
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: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -32,13 +32,14 @@ export interface PartitionInfo {
|
||||
label: string | null
|
||||
capacity: number
|
||||
used: number | null
|
||||
startOs: StartOSDiskInfo | null
|
||||
startOs: Record<string, StartOSDiskInfo>
|
||||
guid: string | null
|
||||
}
|
||||
|
||||
export type StartOSDiskInfo = {
|
||||
hostname: string
|
||||
version: string
|
||||
full: boolean
|
||||
timestamp: string
|
||||
passwordHash: string | null
|
||||
wrappedKey: string | null
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<ion-icon *ngFor="let icon of icons" [name]="icon"></ion-icon>
|
||||
|
||||
<!-- 3rd party components -->
|
||||
<qr-code value="hello"></qr-code>
|
||||
<qr-code control="hello"></qr-code>
|
||||
|
||||
<!-- Ionic components -->
|
||||
<ion-accordion></ion-accordion>
|
||||
@@ -58,21 +58,21 @@
|
||||
<img *ngFor="let icon of taiga" src="assets/taiga-ui/icons/{{ icon }}.svg" />
|
||||
|
||||
<!-- Taiga UI components -->
|
||||
<tui-input></tui-input>
|
||||
<tui-input-time></tui-input-time>
|
||||
<tui-input-date></tui-input-date>
|
||||
<tui-input-date-time></tui-input-date-time>
|
||||
<tui-input-files></tui-input-files>
|
||||
<tui-input-number></tui-input-number>
|
||||
<tui-text-area></tui-text-area>
|
||||
<tui-select></tui-select>
|
||||
<tui-multi-select></tui-multi-select>
|
||||
<tui-input [formControl]="control"></tui-input>
|
||||
<tui-input-time [formControl]="control"></tui-input-time>
|
||||
<tui-input-date [formControl]="control"></tui-input-date>
|
||||
<tui-input-date-time [formControl]="control"></tui-input-date-time>
|
||||
<tui-input-files [formControl]="control"></tui-input-files>
|
||||
<tui-input-number [formControl]="control"></tui-input-number>
|
||||
<tui-textarea [formControl]="control"></tui-textarea>
|
||||
<tui-select [formControl]="control"></tui-select>
|
||||
<tui-multi-select [formControl]="control"></tui-multi-select>
|
||||
<tui-toggle [formControl]="control"></tui-toggle>
|
||||
<tui-radio-list [formControl]="control"></tui-radio-list>
|
||||
<tui-tooltip></tui-tooltip>
|
||||
<tui-toggle></tui-toggle>
|
||||
<tui-radio-list></tui-radio-list>
|
||||
<tui-error></tui-error>
|
||||
<tui-svg></tui-svg>
|
||||
<tui-icon></tui-icon>
|
||||
<tui-svg src="tuiIconTrash"></tui-svg>
|
||||
<tui-icon icon="tuiIconTrash"></tui-icon>
|
||||
<tui-expand></tui-expand>
|
||||
<tui-elastic-container></tui-elastic-container>
|
||||
<tui-scrollbar></tui-scrollbar>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
<div class="inline">
|
||||
<h2 *ngIf="type === 'create'; else restore">
|
||||
<ion-icon name="cloud-outline" color="success"></ion-icon>
|
||||
{{
|
||||
hasValidBackup
|
||||
? 'Available, contains existing backup'
|
||||
: 'Available for fresh backup'
|
||||
}}
|
||||
Available for backup
|
||||
</h2>
|
||||
<ng-template #restore>
|
||||
<h2 *ngIf="hasValidBackup">
|
||||
<h2 *ngIf="hasAnyBackup">
|
||||
<ion-icon name="cloud-done-outline" color="success"></ion-icon>
|
||||
StartOS backup detected
|
||||
StartOS backups detected
|
||||
</h2>
|
||||
<h2 *ngIf="!hasValidBackup">
|
||||
<h2 *ngIf="!hasAnyBackup">
|
||||
<ion-icon name="cloud-offline-outline" color="danger"></ion-icon>
|
||||
No StartOS backup
|
||||
No StartOS backups
|
||||
</h2>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<ng-container *ngIf="cifs.mountable">
|
||||
<backup-drives-status
|
||||
[type]="type"
|
||||
[hasValidBackup]="target.hasValidBackup"
|
||||
[hasAnyBackup]="target.hasAnyBackup"
|
||||
></backup-drives-status>
|
||||
</ng-container>
|
||||
<h2 *ngIf="!cifs.mountable" class="inline">
|
||||
@@ -155,7 +155,7 @@
|
||||
<h1>{{ drive.label || drive.logicalname }}</h1>
|
||||
<backup-drives-status
|
||||
[type]="type"
|
||||
[hasValidBackup]="target.hasValidBackup"
|
||||
[hasAnyBackup]="target.hasAnyBackup"
|
||||
></backup-drives-status>
|
||||
<p>
|
||||
{{ drive.vendor || 'Unknown Vendor' }} -
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<boolean> {
|
||||
return (
|
||||
target.startOs[id] &&
|
||||
this.emver.compare(target.startOs[id].version, '0.3.6') !== -1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<ng-container
|
||||
*ngFor="let entry of spec | keyvalue: asIsOrder"
|
||||
*ngFor="let entry of spec | keyvalue : asIsOrder"
|
||||
tuiMode="onDark"
|
||||
[ngSwitch]="entry.value.type"
|
||||
[tuiTextfieldCleaner]="true"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<tui-text-area
|
||||
<tui-textarea
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
@@ -12,4 +12,4 @@
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<textarea tuiTextfield [placeholder]="spec.placeholder || ''"></textarea>
|
||||
</tui-text-area>
|
||||
</tui-textarea>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
<ion-label>
|
||||
<h2>{{ option.title }}</h2>
|
||||
<p>Version {{ option.version }}</p>
|
||||
<p>Backup made: {{ option.timestamp | date : 'medium' }}</p>
|
||||
<p *ngIf="!option.installed && !option['newer-eos']">
|
||||
<p>Created: {{ option.timestamp | date : 'medium' }}</p>
|
||||
<p *ngIf="!option.installed && !option.newerOS">
|
||||
<ion-text color="success">Ready to restore</ion-text>
|
||||
</p>
|
||||
<p *ngIf="option.installed">
|
||||
@@ -27,7 +27,7 @@
|
||||
Unavailable. {{ option.title }} is already installed.
|
||||
</ion-text>
|
||||
</p>
|
||||
<p *ngIf="option['newer-eos']">
|
||||
<p *ngIf="option.newerOS">
|
||||
<ion-text color="danger">
|
||||
Unavailable. Backup was made on a newer version of StartOS.
|
||||
</ion-text>
|
||||
@@ -36,7 +36,7 @@
|
||||
<ion-checkbox
|
||||
slot="end"
|
||||
[(ngModel)]="option.checked"
|
||||
[disabled]="option.installed || option['newer-eos']"
|
||||
[disabled]="option.installed || option.newerOS"
|
||||
(ionChange)="handleChange(options)"
|
||||
></ion-checkbox>
|
||||
</ion-item>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,35 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Select Server Backup</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<ion-item-group>
|
||||
<ion-item
|
||||
*ngFor="let server of target.entry.startOs | keyvalue"
|
||||
button
|
||||
(click)="presentModalPassword(server.key, server.value)"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>
|
||||
<b>Local Hostname</b>
|
||||
: {{ server.value.hostname }}.local
|
||||
</h2>
|
||||
<h2>
|
||||
<b>StartOS Version</b>
|
||||
: {{ server.value.version }}
|
||||
</h2>
|
||||
<h2>
|
||||
<b>Created</b>
|
||||
: {{ server.value.timestamp | date : 'medium' }}
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
@@ -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<CifsBackupTarget | DiskBackupTarget>
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Decrypt Backup</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="cancel()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<p>
|
||||
Enter the password that was used to encrypt this backup. On the next
|
||||
screen, you will select the individual services you want to restore.
|
||||
</p>
|
||||
<p>
|
||||
<tui-input-password [(ngModel)]="password">
|
||||
Enter password
|
||||
</tui-input-password>
|
||||
</p>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-button
|
||||
class="ion-padding-end"
|
||||
slot="end"
|
||||
color="dark"
|
||||
(click)="cancel()"
|
||||
>
|
||||
Cancel
|
||||
</ion-button>
|
||||
<ion-button
|
||||
class="ion-padding-end"
|
||||
slot="end"
|
||||
color="primary"
|
||||
strong="true"
|
||||
[disabled]="!password"
|
||||
(click)="confirm()"
|
||||
>
|
||||
Next
|
||||
</ion-button>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
`,
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<backup-drives
|
||||
type="restore"
|
||||
class="ion-page"
|
||||
(onSelect)="presentModalPassword($event)"
|
||||
(onSelect)="presentModalSelectServer($event)"
|
||||
></backup-drives>
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<CifsBackupTarget | DiskBackupTarget>,
|
||||
): Promise<void> {
|
||||
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<string>(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<CifsBackupTarget | DiskBackupTarget>,
|
||||
password: string,
|
||||
oldPassword?: string,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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()
|
||||
|
||||
@@ -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<DataModel>,
|
||||
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<string>(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)
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<string, StartOSDiskInfo>
|
||||
}
|
||||
|
||||
export interface CifsBackupTarget {
|
||||
@@ -412,7 +417,7 @@ export interface CifsBackupTarget {
|
||||
path: string
|
||||
username: string
|
||||
mountable: boolean
|
||||
startOs: StartOSDiskInfo | null
|
||||
startOs: Record<string, StartOSDiskInfo>
|
||||
}
|
||||
|
||||
export type RecoverySource = DiskRecoverySource | CifsRecoverySource
|
||||
|
||||
@@ -582,7 +582,7 @@ export class MockApiService extends ApiService {
|
||||
path: path.replace(/\\/g, '/'),
|
||||
username,
|
||||
mountable: true,
|
||||
startOs: null,
|
||||
startOs: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface MappedBackupTarget<T> {
|
||||
id: string
|
||||
hasValidBackup: boolean
|
||||
hasAnyBackup: boolean
|
||||
entry: T
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user