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:
Aiden McClelland
2024-07-11 11:32:46 -06:00
committed by GitHub
parent f2a02b392e
commit 87322744d4
67 changed files with 880 additions and 563 deletions

View File

@@ -25,7 +25,7 @@ PATCH_DB_CLIENT_SRC := $(shell git ls-files --recurse-submodules patch-db/client
GZIP_BIN := $(shell which pigz || which gzip) GZIP_BIN := $(shell which pigz || which gzip)
TAR_BIN := $(shell which gtar || which tar) 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 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),) ifeq ($(REMOTE),)
mkdir = mkdir -p $1 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 # For creating os images. DO NOT USE
install: $(ALL_TARGETS) install: $(ALL_TARGETS)
$(call mkdir,$(DESTDIR)/usr/bin) $(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 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/startd)
$(call ln,/usr/bin/startbox,$(DESTDIR)/usr/bin/start-cli) $(call ln,/usr/bin/startbox,$(DESTDIR)/usr/bin/start-cli)
$(call ln,/usr/bin/startbox,$(DESTDIR)/usr/bin/start-sdk) $(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 [ "$(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 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 mkdir,$(DESTDIR)/lib/systemd/system)
$(call cp,core/startos/startd.service,$(DESTDIR)/lib/systemd/system/startd.service) $(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 ARCH=aarch64 ./build-cargo-dep.sh pi-beep
cargo-deps/$(ARCH)-unknown-linux-musl/release/tokio-console: 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

View File

@@ -17,9 +17,18 @@ if [ -z "$ARCH" ]; then
ARCH=$(uname -m) ARCH=$(uname -m)
fi fi
mkdir -p cargo-deps DOCKER_PLATFORM="linux/${ARCH}"
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' 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-deps
sudo chown -R $USER ~/.cargo sudo chown -R $USER ~/.cargo

View File

@@ -14,6 +14,7 @@ e2fsprogs
ecryptfs-utils ecryptfs-utils
exfatprogs exfatprogs
flashrom flashrom
fuse3
grub-common grub-common
htop htop
httpdirfs httpdirfs

View File

@@ -164,7 +164,7 @@ pub async fn backup_all(
.decrypt(&ctx)?; .decrypt(&ctx)?;
let password = password.decrypt(&ctx)?; let password = password.decrypt(&ctx)?;
let ((fs, package_ids), status_guard) = ( let ((fs, package_ids, server_id), status_guard) = (
ctx.db ctx.db
.mutate(|db| { .mutate(|db| {
check_password_against_db(db, &password)?; check_password_against_db(db, &password)?;
@@ -181,7 +181,11 @@ pub async fn backup_all(
.collect() .collect()
}; };
assure_backing_up(db, &package_ids)?; assure_backing_up(db, &package_ids)?;
Ok((fs, package_ids)) Ok((
fs,
package_ids,
db.as_public().as_server_info().as_id().de()?,
))
}) })
.await?, .await?,
BackupStatusGuard::new(ctx.db.clone()), BackupStatusGuard::new(ctx.db.clone()),
@@ -189,6 +193,7 @@ pub async fn backup_all(
let mut backup_guard = BackupMountGuard::mount( let mut backup_guard = BackupMountGuard::mount(
TmpMountGuard::mount(&fs, ReadWrite).await?, TmpMountGuard::mount(&fs, ReadWrite).await?,
&server_id,
&old_password_decrypted, &old_password_decrypted,
) )
.await?; .await?;
@@ -298,11 +303,11 @@ async fn perform_backup(
let ui = ctx.db.peek().await.into_public().into_ui().de()?; let ui = ctx.db.peek().await.into_public().into_ui().de()?;
let mut os_backup_file = 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 .await
.with_kind(ErrorKind::Filesystem)?; .with_kind(ErrorKind::Filesystem)?;
os_backup_file os_backup_file
.write_all(&IoFormat::Cbor.to_vec(&OsBackup { .write_all(&IoFormat::Json.to_vec(&OsBackup {
account: ctx.account.read().await.clone(), account: ctx.account.read().await.clone(),
ui, ui,
})?) })?)
@@ -325,22 +330,23 @@ async fn perform_backup(
dir_copy(luks_folder, &luks_folder_bak, None).await?; 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.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.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.metadata.package_backups = package_backups;
backup_guard.save().await?; backup_guard.save_and_unmount().await?;
ctx.db ctx.db
.mutate(|v| { .mutate(|v| {
v.as_public_mut() v.as_public_mut()
.as_server_info_mut() .as_server_info_mut()
.as_last_backup_mut() .as_last_backup_mut()
.ser(&timestamp) .ser(&Some(timestamp))
}) })
.await?; .await?;

View File

@@ -44,9 +44,14 @@ pub async fn restore_packages_rpc(
password, password,
}: RestorePackageParams, }: RestorePackageParams,
) -> Result<(), Error> { ) -> Result<(), Error> {
let fs = target_id.load(&ctx.db.peek().await)?; let peek = ctx.db.peek().await;
let backup_guard = let fs = target_id.load(&peek)?;
BackupMountGuard::mount(TmpMountGuard::mount(&fs, ReadWrite).await?, &password).await?; 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?; let tasks = restore_packages(&ctx, backup_guard, ids).await?;
@@ -73,7 +78,8 @@ pub async fn recover_full_embassy(
disk_guid: Arc<String>, disk_guid: Arc<String>,
start_os_password: String, start_os_password: String,
recovery_source: TmpMountGuard, recovery_source: TmpMountGuard,
recovery_password: Option<String>, server_id: &str,
recovery_password: &str,
SetupExecuteProgress { SetupExecuteProgress {
init_phases, init_phases,
restore_phase, restore_phase,
@@ -82,14 +88,11 @@ pub async fn recover_full_embassy(
) -> Result<(SetupResult, RpcContext), Error> { ) -> Result<(SetupResult, RpcContext), Error> {
let mut restore_phase = restore_phase.or_not_found("restore progress")?; let mut restore_phase = restore_phase.or_not_found("restore progress")?;
let backup_guard = BackupMountGuard::mount( let backup_guard =
recovery_source, BackupMountGuard::mount(recovery_source, server_id, recovery_password).await?;
recovery_password.as_deref().unwrap_or_default(),
)
.await?;
let os_backup_path = backup_guard.path().join("os-backup.cbor"); let os_backup_path = backup_guard.path().join("os-backup.json");
let mut os_backup: OsBackup = IoFormat::Cbor.from_slice( let mut os_backup: OsBackup = IoFormat::Json.from_slice(
&tokio::fs::read(&os_backup_path) &tokio::fs::read(&os_backup_path)
.await .await
.with_ctx(|_| (ErrorKind::Filesystem, os_backup_path.display().to_string()))?, .with_ctx(|_| (ErrorKind::Filesystem, os_backup_path.display().to_string()))?,

View File

@@ -14,7 +14,7 @@ use crate::db::model::DatabaseModel;
use crate::disk::mount::filesystem::cifs::Cifs; use crate::disk::mount::filesystem::cifs::Cifs;
use crate::disk::mount::filesystem::ReadOnly; use crate::disk::mount::filesystem::ReadOnly;
use crate::disk::mount::guard::{GenericMountGuard, TmpMountGuard}; 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::prelude::*;
use crate::util::serde::KeyVal; use crate::util::serde::KeyVal;
@@ -43,7 +43,7 @@ pub struct CifsBackupTarget {
path: PathBuf, path: PathBuf,
username: String, username: String,
mountable: bool, mountable: bool,
start_os: Option<EmbassyOsRecoveryInfo>, start_os: BTreeMap<String, StartOsRecoveryInfo>,
} }
pub fn cifs<C: Context>() -> ParentHandler<C> { 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, path: mount_info.path,
username: mount_info.username, username: mount_info.username,
mountable: start_os.is_ok(), mountable: start_os.is_ok(),
start_os: start_os.ok().and_then(|a| a), start_os: start_os.ok().unwrap_or_default(),
}, },
)); ));
} }

View File

@@ -157,6 +157,16 @@ pub fn target<C: Context>() -> ParentHandler<C> {
}) })
.with_call_remote::<CliContext>(), .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))] // #[command(display(display_serializable))]
@@ -250,6 +260,7 @@ fn display_backup_info(params: WithIoFormat<InfoParams>, info: BackupInfo) {
#[command(rename_all = "kebab-case")] #[command(rename_all = "kebab-case")]
pub struct InfoParams { pub struct InfoParams {
target_id: BackupTargetId, target_id: BackupTargetId,
server_id: String,
password: String, password: String,
} }
@@ -258,11 +269,13 @@ pub async fn info(
ctx: RpcContext, ctx: RpcContext,
InfoParams { InfoParams {
target_id, target_id,
server_id,
password, password,
}: InfoParams, }: InfoParams,
) -> Result<BackupInfo, Error> { ) -> Result<BackupInfo, Error> {
let guard = BackupMountGuard::mount( let guard = BackupMountGuard::mount(
TmpMountGuard::mount(&target_id.load(&ctx.db.peek().await)?, ReadWrite).await?, TmpMountGuard::mount(&target_id.load(&ctx.db.peek().await)?, ReadWrite).await?,
&server_id,
&password, &password,
) )
.await?; .await?;
@@ -284,6 +297,7 @@ lazy_static::lazy_static! {
#[command(rename_all = "kebab-case")] #[command(rename_all = "kebab-case")]
pub struct MountParams { pub struct MountParams {
target_id: BackupTargetId, target_id: BackupTargetId,
server_id: String,
password: String, password: String,
} }
@@ -292,6 +306,7 @@ pub async fn mount(
ctx: RpcContext, ctx: RpcContext,
MountParams { MountParams {
target_id, target_id,
server_id,
password, password,
}: MountParams, }: MountParams,
) -> Result<String, Error> { ) -> Result<String, Error> {
@@ -303,6 +318,7 @@ pub async fn mount(
let guard = BackupMountGuard::mount( let guard = BackupMountGuard::mount(
TmpMountGuard::mount(&target_id.clone().load(&ctx.db.peek().await)?, ReadWrite).await?, TmpMountGuard::mount(&target_id.clone().load(&ctx.db.peek().await)?, ReadWrite).await?,
&server_id,
&password, &password,
) )
.await?; .await?;

View File

@@ -1,5 +1,7 @@
use std::path::{Path, PathBuf}; 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 rpc_toolkit::{from_fn_async, CallRemoteHandler, Context, Empty, HandlerExt, ParentHandler};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -102,10 +104,18 @@ fn display_disk_info(params: WithIoFormat<Empty>, args: Vec<DiskInfo>) {
} else { } else {
"N/A" "N/A"
}, },
&if let Some(eos) = part.start_os.as_ref() { &if part.start_os.is_empty() {
eos.version.to_string()
} else {
"N/A".to_owned() "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); table.add_row(row);

View File

@@ -11,9 +11,10 @@ use super::filesystem::ecryptfs::EcryptFS;
use super::guard::{GenericMountGuard, TmpMountGuard}; use super::guard::{GenericMountGuard, TmpMountGuard};
use crate::auth::check_password; use crate::auth::check_password;
use crate::backup::target::BackupInfo; use crate::backup::target::BackupInfo;
use crate::disk::mount::filesystem::backupfs::BackupFS;
use crate::disk::mount::filesystem::ReadWrite; use crate::disk::mount::filesystem::ReadWrite;
use crate::disk::mount::guard::SubPath; 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::crypto::{decrypt_slice, encrypt_slice};
use crate::util::serde::IoFormat; use crate::util::serde::IoFormat;
use crate::{Error, ErrorKind, ResultExt}; use crate::{Error, ErrorKind, ResultExt};
@@ -23,29 +24,27 @@ pub struct BackupMountGuard<G: GenericMountGuard> {
backup_disk_mount_guard: Option<G>, backup_disk_mount_guard: Option<G>,
encrypted_guard: Option<TmpMountGuard>, encrypted_guard: Option<TmpMountGuard>,
enc_key: String, enc_key: String,
pub unencrypted_metadata: EmbassyOsRecoveryInfo, unencrypted_metadata_path: PathBuf,
pub unencrypted_metadata: StartOsRecoveryInfo,
pub metadata: BackupInfo, pub metadata: BackupInfo,
} }
impl<G: GenericMountGuard> BackupMountGuard<G> { 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)] #[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 backup_disk_path = backup_disk_mount_guard.path();
let unencrypted_metadata_path = let backup_dir = backup_disk_path.join("StartOSBackups").join(server_id);
backup_disk_path.join("EmbassyBackups/unencrypted-metadata.cbor"); let unencrypted_metadata_path = backup_dir.join("unencrypted-metadata.json");
let mut unencrypted_metadata: EmbassyOsRecoveryInfo = let crypt_path = backup_dir.join("crypt");
let mut unencrypted_metadata: StartOsRecoveryInfo =
if tokio::fs::metadata(&unencrypted_metadata_path) if tokio::fs::metadata(&unencrypted_metadata_path)
.await .await
.is_ok() .is_ok()
{ {
IoFormat::Cbor.from_slice( IoFormat::Json.from_slice(
&tokio::fs::read(&unencrypted_metadata_path) &tokio::fs::read(&unencrypted_metadata_path)
.await .await
.with_ctx(|_| { .with_ctx(|_| {
@@ -56,6 +55,9 @@ impl<G: GenericMountGuard> BackupMountGuard<G> {
})?, })?,
)? )?
} else { } else {
if tokio::fs::metadata(&crypt_path).await.is_ok() {
tokio::fs::remove_dir_all(&crypt_path).await?;
}
Default::default() Default::default()
}; };
let enc_key = if let (Some(hash), Some(wrapped_key)) = ( 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() { if tokio::fs::metadata(&crypt_path).await.is_err() {
tokio::fs::create_dir_all(&crypt_path).await.with_ctx(|_| { tokio::fs::create_dir_all(&crypt_path).await.with_ctx(|_| {
( (
@@ -106,11 +107,11 @@ impl<G: GenericMountGuard> BackupMountGuard<G> {
})?; })?;
} }
let encrypted_guard = 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() { 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, crate::ErrorKind::Filesystem,
metadata_path.display().to_string(), metadata_path.display().to_string(),
@@ -124,6 +125,7 @@ impl<G: GenericMountGuard> BackupMountGuard<G> {
backup_disk_mount_guard: Some(backup_disk_mount_guard), backup_disk_mount_guard: Some(backup_disk_mount_guard),
encrypted_guard: Some(encrypted_guard), encrypted_guard: Some(encrypted_guard),
enc_key, enc_key,
unencrypted_metadata_path,
unencrypted_metadata, unencrypted_metadata,
metadata, metadata,
}) })
@@ -152,20 +154,17 @@ impl<G: GenericMountGuard> BackupMountGuard<G> {
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn save(&self) -> Result<(), Error> { pub async fn save(&self) -> Result<(), Error> {
let metadata_path = self.path().join("metadata.cbor"); let metadata_path = self.path().join("metadata.json");
let backup_disk_path = self.backup_disk_path();
let mut file = AtomicFile::new(&metadata_path, None::<PathBuf>) let mut file = AtomicFile::new(&metadata_path, None::<PathBuf>)
.await .await
.with_kind(ErrorKind::Filesystem)?; .with_kind(ErrorKind::Filesystem)?;
file.write_all(&IoFormat::Cbor.to_vec(&self.metadata)?) file.write_all(&IoFormat::Json.to_vec(&self.metadata)?)
.await?; .await?;
file.save().await.with_kind(ErrorKind::Filesystem)?; file.save().await.with_kind(ErrorKind::Filesystem)?;
let unencrypted_metadata_path = let mut file = AtomicFile::new(&self.unencrypted_metadata_path, None::<PathBuf>)
backup_disk_path.join("EmbassyBackups/unencrypted-metadata.cbor");
let mut file = AtomicFile::new(&unencrypted_metadata_path, None::<PathBuf>)
.await .await
.with_kind(ErrorKind::Filesystem)?; .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?; .await?;
file.save().await.with_kind(ErrorKind::Filesystem)?; file.save().await.with_kind(ErrorKind::Filesystem)?;
Ok(()) Ok(())

View 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())
}
}

View File

@@ -1,6 +1,7 @@
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fmt::{Display, Write}; use std::fmt::{Display, Write};
use std::path::Path; use std::path::Path;
use std::time::Duration;
use digest::generic_array::GenericArray; use digest::generic_array::GenericArray;
use digest::OutputSizeUser; use digest::OutputSizeUser;
@@ -11,6 +12,7 @@ use tokio::process::Command;
use crate::prelude::*; use crate::prelude::*;
use crate::util::Invoke; use crate::util::Invoke;
pub mod backupfs;
pub mod bind; pub mod bind;
pub mod block_dev; pub mod block_dev;
pub mod cifs; pub mod cifs;
@@ -71,6 +73,7 @@ pub(self) async fn default_mount_impl(
fs.pre_mount().await?; fs.pre_mount().await?;
tokio::fs::create_dir_all(mountpoint.as_ref()).await?; tokio::fs::create_dir_all(mountpoint.as_ref()).await?;
Command::from(default_mount_command(fs, mountpoint, mount_type).await?) Command::from(default_mount_command(fs, mountpoint, mount_type).await?)
.capture(false)
.invoke(ErrorKind::Filesystem) .invoke(ErrorKind::Filesystem)
.await?; .await?;

View File

@@ -1,6 +1,7 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use color_eyre::eyre::{self, eyre}; use color_eyre::eyre::{self, eyre};
use futures::TryStreamExt; use futures::TryStreamExt;
use nom::bytes::complete::{tag, take_till1}; use nom::bytes::complete::{tag, take_till1};
@@ -19,6 +20,7 @@ use super::mount::filesystem::ReadOnly;
use super::mount::guard::TmpMountGuard; use super::mount::guard::TmpMountGuard;
use crate::disk::mount::guard::GenericMountGuard; use crate::disk::mount::guard::GenericMountGuard;
use crate::disk::OsPartitionInfo; use crate::disk::OsPartitionInfo;
use crate::hostname::Hostname;
use crate::util::serde::IoFormat; use crate::util::serde::IoFormat;
use crate::util::Invoke; use crate::util::Invoke;
use crate::{Error, ResultExt as _}; use crate::{Error, ResultExt as _};
@@ -49,15 +51,16 @@ pub struct PartitionInfo {
pub label: Option<String>, pub label: Option<String>,
pub capacity: u64, pub capacity: u64,
pub used: Option<u64>, pub used: Option<u64>,
pub start_os: Option<EmbassyOsRecoveryInfo>, pub start_os: BTreeMap<String, StartOsRecoveryInfo>,
pub guid: Option<String>, pub guid: Option<String>,
} }
#[derive(Clone, Debug, Default, Deserialize, Serialize)] #[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct EmbassyOsRecoveryInfo { pub struct StartOsRecoveryInfo {
pub hostname: Hostname,
pub version: exver::Version, pub version: exver::Version,
pub full: bool, pub timestamp: DateTime<Utc>,
pub password_hash: Option<String>, pub password_hash: Option<String>,
pub wrapped_key: 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( pub async fn recovery_info(
mountpoint: impl AsRef<Path>, mountpoint: impl AsRef<Path>,
) -> Result<Option<EmbassyOsRecoveryInfo>, Error> { ) -> Result<BTreeMap<String, StartOsRecoveryInfo>, Error> {
let backup_unencrypted_metadata_path = mountpoint let backup_root = mountpoint.as_ref().join("StartOSBackups");
.as_ref() let mut res = BTreeMap::new();
.join("EmbassyBackups/unencrypted-metadata.cbor"); if tokio::fs::metadata(&backup_root).await.is_ok() {
if tokio::fs::metadata(&backup_unencrypted_metadata_path) let mut dir = tokio::fs::read_dir(&backup_root).await?;
.await while let Some(entry) = dir.next_entry().await? {
.is_ok() let server_id = entry.file_name().to_string_lossy().into_owned();
{ let backup_unencrypted_metadata_path = backup_root
return Ok(Some( .join(&server_id)
IoFormat::Cbor.from_slice( .join("unencrypted-metadata.json");
&tokio::fs::read(&backup_unencrypted_metadata_path) if tokio::fs::metadata(&backup_unencrypted_metadata_path)
.await .await
.with_ctx(|_| { .is_ok()
( {
crate::ErrorKind::Filesystem, res.insert(
backup_unencrypted_metadata_path.display().to_string(), 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)] #[instrument(skip_all)]
@@ -390,7 +402,7 @@ async fn disk_info(disk: PathBuf) -> DiskInfo {
} }
async fn part_info(part: PathBuf) -> PartitionInfo { async fn part_info(part: PathBuf) -> PartitionInfo {
let mut start_os = None; let mut start_os = BTreeMap::new();
let label = get_label(&part) let label = get_label(&part)
.await .await
.map_err(|e| tracing::warn!("Could not get label of {}: {}", part.display(), e.source)) .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) tracing::warn!("Could not get usage of {}: {}", part.display(), e.source)
}) })
.ok(); .ok();
if let Some(recovery_info) = match recovery_info(mount_guard.path()).await { match recovery_info(mount_guard.path()).await {
Ok(a) => a, Ok(a) => {
start_os = a;
}
Err(e) => { Err(e) => {
tracing::error!("Error fetching unencrypted backup metadata: {}", e); tracing::error!("Error fetching unencrypted backup metadata: {}", e);
None
} }
} {
start_os = Some(recovery_info)
} }
if let Err(e) = mount_guard.unmount().await { if let Err(e) = mount_guard.unmount().await {
tracing::error!("Error unmounting partition {}: {}", part.display(), e); tracing::error!("Error unmounting partition {}: {}", part.display(), e);

View File

@@ -4,7 +4,7 @@ use tracing::instrument;
use crate::util::Invoke; use crate::util::Invoke;
use crate::{Error, ErrorKind}; use crate::{Error, ErrorKind};
#[derive(Clone, serde::Deserialize, serde::Serialize, Debug)] #[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct Hostname(pub String); pub struct Hostname(pub String);
lazy_static::lazy_static! { lazy_static::lazy_static! {

View File

@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; 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::cifs::Cifs;
use crate::disk::mount::filesystem::ReadWrite; use crate::disk::mount::filesystem::ReadWrite;
use crate::disk::mount::guard::{GenericMountGuard, TmpMountGuard}; 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::disk::REPAIR_DISK_PATH;
use crate::init::{init, InitPhases, InitResult}; use crate::init::{init, InitPhases, InitResult};
use crate::net::net_controller::PreInitNetController; use crate::net::net_controller::PreInitNetController;
@@ -237,7 +238,7 @@ pub async fn verify_cifs(
username, username,
password, password,
}: VerifyCifsParams, }: VerifyCifsParams,
) -> Result<EmbassyOsRecoveryInfo, Error> { ) -> Result<BTreeMap<String, StartOsRecoveryInfo>, Error> {
let password: Option<String> = password.map(|x| x.decrypt(&ctx)).flatten(); let password: Option<String> = password.map(|x| x.decrypt(&ctx)).flatten();
let guard = TmpMountGuard::mount( let guard = TmpMountGuard::mount(
&Cifs { &Cifs {
@@ -251,15 +252,28 @@ pub async fn verify_cifs(
.await?; .await?;
let start_os = recovery_info(guard.path()).await?; let start_os = recovery_info(guard.path()).await?;
guard.unmount().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)] #[derive(Debug, Deserialize, Serialize, TS)]
#[serde(tag = "type")] #[serde(tag = "type")]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum RecoverySource { #[serde(rename_all_fields = "camelCase")]
Migrate { guid: String }, pub enum RecoverySource<Password> {
Backup { target: BackupTargetFS }, Migrate {
guid: String,
},
Backup {
target: BackupTargetFS,
password: Password,
server_id: String,
},
} }
#[derive(Deserialize, Serialize, TS)] #[derive(Deserialize, Serialize, TS)]
@@ -268,8 +282,7 @@ pub enum RecoverySource {
pub struct SetupExecuteParams { pub struct SetupExecuteParams {
start_os_logicalname: PathBuf, start_os_logicalname: PathBuf,
start_os_password: EncryptedWire, start_os_password: EncryptedWire,
recovery_source: Option<RecoverySource>, recovery_source: Option<RecoverySource<EncryptedWire>>,
recovery_password: Option<EncryptedWire>,
} }
// #[command(rpc_only)] // #[command(rpc_only)]
@@ -279,7 +292,6 @@ pub async fn execute(
start_os_logicalname, start_os_logicalname,
start_os_password, start_os_password,
recovery_source, recovery_source,
recovery_password,
}: SetupExecuteParams, }: SetupExecuteParams,
) -> Result<SetupProgress, Error> { ) -> Result<SetupProgress, Error> {
let start_os_password = match start_os_password.decrypt(&ctx) { 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 { let recovery = match recovery_source {
Some(a) => match a.decrypt(&ctx) { Some(RecoverySource::Backup {
Some(a) => Some(a), target,
None => { password,
return Err(Error::new( server_id,
}) => Some(RecoverySource::Backup {
target,
password: password.decrypt(&ctx).ok_or_else(|| {
Error::new(
color_eyre::eyre::eyre!("Couldn't decode recoveryPassword"), color_eyre::eyre::eyre!("Couldn't decode recoveryPassword"),
crate::ErrorKind::Unknown, crate::ErrorKind::Unknown,
)) )
} })?,
}, server_id,
}),
Some(RecoverySource::Migrate { guid }) => Some(RecoverySource::Migrate { guid }),
None => None, None => None,
}; };
let setup_ctx = ctx.clone(); let setup_ctx = ctx.clone();
ctx.run_setup(|| { ctx.run_setup(|| execute_inner(setup_ctx, start_os_logicalname, start_os_password, recovery))?;
execute_inner(
setup_ctx,
start_os_logicalname,
start_os_password,
recovery_source,
recovery_password,
)
})?;
Ok(ctx.progress().await) Ok(ctx.progress().await)
} }
@@ -348,12 +358,11 @@ pub async fn execute_inner(
ctx: SetupContext, ctx: SetupContext,
start_os_logicalname: PathBuf, start_os_logicalname: PathBuf,
start_os_password: String, start_os_password: String,
recovery_source: Option<RecoverySource>, recovery_source: Option<RecoverySource<String>>,
recovery_password: Option<String>,
) -> Result<(SetupResult, RpcContext), Error> { ) -> Result<(SetupResult, RpcContext), Error> {
let progress = &ctx.progress; let progress = &ctx.progress;
let mut disk_phase = progress.add_phase("Formatting data drive".into(), Some(10)); 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(RecoverySource::Backup { .. }) => {
Some(progress.add_phase("Restoring backup".into(), Some(100))) Some(progress.add_phase("Restoring backup".into(), Some(100)))
} }
@@ -396,13 +405,18 @@ pub async fn execute_inner(
}; };
match recovery_source { match recovery_source {
Some(RecoverySource::Backup { target }) => { Some(RecoverySource::Backup {
target,
password,
server_id,
}) => {
recover( recover(
&ctx, &ctx,
guid, guid,
start_os_password, start_os_password,
target, target,
recovery_password, server_id,
password,
progress, progress,
) )
.await .await
@@ -448,7 +462,8 @@ async fn recover(
guid: Arc<String>, guid: Arc<String>,
start_os_password: String, start_os_password: String,
recovery_source: BackupTargetFS, recovery_source: BackupTargetFS,
recovery_password: Option<String>, server_id: String,
recovery_password: String,
progress: SetupExecuteProgress, progress: SetupExecuteProgress,
) -> Result<(SetupResult, RpcContext), Error> { ) -> Result<(SetupResult, RpcContext), Error> {
let recovery_source = TmpMountGuard::mount(&recovery_source, ReadWrite).await?; let recovery_source = TmpMountGuard::mount(&recovery_source, ReadWrite).await?;
@@ -457,7 +472,8 @@ async fn recover(
guid.clone(), guid.clone(),
start_os_password, start_os_password,
recovery_source, recovery_source,
recovery_password, &server_id,
&recovery_password,
progress, progress,
) )
.await .await

View File

@@ -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) 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)] #[derive(Debug, Serialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum ValuePrimative { pub enum ValuePrimitive {
Null, Null,
Boolean(bool), Boolean(bool),
String(String), String(String),
Number(serde_json::Number), 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> fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where where
D: serde::de::Deserializer<'de>, D: serde::de::Deserializer<'de>,
{ {
struct Visitor; struct Visitor;
impl<'de> serde::de::Visitor<'de> for 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 { fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "a JSON primative value") write!(formatter, "a JSON primative value")
} }
@@ -172,37 +130,37 @@ impl<'de> serde::de::Deserialize<'de> for ValuePrimative {
where where
E: serde::de::Error, E: serde::de::Error,
{ {
Ok(ValuePrimative::Null) Ok(ValuePrimitive::Null)
} }
fn visit_none<E>(self) -> Result<Self::Value, E> fn visit_none<E>(self) -> Result<Self::Value, E>
where where
E: serde::de::Error, E: serde::de::Error,
{ {
Ok(ValuePrimative::Null) Ok(ValuePrimitive::Null)
} }
fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E> fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
where where
E: serde::de::Error, E: serde::de::Error,
{ {
Ok(ValuePrimative::Boolean(v)) Ok(ValuePrimitive::Boolean(v))
} }
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where where
E: serde::de::Error, 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> fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
where where
E: serde::de::Error, E: serde::de::Error,
{ {
Ok(ValuePrimative::String(v)) Ok(ValuePrimitive::String(v))
} }
fn visit_f32<E>(self, v: f32) -> Result<Self::Value, E> fn visit_f32<E>(self, v: f32) -> Result<Self::Value, E>
where where
E: serde::de::Error, E: serde::de::Error,
{ {
Ok(ValuePrimative::Number( Ok(ValuePrimitive::Number(
serde_json::Number::from_f64(v as f64).ok_or_else(|| { serde_json::Number::from_f64(v as f64).ok_or_else(|| {
serde::de::Error::invalid_value( serde::de::Error::invalid_value(
serde::de::Unexpected::Float(v as f64), serde::de::Unexpected::Float(v as f64),
@@ -215,7 +173,7 @@ impl<'de> serde::de::Deserialize<'de> for ValuePrimative {
where where
E: serde::de::Error, E: serde::de::Error,
{ {
Ok(ValuePrimative::Number( Ok(ValuePrimitive::Number(
serde_json::Number::from_f64(v).ok_or_else(|| { serde_json::Number::from_f64(v).ok_or_else(|| {
serde::de::Error::invalid_value( serde::de::Error::invalid_value(
serde::de::Unexpected::Float(v), serde::de::Unexpected::Float(v),
@@ -228,49 +186,49 @@ impl<'de> serde::de::Deserialize<'de> for ValuePrimative {
where where
E: serde::de::Error, 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> fn visit_u16<E>(self, v: u16) -> Result<Self::Value, E>
where where
E: serde::de::Error, 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> fn visit_u32<E>(self, v: u32) -> Result<Self::Value, E>
where where
E: serde::de::Error, 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> fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
where where
E: serde::de::Error, 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> fn visit_i8<E>(self, v: i8) -> Result<Self::Value, E>
where where
E: serde::de::Error, 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> fn visit_i16<E>(self, v: i16) -> Result<Self::Value, E>
where where
E: serde::de::Error, 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> fn visit_i32<E>(self, v: i32) -> Result<Self::Value, E>
where where
E: serde::de::Error, 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> fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
where where
E: serde::de::Error, E: serde::de::Error,
{ {
Ok(ValuePrimative::Number(v.into())) Ok(ValuePrimitive::Number(v.into()))
} }
} }
deserializer.deserialize_any(Visitor) deserializer.deserialize_any(Visitor)

1
foo
View File

@@ -1 +0,0 @@
firmware/x86_64/foo.romr.gz

View File

@@ -1,6 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // 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" import type { BackupTargetFS } from "./BackupTargetFS"
export type RecoverySource = export type RecoverySource<Password> =
| { type: "migrate"; guid: string } | { type: "migrate"; guid: string }
| { type: "backup"; target: BackupTargetFS } | {
type: "backup"
target: BackupTargetFS
password: Password
serverId: string
}

View File

@@ -5,6 +5,5 @@ import type { RecoverySource } from "./RecoverySource"
export type SetupExecuteParams = { export type SetupExecuteParams = {
startOsLogicalname: string startOsLogicalname: string
startOsPassword: EncryptedWire startOsPassword: EncryptedWire
recoverySource: RecoverySource | null recoverySource: RecoverySource<EncryptedWire> | null
recoveryPassword: EncryptedWire | null
} }

View File

@@ -18,11 +18,14 @@ export class MockApiService implements ApiService {
capacity: 73264762332, capacity: 73264762332,
used: null, used: null,
startOs: { startOs: {
version: '0.2.17', '1234-5678-9876-5432': {
full: true, hostname: 'adjective-noun',
passwordHash: timestamp: new Date().toISOString(),
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', version: '0.2.17',
wrappedKey: null, passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
}, },
guid: null, guid: null,
}, },
@@ -41,11 +44,14 @@ export class MockApiService implements ApiService {
capacity: 73264762332, capacity: 73264762332,
used: null, used: null,
startOs: { startOs: {
version: '0.3.3', '1234-5678-9876-5432': {
full: true, hostname: 'adjective-noun',
passwordHash: timestamp: new Date().toISOString(),
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', version: '0.2.17',
wrappedKey: null, passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
}, },
guid: null, guid: null,
}, },
@@ -64,11 +70,14 @@ export class MockApiService implements ApiService {
capacity: 73264762332, capacity: 73264762332,
used: null, used: null,
startOs: { startOs: {
version: '0.3.2', '1234-5678-9876-5432': {
full: true, hostname: 'adjective-noun',
passwordHash: timestamp: new Date().toISOString(),
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', version: '0.2.17',
wrappedKey: null, passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
}, },
guid: 'guid-guid-guid-guid', guid: 'guid-guid-guid-guid',
}, },

View File

@@ -3,10 +3,11 @@ import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular' import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { CifsModal } from './cifs-modal.page' import { CifsModal } from './cifs-modal.page'
import { ServerBackupSelectModule } from '../server-backup-select/server-backup-select.module'
@NgModule({ @NgModule({
declarations: [CifsModal], declarations: [CifsModal],
imports: [CommonModule, FormsModule, IonicModule], imports: [CommonModule, FormsModule, IonicModule, ServerBackupSelectModule],
exports: [CifsModal], exports: [CifsModal],
}) })
export class CifsModalModule {} export class CifsModalModule {}

View File

@@ -4,9 +4,9 @@ import {
LoadingController, LoadingController,
ModalController, ModalController,
} from '@ionic/angular' } 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 { StartOSDiskInfo } from '@start9labs/shared'
import { PasswordPage } from '../password/password.page' import { ServerBackupSelectModal } from '../server-backup-select/server-backup-select.page'
@Component({ @Component({
selector: 'cifs-modal', selector: 'cifs-modal',
@@ -50,30 +50,29 @@ export class CifsModal {
await loader.dismiss() await loader.dismiss()
this.presentModalPassword(diskInfo) this.presentModalSelectServer(diskInfo)
} catch (e) { } catch (e) {
await loader.dismiss() await loader.dismiss()
this.presentAlertFailed() this.presentAlertFailed()
} }
} }
private async presentModalPassword(diskInfo: StartOSDiskInfo): Promise<void> { private async presentModalSelectServer(
const target: CifsBackupTarget = { servers: Record<string, StartOSDiskInfo>,
...this.cifs, ): Promise<void> {
mountable: true,
startOs: diskInfo,
}
const modal = await this.modalController.create({ const modal = await this.modalController.create({
component: PasswordPage, component: ServerBackupSelectModal,
componentProps: { target }, componentProps: {
servers: Object.keys(servers).map(id => ({ id, ...servers[id] })),
},
}) })
modal.onDidDismiss().then(res => { modal.onDidDismiss().then(res => {
if (res.role === 'success') { if (res.role === 'success') {
this.modalController.dismiss( this.modalController.dismiss(
{ {
cifs: this.cifs, cifs: this.cifs,
recoveryPassword: res.data.password, serverId: res.data.serverId,
recoveryPassword: res.data.recoveryPassword,
}, },
'success', 'success',
) )

View File

@@ -1,9 +1,5 @@
import { Component, Input, ViewChild } from '@angular/core' import { Component, Input, ViewChild } from '@angular/core'
import { IonInput, ModalController } from '@ionic/angular' import { IonInput, ModalController } from '@ionic/angular'
import {
CifsBackupTarget,
DiskBackupTarget,
} from 'src/app/services/api/api.service'
import * as argon2 from '@start9labs/argon2' import * as argon2 from '@start9labs/argon2'
@Component({ @Component({
@@ -13,7 +9,7 @@ import * as argon2 from '@start9labs/argon2'
}) })
export class PasswordPage { export class PasswordPage {
@ViewChild('focusInput') elem?: IonInput @ViewChild('focusInput') elem?: IonInput
@Input() target?: CifsBackupTarget | DiskBackupTarget @Input() passwordHash = ''
@Input() storageDrive = false @Input() storageDrive = false
pwError = '' pwError = ''
@@ -31,13 +27,8 @@ export class PasswordPage {
} }
async verifyPw() { async verifyPw() {
if (!this.target || !this.target.startOs)
this.pwError = 'No recovery target' // unreachable
try { try {
const passwordHash = this.target!.startOs?.passwordHash || '' argon2.verify(this.passwordHash, this.password)
argon2.verify(passwordHash, this.password)
this.modalController.dismiss({ password: this.password }, 'success') this.modalController.dismiss({ password: this.password }, 'success')
} catch (e) { } catch (e) {
this.pwError = 'Incorrect password provided' this.pwError = 'Incorrect password provided'
@@ -55,7 +46,7 @@ export class PasswordPage {
} }
validate() { validate() {
if (!!this.target) return (this.pwError = '') if (!!this.passwordHash) return (this.pwError = '')
if (this.passwordVer) { if (this.passwordVer) {
this.checkVer() this.checkVer()

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -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()
}
}

View File

@@ -9,6 +9,7 @@ import { DiskInfo, ErrorService, GuidPipe } from '@start9labs/shared'
import { ApiService } from 'src/app/services/api/api.service' import { ApiService } from 'src/app/services/api/api.service'
import { StateService } from 'src/app/services/state.service' import { StateService } from 'src/app/services/state.service'
import { PasswordPage } from '../../modals/password/password.page' import { PasswordPage } from '../../modals/password/password.page'
import { T } from '@start9labs/start-sdk'
@Component({ @Component({
selector: 'app-embassy', selector: 'app-embassy',
@@ -50,15 +51,19 @@ export class EmbassyPage {
const disks = await this.apiService.getDrives() const disks = await this.apiService.getDrives()
if (this.stateService.setupType === 'fresh') { if (this.stateService.setupType === 'fresh') {
this.storageDrives = disks this.storageDrives = disks
} else if (this.stateService.setupType === 'restore') { } else if (
this.storageDrives = disks.filter( this.stateService.setupType === 'restore' &&
d => this.stateService.recoverySource?.type === 'backup'
this.stateService.recoverySource?.type === 'backup' && ) {
this.stateService.recoverySource.target?.type === 'disk' && if (this.stateService.recoverySource.target.type === 'disk') {
!d.partitions const logicalname =
.map(p => p.logicalname) this.stateService.recoverySource.target.logicalname
.includes(this.stateService.recoverySource.target.logicalname), this.storageDrives = disks.filter(
) d => !d.partitions.map(p => p.logicalname).includes(logicalname),
)
} else {
this.storageDrives = disks
}
} else if ( } else if (
this.stateService.setupType === 'transfer' && this.stateService.setupType === 'transfer' &&
this.stateService.recoverySource?.type === 'migrate' this.stateService.recoverySource?.type === 'migrate'
@@ -95,10 +100,10 @@ export class EmbassyPage {
text: 'Continue', text: 'Continue',
handler: () => { handler: () => {
// for backup recoveries // for backup recoveries
if (this.stateService.recoveryPassword) { if (this.stateService.recoverySource?.type === 'backup') {
this.setupEmbassy( this.setupEmbassy(
drive.logicalname, drive.logicalname,
this.stateService.recoveryPassword, this.stateService.recoverySource.password,
) )
} else { } else {
// for migrations and fresh setups // for migrations and fresh setups
@@ -111,8 +116,11 @@ export class EmbassyPage {
await alert.present() await alert.present()
} else { } else {
// for backup recoveries // for backup recoveries
if (this.stateService.recoveryPassword) { if (this.stateService.recoverySource?.type === 'backup') {
this.setupEmbassy(drive.logicalname, this.stateService.recoveryPassword) this.setupEmbassy(
drive.logicalname,
this.stateService.recoverySource.password,
)
} else { } else {
// for migrations and fresh setups // for migrations and fresh setups
this.presentModalPassword(drive.logicalname) 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'
}

View File

@@ -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>

View File

@@ -3,13 +3,13 @@ import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular' import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { UnitConversionPipesModule } from '@start9labs/shared' import { UnitConversionPipesModule } from '@start9labs/shared'
import { DriveStatusComponent, RecoverPage } from './recover.page' import { RecoverPage } from './recover.page'
import { PasswordPageModule } from '../../modals/password/password.module' import { PasswordPageModule } from '../../modals/password/password.module'
import { RecoverPageRoutingModule } from './recover-routing.module' import { RecoverPageRoutingModule } from './recover-routing.module'
import { CifsModalModule } from 'src/app/modals/cifs-modal/cifs-modal.module' import { CifsModalModule } from 'src/app/modals/cifs-modal/cifs-modal.module'
@NgModule({ @NgModule({
declarations: [RecoverPage, DriveStatusComponent], declarations: [RecoverPage],
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,

View File

@@ -54,29 +54,21 @@
</b> </b>
</div> </div>
<ng-container *ngFor="let mapped of mappedDrives"> <ng-container *ngFor="let server of servers">
<ion-item <ion-item button (click)="select(server)" lines="none">
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>
<ion-label> <ion-label>
<h1>{{ drive.label || drive.logicalname }}</h1> <h2>
<drive-status <b>Local Hostname</b>
[hasValidBackup]="mapped.hasValidBackup" : {{ server.hostname }}.local
></drive-status> </h2>
<p> <h2>
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model || <b>StartOS Version</b>
'Unknown Model' }} : {{ server.version }}
</p> </h2>
<p>Capacity: {{ drive.capacity | convertBytes }}</p> <h2>
<b>Created</b>
: {{ server.timestamp | date : 'medium' }}
</h2>
</ion-label> </ion-label>
</ion-item> </ion-item>
</ng-container> </ng-container>

View File

@@ -1,8 +1,11 @@
import { Component, Input } from '@angular/core' import { Component } from '@angular/core'
import { ModalController, NavController } from '@ionic/angular' import { ModalController, NavController } from '@ionic/angular'
import { ErrorService } from '@start9labs/shared' import { ErrorService } from '@start9labs/shared'
import { CifsModal } from 'src/app/modals/cifs-modal/cifs-modal.page' 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 { StateService } from 'src/app/services/state.service'
import { PasswordPage } from '../../modals/password/password.page' import { PasswordPage } from '../../modals/password/password.page'
@@ -13,7 +16,7 @@ import { PasswordPage } from '../../modals/password/password.page'
}) })
export class RecoverPage { export class RecoverPage {
loading = true loading = true
mappedDrives: MappedDisk[] = [] servers: StartOSDiskInfoWithId[] = []
constructor( constructor(
private readonly apiService: ApiService, private readonly apiService: ApiService,
@@ -34,33 +37,19 @@ export class RecoverPage {
await this.getDrives() await this.getDrives()
} }
driveClickable(mapped: MappedDisk) {
return mapped.drive.startOs?.full
}
async getDrives() { async getDrives() {
this.mappedDrives = []
try { try {
const disks = await this.apiService.getDrives() const drives = await this.apiService.getDrives()
disks this.servers = drives.flatMap(drive =>
.filter(d => d.partitions.length) drive.partitions.flatMap(partition =>
.forEach(d => { Object.entries(partition.startOs).map(([id, val]) => ({
d.partitions.forEach(p => { id,
const drive: DiskBackupTarget = { ...val,
vendor: d.vendor, partition,
model: d.model, drive,
logicalname: p.logicalname, })),
label: p.label, ),
capacity: p.capacity, )
used: p.used,
startOs: p.startOs,
}
this.mappedDrives.push({
hasValidBackup: !!p.startOs?.full,
drive,
})
})
})
} catch (e: any) { } catch (e: any) {
this.errorService.handleError(e) this.errorService.handleError(e)
} finally { } finally {
@@ -74,65 +63,41 @@ export class RecoverPage {
}) })
modal.onDidDismiss().then(res => { modal.onDidDismiss().then(res => {
if (res.role === 'success') { if (res.role === 'success') {
const { hostname, path, username, password } = res.data.cifs
this.stateService.recoverySource = { this.stateService.recoverySource = {
type: 'backup', type: 'backup',
target: { target: {
type: 'cifs', type: 'cifs',
hostname, ...res.data.cifs,
path,
username,
password,
}, },
serverId: res.data.serverId,
password: res.data.recoveryPassword,
} }
this.stateService.recoveryPassword = res.data.recoveryPassword
this.navCtrl.navigateForward('/storage') this.navCtrl.navigateForward('/storage')
} }
}) })
await modal.present() await modal.present()
} }
async select(target: DiskBackupTarget) { async select(server: StartOSDiskInfoWithId) {
const { logicalname } = target
if (!logicalname) return
const modal = await this.modalController.create({ const modal = await this.modalController.create({
component: PasswordPage, component: PasswordPage,
componentProps: { target }, componentProps: { passwordHash: server.passwordHash },
cssClass: 'alertlike-modal', cssClass: 'alertlike-modal',
}) })
modal.onDidDismiss().then(res => { modal.onDidDismiss().then(res => {
if (res.data?.password) { if (res.role === 'success') {
this.selectRecoverySource(logicalname, res.data.password) 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() 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
} }

View File

@@ -3,7 +3,6 @@ import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular' import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { ResponsiveColModule } from '@start9labs/shared' import { ResponsiveColModule } from '@start9labs/shared'
import { SuccessPage } from './success.page' import { SuccessPage } from './success.page'
import { PasswordPageModule } from '../../modals/password/password.module' import { PasswordPageModule } from '../../modals/password/password.module'
import { SuccessPageRoutingModule } from './success-routing.module' import { SuccessPageRoutingModule } from './success-routing.module'

View File

@@ -8,7 +8,6 @@ import { StateService } from 'src/app/services/state.service'
selector: 'success', selector: 'success',
templateUrl: 'success.page.html', templateUrl: 'success.page.html',
styleUrls: ['success.page.scss'], styleUrls: ['success.page.scss'],
providers: [DownloadHTMLService],
}) })
export class SuccessPage { export class SuccessPage {
@ViewChild('canvas', { static: true }) @ViewChild('canvas', { static: true })

View File

@@ -1,5 +1,10 @@
import * as jose from 'node-jose' 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 { T } from '@start9labs/start-sdk'
import { WebSocketSubjectConfig } from 'rxjs/webSocket' import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
@@ -10,14 +15,16 @@ export abstract class ApiService {
abstract getStatus(): Promise<T.SetupStatusRes | null> // setup.status abstract getStatus(): Promise<T.SetupStatusRes | null> // setup.status
abstract getPubKey(): Promise<void> // setup.get-pubkey abstract getPubKey(): Promise<void> // setup.get-pubkey
abstract getDrives(): Promise<DiskListResponse> // setup.disk.list 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 attach(importInfo: T.AttachParams): Promise<T.SetupProgress> // setup.attach
abstract execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> // setup.execute abstract execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
abstract complete(): Promise<T.SetupResult> // setup.complete abstract complete(): Promise<T.SetupResult> // setup.complete
abstract exit(): Promise<void> // setup.exit abstract exit(): Promise<void> // setup.exit
abstract openProgressWebsocket$(guid: string): Observable<T.FullProgress> 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!') if (!this.pubkey) throw new Error('No pubkey found!')
const encrypted = await jose.JWE.createEncrypt(this.pubkey!) const encrypted = await jose.JWE.createEncrypt(this.pubkey!)
.update(toEncrypt) .update(toEncrypt)
@@ -28,26 +35,13 @@ export abstract class ApiService {
} }
} }
type Encrypted = {
encrypted: string
}
export type WebsocketConfig<T> = Omit<WebSocketSubjectConfig<T>, 'url'> export type WebsocketConfig<T> = Omit<WebSocketSubjectConfig<T>, 'url'>
export type DiskBackupTarget = { export type StartOSDiskInfoWithId = StartOSDiskInfo & {
vendor: string | null id: string
model: string | null
logicalname: string | null
label: string | null
capacity: number
used: number | null
startOs: StartOSDiskInfo | null
} }
export type CifsBackupTarget = { export type StartOSDiskInfoFull = StartOSDiskInfoWithId & {
hostname: string partition: PartitionInfo
path: string drive: DiskInfo
username: string
mountable: boolean
startOs: StartOSDiskInfo | null
} }

View File

@@ -9,7 +9,7 @@ import {
RPCOptions, RPCOptions,
} from '@start9labs/shared' } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
import { ApiService, WebsocketConfig } from './api.service' import { ApiService } from './api.service'
import * as jose from 'node-jose' import * as jose from 'node-jose'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { DOCUMENT } from '@angular/common' 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', '/') source.path = source.path.replace('/\\/g', '/')
return this.rpcRequest<StartOSDiskInfo>({ return this.rpcRequest<Record<string, StartOSDiskInfo>>({
method: 'setup.cifs.verify', method: 'setup.cifs.verify',
params: source, params: source,
}) })

View File

@@ -175,11 +175,14 @@ export class MockApiService extends ApiService {
capacity: 1979120929996, capacity: 1979120929996,
used: null, used: null,
startOs: { startOs: {
version: '0.2.17', '1234-5678-9876-5432': {
full: true, hostname: 'adjective-noun',
passwordHash: version: '0.2.17',
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', timestamp: new Date().toISOString(),
wrappedKey: null, passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
}, },
guid: null, guid: null,
}, },
@@ -198,11 +201,14 @@ export class MockApiService extends ApiService {
capacity: 73264762332, capacity: 73264762332,
used: null, used: null,
startOs: { startOs: {
version: '0.3.3', '1234-5678-9876-5432': {
full: true, hostname: 'adjective-noun',
passwordHash: version: '0.2.17',
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', timestamp: new Date().toISOString(),
wrappedKey: null, passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
}, },
guid: null, guid: null,
}, },
@@ -221,11 +227,14 @@ export class MockApiService extends ApiService {
capacity: 73264762332, capacity: 73264762332,
used: null, used: null,
startOs: { startOs: {
version: '0.3.2', '1234-5678-9876-5432': {
full: true, hostname: 'adjective-noun',
passwordHash: version: '0.2.17',
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', timestamp: new Date().toISOString(),
wrappedKey: null, passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
}, },
guid: 'guid-guid-guid-guid', 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) await pauseFor(1000)
return { return {
version: '0.3.0', '9876-5432-1234-5678': {
full: true, hostname: 'adjective-noun',
passwordHash: version: '0.3.6',
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', timestamp: new Date().toISOString(),
wrappedKey: '', passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: '',
},
} }
} }

View File

@@ -7,8 +7,7 @@ import { T } from '@start9labs/start-sdk'
}) })
export class StateService { export class StateService {
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer' setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
recoverySource?: T.RecoverySource recoverySource?: T.RecoverySource<string>
recoveryPassword?: string
constructor(private readonly api: ApiService) {} constructor(private readonly api: ApiService) {}
@@ -26,9 +25,13 @@ export class StateService {
await this.api.execute({ await this.api.execute({
startOsLogicalname: storageLogicalname, startOsLogicalname: storageLogicalname,
startOsPassword: await this.api.encrypt(password), startOsPassword: await this.api.encrypt(password),
recoverySource: this.recoverySource || null, recoverySource: this.recoverySource
recoveryPassword: this.recoveryPassword ? this.recoverySource.type === 'migrate'
? await this.api.encrypt(this.recoveryPassword) ? this.recoverySource
: {
...this.recoverySource,
password: await this.api.encrypt(this.recoverySource.password),
}
: null, : null,
}) })
} }

View File

@@ -1,7 +1,9 @@
import { DOCUMENT } from '@angular/common' import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core' import { Inject, Injectable } from '@angular/core'
@Injectable() @Injectable({
providedIn: 'root',
})
export class DownloadHTMLService { export class DownloadHTMLService {
constructor(@Inject(DOCUMENT) private readonly document: Document) {} constructor(@Inject(DOCUMENT) private readonly document: Document) {}

View File

@@ -32,13 +32,14 @@ export interface PartitionInfo {
label: string | null label: string | null
capacity: number capacity: number
used: number | null used: number | null
startOs: StartOSDiskInfo | null startOs: Record<string, StartOSDiskInfo>
guid: string | null guid: string | null
} }
export type StartOSDiskInfo = { export type StartOSDiskInfo = {
hostname: string
version: string version: string
full: boolean timestamp: string
passwordHash: string | null passwordHash: string | null
wrappedKey: string | null wrappedKey: string | null
} }

View File

@@ -3,7 +3,7 @@
<ion-icon *ngFor="let icon of icons" [name]="icon"></ion-icon> <ion-icon *ngFor="let icon of icons" [name]="icon"></ion-icon>
<!-- 3rd party components --> <!-- 3rd party components -->
<qr-code value="hello"></qr-code> <qr-code control="hello"></qr-code>
<!-- Ionic components --> <!-- Ionic components -->
<ion-accordion></ion-accordion> <ion-accordion></ion-accordion>
@@ -58,21 +58,21 @@
<img *ngFor="let icon of taiga" src="assets/taiga-ui/icons/{{ icon }}.svg" /> <img *ngFor="let icon of taiga" src="assets/taiga-ui/icons/{{ icon }}.svg" />
<!-- Taiga UI components --> <!-- Taiga UI components -->
<tui-input></tui-input> <tui-input [formControl]="control"></tui-input>
<tui-input-time></tui-input-time> <tui-input-time [formControl]="control"></tui-input-time>
<tui-input-date></tui-input-date> <tui-input-date [formControl]="control"></tui-input-date>
<tui-input-date-time></tui-input-date-time> <tui-input-date-time [formControl]="control"></tui-input-date-time>
<tui-input-files></tui-input-files> <tui-input-files [formControl]="control"></tui-input-files>
<tui-input-number></tui-input-number> <tui-input-number [formControl]="control"></tui-input-number>
<tui-text-area></tui-text-area> <tui-textarea [formControl]="control"></tui-textarea>
<tui-select></tui-select> <tui-select [formControl]="control"></tui-select>
<tui-multi-select></tui-multi-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-tooltip></tui-tooltip>
<tui-toggle></tui-toggle>
<tui-radio-list></tui-radio-list>
<tui-error></tui-error> <tui-error></tui-error>
<tui-svg></tui-svg> <tui-svg src="tuiIconTrash"></tui-svg>
<tui-icon></tui-icon> <tui-icon icon="tuiIconTrash"></tui-icon>
<tui-expand></tui-expand> <tui-expand></tui-expand>
<tui-elastic-container></tui-elastic-container> <tui-elastic-container></tui-elastic-container>
<tui-scrollbar></tui-scrollbar> <tui-scrollbar></tui-scrollbar>

View File

@@ -1,4 +1,5 @@
import { ChangeDetectionStrategy, Component } from '@angular/core' import { ChangeDetectionStrategy, Component } from '@angular/core'
import { FormControl } from '@angular/forms'
import { import {
ActionSheetController, ActionSheetController,
AlertController, AlertController,
@@ -122,6 +123,7 @@ const TAIGA = [
export class PreloaderComponent { export class PreloaderComponent {
readonly icons = ICONS readonly icons = ICONS
readonly taiga = TAIGA readonly taiga = TAIGA
readonly control = new FormControl()
constructor( constructor(
_modals: ModalController, _modals: ModalController,

View File

@@ -1,5 +1,6 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core' import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'
import { ReactiveFormsModule } from '@angular/forms'
import { IonicModule } from '@ionic/angular' import { IonicModule } from '@ionic/angular'
import { import {
TuiErrorModule, TuiErrorModule,
@@ -26,7 +27,7 @@ import {
TuiProgressModule, TuiProgressModule,
TuiRadioListModule, TuiRadioListModule,
TuiSelectModule, TuiSelectModule,
TuiTextAreaModule, TuiTextareaModule,
TuiToggleModule, TuiToggleModule,
} from '@taiga-ui/kit' } from '@taiga-ui/kit'
import { QrCodeModule } from 'ng-qrcode' import { QrCodeModule } from 'ng-qrcode'
@@ -35,6 +36,7 @@ import { PreloaderComponent } from './preloader.component'
@NgModule({ @NgModule({
imports: [ imports: [
CommonModule, CommonModule,
ReactiveFormsModule,
IonicModule, IonicModule,
QrCodeModule, QrCodeModule,
TuiTooltipModule, TuiTooltipModule,
@@ -52,7 +54,7 @@ import { PreloaderComponent } from './preloader.component'
TuiInputNumberModule, TuiInputNumberModule,
TuiExpandModule, TuiExpandModule,
TuiSelectModule, TuiSelectModule,
TuiTextAreaModule, TuiTextareaModule,
TuiToggleModule, TuiToggleModule,
TuiElasticContainerModule, TuiElasticContainerModule,
TuiCellModule, TuiCellModule,

View File

@@ -1,20 +1,16 @@
<div class="inline"> <div class="inline">
<h2 *ngIf="type === 'create'; else restore"> <h2 *ngIf="type === 'create'; else restore">
<ion-icon name="cloud-outline" color="success"></ion-icon> <ion-icon name="cloud-outline" color="success"></ion-icon>
{{ Available for backup
hasValidBackup
? 'Available, contains existing backup'
: 'Available for fresh backup'
}}
</h2> </h2>
<ng-template #restore> <ng-template #restore>
<h2 *ngIf="hasValidBackup"> <h2 *ngIf="hasAnyBackup">
<ion-icon name="cloud-done-outline" color="success"></ion-icon> <ion-icon name="cloud-done-outline" color="success"></ion-icon>
StartOS backup detected StartOS backups detected
</h2> </h2>
<h2 *ngIf="!hasValidBackup"> <h2 *ngIf="!hasAnyBackup">
<ion-icon name="cloud-offline-outline" color="danger"></ion-icon> <ion-icon name="cloud-offline-outline" color="danger"></ion-icon>
No StartOS backup No StartOS backups
</h2> </h2>
</ng-template> </ng-template>
</div> </div>

View File

@@ -73,7 +73,7 @@
<ng-container *ngIf="cifs.mountable"> <ng-container *ngIf="cifs.mountable">
<backup-drives-status <backup-drives-status
[type]="type" [type]="type"
[hasValidBackup]="target.hasValidBackup" [hasAnyBackup]="target.hasAnyBackup"
></backup-drives-status> ></backup-drives-status>
</ng-container> </ng-container>
<h2 *ngIf="!cifs.mountable" class="inline"> <h2 *ngIf="!cifs.mountable" class="inline">
@@ -155,7 +155,7 @@
<h1>{{ drive.label || drive.logicalname }}</h1> <h1>{{ drive.label || drive.logicalname }}</h1>
<backup-drives-status <backup-drives-status
[type]="type" [type]="type"
[hasValidBackup]="target.hasValidBackup" [hasAnyBackup]="target.hasAnyBackup"
></backup-drives-status> ></backup-drives-status>
<p> <p>
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.vendor || 'Unknown Vendor' }} -

View File

@@ -72,10 +72,10 @@ export class BackupDrivesComponent {
return return
} }
if (this.type === 'restore' && !target.hasValidBackup) { if (this.type === 'restore' && !target.hasAnyBackup) {
const message = `${ const message = `${
target.entry.type === 'cifs' ? 'Network Folder' : 'Drive partition' 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) this.presentAlertError(message)
return return
} }
@@ -153,7 +153,7 @@ export class BackupDrivesComponent {
const [id, entry] = Object.entries(res)[0] const [id, entry] = Object.entries(res)[0]
this.backupService.cifs.unshift({ this.backupService.cifs.unshift({
id, id,
hasValidBackup: this.backupService.hasValidBackup(entry), hasAnyBackup: this.backupService.hasAnyBackup(entry),
entry, entry,
}) })
return true return true
@@ -258,7 +258,7 @@ export class BackupDrivesHeaderComponent {
}) })
export class BackupDrivesStatusComponent { export class BackupDrivesStatusComponent {
@Input() type!: BackupType @Input() type!: BackupType
@Input() hasValidBackup!: boolean @Input() hasAnyBackup!: boolean
} }
const cifsSpec = CB.Config.of({ const cifsSpec = CB.Config.of({

View File

@@ -34,7 +34,7 @@ export class BackupService {
.map(([id, cifs]) => { .map(([id, cifs]) => {
return { return {
id, id,
hasValidBackup: this.hasValidBackup(cifs), hasAnyBackup: this.hasAnyBackup(cifs),
entry: cifs as CifsBackupTarget, entry: cifs as CifsBackupTarget,
} }
}) })
@@ -44,7 +44,7 @@ export class BackupService {
.map(([id, drive]) => { .map(([id, drive]) => {
return { return {
id, id,
hasValidBackup: this.hasValidBackup(drive), hasAnyBackup: this.hasAnyBackup(drive),
entry: drive as DiskBackupTarget, entry: drive as DiskBackupTarget,
} }
}) })
@@ -55,8 +55,16 @@ export class BackupService {
} }
} }
hasValidBackup(target: BackupTarget): boolean { hasAnyBackup(target: BackupTarget): boolean {
const backup = target.startOs return Object.values(target.startOs).some(
return !!backup && this.emver.compare(backup.version, '0.3.0') !== -1 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
)
} }
} }

View File

@@ -1,5 +1,5 @@
<ng-container <ng-container
*ngFor="let entry of spec | keyvalue: asIsOrder" *ngFor="let entry of spec | keyvalue : asIsOrder"
tuiMode="onDark" tuiMode="onDark"
[ngSwitch]="entry.value.type" [ngSwitch]="entry.value.type"
[tuiTextfieldCleaner]="true" [tuiTextfieldCleaner]="true"

View File

@@ -1,4 +1,4 @@
<tui-text-area <tui-textarea
[tuiHintContent]="spec | hint" [tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled" [disabled]="!!spec.disabled"
[readOnly]="readOnly" [readOnly]="readOnly"
@@ -12,4 +12,4 @@
{{ spec.name }} {{ spec.name }}
<span *ngIf="spec.required">*</span> <span *ngIf="spec.required">*</span>
<textarea tuiTextfield [placeholder]="spec.placeholder || ''"></textarea> <textarea tuiTextfield [placeholder]="spec.placeholder || ''"></textarea>
</tui-text-area> </tui-textarea>

View File

@@ -30,7 +30,7 @@ import {
TuiPromptModule, TuiPromptModule,
TuiSelectModule, TuiSelectModule,
TuiTagModule, TuiTagModule,
TuiTextAreaModule, TuiTextareaModule,
TuiToggleModule, TuiToggleModule,
} from '@taiga-ui/kit' } from '@taiga-ui/kit'
@@ -60,7 +60,7 @@ import { HintPipe } from './hint.pipe'
TuiInputModule, TuiInputModule,
TuiInputNumberModule, TuiInputNumberModule,
TuiInputFilesModule, TuiInputFilesModule,
TuiTextAreaModule, TuiTextareaModule,
TuiSelectModule, TuiSelectModule,
TuiMultiSelectModule, TuiMultiSelectModule,
TuiToggleModule, TuiToggleModule,

View File

@@ -39,7 +39,7 @@ var convert = new Convert({
selector: 'logs', selector: 'logs',
templateUrl: './logs.component.html', templateUrl: './logs.component.html',
styleUrls: ['./logs.component.scss'], styleUrls: ['./logs.component.scss'],
providers: [TuiDestroyService, DownloadHTMLService], providers: [TuiDestroyService],
}) })
export class LogsComponent { export class LogsComponent {
@ViewChild(IonContent) @ViewChild(IonContent)

View File

@@ -18,8 +18,8 @@
<ion-label> <ion-label>
<h2>{{ option.title }}</h2> <h2>{{ option.title }}</h2>
<p>Version {{ option.version }}</p> <p>Version {{ option.version }}</p>
<p>Backup made: {{ option.timestamp | date : 'medium' }}</p> <p>Created: {{ option.timestamp | date : 'medium' }}</p>
<p *ngIf="!option.installed && !option['newer-eos']"> <p *ngIf="!option.installed && !option.newerOS">
<ion-text color="success">Ready to restore</ion-text> <ion-text color="success">Ready to restore</ion-text>
</p> </p>
<p *ngIf="option.installed"> <p *ngIf="option.installed">
@@ -27,7 +27,7 @@
Unavailable. {{ option.title }} is already installed. Unavailable. {{ option.title }} is already installed.
</ion-text> </ion-text>
</p> </p>
<p *ngIf="option['newer-eos']"> <p *ngIf="option.newerOS">
<ion-text color="danger"> <ion-text color="danger">
Unavailable. Backup was made on a newer version of StartOS. Unavailable. Backup was made on a newer version of StartOS.
</ion-text> </ion-text>
@@ -36,7 +36,7 @@
<ion-checkbox <ion-checkbox
slot="end" slot="end"
[(ngModel)]="option.checked" [(ngModel)]="option.checked"
[disabled]="option.installed || option['newer-eos']" [disabled]="option.installed || option.newerOS"
(ionChange)="handleChange(options)" (ionChange)="handleChange(options)"
></ion-checkbox> ></ion-checkbox>
</ion-item> </ion-item>

View File

@@ -14,10 +14,10 @@ import { AppRecoverOption } from './to-options.pipe'
styleUrls: ['./app-recover-select.page.scss'], styleUrls: ['./app-recover-select.page.scss'],
}) })
export class AppRecoverSelectPage { export class AppRecoverSelectPage {
@Input() id!: string @Input() targetId!: string
@Input() serverId!: string
@Input() backupInfo!: BackupInfo @Input() backupInfo!: BackupInfo
@Input() password!: string @Input() password!: string
@Input() oldPassword?: string
readonly packageData$ = this.patch.watch$('packageData').pipe(take(1)) readonly packageData$ = this.patch.watch$('packageData').pipe(take(1))
@@ -46,8 +46,8 @@ export class AppRecoverSelectPage {
try { try {
await this.embassyApi.restorePackages({ await this.embassyApi.restorePackages({
ids, ids,
targetId: this.id, targetId: this.targetId,
oldPassword: this.oldPassword || null, serverId: this.serverId,
password: this.password, password: this.password,
}) })
this.modalCtrl.dismiss(undefined, 'success') this.modalCtrl.dismiss(undefined, 'success')

View File

@@ -10,7 +10,7 @@ export interface AppRecoverOption extends PackageBackupInfo {
id: string id: string
checked: boolean checked: boolean
installed: boolean installed: boolean
'newer-eos': boolean newerOS: boolean
} }
@Pipe({ @Pipe({
@@ -34,7 +34,7 @@ export class ToOptionsPipe implements PipeTransform {
id, id,
installed: !!packageData[id], installed: !!packageData[id],
checked: false, checked: false,
'newer-eos': this.compare(packageBackups[id].osVersion), newerOS: this.compare(packageBackups[id].osVersion),
})) }))
.sort((a, b) => .sort((a, b) =>
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1, b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -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()
}
}

View File

@@ -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')
}
}

View File

@@ -1,5 +1,5 @@
<backup-drives <backup-drives
type="restore" type="restore"
class="ion-page" class="ion-page"
(onSelect)="presentModalPassword($event)" (onSelect)="presentModalSelectServer($event)"
></backup-drives> ></backup-drives>

View File

@@ -5,7 +5,7 @@ import { IonicModule } from '@ionic/angular'
import { RestorePage } from './restore.component' import { RestorePage } from './restore.component'
import { SharedPipesModule } from '@start9labs/shared' import { SharedPipesModule } from '@start9labs/shared'
import { BackupDrivesComponentModule } from 'src/app/components/backup-drives/backup-drives.component.module' 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 = [ const routes: Routes = [
{ {
@@ -21,7 +21,7 @@ const routes: Routes = [
RouterModule.forChild(routes), RouterModule.forChild(routes),
SharedPipesModule, SharedPipesModule,
BackupDrivesComponentModule, BackupDrivesComponentModule,
AppRecoverSelectPageModule, BackupServerSelectModule,
], ],
declarations: [RestorePage], declarations: [RestorePage],
}) })

View File

@@ -1,18 +1,11 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { ModalController, NavController } from '@ionic/angular' import { ModalController } 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 { MappedBackupTarget } from 'src/app/types/mapped-backup-target' import { MappedBackupTarget } from 'src/app/types/mapped-backup-target'
import { import {
BackupInfo,
CifsBackupTarget, CifsBackupTarget,
DiskBackupTarget, DiskBackupTarget,
} from 'src/app/services/api/api.types' } from 'src/app/services/api/api.types'
import { AppRecoverSelectPage } from 'src/app/modals/app-recover-select/app-recover-select.page' import { BackupServerSelectModal } from 'src/app/modals/backup-server-select/backup-server-select.page'
import * as argon2 from '@start9labs/argon2'
@Component({ @Component({
selector: 'restore', selector: 'restore',
@@ -20,78 +13,15 @@ import * as argon2 from '@start9labs/argon2'
styleUrls: ['./restore.component.scss'], styleUrls: ['./restore.component.scss'],
}) })
export class RestorePage { export class RestorePage {
constructor( constructor(private readonly modalCtrl: ModalController) {}
private readonly modalCtrl: ModalController,
private readonly dialogs: TuiDialogService,
private readonly navCtrl: NavController,
private readonly embassyApi: ApiService,
private readonly loader: LoadingService,
) {}
async presentModalPassword( async presentModalSelectServer(
target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>, 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> { ): Promise<void> {
const modal = await this.modalCtrl.create({ const modal = await this.modalCtrl.create({
componentProps: { componentProps: { target },
id,
backupInfo,
password,
oldPassword,
},
presentingElement: await this.modalCtrl.getTop(), presentingElement: await this.modalCtrl.getTop(),
component: AppRecoverSelectPage, component: BackupServerSelectModal,
})
modal.onWillDismiss().then(res => {
if (res.role === 'success') {
this.navCtrl.navigateRoot('/services')
}
}) })
await modal.present() await modal.present()

View File

@@ -17,6 +17,7 @@ import { BackupSelectPage } from 'src/app/modals/backup-select/backup-select.pag
import { EOSService } from 'src/app/services/eos.service' import { EOSService } from 'src/app/services/eos.service'
import { getServerInfo } from 'src/app/util/get-server-info' import { getServerInfo } from 'src/app/util/get-server-info'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
import { BackupService } from 'src/app/components/backup-drives/backup.service'
@Component({ @Component({
selector: 'server-backup', selector: 'server-backup',
@@ -38,6 +39,7 @@ export class ServerBackupPage {
private readonly destroy$: TuiDestroyService, private readonly destroy$: TuiDestroyService,
private readonly eosService: EOSService, private readonly eosService: EOSService,
private readonly patch: PatchDB<DataModel>, private readonly patch: PatchDB<DataModel>,
private readonly backupService: BackupService,
) {} ) {}
ngOnInit() { ngOnInit() {
@@ -86,19 +88,18 @@ export class ServerBackupPage {
}) })
.pipe(take(1)) .pipe(take(1))
.subscribe(async (password: string) => { .subscribe(async (password: string) => {
const { passwordHash, id } = await getServerInfo(this.patch)
// confirm password matches current master password // confirm password matches current master password
const { passwordHash } = await getServerInfo(this.patch)
argon2.verify(passwordHash, password) argon2.verify(passwordHash, password)
// first time backup // first time backup
if (!target.hasValidBackup) { if (!this.backupService.hasThisBackup(target.entry, id)) {
await this.createBackup(target, password) await this.createBackup(target, password)
// existing backup // existing backup
} else { } else {
try { try {
const passwordHash = target.entry.startOs?.passwordHash || '' argon2.verify(target.entry.startOs[id].passwordHash!, password)
argon2.verify(passwordHash, password)
} catch { } catch {
setTimeout( setTimeout(
() => this.presentModalOldPassword(target, password), () => this.presentModalOldPassword(target, password),
@@ -124,6 +125,8 @@ export class ServerBackupPage {
buttonText: 'Create Backup', buttonText: 'Create Backup',
} }
const { id } = await getServerInfo(this.patch)
this.dialogs this.dialogs
.open<string>(PROMPT, { .open<string>(PROMPT, {
label: 'Original Password Needed', label: 'Original Password Needed',
@@ -131,8 +134,7 @@ export class ServerBackupPage {
}) })
.pipe(take(1)) .pipe(take(1))
.subscribe(async (oldPassword: string) => { .subscribe(async (oldPassword: string) => {
const passwordHash = target.entry.startOs?.passwordHash || '' const passwordHash = target.entry.startOs[id].passwordHash!
argon2.verify(passwordHash, oldPassword) argon2.verify(passwordHash, oldPassword)
await this.createBackup(target, password, oldPassword) await this.createBackup(target, password, oldPassword)
}) })

View File

@@ -600,12 +600,15 @@ export module Mock {
username: 'TestUser', username: 'TestUser',
mountable: false, mountable: false,
startOs: { startOs: {
version: '0.3.0', '1234-5678-9876-5432': {
full: true, hostname: 'adjective-noun',
passwordHash: timestamp: new Date().toISOString(),
// password is asdfasdf version: '0.3.6',
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', passwordHash:
wrappedKey: '', // password is asdfasdf
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: '',
},
}, },
}, },
// 'ftcvewdnkemfksdm': { // 'ftcvewdnkemfksdm': {
@@ -616,7 +619,7 @@ export module Mock {
// used: 0, // used: 0,
// model: 'Evo SATA 2.5', // model: 'Evo SATA 2.5',
// vendor: 'Samsung', // vendor: 'Samsung',
// startOs: null, // startOs: {},
// }, // },
csgashbdjkasnd: { csgashbdjkasnd: {
type: 'cifs', type: 'cifs',
@@ -624,7 +627,7 @@ export module Mock {
path: '/Desktop/startos-backups-2', path: '/Desktop/startos-backups-2',
username: 'TestUser', username: 'TestUser',
mountable: true, mountable: true,
startOs: null, startOs: {},
}, },
powjefhjbnwhdva: { powjefhjbnwhdva: {
type: 'disk', type: 'disk',
@@ -635,30 +638,33 @@ export module Mock {
model: null, model: null,
vendor: 'SSK', vendor: 'SSK',
startOs: { startOs: {
version: '0.3.0', '1234-5678-9876-5432': {
full: true, hostname: 'adjective-noun',
// password is asdfasdf timestamp: new Date().toISOString(),
passwordHash: version: '0.3.6',
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', passwordHash:
wrappedKey: '', // password is asdfasdf
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: '',
},
}, },
}, },
} }
export const BackupInfo: RR.GetBackupInfoRes = { export const BackupInfo: RR.GetBackupInfoRes = {
version: '0.3.0', version: '0.3.6',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
packageBackups: { packageBackups: {
bitcoind: { bitcoind: {
title: 'Bitcoin Core', title: 'Bitcoin Core',
version: '0.21.0', version: '0.21.0',
osVersion: '0.3.0', osVersion: '0.3.6',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}, },
'btc-rpc-proxy': { 'btc-rpc-proxy': {
title: 'Bitcoin Proxy', title: 'Bitcoin Proxy',
version: '0.2.2', version: '0.2.2',
osVersion: '0.3.0', osVersion: '0.3.6',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}, },
}, },

View File

@@ -191,7 +191,12 @@ export module RR {
export type RemoveBackupTargetReq = { id: string } // backup.target.cifs.remove export type RemoveBackupTargetReq = { id: string } // backup.target.cifs.remove
export type RemoveBackupTargetRes = null 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 GetBackupInfoRes = BackupInfo
export type CreateBackupReq = { export type CreateBackupReq = {
@@ -239,7 +244,7 @@ export module RR {
// package.backup.restore // package.backup.restore
ids: string[] ids: string[]
targetId: string targetId: string
oldPassword: string | null serverId: string
password: string password: string
} }
export type RestorePackagesRes = null export type RestorePackagesRes = null
@@ -403,7 +408,7 @@ export interface DiskBackupTarget {
label: string | null label: string | null
capacity: number capacity: number
used: number | null used: number | null
startOs: StartOSDiskInfo | null startOs: Record<string, StartOSDiskInfo>
} }
export interface CifsBackupTarget { export interface CifsBackupTarget {
@@ -412,7 +417,7 @@ export interface CifsBackupTarget {
path: string path: string
username: string username: string
mountable: boolean mountable: boolean
startOs: StartOSDiskInfo | null startOs: Record<string, StartOSDiskInfo>
} }
export type RecoverySource = DiskRecoverySource | CifsRecoverySource export type RecoverySource = DiskRecoverySource | CifsRecoverySource

View File

@@ -582,7 +582,7 @@ export class MockApiService extends ApiService {
path: path.replace(/\\/g, '/'), path: path.replace(/\\/g, '/'),
username, username,
mountable: true, mountable: true,
startOs: null, startOs: {},
}, },
} }
} }

View File

@@ -59,7 +59,7 @@ export const mockPatchData: DataModel = {
// password is asdfasdf // password is asdfasdf
passwordHash: passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', '$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: { statusInfo: {
backupProgress: null, backupProgress: null,
updated: false, updated: false,

View File

@@ -1,5 +1,5 @@
export interface MappedBackupTarget<T> { export interface MappedBackupTarget<T> {
id: string id: string
hasValidBackup: boolean hasAnyBackup: boolean
entry: T entry: T
} }