Feature/setup migrate (#1841)

* add migrate component

* finish out migrate page and adjust recover options

* fix typo

* rename migrate -> transfer, adjust copy and typos, update transfer component logic

* add alert for old drive data when transferring

* comments for clarity

* auto adjust swiper slide height

* cleanup uneeded imports from transfer module

* pr feedback suggestions

* remove 02x from setup wiz

* clean up copy/styling for recover flow

* add support for migrating from old drive

* add RecoverySource lifted type

Co-authored-by: Matt Hill <matthewonthemoon@gmail.com>
Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
Lucy C
2022-11-01 09:00:25 -06:00
committed by Aiden McClelland
parent 1d151d8fa6
commit 74af03408f
25 changed files with 502 additions and 695 deletions

View File

@@ -1,23 +1,15 @@
use std::collections::BTreeMap; use std::path::PathBuf;
use std::os::unix::prelude::MetadataExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use color_eyre::eyre::eyre; use color_eyre::eyre::eyre;
use digest::generic_array::GenericArray;
use digest::OutputSizeUser;
use futures::future::BoxFuture; use futures::future::BoxFuture;
use futures::{FutureExt, TryFutureExt, TryStreamExt}; use futures::{StreamExt, TryFutureExt};
use josekit::jwk::Jwk; use josekit::jwk::Jwk;
use nix::unistd::{Gid, Uid};
use openssl::x509::X509; use openssl::x509::X509;
use patch_db::{DbHandle, LockType}; use patch_db::DbHandle;
use rpc_toolkit::command; use rpc_toolkit::command;
use rpc_toolkit::yajrc::RpcError; use rpc_toolkit::yajrc::RpcError;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::{Connection, Executor, Postgres}; use sqlx::{Connection, Executor, Postgres};
use tokio::fs::File; use tokio::fs::File;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
@@ -29,27 +21,20 @@ use crate::backup::target::BackupTargetFS;
use crate::context::rpc::RpcContextConfig; use crate::context::rpc::RpcContextConfig;
use crate::context::setup::SetupResult; use crate::context::setup::SetupResult;
use crate::context::SetupContext; use crate::context::SetupContext;
use crate::db::model::RecoveredPackageInfo;
use crate::disk::fsck::RepairStrategy; use crate::disk::fsck::RepairStrategy;
use crate::disk::main::DEFAULT_PASSWORD; use crate::disk::main::DEFAULT_PASSWORD;
use crate::disk::mount::filesystem::block_dev::BlockDev;
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::TmpMountGuard; use crate::disk::mount::guard::TmpMountGuard;
use crate::disk::util::{pvscan, recovery_info, DiskInfo, EmbassyOsRecoveryInfo}; use crate::disk::util::{pvscan, recovery_info, DiskInfo, EmbassyOsRecoveryInfo};
use crate::disk::REPAIR_DISK_PATH; use crate::disk::REPAIR_DISK_PATH;
use crate::hostname::{get_hostname, HostNameReceipt, Hostname}; use crate::hostname::{get_hostname, HostNameReceipt, Hostname};
use crate::id::Id;
use crate::init::init; use crate::init::init;
use crate::install::PKG_PUBLIC_DIR;
use crate::middleware::encrypt::EncryptedWire; use crate::middleware::encrypt::EncryptedWire;
use crate::net::ssl::SslManager; use crate::net::ssl::SslManager;
use crate::s9pk::manifest::PackageId;
use crate::sound::BEETHOVEN; use crate::sound::BEETHOVEN;
use crate::util::io::{dir_size, from_yaml_async_reader}; use crate::util::rsync::{Rsync, RsyncOptions};
use crate::util::Version; use crate::{Error, ErrorKind, ResultExt};
use crate::volume::{data_dir, VolumeId};
use crate::{ensure_code, Error, ErrorKind, ResultExt};
#[instrument(skip(secrets))] #[instrument(skip(secrets))]
pub async fn password_hash<Ex>(secrets: &mut Ex) -> Result<String, Error> pub async fn password_hash<Ex>(secrets: &mut Ex) -> Result<String, Error>
@@ -92,6 +77,44 @@ pub async fn list_disks(#[context] ctx: SetupContext) -> Result<Vec<DiskInfo>, E
crate::disk::util::list(&ctx.os_partitions).await crate::disk::util::list(&ctx.os_partitions).await
} }
async fn setup_init(
ctx: &SetupContext,
password: Option<String>,
) -> Result<(Hostname, OnionAddressV3, X509), Error> {
init(&RpcContextConfig::load(ctx.config_path.clone()).await?).await?;
let secrets = ctx.secret_store().await?;
let db = ctx.db(&secrets).await?;
let mut secrets_handle = secrets.acquire().await?;
let mut db_handle = db.handle();
let mut secrets_tx = secrets_handle.begin().await?;
let mut db_tx = db_handle.begin().await?;
if let Some(password) = password {
let set_password_receipt = crate::auth::SetPasswordReceipt::new(&mut db_tx).await?;
crate::auth::set_password(
&mut db_tx,
&set_password_receipt,
&mut secrets_tx,
&password,
)
.await?;
}
let tor_key = crate::net::tor::os_key(&mut secrets_tx).await?;
db_tx.commit().await?;
secrets_tx.commit().await?;
let hostname_receipts = HostNameReceipt::new(&mut db_handle).await?;
let hostname = get_hostname(&mut db_handle, &hostname_receipts).await?;
let (_, root_ca) = SslManager::init(secrets, &mut db_handle)
.await?
.export_root_ca()
.await?;
Ok((hostname, tor_key.public().get_onion_address(), root_ca))
}
#[command(rpc_only)] #[command(rpc_only)]
pub async fn attach( pub async fn attach(
#[context] ctx: SetupContext, #[context] ctx: SetupContext,
@@ -135,39 +158,9 @@ pub async fn attach(
ErrorKind::DiskManagement, ErrorKind::DiskManagement,
)); ));
} }
init(&RpcContextConfig::load(ctx.config_path.clone()).await?).await?; let (hostname, tor_addr, root_ca) = setup_init(&ctx, password).await?;
let secrets = ctx.secret_store().await?;
let db = ctx.db(&secrets).await?;
let mut secrets_handle = secrets.acquire().await?;
let mut db_handle = db.handle();
let mut secrets_tx = secrets_handle.begin().await?;
let mut db_tx = db_handle.begin().await?;
if let Some(password) = password {
let set_password_receipt = crate::auth::SetPasswordReceipt::new(&mut db_tx).await?;
crate::auth::set_password(
&mut db_tx,
&set_password_receipt,
&mut secrets_tx,
&password,
)
.await?;
}
let tor_key = crate::net::tor::os_key(&mut secrets_tx).await?;
db_tx.commit().await?;
secrets_tx.commit().await?;
let hostname_receipts = HostNameReceipt::new(&mut db_handle).await?;
let hostname = get_hostname(&mut db_handle, &hostname_receipts).await?;
let (_, root_ca) = SslManager::init(secrets, &mut db_handle)
.await?
.export_root_ca()
.await?;
let setup_result = SetupResult { let setup_result = SetupResult {
tor_address: format!("http://{}", tor_key.public().get_onion_address()), tor_address: format!("http://{}", tor_addr),
lan_address: hostname.lan_address(), lan_address: hostname.lan_address(),
root_ca: String::from_utf8(root_ca.to_pem()?)?, root_ca: String::from_utf8(root_ca.to_pem()?)?,
}; };
@@ -175,29 +168,11 @@ pub async fn attach(
Ok(setup_result) Ok(setup_result)
} }
#[command(subcommands(v2, recovery_status))] #[command(subcommands(recovery_status))]
pub fn recovery() -> Result<(), Error> { pub fn recovery() -> Result<(), Error> {
Ok(()) Ok(())
} }
#[command(subcommands(set))]
pub fn v2() -> Result<(), Error> {
Ok(())
}
#[command(rpc_only, metadata(authenticated = false))]
pub async fn set(#[context] ctx: SetupContext, #[arg] logicalname: PathBuf) -> Result<(), Error> {
let guard = TmpMountGuard::mount(&BlockDev::new(&logicalname), ReadOnly).await?;
let product_key = tokio::fs::read_to_string(guard.as_ref().join("root/agent/product_key"))
.await?
.trim()
.to_owned();
guard.unmount().await?;
*ctx.cached_product_key.write().await = Some(Arc::new(product_key));
*ctx.selected_v2_drive.write().await = Some(logicalname);
Ok(())
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct RecoveryStatus { pub struct RecoveryStatus {
@@ -253,19 +228,27 @@ pub async fn verify_cifs(
embassy_os.ok_or_else(|| Error::new(eyre!("No Backup Found"), crate::ErrorKind::NotFound)) embassy_os.ok_or_else(|| Error::new(eyre!("No Backup Found"), crate::ErrorKind::NotFound))
} }
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
#[serde(rename_all = "kebab-case")]
pub enum RecoverySource {
Migrate { guid: String },
Backup { target: BackupTargetFS },
}
#[command(rpc_only)] #[command(rpc_only)]
pub async fn execute( pub async fn execute(
#[context] ctx: SetupContext, #[context] ctx: SetupContext,
#[arg(rename = "embassy-logicalname")] embassy_logicalname: PathBuf, #[arg(rename = "embassy-logicalname")] embassy_logicalname: PathBuf,
#[arg(rename = "embassy-password")] embassy_password: EncryptedWire, #[arg(rename = "embassy-password")] embassy_password: EncryptedWire,
#[arg(rename = "recovery-source")] mut recovery_source: Option<BackupTargetFS>, #[arg(rename = "recovery-source")] recovery_source: Option<RecoverySource>,
#[arg(rename = "recovery-password")] recovery_password: Option<EncryptedWire>, #[arg(rename = "recovery-password")] recovery_password: Option<EncryptedWire>,
) -> Result<SetupResult, Error> { ) -> Result<SetupResult, Error> {
let embassy_password = match embassy_password.decrypt(&*ctx) { let embassy_password = match embassy_password.decrypt(&*ctx) {
Some(a) => a, Some(a) => a,
None => { None => {
return Err(Error::new( return Err(Error::new(
color_eyre::eyre::eyre!("Couldn't decode embassy_password"), color_eyre::eyre::eyre!("Couldn't decode embassy-password"),
crate::ErrorKind::Unknown, crate::ErrorKind::Unknown,
)) ))
} }
@@ -275,16 +258,13 @@ pub async fn execute(
Some(a) => Some(a), Some(a) => Some(a),
None => { None => {
return Err(Error::new( return Err(Error::new(
color_eyre::eyre::eyre!("Couldn't decode recovery_password"), color_eyre::eyre::eyre!("Couldn't decode recovery-password"),
crate::ErrorKind::Unknown, crate::ErrorKind::Unknown,
)) ))
} }
}, },
None => None, None => None,
}; };
if let Some(v2_drive) = &*ctx.selected_v2_drive.read().await {
recovery_source = Some(BackupTargetFS::Disk(BlockDev::new(v2_drive.clone())))
}
match execute_inner( match execute_inner(
ctx.clone(), ctx.clone(),
embassy_logicalname, embassy_logicalname,
@@ -343,7 +323,7 @@ pub async fn execute_inner(
ctx: SetupContext, ctx: SetupContext,
embassy_logicalname: PathBuf, embassy_logicalname: PathBuf,
embassy_password: String, embassy_password: String,
recovery_source: Option<BackupTargetFS>, recovery_source: Option<RecoverySource>,
recovery_password: Option<String>, recovery_password: Option<String>,
) -> Result<(Hostname, OnionAddressV3, X509), Error> { ) -> Result<(Hostname, OnionAddressV3, X509), Error> {
if ctx.recovery_status.read().await.is_some() { if ctx.recovery_status.read().await.is_some() {
@@ -369,12 +349,12 @@ pub async fn execute_inner(
) )
.await?; .await?;
let res = if let Some(recovery_source) = recovery_source { let res = if let Some(RecoverySource::Backup { target }) = recovery_source {
let (tor_addr, root_ca, recover_fut) = recover( let (tor_addr, root_ca, recover_fut) = recover(
ctx.clone(), ctx.clone(),
guid.clone(), guid.clone(),
embassy_password, embassy_password,
recovery_source, target,
recovery_password, recovery_password,
) )
.await?; .await?;
@@ -414,6 +394,87 @@ pub async fn execute_inner(
} }
}); });
res res
} else if let Some(RecoverySource::Migrate { guid: old_guid }) = recovery_source {
let _ = crate::disk::main::mount_fs(
&old_guid,
"/media/embassy/migrate",
"main",
RepairStrategy::Preen,
DEFAULT_PASSWORD,
)
.await?;
Rsync::new(
"/media/embassy/migrate/main",
"/embassy-data/main",
RsyncOptions {
delete: true,
force: true,
ignore_existing: false,
},
)?
.wait()
.await?;
let _ = crate::disk::main::mount_fs(
&old_guid,
"/media/embassy/migrate",
"package-data",
RepairStrategy::Preen,
DEFAULT_PASSWORD,
)
.await?;
let mut package_data_transfer = Rsync::new(
"/media/embassy/migrate/package-data",
"/embassy-data/package-data",
RsyncOptions {
delete: true,
force: true,
ignore_existing: false,
},
)?;
*ctx.recovery_status.write().await = Some(Ok(RecoveryStatus {
bytes_transferred: 0,
total_bytes: 100,
complete: false,
}));
let (hostname, tor_addr, root_ca) = setup_init(&ctx, Some(embassy_password)).await?;
let res = (hostname.clone(), tor_addr.clone(), root_ca.clone());
tokio::spawn(async move {
if let Err(e) = async {
while let Some(progress) = package_data_transfer.progress.next().await {
*ctx.recovery_status.write().await = Some(Ok(RecoveryStatus {
bytes_transferred: (progress * 100.0) as u64,
total_bytes: 100,
complete: false,
}));
}
package_data_transfer.wait().await?;
Ok::<_, Error>(())
}
.and_then(|_| async {
*ctx.setup_result.write().await = Some((
guid,
SetupResult {
tor_address: format!("http://{}", tor_addr),
lan_address: hostname.lan_address(),
root_ca: String::from_utf8(root_ca.to_pem()?)?,
},
));
if let Some(Ok(recovery_status)) = &mut *ctx.recovery_status.write().await {
recovery_status.complete = true;
}
Ok(())
})
.await
{
(&BEETHOVEN).play().await.unwrap_or_default(); // ignore error in playing the song
tracing::error!("Error recovering drive!: {}", e);
tracing::debug!("{:?}", e);
*ctx.recovery_status.write().await = Some(Err(e.into()));
} else {
tracing::info!("Recovery Complete!");
}
});
res
} else { } else {
let (tor_addr, root_ca) = fresh_setup(&ctx, &embassy_password).await?; let (tor_addr, root_ca) = fresh_setup(&ctx, &embassy_password).await?;
let db = init(&RpcContextConfig::load(ctx.config_path.clone()).await?) let db = init(&RpcContextConfig::load(ctx.config_path.clone()).await?)
@@ -475,385 +536,12 @@ async fn recover(
recovery_password: Option<String>, recovery_password: Option<String>,
) -> Result<(OnionAddressV3, X509, BoxFuture<'static, Result<(), Error>>), Error> { ) -> Result<(OnionAddressV3, X509, BoxFuture<'static, Result<(), Error>>), Error> {
let recovery_source = TmpMountGuard::mount(&recovery_source, ReadOnly).await?; let recovery_source = TmpMountGuard::mount(&recovery_source, ReadOnly).await?;
let recovery_version = recovery_info(&recovery_source) recover_full_embassy(
.await? ctx.clone(),
.as_ref() guid.clone(),
.map(|i| i.version.clone()) embassy_password,
.unwrap_or_else(|| emver::Version::new(0, 2, 0, 0).into()); recovery_source,
let res = if recovery_version.major() == 0 && recovery_version.minor() == 2 { recovery_password,
recover_v2(ctx.clone(), &embassy_password, recovery_source).await?
} else if recovery_version.major() == 0 && recovery_version.minor() == 3 {
recover_full_embassy(
ctx.clone(),
guid.clone(),
embassy_password,
recovery_source,
recovery_password,
)
.await?
} else {
return Err(Error::new(
eyre!("Unsupported version of embassyOS: {}", recovery_version),
crate::ErrorKind::VersionIncompatible,
));
};
Ok(res)
}
async fn shasum(
path: impl AsRef<Path>,
) -> Result<GenericArray<u8, <Sha256 as OutputSizeUser>::OutputSize>, Error> {
use tokio::io::AsyncReadExt;
let mut rdr = tokio::fs::File::open(path).await?;
let mut hasher = Sha256::new();
let mut buf = [0; 1024];
let mut read;
while {
read = rdr.read(&mut buf).await?;
read != 0
} {
hasher.update(&buf[0..read]);
}
Ok(hasher.finalize())
}
async fn validated_copy(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<(), Error> {
let src_path = src.as_ref();
let dst_path = dst.as_ref();
tokio::fs::copy(src_path, dst_path).await.with_ctx(|_| {
(
crate::ErrorKind::Filesystem,
format!("cp {} -> {}", src_path.display(), dst_path.display()),
)
})?;
let (src_hash, dst_hash) = tokio::try_join!(shasum(src_path), shasum(dst_path))?;
if src_hash != dst_hash {
Err(Error::new(
eyre!(
"source hash does not match destination hash for {}",
dst_path.display()
),
crate::ErrorKind::Filesystem,
))
} else {
Ok(())
}
}
fn dir_copy<'a, P0: AsRef<Path> + 'a + Send + Sync, P1: AsRef<Path> + 'a + Send + Sync>(
src: P0,
dst: P1,
ctr: &'a AtomicU64,
) -> BoxFuture<'a, Result<(), Error>> {
async move {
let m = tokio::fs::metadata(&src).await?;
let dst_path = dst.as_ref();
tokio::fs::create_dir_all(&dst_path).await.with_ctx(|_| {
(
crate::ErrorKind::Filesystem,
format!("mkdir {}", dst_path.display()),
)
})?;
tokio::fs::set_permissions(&dst_path, m.permissions())
.await
.with_ctx(|_| {
(
crate::ErrorKind::Filesystem,
format!("chmod {}", dst_path.display()),
)
})?;
let tmp_dst_path = dst_path.to_owned();
tokio::task::spawn_blocking(move || {
nix::unistd::chown(
&tmp_dst_path,
Some(Uid::from_raw(m.uid())),
Some(Gid::from_raw(m.gid())),
)
})
.await
.with_kind(crate::ErrorKind::Unknown)?
.with_ctx(|_| {
(
crate::ErrorKind::Filesystem,
format!("chown {}", dst_path.display()),
)
})?;
tokio_stream::wrappers::ReadDirStream::new(tokio::fs::read_dir(src.as_ref()).await?)
.map_err(|e| Error::new(e, crate::ErrorKind::Filesystem))
.try_for_each(|e| async move {
let m = e.metadata().await?;
let src_path = e.path();
let dst_path = dst_path.join(e.file_name());
if m.is_file() {
let len = m.len();
let mut cp_res = Ok(());
for _ in 0..10 {
cp_res = validated_copy(&src_path, &dst_path).await;
if cp_res.is_ok() {
break;
}
}
cp_res?;
let tmp_dst_path = dst_path.clone();
tokio::task::spawn_blocking(move || {
nix::unistd::chown(
&tmp_dst_path,
Some(Uid::from_raw(m.uid())),
Some(Gid::from_raw(m.gid())),
)
})
.await
.with_kind(crate::ErrorKind::Unknown)?
.with_ctx(|_| {
(
crate::ErrorKind::Filesystem,
format!("chown {}", dst_path.display()),
)
})?;
ctr.fetch_add(len, Ordering::Relaxed);
} else if m.is_dir() {
dir_copy(src_path, dst_path, ctr).await?;
} else if m.file_type().is_symlink() {
tokio::fs::symlink(
tokio::fs::read_link(&src_path).await.with_ctx(|_| {
(
crate::ErrorKind::Filesystem,
format!("readlink {}", src_path.display()),
)
})?,
&dst_path,
)
.await
.with_ctx(|_| {
(
crate::ErrorKind::Filesystem,
format!("cp -P {} -> {}", src_path.display(), dst_path.display()),
)
})?;
// Do not set permissions (see https://unix.stackexchange.com/questions/87200/change-permissions-for-a-symbolic-link)
}
Ok(())
})
.await?;
Ok(())
}
.boxed()
}
#[instrument(skip(ctx))]
async fn recover_v2(
ctx: SetupContext,
embassy_password: &str,
recovery_source: TmpMountGuard,
) -> Result<(OnionAddressV3, X509, BoxFuture<'static, Result<(), Error>>), Error> {
let secret_store = ctx.secret_store().await?;
// migrate the root CA
let root_ca_key_path = recovery_source
.as_ref()
.join("root")
.join("agent")
.join("ca")
.join("private")
.join("embassy-root-ca.key.pem");
let root_ca_cert_path = recovery_source
.as_ref()
.join("root")
.join("agent")
.join("ca")
.join("certs")
.join("embassy-root-ca.cert.pem");
let (root_ca_key_bytes, root_ca_cert_bytes) = tokio::try_join!(
tokio::fs::read(root_ca_key_path),
tokio::fs::read(root_ca_cert_path)
)?;
let root_ca_key = openssl::pkey::PKey::private_key_from_pem(&root_ca_key_bytes)?;
let root_ca_cert = openssl::x509::X509::from_pem(&root_ca_cert_bytes)?;
crate::net::ssl::SslManager::import_root_ca(
secret_store.clone(),
root_ca_key,
root_ca_cert.clone(),
) )
.await?; .await
// migrate the tor address
let tor_key_path = recovery_source
.as_ref()
.join("var")
.join("lib")
.join("tor")
.join("agent")
.join("hs_ed25519_secret_key");
let tor_key_bytes = tokio::fs::read(tor_key_path).await?;
let mut tor_key_array_tmp = [0u8; 64];
tor_key_array_tmp.clone_from_slice(&tor_key_bytes[32..]);
let tor_key: TorSecretKeyV3 = tor_key_array_tmp.into();
let key_vec = tor_key.as_bytes().to_vec();
let password = argon2::hash_encoded(
embassy_password.as_bytes(),
&rand::random::<[u8; 16]>()[..],
&argon2::Config::default(),
)
.with_kind(crate::ErrorKind::PasswordHashGeneration)?;
let sqlite_pool = ctx.secret_store().await?;
sqlx::query!(
"INSERT INTO account (id, password, tor_key) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET password = $2, tor_key = $3",
0,
password,
key_vec
)
.execute(&mut sqlite_pool.acquire().await?)
.await?;
// rest of migration as future
let fut = async move {
let db = ctx.db(&secret_store).await?;
let mut handle = db.handle();
// lock everything to avoid issues with renamed packages (bitwarden)
crate::db::DatabaseModel::new()
.lock(&mut handle, LockType::Write)
.await?;
let apps_yaml_path = recovery_source
.as_ref()
.join("root")
.join("appmgr")
.join("apps.yaml");
#[derive(Deserialize)]
struct LegacyAppInfo {
title: String,
version: Version,
}
let packages: BTreeMap<PackageId, LegacyAppInfo> =
from_yaml_async_reader(File::open(&apps_yaml_path).await.with_ctx(|_| {
(
crate::ErrorKind::Filesystem,
apps_yaml_path.display().to_string(),
)
})?)
.await?;
let volume_path = recovery_source.as_ref().join("root/volumes");
let mut total_bytes = 0;
for (pkg_id, _) in &packages {
let volume_src_path = volume_path.join(&pkg_id);
total_bytes += dir_size(&volume_src_path).await.with_ctx(|_| {
(
crate::ErrorKind::Filesystem,
volume_src_path.display().to_string(),
)
})?;
}
*ctx.recovery_status.write().await = Some(Ok(RecoveryStatus {
bytes_transferred: 0,
total_bytes,
complete: false,
}));
let bytes_transferred = AtomicU64::new(0);
let volume_id = VolumeId::Custom(Id::try_from("main".to_owned())?);
for (pkg_id, info) in packages {
let (src_id, dst_id) = rename_pkg_id(pkg_id);
let volume_src_path = volume_path.join(&src_id);
let volume_dst_path = data_dir(&ctx.datadir, &dst_id, &volume_id);
tokio::select!(
res = dir_copy(
&volume_src_path,
&volume_dst_path,
&bytes_transferred
) => res?,
_ = async {
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
*ctx.recovery_status.write().await = Some(Ok(RecoveryStatus {
bytes_transferred: bytes_transferred.load(Ordering::Relaxed),
total_bytes,
complete: false
}));
}
} => (),
);
let tor_src_path = recovery_source
.as_ref()
.join("var/lib/tor")
.join(format!("app-{}", src_id))
.join("hs_ed25519_secret_key");
let key_vec = tokio::fs::read(&tor_src_path).await.with_ctx(|_| {
(
crate::ErrorKind::Filesystem,
tor_src_path.display().to_string(),
)
})?;
ensure_code!(
key_vec.len() == 96,
crate::ErrorKind::Tor,
"{} not 96 bytes",
tor_src_path.display()
);
let key_vec = key_vec[32..].to_vec();
sqlx::query!(
"INSERT INTO tor (package, interface, key) VALUES ($1, 'main', $2) ON CONFLICT (package, interface) DO UPDATE SET key = $2",
*dst_id,
key_vec,
)
.execute(&mut secret_store.acquire().await?)
.await?;
let icon_leaf = AsRef::<Path>::as_ref(&dst_id)
.join(info.version.as_str())
.join("icon.png");
let icon_src_path = recovery_source
.as_ref()
.join("root/agent/icons")
.join(format!("{}.png", src_id));
let icon_dst_path = ctx.datadir.join(PKG_PUBLIC_DIR).join(&icon_leaf);
if let Some(parent) = icon_dst_path.parent() {
tokio::fs::create_dir_all(&parent)
.await
.with_ctx(|_| (crate::ErrorKind::Filesystem, parent.display().to_string()))?;
}
tokio::fs::copy(&icon_src_path, &icon_dst_path)
.await
.with_ctx(|_| {
(
crate::ErrorKind::Filesystem,
format!(
"cp {} -> {}",
icon_src_path.display(),
icon_dst_path.display()
),
)
})?;
let icon_url = Path::new("/public/package-data").join(&icon_leaf);
crate::db::DatabaseModel::new()
.recovered_packages()
.idx_model(&dst_id)
.put(
&mut handle,
&RecoveredPackageInfo {
title: info.title,
icon: icon_url.display().to_string(),
version: info.version,
},
)
.await?;
}
secret_store.close().await;
recovery_source.unmount().await?;
Ok(())
};
Ok((
tor_key.public().get_onion_address(),
root_ca_cert,
fut.boxed(),
))
}
fn rename_pkg_id(src_pkg_id: PackageId) -> (PackageId, PackageId) {
if &*src_pkg_id == "bitwarden" {
(src_pkg_id, "vaultwarden".parse().unwrap())
} else {
(src_pkg_id.clone(), src_pkg_id)
}
} }

View File

@@ -13,6 +13,13 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/recover/recover.module').then(m => m.RecoverPageModule), import('./pages/recover/recover.module').then(m => m.RecoverPageModule),
}, },
{
path: 'transfer',
loadChildren: () =>
import('./pages/transfer/transfer.module').then(
m => m.TransferPageModule,
),
},
{ {
path: 'embassy', path: 'embassy',
loadChildren: () => loadChildren: () =>

View File

@@ -16,6 +16,7 @@ import { SuccessPageModule } from './pages/success/success.module'
import { HomePageModule } from './pages/home/home.module' import { HomePageModule } from './pages/home/home.module'
import { LoadingPageModule } from './pages/loading/loading.module' import { LoadingPageModule } from './pages/loading/loading.module'
import { RecoverPageModule } from './pages/recover/recover.module' import { RecoverPageModule } from './pages/recover/recover.module'
import { TransferPageModule } from './pages/transfer/transfer.module'
import { RELATIVE_URL, WorkspaceConfig } from '@start9labs/shared' import { RELATIVE_URL, WorkspaceConfig } from '@start9labs/shared'
const { const {
@@ -37,6 +38,7 @@ const {
HomePageModule, HomePageModule,
LoadingPageModule, LoadingPageModule,
RecoverPageModule, RecoverPageModule,
TransferPageModule,
], ],
providers: [ providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },

View File

@@ -53,18 +53,7 @@ export class CifsModal {
await loader.dismiss() await loader.dismiss()
const is02x = embassyOS.version.startsWith('0.2') this.presentModalPassword(embassyOS)
if (is02x) {
this.modalController.dismiss(
{
cifs: this.cifs,
},
'success',
)
} else {
this.presentModalPassword(embassyOS)
}
} catch (e) { } catch (e) {
await loader.dismiss() await loader.dismiss()
this.presentAlertFailed() this.presentAlertFailed()

View File

@@ -14,9 +14,6 @@
Choose a password for your Embassy. Choose a password for your Embassy.
<i>Make it good. Write it down.</i> <i>Make it good. Write it down.</i>
</p> </p>
<p style="color: var(--ion-color-warning)">
Losing your password can result in total loss of data.
</p>
</ng-template> </ng-template>
<p *ngIf="!storageDrive else choose"> <p *ngIf="!storageDrive else choose">
Enter the password that was used to encrypt this drive. Enter the password that was used to encrypt this drive.

View File

@@ -25,8 +25,8 @@
> >
<ion-card-title>No drives found</ion-card-title> <ion-card-title>No drives found</ion-card-title>
<ion-card-subtitle <ion-card-subtitle
>Please connect a storage drive to your Embassy and click >Please connect an external storage drive to your Embassy, if
"Refresh".</ion-card-subtitle applicable. Next, click "Refresh".</ion-card-subtitle
> >
</ion-card-header> </ion-card-header>
</ng-template> </ng-template>

View File

@@ -7,12 +7,14 @@ import {
} from '@ionic/angular' } from '@ionic/angular'
import { import {
ApiService, ApiService,
BackupRecoverySource,
DiskInfo, DiskInfo,
DiskRecoverySource, DiskRecoverySource,
} from 'src/app/services/api/api.service' } from 'src/app/services/api/api.service'
import { ErrorToastService } from '@start9labs/shared' import { ErrorToastService } from '@start9labs/shared'
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 { ActivatedRoute } from '@angular/router'
@Component({ @Component({
selector: 'app-embassy', selector: 'app-embassy',
@@ -31,6 +33,7 @@ export class EmbassyPage {
private readonly stateService: StateService, private readonly stateService: StateService,
private readonly loadingCtrl: LoadingController, private readonly loadingCtrl: LoadingController,
private readonly errorToastService: ErrorToastService, private readonly errorToastService: ErrorToastService,
private route: ActivatedRoute,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -55,8 +58,10 @@ export class EmbassyPage {
!d.partitions !d.partitions
.map(p => p.logicalname) .map(p => p.logicalname)
.includes( .includes(
(this.stateService.recoverySource as DiskRecoverySource) (
?.logicalname, (this.stateService.recoverySource as BackupRecoverySource)
?.target as DiskRecoverySource
)?.logicalname,
), ),
) )
} catch (e: any) { } catch (e: any) {
@@ -80,12 +85,14 @@ export class EmbassyPage {
{ {
text: 'Continue', text: 'Continue',
handler: () => { handler: () => {
// for backup recoveries
if (this.stateService.recoveryPassword) { if (this.stateService.recoveryPassword) {
this.setupEmbassy( this.setupEmbassy(
drive.logicalname, drive.logicalname,
this.stateService.recoveryPassword, this.stateService.recoveryPassword,
) )
} else { } else {
// for migrations and fresh setups
this.presentModalPassword(drive.logicalname) this.presentModalPassword(drive.logicalname)
} }
}, },
@@ -94,9 +101,11 @@ export class EmbassyPage {
}) })
await alert.present() await alert.present()
} else { } else {
// for backup recoveries
if (this.stateService.recoveryPassword) { if (this.stateService.recoveryPassword) {
this.setupEmbassy(drive.logicalname, this.stateService.recoveryPassword) this.setupEmbassy(drive.logicalname, this.stateService.recoveryPassword)
} else { } else {
// for migrations and fresh setups
this.presentModalPassword(drive.logicalname) this.presentModalPassword(drive.logicalname)
} }
} }
@@ -129,7 +138,9 @@ export class EmbassyPage {
try { try {
await this.stateService.setupEmbassy(logicalname, password) await this.stateService.setupEmbassy(logicalname, password)
if (!!this.stateService.recoverySource) { if (!!this.stateService.recoverySource) {
await this.navCtrl.navigateForward(`/loading`) await this.navCtrl.navigateForward(`/loading`, {
queryParams: { action: this.route.snapshot.paramMap.get('action') },
})
} else { } else {
await this.navCtrl.navigateForward(`/success`) await this.navCtrl.navigateForward(`/success`)
} }

View File

@@ -1,4 +1,4 @@
<ion-content> <ion-content *ngIf="loaded">
<ion-grid> <ion-grid>
<ion-row> <ion-row>
<ion-col class="ion-text-center"> <ion-col class="ion-text-center">
@@ -23,7 +23,7 @@
</ion-card-title> </ion-card-title>
</ion-card-header> </ion-card-header>
<ion-card-content class="ion-margin-bottom"> <ion-card-content class="ion-margin-bottom">
<swiper (swiper)="setSwiperInstance($event)"> <swiper [autoHeight]="true" (swiper)="setSwiperInstance($event)">
<ng-template swiperSlide> <ng-template swiperSlide>
<ion-item <ion-item
button button
@@ -47,24 +47,30 @@
<ion-icon color="dark" slot="start" name="reload"></ion-icon> <ion-icon color="dark" slot="start" name="reload"></ion-icon>
<ion-label> <ion-label>
<h2><ion-text color="danger">Recover</ion-text></h2> <h2><ion-text color="danger">Recover</ion-text></h2>
<p> <p>Recover, restore, or transfer Embassy data</p>
Restore from backup or use an existing Embassy data drive
</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
</ng-template> </ng-template>
<ng-template swiperSlide> <ng-template swiperSlide>
<ion-item button detail="true" routerLink="/recover"> <ion-item button detail="true" routerLink="/recover">
<ion-icon color="dark" slot="start" name="save"></ion-icon> <ion-icon
color="dark"
slot="start"
name="save-outline"
></ion-icon>
<ion-label> <ion-label>
<h2> <h2>
<ion-text color="warning">Restore From Backup</ion-text> <ion-text color="warning">Restore From Backup</ion-text>
</h2> </h2>
<p>Recover an Embassy from encrypted backup</p> <p>Recover an Embassy from an encrypted backup</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item button detail="true" lines="none" (click)="import()"> <ion-item button detail="true" lines="none" (click)="import()">
<ion-icon color="dark" slot="start" name="cube"></ion-icon> <ion-icon
color="dark"
slot="start"
name="cube-outline"
></ion-icon>
<ion-label> <ion-label>
<h2> <h2>
<ion-text color="primary">Use Existing Drive</ion-text> <ion-text color="primary">Use Existing Drive</ion-text>
@@ -72,6 +78,27 @@
<p>Attach and use a valid Embassy data drive</p> <p>Attach and use a valid Embassy data drive</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item
button
detail="true"
lines="none"
routerLink="/transfer"
>
<ion-icon
color="dark"
slot="start"
name="share-outline"
></ion-icon>
<ion-label>
<h2>
<ion-text color="success">Transfer</ion-text>
</h2>
<p>
Transfer data to a new drive<br />(e.g. upgrade to a
larger drive or an Embassy Pro)
</p>
</ion-label>
</ion-item>
</ng-template> </ng-template>
</swiper> </swiper>
</ion-card-content> </ion-card-content>

View File

@@ -23,6 +23,7 @@ export class HomePage {
swiper?: Swiper swiper?: Swiper
guid?: string | null guid?: string | null
error = false error = false
loaded = false
constructor( constructor(
private readonly api: ApiService, private readonly api: ApiService,
@@ -46,6 +47,7 @@ export class HomePage {
} }
async ionViewDidEnter() { async ionViewDidEnter() {
this.loaded = true // needed to accomodate autoHight="true" on swiper. Otherwise Swiper height might be 0 when navigatging *to* this page from later page. Happens on refresh.
if (this.swiper) { if (this.swiper) {
this.swiper.allowTouchMove = false this.swiper.allowTouchMove = false
} }
@@ -100,11 +102,3 @@ export class HomePage {
} }
} }
} }
function decodeHex(hex: string) {
let str = ''
for (let n = 0; n < hex.length; n += 2) {
str += String.fromCharCode(parseInt(hex.substring(n, 2), 16))
}
return str
}

View File

@@ -8,7 +8,10 @@
<ion-card color="dark"> <ion-card color="dark">
<ion-card-header> <ion-card-header>
<ion-card-title style="font-size: 40px">Recovering</ion-card-title> <ion-card-title style="font-size: 40px">
<span *ngIf="incomingAction === 'transfer'">Transferring</span>
<span *ngIf="incomingAction === 'recover'">Recovering</span>
</ion-card-title>
<ion-card-subtitle <ion-card-subtitle
>Progress: {{ (stateService.dataProgress * 100).toFixed(0) >Progress: {{ (stateService.dataProgress * 100).toFixed(0)
}}%</ion-card-subtitle }}%</ion-card-subtitle

View File

@@ -1,4 +1,5 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { NavController } from '@ionic/angular' import { NavController } from '@ionic/angular'
import { StateService } from 'src/app/services/state.service' import { StateService } from 'src/app/services/state.service'
@@ -8,12 +9,16 @@ import { StateService } from 'src/app/services/state.service'
styleUrls: ['loading.page.scss'], styleUrls: ['loading.page.scss'],
}) })
export class LoadingPage { export class LoadingPage {
incomingAction!: string
constructor( constructor(
public stateService: StateService, public stateService: StateService,
private navCtrl: NavController, private navCtrl: NavController,
private route: ActivatedRoute,
) {} ) {}
ngOnInit() { ngOnInit() {
this.incomingAction = this.route.snapshot.paramMap.get('action')!
this.stateService.pollDataTransferProgress() this.stateService.pollDataTransferProgress()
const progSub = this.stateService.dataCompletionSubject.subscribe( const progSub = this.stateService.dataCompletionSubject.subscribe(
async complete => { async complete => {

View File

@@ -2,7 +2,7 @@
<!-- has backup --> <!-- has backup -->
<h2 *ngIf="hasValidBackup; else noBackup"> <h2 *ngIf="hasValidBackup; else noBackup">
<ion-icon name="cloud-done" color="success"></ion-icon> <ion-icon name="cloud-done" color="success"></ion-icon>
{{ is02x ? 'Embassy 0.2.x detected' : 'Embassy backup detected' }} Embassy backup detected
</h2> </h2>
<!-- no backup --> <!-- no backup -->
<ng-template #noBackup> <ng-template #noBackup>

View File

@@ -8,11 +8,7 @@
<ion-card color="dark"> <ion-card color="dark">
<ion-card-header class="ion-text-center"> <ion-card-header class="ion-text-center">
<ion-card-title>Recover</ion-card-title> <ion-card-title>Restore from Backup</ion-card-title>
<ion-card-subtitle
>Select the LAN Shared Folder or physical drive containing your
Embassy backup</ion-card-subtitle
>
</ion-card-header> </ion-card-header>
<ion-card-content class="ion-margin"> <ion-card-content class="ion-margin">
@@ -25,17 +21,10 @@
<!-- loaded --> <!-- loaded -->
<ion-item-group *ngIf="!loading" class="ion-text-center"> <ion-item-group *ngIf="!loading" class="ion-text-center">
<!-- cifs --> <!-- cifs -->
<h2 class="target-label">LAN Shared Folder</h2> <h2 class="target-label">Network Folder</h2>
<p class="ion-padding-bottom"> <p class="ion-padding-bottom ion-text-left">
Using a LAN Shared Folder is the recommended way to recover from Restore your Embassy from a folder on another computer that is
backup, since it works with all Embassy hardware configurations. connected to the same network as your Embassy.
View the
<a
href="https://start9.com/latest/user-manual/backups/backup-restore"
target="_blank"
noreferrer
>instructions</a
>.
</p> </p>
<!-- connect --> <!-- connect -->
@@ -47,7 +36,7 @@
color="light" color="light"
></ion-icon> ></ion-icon>
<ion-label> <ion-label>
<b>Open New</b> <b>Open</b>
</ion-label> </ion-label>
</ion-item> </ion-item>
@@ -55,18 +44,20 @@
<br /> <br />
<!-- drives --> <!-- drives -->
<h2 class="target-label">Physical Drives</h2> <h2 class="target-label">Physical Drive</h2>
<p class="ion-padding-bottom"> <div class="ion-text-left ion-padding-bottom">
Warning! Plugging in more than one physical drive to Embassy can <p>
lead to power failure and data corruption. To recover from a Restore your Emabssy from a physcial drive that is plugged
physical drive, please follow the directly into your Embassy.
<a </p>
href="https://start9.com/latest/user-manual/backups/backup-restore" <br />
target="_blank" <b>
noreferrer Warning. Do not use this option if you are using a Raspberry
>instructions</a Pi with an external SSD as your main data drive. The Raspberry
>. Pi cannot not support more than one external drive without
</p> additional power and can cause data corruption.
</b>
</div>
<ng-container *ngFor="let mapped of mappedDrives"> <ng-container *ngFor="let mapped of mappedDrives">
<ion-item <ion-item
@@ -86,7 +77,6 @@
<h1>{{ drive.label || drive.logicalname }}</h1> <h1>{{ drive.label || drive.logicalname }}</h1>
<drive-status <drive-status
[hasValidBackup]="mapped.hasValidBackup" [hasValidBackup]="mapped.hasValidBackup"
[is02x]="mapped.is02x"
></drive-status> ></drive-status>
<p> <p>
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model || {{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model ||

View File

@@ -56,7 +56,6 @@ export class RecoverPage {
} }
this.mappedDrives.push({ this.mappedDrives.push({
hasValidBackup: !!p['embassy-os']?.full, hasValidBackup: !!p['embassy-os']?.full,
is02x: !!drive['embassy-os']?.version.startsWith('0.2'),
drive, drive,
}) })
}) })
@@ -76,11 +75,14 @@ export class RecoverPage {
if (res.role === 'success') { if (res.role === 'success') {
const { hostname, path, username, password } = res.data.cifs const { hostname, path, username, password } = res.data.cifs
this.stateService.recoverySource = { this.stateService.recoverySource = {
type: 'cifs', type: 'backup',
hostname, target: {
path, type: 'cifs',
username, hostname,
password, path,
username,
password,
},
} }
this.stateService.recoveryPassword = res.data.recoveryPassword this.stateService.recoveryPassword = res.data.recoveryPassword
this.navCtrl.navigateForward('/embassy') this.navCtrl.navigateForward('/embassy')
@@ -90,35 +92,35 @@ export class RecoverPage {
} }
async select(target: DiskBackupTarget) { async select(target: DiskBackupTarget) {
const is02x = target['embassy-os']?.version.startsWith('0.2')
const { logicalname } = target const { logicalname } = target
if (!logicalname) return if (!logicalname) return
if (is02x) { const modal = await this.modalController.create({
this.selectRecoverySource(logicalname) component: PasswordPage,
} else { componentProps: { target },
const modal = await this.modalController.create({ cssClass: 'alertlike-modal',
component: PasswordPage, })
componentProps: { target }, modal.onDidDismiss().then(res => {
cssClass: 'alertlike-modal', if (res.data?.password) {
}) this.selectRecoverySource(logicalname, res.data.password)
modal.onDidDismiss().then(res => { }
if (res.data?.password) { })
this.selectRecoverySource(logicalname, res.data.password) await modal.present()
}
})
await modal.present()
}
} }
private async selectRecoverySource(logicalname: string, password?: string) { private async selectRecoverySource(logicalname: string, password?: string) {
this.stateService.recoverySource = { this.stateService.recoverySource = {
type: 'disk', type: 'backup',
logicalname, target: {
type: 'disk',
logicalname,
},
} }
this.stateService.recoveryPassword = password this.stateService.recoveryPassword = password
this.navCtrl.navigateForward(`/embassy`) this.navCtrl.navigateForward(`/embassy`, {
queryParams: { action: 'recover' },
})
} }
} }
@@ -129,11 +131,9 @@ export class RecoverPage {
}) })
export class DriveStatusComponent { export class DriveStatusComponent {
@Input() hasValidBackup!: boolean @Input() hasValidBackup!: boolean
@Input() is02x!: boolean
} }
interface MappedDisk { interface MappedDisk {
is02x: boolean
hasValidBackup: boolean hasValidBackup: boolean
drive: DiskBackupTarget drive: DiskBackupTarget
} }

View File

@@ -11,7 +11,7 @@
<ion-card-title>Setup Complete</ion-card-title> <ion-card-title>Setup Complete</ion-card-title>
<ion-card-subtitle <ion-card-subtitle
><b ><b
>You have successully claimed your Embassy!</b >You have successfully claimed your Embassy!</b
></ion-card-subtitle ></ion-card-subtitle
> >
<br /> <br />
@@ -19,11 +19,13 @@
<ion-card-content> <ion-card-content>
<br /> <br />
<br /> <br />
<h2 <h2 *ngIf="recoverySource" class="ion-padding-bottom">
*ngIf="recoverySource && recoverySource.type === 'disk'" <span *ngIf="recoverySource.type === 'backup'"
class="ion-padding-bottom" >You can now safely unplug your backup drive.</span
> >
You can now safely unplug your backup drive. <span *ngIf="recoverySource.type === 'migrate'"
>You can now safely unplug your old drive.</span
>
</h2> </h2>
<h2 style="font-weight: bold"> <h2 style="font-weight: bold">
Access your Embassy using the methods below. You should Access your Embassy using the methods below. You should
@@ -40,7 +42,7 @@
<div class="ion-padding ion-text-start"> <div class="ion-padding ion-text-start">
<p> <p>
Visit the address below when you are conncted to the same WiFi Visit the address below when you are connected to the same WiFi
or Local Area Network (LAN) as your Embassy: or Local Area Network (LAN) as your Embassy:
</p> </p>
@@ -94,7 +96,7 @@
follow the instructions follow the instructions
<ion-icon name="open-outline"></ion-icon> <ion-icon name="open-outline"></ion-icon>
</a> </a>
to downlaod and trust your Embassy's Root Certificate Authority. to download and trust your Embassy's Root Certificate Authority.
</p> </p>
<ion-button style="margin-top: 24px" (click)="installCert()"> <ion-button style="margin-top: 24px" (click)="installCert()">

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { TransferPage } from './transfer.page'
const routes: Routes = [
{
path: '',
component: TransferPage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class TransferPageRoutingModule {}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { UnitConversionPipesModule } from '@start9labs/shared'
import { TransferPage } from './transfer.page'
import { TransferPageRoutingModule } from './transfer-routing.module'
@NgModule({
declarations: [TransferPage],
imports: [
CommonModule,
IonicModule,
TransferPageRoutingModule,
UnitConversionPipesModule,
],
})
export class TransferPageModule {}

View File

@@ -0,0 +1,63 @@
<ion-content>
<ion-grid>
<ion-row>
<ion-col>
<div style="padding-bottom: 32px" class="ion-text-center">
<img src="assets/img/logo.png" style="max-width: 240px" />
</div>
<ion-card color="dark">
<ion-card-header class="ion-text-center">
<ion-card-title>Transfer</ion-card-title>
<ion-card-subtitle
>Select the physical drive containing your previous Embassy
data</ion-card-subtitle
>
</ion-card-header>
<ion-card-content class="ion-margin">
<ion-spinner
*ngIf="loading"
class="center-spinner"
name="lines"
></ion-spinner>
<!-- loaded -->
<ion-item-group *ngIf="!loading" class="ion-text-center">
<!-- drives -->
<h2 class="target-label">Available Drives</h2>
<ng-container *ngFor="let drive of drives">
<ion-item button (click)="select(drive)" lines="none">
<ion-icon
slot="start"
name="save-outline"
size="large"
color="light"
></ion-icon>
<ion-label>
<h1>{{ drive.logicalname }}</h1>
<p>
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model ||
'Unknown Model' }}
</p>
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
</ion-label>
</ion-item>
</ng-container>
<ion-button
class="ion-margin-top"
fill="clear"
color="primary"
(click)="refresh()"
>
<ion-icon slot="start" name="refresh"></ion-icon>
Refresh
</ion-button>
</ion-item-group>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -0,0 +1,4 @@
.target-label {
font-weight: bold;
padding-bottom: 6px;
}

View File

@@ -0,0 +1,74 @@
import { Component } from '@angular/core'
import { AlertController, NavController } from '@ionic/angular'
import { ApiService, DiskInfo } from 'src/app/services/api/api.service'
import { ErrorToastService } from '@start9labs/shared'
import { StateService } from 'src/app/services/state.service'
@Component({
selector: 'app-transfer',
templateUrl: 'transfer.page.html',
styleUrls: ['transfer.page.scss'],
})
export class TransferPage {
loading = true
drives: DiskInfo[] = []
constructor(
private readonly apiService: ApiService,
private readonly navCtrl: NavController,
private readonly alertCtrl: AlertController,
private readonly errToastService: ErrorToastService,
private readonly stateService: StateService,
) {}
async ngOnInit() {
await this.getDrives()
}
async refresh() {
this.loading = true
await this.getDrives()
}
async getDrives() {
try {
const disks = await this.apiService.getDrives()
this.drives = disks.filter(d => d.partitions.length && d.guid)
} catch (e: any) {
this.errToastService.present(e)
} finally {
this.loading = false
}
}
async select(target: DiskInfo) {
const { logicalname, guid } = target
if (!logicalname) return
const alert = await this.alertCtrl.create({
header: 'Warning',
message:
'Data from this drive will not be deleted, but you will not be able to use this drive to run embassyOS after the data is transferred. Attempting to use this drive after data is transferred could cause transferred services to not function properly, and may cause data corruption.',
buttons: [
{
role: 'cancel',
text: 'Cancel',
},
{
text: 'Continue',
handler: () => {
this.stateService.recoverySource = {
type: 'migrate',
guid: guid!,
}
this.navCtrl.navigateForward(`/embassy`, {
queryParams: { action: 'transfer' },
})
},
},
],
})
await alert.present()
}
}

View File

@@ -5,7 +5,6 @@ export abstract class ApiService {
abstract getStatus(): Promise<GetStatusRes> // setup.status abstract getStatus(): Promise<GetStatusRes> // 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 set02XDrive(logicalname: string): Promise<void> // setup.recovery.v2.set
abstract getRecoveryStatus(): Promise<RecoveryStatusRes> // setup.recovery.status abstract getRecoveryStatus(): Promise<RecoveryStatusRes> // setup.recovery.status
abstract verifyCifs(cifs: CifsRecoverySource): Promise<EmbassyOSRecoveryInfo> // setup.cifs.verify abstract verifyCifs(cifs: CifsRecoverySource): Promise<EmbassyOSRecoveryInfo> // setup.cifs.verify
abstract importDrive(importInfo: ImportDriveReq): Promise<SetupEmbassyRes> // setup.attach abstract importDrive(importInfo: ImportDriveReq): Promise<SetupEmbassyRes> // setup.attach
@@ -39,7 +38,7 @@ export type ImportDriveReq = {
export type SetupEmbassyReq = { export type SetupEmbassyReq = {
'embassy-logicalname': string 'embassy-logicalname': string
'embassy-password': Encrypted 'embassy-password': Encrypted
'recovery-source': CifsRecoverySource | DiskRecoverySource | null 'recovery-source': RecoverySource | null
'recovery-password': Encrypted | null 'recovery-password': Encrypted | null
} }
@@ -81,6 +80,17 @@ export type DiskRecoverySource = {
logicalname: string // partition logicalname logicalname: string // partition logicalname
} }
export type BackupRecoverySource = {
type: 'backup'
target: CifsRecoverySource | DiskRecoverySource
}
export type RecoverySource = BackupRecoverySource | DiskMigrateSource
export type DiskMigrateSource = {
type: 'migrate'
guid: string
}
export type CifsRecoverySource = { export type CifsRecoverySource = {
type: 'cifs' type: 'cifs'
hostname: string hostname: string
@@ -95,7 +105,7 @@ export type DiskInfo = {
model: string | null model: string | null
partitions: PartitionInfo[] partitions: PartitionInfo[]
capacity: number capacity: number
guid: string | null // cant back up if guid exists guid: string | null // cant back up if guid exists, but needed if migrating
} }
export type RecoveryStatusRes = { export type RecoveryStatusRes = {

View File

@@ -10,6 +10,7 @@ import {
ApiService, ApiService,
CifsRecoverySource, CifsRecoverySource,
DiskListResponse, DiskListResponse,
DiskMigrateSource,
DiskRecoverySource, DiskRecoverySource,
EmbassyOSRecoveryInfo, EmbassyOSRecoveryInfo,
GetStatusRes, GetStatusRes,
@@ -58,13 +59,6 @@ export class LiveApiService extends ApiService {
}) })
} }
async set02XDrive(logicalname: string) {
return this.rpcRequest<void>({
method: 'setup.recovery.v2.set',
params: { logicalname },
})
}
async getRecoveryStatus() { async getRecoveryStatus() {
return this.rpcRequest<RecoveryStatusRes>({ return this.rpcRequest<RecoveryStatusRes>({
method: 'setup.recovery.status', method: 'setup.recovery.status',
@@ -93,10 +87,12 @@ export class LiveApiService extends ApiService {
} }
async setupEmbassy(setupInfo: SetupEmbassyReq) { async setupEmbassy(setupInfo: SetupEmbassyReq) {
if (isCifsSource(setupInfo['recovery-source'])) { if (setupInfo['recovery-source']?.type === 'backup') {
setupInfo['recovery-source'].path = setupInfo[ if (isCifsSource(setupInfo['recovery-source'].target)) {
'recovery-source' setupInfo['recovery-source'].target.path = setupInfo[
].path.replace('/\\/g', '/') 'recovery-source'
].target.path.replace('/\\/g', '/')
}
} }
const res = await this.rpcRequest<SetupEmbassyRes>({ const res = await this.rpcRequest<SetupEmbassyRes>({

View File

@@ -54,7 +54,30 @@ export class MockApiService extends ApiService {
'embassy-os': { 'embassy-os': {
version: '0.2.17', version: '0.2.17',
full: true, full: true,
'password-hash': null, 'password-hash':
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
'wrapped-key': null,
},
},
],
capacity: 123456789123,
guid: 'uuid-uuid-uuid-uuid',
},
{
logicalname: 'dcba',
vendor: 'Samsung',
model: 'T5',
partitions: [
{
logicalname: 'pbcba',
label: null,
capacity: 73264762332,
used: null,
'embassy-os': {
version: '0.3.1',
full: true,
'password-hash':
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
'wrapped-key': null, 'wrapped-key': null,
}, },
}, },
@@ -65,11 +88,6 @@ export class MockApiService extends ApiService {
] ]
} }
async set02XDrive() {
await pauseFor(1000)
return
}
async getRecoveryStatus() { async getRecoveryStatus() {
tries = Math.min(tries + 1, 4) tries = Math.min(tries + 1, 4)
return { return {
@@ -134,105 +152,3 @@ const setupRes = {
'lan-address': 'https://embassy-abcdefgh.local', 'lan-address': 'https://embassy-abcdefgh.local',
'root-ca': encodeBase64(rootCA), 'root-ca': encodeBase64(rootCA),
} }
const disks = [
{
vendor: 'Samsung',
model: 'SATA',
logicalname: '/dev/sda',
guid: 'theguid',
partitions: [
{
logicalname: 'sda1',
label: 'label 1',
capacity: 100000,
used: 200.1255312,
'embassy-os': null,
},
{
logicalname: 'sda2',
label: 'label 2',
capacity: 50000,
used: 200.1255312,
'embassy-os': null,
},
],
capacity: 150000,
},
{
vendor: 'Samsung',
model: null,
logicalname: 'dev/sdb',
partitions: [],
capacity: 34359738369,
guid: null,
},
{
vendor: 'Crucial',
model: 'MX500',
logicalname: 'dev/sdc',
guid: null,
partitions: [
{
logicalname: 'sdc1',
label: 'label 1',
capacity: 0,
used: null,
'embassy-os': {
version: '0.3.3',
full: true,
'password-hash': 'asdfasdfasdf',
'wrapped-key': '',
},
},
{
logicalname: 'sdc1MOCKTESTER',
label: 'label 1',
capacity: 0,
used: null,
'embassy-os': {
version: '0.3.6',
full: true,
// password is 'asdfasdf'
'password-hash':
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
'wrapped-key': '',
},
},
{
logicalname: 'sdc1',
label: 'label 1',
capacity: 0,
used: null,
'embassy-os': {
version: '0.3.3',
full: false,
'password-hash': 'asdfasdfasdf',
'wrapped-key': '',
},
},
],
capacity: 100000,
},
{
vendor: 'Sandisk',
model: null,
logicalname: '/dev/sdd',
guid: null,
partitions: [
{
logicalname: 'sdd1',
label: null,
capacity: 10000,
used: null,
'embassy-os': {
version: '0.2.7',
full: true,
'password-hash': 'asdfasdfasdf',
'wrapped-key': '',
},
},
],
capacity: 10000,
},
]

View File

@@ -1,10 +1,6 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { BehaviorSubject } from 'rxjs' import { BehaviorSubject } from 'rxjs'
import { import { ApiService, RecoverySource } from './api/api.service'
ApiService,
CifsRecoverySource,
DiskRecoverySource,
} from './api/api.service'
import { pauseFor, ErrorToastService } from '@start9labs/shared' import { pauseFor, ErrorToastService } from '@start9labs/shared'
@Injectable({ @Injectable({
@@ -14,7 +10,7 @@ export class StateService {
polling = false polling = false
embassyLoaded = false embassyLoaded = false
recoverySource?: CifsRecoverySource | DiskRecoverySource recoverySource?: RecoverySource
recoveryPassword?: string recoveryPassword?: string
dataTransferProgress?: { dataTransferProgress?: {

View File

@@ -70,14 +70,14 @@ export class BackupDrivesComponent {
): void { ): void {
if (target.entry.type === 'cifs' && !target.entry.mountable) { if (target.entry.type === 'cifs' && !target.entry.mountable) {
const message = const message =
'Unable to connect to LAN Shared Folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.' 'Unable to connect to Network Folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.'
this.presentAlertError(message) this.presentAlertError(message)
return return
} }
if (this.type === 'restore' && !target.hasValidBackup) { if (this.type === 'restore' && !target.hasValidBackup) {
const message = `${ const message = `${
target.entry.type === 'cifs' ? 'LAN Shared Folder' : 'Drive partition' target.entry.type === 'cifs' ? 'Network Folder' : 'Drive partition'
} does not contain a valid Embassy backup.` } does not contain a valid Embassy backup.`
this.presentAlertError(message) this.presentAlertError(message)
return return
@@ -90,7 +90,7 @@ export class BackupDrivesComponent {
const modal = await this.modalCtrl.create({ const modal = await this.modalCtrl.create({
component: GenericFormPage, component: GenericFormPage,
componentProps: { componentProps: {
title: 'New LAN Shared Folder', title: 'New Network Folder',
spec: CifsSpec, spec: CifsSpec,
buttons: [ buttons: [
{ {