diff --git a/appmgr/migrations/20210629193146_Init.sql b/appmgr/migrations/20210629193146_Init.sql index 418610eef..80543e22a 100644 --- a/appmgr/migrations/20210629193146_Init.sql +++ b/appmgr/migrations/20210629193146_Init.sql @@ -47,4 +47,12 @@ CREATE TABLE IF NOT EXISTS notifications title TEXT NOT NULL, message TEXT NOT NULL, data TEXT +); +CREATE TABLE IF NOT EXISTS cifs_shares +( + id INTEGER PRIMARY KEY, + hostname TEXT NOT NULL, + path TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT ); \ No newline at end of file diff --git a/appmgr/sqlx-data.json b/appmgr/sqlx-data.json index e606f580a..347771d5e 100644 --- a/appmgr/sqlx-data.json +++ b/appmgr/sqlx-data.json @@ -40,6 +40,24 @@ "nullable": [] } }, + "1b2242afa55e730b37b00929b656d80940b457ec86c234ddd0de917bd8872611": { + "query": "INSERT INTO cifs_shares (hostname, path, username, password) VALUES (?, ?, ?, ?) RETURNING id AS \"id: u32\"", + "describe": { + "columns": [ + { + "name": "id: u32", + "ordinal": 0, + "type_info": "Null" + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + false + ] + } + }, "1eee1fdc793919c391008854407143d7a11b4668486c11a760b49af49992f9f8": { "query": "REPLACE INTO tor (package, interface, key) VALUES (?, 'main', ?)", "describe": { @@ -262,6 +280,48 @@ ] } }, + "668f39c868f90cdbcc635858bac9e55ed73192ed2aec5c52dcfba9800a7a4a41": { + "query": "SELECT id AS \"id: u32\", hostname, path, username, password FROM cifs_shares", + "describe": { + "columns": [ + { + "name": "id: u32", + "ordinal": 0, + "type_info": "Int64" + }, + { + "name": "hostname", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "path", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "username", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "password", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + true + ] + } + }, "6b9abc9e079cff975f8a7f07ff70548c7877ecae3be0d0f2d3f439a6713326c0": { "query": "DELETE FROM notifications WHERE id < ?", "describe": { @@ -374,12 +434,12 @@ "nullable": [] } }, - "a596bdc5014ba9e7b362398abf09ec6a100923e001247a79503d1e820ffe71c3": { - "query": "-- Add migration script here\nCREATE TABLE IF NOT EXISTS tor\n(\n package TEXT NOT NULL,\n interface TEXT NOT NULL,\n key BLOB NOT NULL CHECK (length(key) = 64),\n PRIMARY KEY (package, interface)\n);\nCREATE TABLE IF NOT EXISTS session\n(\n id TEXT NOT NULL PRIMARY KEY,\n logged_in TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n logged_out TIMESTAMP,\n last_active TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n user_agent TEXT,\n metadata TEXT NOT NULL DEFAULT 'null'\n);\nCREATE TABLE IF NOT EXISTS account\n(\n id INTEGER PRIMARY KEY CHECK (id = 0),\n password TEXT NOT NULL,\n tor_key BLOB NOT NULL CHECK (length(tor_key) = 64)\n);\nCREATE TABLE IF NOT EXISTS ssh_keys\n(\n fingerprint TEXT NOT NULL,\n openssh_pubkey TEXT NOT NULL,\n created_at TEXT NOT NULL,\n PRIMARY KEY (fingerprint)\n);\nCREATE TABLE IF NOT EXISTS certificates\n(\n id INTEGER PRIMARY KEY, -- Root = 0, Int = 1, Other = 2..\n priv_key_pem TEXT NOT NULL,\n certificate_pem TEXT NOT NULL,\n lookup_string TEXT UNIQUE,\n created_at TEXT,\n updated_at TEXT\n);\nCREATE TABLE IF NOT EXISTS notifications\n(\n id INTEGER PRIMARY KEY,\n package_id TEXT,\n created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n code INTEGER NOT NULL,\n level TEXT NOT NULL,\n title TEXT NOT NULL,\n message TEXT NOT NULL,\n data TEXT\n);", + "a4e7162322b28508310b9de7ebc891e619b881ff6d3ea09eba13da39626ab12f": { + "query": "UPDATE cifs_shares SET hostname = ?, path = ?, username = ?, password = ? WHERE id = ?", "describe": { "columns": [], "parameters": { - "Right": 0 + "Right": 5 }, "nullable": [] } @@ -474,6 +534,52 @@ ] } }, + "b376d9e77e0861a9af2d1081ca48d14e83abc5a1546213d15bb570972c403beb": { + "query": "-- Add migration script here\nCREATE TABLE IF NOT EXISTS tor\n(\n package TEXT NOT NULL,\n interface TEXT NOT NULL,\n key BLOB NOT NULL CHECK (length(key) = 64),\n PRIMARY KEY (package, interface)\n);\nCREATE TABLE IF NOT EXISTS session\n(\n id TEXT NOT NULL PRIMARY KEY,\n logged_in TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n logged_out TIMESTAMP,\n last_active TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n user_agent TEXT,\n metadata TEXT NOT NULL DEFAULT 'null'\n);\nCREATE TABLE IF NOT EXISTS account\n(\n id INTEGER PRIMARY KEY CHECK (id = 0),\n password TEXT NOT NULL,\n tor_key BLOB NOT NULL CHECK (length(tor_key) = 64)\n);\nCREATE TABLE IF NOT EXISTS ssh_keys\n(\n fingerprint TEXT NOT NULL,\n openssh_pubkey TEXT NOT NULL,\n created_at TEXT NOT NULL,\n PRIMARY KEY (fingerprint)\n);\nCREATE TABLE IF NOT EXISTS certificates\n(\n id INTEGER PRIMARY KEY, -- Root = 0, Int = 1, Other = 2..\n priv_key_pem TEXT NOT NULL,\n certificate_pem TEXT NOT NULL,\n lookup_string TEXT UNIQUE,\n created_at TEXT,\n updated_at TEXT\n);\nCREATE TABLE IF NOT EXISTS notifications\n(\n id INTEGER PRIMARY KEY,\n package_id TEXT,\n created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n code INTEGER NOT NULL,\n level TEXT NOT NULL,\n title TEXT NOT NULL,\n message TEXT NOT NULL,\n data TEXT\n);\nCREATE TABLE IF NOT EXISTS cifs_shares\n(\n id INTEGER PRIMARY KEY,\n hostname TEXT NOT NULL,\n path TEXT NOT NULL,\n username TEXT NOT NULL,\n password TEXT\n);", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + } + }, + "cc33fe2958fe7caeac6999a217f918a68b45ad596664170b4d07671c6ea49566": { + "query": "SELECT hostname, path, username, password FROM cifs_shares WHERE id = ?", + "describe": { + "columns": [ + { + "name": "hostname", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "path", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "username", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "password", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true + ] + } + }, "d5117054072476377f3c4f040ea429d4c9b2cf534e76f35c80a2bf60e8599cca": { "query": "SELECT openssh_pubkey FROM ssh_keys", "describe": { @@ -565,5 +671,15 @@ false ] } + }, + "f63c8c5a8754b34a49ef5d67802fa2b72aa409bbec92ecc6901492092974b71a": { + "query": "DELETE FROM cifs_shares WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + } } } \ No newline at end of file diff --git a/appmgr/src/action/docker.rs b/appmgr/src/action/docker.rs index c9be82392..8b35882b7 100644 --- a/appmgr/src/action/docker.rs +++ b/appmgr/src/action/docker.rs @@ -5,7 +5,6 @@ use std::net::Ipv4Addr; use std::path::PathBuf; use std::time::Duration; -use bollard::container::StopContainerOptions; use futures::future::Either as EitherFuture; use nix::sys::signal; use nix::unistd::Pid; @@ -16,7 +15,8 @@ use tracing::instrument; use crate::context::RpcContext; use crate::id::{Id, ImageId}; use crate::s9pk::manifest::{PackageId, SYSTEM_PACKAGE_ID}; -use crate::util::{IoFormat, Version}; +use crate::util::serde::IoFormat; +use crate::util::Version; use crate::volume::{VolumeId, Volumes}; use crate::{Error, ResultExt, HOST_IP}; diff --git a/appmgr/src/action/mod.rs b/appmgr/src/action/mod.rs index 8950508d8..99eba5d05 100644 --- a/appmgr/src/action/mod.rs +++ b/appmgr/src/action/mod.rs @@ -16,7 +16,8 @@ use crate::config::{Config, ConfigSpec}; use crate::context::RpcContext; use crate::id::{Id, InvalidId}; use crate::s9pk::manifest::PackageId; -use crate::util::{display_serializable, parse_stdin_deserializable, IoFormat, Version}; +use crate::util::serde::{display_serializable, parse_stdin_deserializable, IoFormat}; +use crate::util::Version; use crate::volume::Volumes; use crate::{Error, ResultExt}; diff --git a/appmgr/src/auth.rs b/appmgr/src/auth.rs index 6afd6f52e..030589373 100644 --- a/appmgr/src/auth.rs +++ b/appmgr/src/auth.rs @@ -14,7 +14,8 @@ use tracing::instrument; use crate::context::{CliContext, RpcContext}; use crate::middleware::auth::{AsLogoutSessionId, HasLoggedOutSessions, HashSessionToken}; -use crate::util::{display_none, display_serializable, IoFormat}; +use crate::util::display_none; +use crate::util::serde::{display_serializable, IoFormat}; use crate::{ensure_code, Error, ResultExt}; #[command(subcommands(login, logout, session))] diff --git a/appmgr/src/backup/backup_bulk.rs b/appmgr/src/backup/backup_bulk.rs index 193c0512e..c8a80cd5b 100644 --- a/appmgr/src/backup/backup_bulk.rs +++ b/appmgr/src/backup/backup_bulk.rs @@ -1,5 +1,4 @@ use std::collections::BTreeMap; -use std::path::PathBuf; use std::sync::Arc; use chrono::Utc; @@ -14,17 +13,20 @@ use tokio::io::AsyncWriteExt; use torut::onion::TorSecretKeyV3; use tracing::instrument; +use super::target::BackupTargetId; use super::PackageBackupReport; use crate::auth::check_password_against_db; use crate::backup::{BackupReport, ServerBackupReport}; use crate::context::RpcContext; use crate::db::model::ServerStatus; use crate::db::util::WithRevision; -use crate::disk::util::{BackupMountGuard, TmpMountGuard}; +use crate::disk::mount::backup::BackupMountGuard; +use crate::disk::mount::guard::TmpMountGuard; use crate::notifications::NotificationLevel; use crate::s9pk::manifest::PackageId; use crate::status::MainStatus; -use crate::util::{display_none, AtomicFile, IoFormat}; +use crate::util::serde::IoFormat; +use crate::util::{display_none, AtomicFile}; use crate::version::VersionT; use crate::Error; @@ -112,14 +114,17 @@ impl Serialize for OsBackup { #[instrument(skip(ctx, old_password, password))] pub async fn backup_all( #[context] ctx: RpcContext, - #[arg] logicalname: PathBuf, + #[arg(rename = "target-id")] target_id: BackupTargetId, #[arg(rename = "old-password", long = "old-password")] old_password: Option, #[arg] password: String, ) -> Result, Error> { let mut db = ctx.db.handle(); check_password_against_db(&mut ctx.secret_store.acquire().await?, &password).await?; + let fs = target_id + .load(&mut ctx.secret_store.acquire().await?) + .await?; let mut backup_guard = BackupMountGuard::mount( - TmpMountGuard::mount(&logicalname, None).await?, + TmpMountGuard::mount(&fs).await?, old_password.as_ref().unwrap_or(&password), ) .await?; diff --git a/appmgr/src/backup/mod.rs b/appmgr/src/backup/mod.rs index 560934163..887d55e06 100644 --- a/appmgr/src/backup/mod.rs +++ b/appmgr/src/backup/mod.rs @@ -11,19 +11,21 @@ use tokio::fs::File; use tokio::io::AsyncWriteExt; use tracing::instrument; +use self::target::PackageBackupInfo; use crate::action::{ActionImplementation, NoOutput}; use crate::context::RpcContext; -use crate::disk::PackageBackupInfo; use crate::install::PKG_ARCHIVE_DIR; use crate::net::interface::{InterfaceId, Interfaces}; use crate::s9pk::manifest::PackageId; -use crate::util::{AtomicFile, IoFormat, Version}; +use crate::util::serde::IoFormat; +use crate::util::{AtomicFile, Version}; use crate::version::{Current, VersionT}; use crate::volume::{backup_dir, Volume, VolumeId, Volumes, BACKUP_DIR}; use crate::{Error, ResultExt}; pub mod backup_bulk; pub mod restore; +pub mod target; #[derive(Debug, Deserialize, Serialize)] pub struct BackupReport { @@ -42,7 +44,7 @@ pub struct PackageBackupReport { error: Option, } -#[command(subcommands(backup_bulk::backup_all))] +#[command(subcommands(backup_bulk::backup_all, target::target))] pub fn backup() -> Result<(), Error> { Ok(()) } diff --git a/appmgr/src/backup/restore.rs b/appmgr/src/backup/restore.rs index 46091caef..a9094b39d 100644 --- a/appmgr/src/backup/restore.rs +++ b/appmgr/src/backup/restore.rs @@ -1,5 +1,5 @@ use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::Duration; @@ -16,20 +16,23 @@ use tokio::task::JoinHandle; use torut::onion::OnionAddressV3; use tracing::instrument; +use super::target::BackupTargetId; use crate::auth::check_password_against_db; use crate::backup::backup_bulk::OsBackup; use crate::context::{RpcContext, SetupContext}; use crate::db::model::{PackageDataEntry, StaticFiles}; use crate::db::util::WithRevision; -use crate::disk::util::{BackupMountGuard, PackageBackupMountGuard, PartitionInfo, TmpMountGuard}; +use crate::disk::mount::backup::{BackupMountGuard, PackageBackupMountGuard}; +use crate::disk::mount::guard::TmpMountGuard; use crate::install::progress::InstallProgress; use crate::install::{download_install_s9pk, PKG_PUBLIC_DIR}; use crate::net::ssl::SslManager; use crate::s9pk::manifest::{Manifest, PackageId}; use crate::s9pk::reader::S9pkReader; use crate::setup::RecoveryStatus; +use crate::util::display_none; use crate::util::io::dir_size; -use crate::util::{display_none, IoFormat}; +use crate::util::serde::IoFormat; use crate::volume::{backup_dir, BACKUP_DIR, PKG_VOLUME_DIR}; use crate::{Error, ResultExt}; @@ -44,14 +47,17 @@ fn parse_comma_separated(arg: &str, _: &ArgMatches<'_>) -> Result pub async fn restore_packages_rpc( #[context] ctx: RpcContext, #[arg(parse(parse_comma_separated))] ids: Vec, - #[arg] logicalname: PathBuf, + #[arg(rename = "target-id")] target_id: BackupTargetId, #[arg(rename = "old-password", long = "old-password")] old_password: Option, #[arg] password: String, ) -> Result, Error> { let mut db = ctx.db.handle(); check_password_against_db(&mut ctx.secret_store.acquire().await?, &password).await?; + let fs = target_id + .load(&mut ctx.secret_store.acquire().await?) + .await?; let mut backup_guard = BackupMountGuard::mount( - TmpMountGuard::mount(&logicalname, None).await?, + TmpMountGuard::mount(&fs).await?, old_password.as_ref().unwrap_or(&password), ) .await?; @@ -140,6 +146,7 @@ impl ProgressInfo { RecoveryStatus { total_bytes, bytes_transferred, + complete: false, } } } @@ -149,11 +156,11 @@ pub async fn recover_full_embassy( ctx: SetupContext, disk_guid: Arc, embassy_password: String, - recovery_partition: PartitionInfo, + recovery_source: TmpMountGuard, recovery_password: Option, ) -> Result<(OnionAddressV3, X509, BoxFuture<'static, Result<(), Error>>), Error> { let backup_guard = BackupMountGuard::mount( - TmpMountGuard::mount(&recovery_partition.logicalname, None).await?, + recovery_source, recovery_password.as_deref().unwrap_or_default(), ) .await?; diff --git a/appmgr/src/backup/target/cifs.rs b/appmgr/src/backup/target/cifs.rs new file mode 100644 index 000000000..d94ec6071 --- /dev/null +++ b/appmgr/src/backup/target/cifs.rs @@ -0,0 +1,211 @@ +use std::path::PathBuf; + +use color_eyre::eyre::eyre; +use futures::TryStreamExt; +use rpc_toolkit::command; +use serde::{Deserialize, Serialize}; +use sqlx::{Executor, Sqlite}; + +use super::{BackupTarget, BackupTargetId}; +use crate::context::RpcContext; +use crate::disk::mount::filesystem::cifs::Cifs; +use crate::disk::mount::guard::TmpMountGuard; +use crate::disk::util::{recovery_info, EmbassyOsRecoveryInfo}; +use crate::util::display_none; +use crate::util::serde::KeyVal; +use crate::Error; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct CifsBackupTarget { + hostname: String, + path: PathBuf, + username: String, + mountable: bool, + embassy_os: Option, +} + +#[command(subcommands(add, update, remove))] +pub fn cifs() -> Result<(), Error> { + Ok(()) +} + +#[command(display(display_none))] +pub async fn add( + #[context] ctx: RpcContext, + #[arg] hostname: String, + #[arg] path: PathBuf, + #[arg] username: String, + #[arg] password: Option, +) -> Result, Error> { + let cifs = Cifs { + hostname, + path, + username, + password, + }; + let guard = TmpMountGuard::mount(&cifs).await?; + let embassy_os = recovery_info(&guard).await?; + guard.unmount().await?; + let path_string = cifs.path.display().to_string(); + let id: u32 = sqlx::query!( + "INSERT INTO cifs_shares (hostname, path, username, password) VALUES (?, ?, ?, ?) RETURNING id AS \"id: u32\"", + cifs.hostname, + path_string, + cifs.username, + cifs.password, + ) + .fetch_one(&ctx.secret_store) + .await?.id; + Ok(KeyVal { + key: BackupTargetId::Cifs { id }, + value: BackupTarget::Cifs(CifsBackupTarget { + hostname: cifs.hostname, + path: cifs.path, + username: cifs.username, + mountable: true, + embassy_os, + }), + }) +} + +#[command(display(display_none))] +pub async fn update( + #[context] ctx: RpcContext, + #[arg] id: BackupTargetId, + #[arg] hostname: String, + #[arg] path: PathBuf, + #[arg] username: String, + #[arg] password: Option, +) -> Result, Error> { + let id = if let BackupTargetId::Cifs { id } = id { + id + } else { + return Err(Error::new( + eyre!("Backup Target ID {} Not Found", id), + crate::ErrorKind::NotFound, + )); + }; + let cifs = Cifs { + hostname, + path, + username, + password, + }; + let guard = TmpMountGuard::mount(&cifs).await?; + let embassy_os = recovery_info(&guard).await?; + guard.unmount().await?; + let path_string = cifs.path.display().to_string(); + if sqlx::query!( + "UPDATE cifs_shares SET hostname = ?, path = ?, username = ?, password = ? WHERE id = ?", + cifs.hostname, + path_string, + cifs.username, + cifs.password, + id, + ) + .execute(&ctx.secret_store) + .await? + .rows_affected() + == 0 + { + return Err(Error::new( + eyre!("Backup Target ID {} Not Found", BackupTargetId::Cifs { id }), + crate::ErrorKind::NotFound, + )); + }; + Ok(KeyVal { + key: BackupTargetId::Cifs { id }, + value: BackupTarget::Cifs(CifsBackupTarget { + hostname: cifs.hostname, + path: cifs.path, + username: cifs.username, + mountable: true, + embassy_os, + }), + }) +} + +#[command(display(display_none))] +pub async fn remove(#[context] ctx: RpcContext, #[arg] id: BackupTargetId) -> Result<(), Error> { + let id = if let BackupTargetId::Cifs { id } = id { + id + } else { + return Err(Error::new( + eyre!("Backup Target ID {} Not Found", id), + crate::ErrorKind::NotFound, + )); + }; + if sqlx::query!("DELETE FROM cifs_shares WHERE id = ?", id) + .execute(&ctx.secret_store) + .await? + .rows_affected() + == 0 + { + return Err(Error::new( + eyre!("Backup Target ID {} Not Found", BackupTargetId::Cifs { id }), + crate::ErrorKind::NotFound, + )); + }; + Ok(()) +} + +pub async fn load(secrets: &mut Ex, id: u32) -> Result +where + for<'a> &'a mut Ex: Executor<'a, Database = Sqlite>, +{ + let record = sqlx::query!( + "SELECT hostname, path, username, password FROM cifs_shares WHERE id = ?", + id + ) + .fetch_one(secrets) + .await?; + + Ok(Cifs { + hostname: record.hostname, + path: PathBuf::from(record.path), + username: record.username, + password: record.password, + }) +} + +pub async fn list(secrets: &mut Ex) -> Result, Error> +where + for<'a> &'a mut Ex: Executor<'a, Database = Sqlite>, +{ + let mut records = sqlx::query!( + "SELECT id AS \"id: u32\", hostname, path, username, password FROM cifs_shares" + ) + .fetch_many(secrets); + + let mut cifs = Vec::new(); + while let Some(query_result) = records.try_next().await? { + if let Some(record) = query_result.right() { + let mount_info = Cifs { + hostname: record.hostname, + path: PathBuf::from(record.path), + username: record.username, + password: record.password, + }; + let embassy_os = async { + let guard = TmpMountGuard::mount(&mount_info).await?; + let embassy_os = recovery_info(&guard).await?; + guard.unmount().await?; + Ok::<_, Error>(embassy_os) + } + .await; + cifs.push(( + record.id, + CifsBackupTarget { + hostname: mount_info.hostname, + path: mount_info.path, + username: mount_info.username, + mountable: embassy_os.is_ok(), + embassy_os: embassy_os.ok().and_then(|a| a), + }, + )); + } + } + + Ok(cifs) +} diff --git a/appmgr/src/backup/target/mod.rs b/appmgr/src/backup/target/mod.rs new file mode 100644 index 000000000..ac7480bf8 --- /dev/null +++ b/appmgr/src/backup/target/mod.rs @@ -0,0 +1,239 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use clap::ArgMatches; +use color_eyre::eyre::eyre; +use digest::generic_array::GenericArray; +use digest::Digest; +use rpc_toolkit::command; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use sqlx::{Executor, Sqlite}; +use tracing::instrument; + +use self::cifs::CifsBackupTarget; +use crate::context::RpcContext; +use crate::disk::mount::backup::BackupMountGuard; +use crate::disk::mount::filesystem::block_dev::BlockDev; +use crate::disk::mount::filesystem::cifs::Cifs; +use crate::disk::mount::filesystem::FileSystem; +use crate::disk::mount::guard::TmpMountGuard; +use crate::disk::util::PartitionInfo; +use crate::s9pk::manifest::PackageId; +use crate::util::serde::{deserialize_from_str, display_serializable, serialize_display}; +use crate::util::Version; +use crate::Error; + +pub mod cifs; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "type")] +#[serde(rename_all = "kebab-case")] +pub enum BackupTarget { + #[serde(rename_all = "kebab-case")] + Disk { + vendor: Option, + model: Option, + #[serde(flatten)] + partition_info: PartitionInfo, + }, + Cifs(CifsBackupTarget), +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum BackupTargetId { + Disk { logicalname: PathBuf }, + Cifs { id: u32 }, +} +impl BackupTargetId { + pub async fn load(self, secrets: &mut Ex) -> Result + where + for<'a> &'a mut Ex: Executor<'a, Database = Sqlite>, + { + Ok(match self { + BackupTargetId::Disk { logicalname } => { + BackupTargetFS::Disk(BlockDev::new(logicalname)) + } + BackupTargetId::Cifs { id } => BackupTargetFS::Cifs(cifs::load(secrets, id).await?), + }) + } +} +impl std::fmt::Display for BackupTargetId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BackupTargetId::Disk { logicalname } => write!(f, "disk-{}", logicalname.display()), + BackupTargetId::Cifs { id } => write!(f, "cifs-{}", id), + } + } +} +impl std::str::FromStr for BackupTargetId { + type Err = Error; + fn from_str(s: &str) -> Result { + match s.split_once("-") { + Some(("disk", logicalname)) => Ok(BackupTargetId::Disk { + logicalname: Path::new(logicalname).to_owned(), + }), + Some(("cifs", id)) => Ok(BackupTargetId::Cifs { id: id.parse()? }), + _ => Err(Error::new( + eyre!("Invalid Backup Target ID"), + crate::ErrorKind::InvalidBackupTargetId, + )), + } + } +} +impl<'de> Deserialize<'de> for BackupTargetId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserialize_from_str(deserializer) + } +} +impl Serialize for BackupTargetId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serialize_display(self, serializer) + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "type")] +#[serde(rename_all = "kebab-case")] +pub enum BackupTargetFS { + Disk(BlockDev), + Cifs(Cifs), +} +#[async_trait] +impl FileSystem for BackupTargetFS { + async fn mount + Send + Sync>(&self, mountpoint: P) -> Result<(), Error> { + match self { + BackupTargetFS::Disk(a) => a.mount(mountpoint).await, + BackupTargetFS::Cifs(a) => a.mount(mountpoint).await, + } + } + async fn source_hash(&self) -> Result::OutputSize>, Error> { + match self { + BackupTargetFS::Disk(a) => a.source_hash().await, + BackupTargetFS::Cifs(a) => a.source_hash().await, + } + } +} + +#[command(subcommands(cifs::cifs, list, info))] +pub fn target() -> Result<(), Error> { + Ok(()) +} + +#[command(display(display_serializable))] +pub async fn list( + #[context] ctx: RpcContext, +) -> Result, Error> { + let mut sql_handle = ctx.secret_store.acquire().await?; + let (disks, cifs) = tokio::try_join!(crate::disk::util::list(), cifs::list(&mut sql_handle),)?; + Ok(disks + .into_iter() + .flat_map(|mut disk| { + std::mem::take(&mut disk.partitions) + .into_iter() + .map(|part| { + ( + BackupTargetId::Disk { + logicalname: part.logicalname.clone(), + }, + BackupTarget::Disk { + vendor: disk.vendor.clone(), + model: disk.model.clone(), + partition_info: part, + }, + ) + }) + .collect::>() + }) + .chain( + cifs.into_iter() + .map(|(id, cifs)| (BackupTargetId::Cifs { id }, BackupTarget::Cifs(cifs))), + ) + .collect()) +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct BackupInfo { + pub version: Version, + pub timestamp: Option>, + pub package_backups: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct PackageBackupInfo { + pub title: String, + pub version: Version, + pub os_version: Version, + pub timestamp: DateTime, +} + +fn display_backup_info(info: BackupInfo, matches: &ArgMatches<'_>) { + use prettytable::*; + + if matches.is_present("format") { + return display_serializable(info, matches); + } + + let mut table = Table::new(); + table.add_row(row![bc => + "ID", + "VERSION", + "OS VERSION", + "TIMESTAMP", + ]); + table.add_row(row![ + "EMBASSY OS", + info.version.as_str(), + info.version.as_str(), + &if let Some(ts) = &info.timestamp { + ts.to_string() + } else { + "N/A".to_owned() + }, + ]); + for (id, info) in info.package_backups { + let row = row![ + id.as_str(), + info.version.as_str(), + info.os_version.as_str(), + &info.timestamp.to_string(), + ]; + table.add_row(row); + } + table.print_tty(false); +} + +#[command(display(display_backup_info))] +#[instrument(skip(ctx, password))] +pub async fn info( + #[context] ctx: RpcContext, + #[arg(rename = "target-id")] target_id: BackupTargetId, + #[arg] password: String, +) -> Result { + let guard = BackupMountGuard::mount( + TmpMountGuard::mount( + &target_id + .load(&mut ctx.secret_store.acquire().await?) + .await?, + ) + .await?, + &password, + ) + .await?; + + let res = guard.metadata.clone(); + + guard.unmount().await?; + + Ok(res) +} diff --git a/appmgr/src/bin/embassy-init.rs b/appmgr/src/bin/embassy-init.rs index c05c2f240..096c5f39f 100644 --- a/appmgr/src/bin/embassy-init.rs +++ b/appmgr/src/bin/embassy-init.rs @@ -25,7 +25,6 @@ fn status_fn(_: i32) -> StatusCode { #[instrument] async fn setup_or_init(cfg_path: Option<&str>) -> Result<(), Error> { - embassy::disk::util::mount("LABEL=EMBASSY", "/embassy-os").await?; if tokio::fs::metadata("/embassy-os/disk.guid").await.is_err() { #[cfg(feature = "avahi")] let _mdns = MdnsController::init(); diff --git a/appmgr/src/bin/embassyd.rs b/appmgr/src/bin/embassyd.rs index 947a16954..342cb4324 100644 --- a/appmgr/src/bin/embassyd.rs +++ b/appmgr/src/bin/embassyd.rs @@ -38,6 +38,7 @@ fn err_to_500(e: Error) -> Response { #[instrument] async fn inner_main(cfg_path: Option<&str>) -> Result, Error> { let (rpc_ctx, shutdown) = { + embassy::hostname::sync_hostname().await?; let rpc_ctx = RpcContext::init( cfg_path, Arc::new( @@ -179,16 +180,14 @@ async fn inner_main(cfg_path: Option<&str>) -> Result, Error> { None => Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::empty()), - Some(cont) => { - match (cont.handler)(req).await { - Ok(r) => Ok(r), - Err(e) => Response::builder() - .status( - StatusCode::INTERNAL_SERVER_ERROR, - ) - .body(Body::from(format!("{}", e))), - } - } + Some(cont) => match (cont.handler)(req).await { + Ok(r) => Ok(r), + Err(e) => Response::builder() + .status( + StatusCode::INTERNAL_SERVER_ERROR, + ) + .body(Body::from(format!("{}", e))), + }, } } } diff --git a/appmgr/src/config/action.rs b/appmgr/src/config/action.rs index 7ab325fc8..339b18e12 100644 --- a/appmgr/src/config/action.rs +++ b/appmgr/src/config/action.rs @@ -96,8 +96,8 @@ impl ConfigActions { #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct SetResult { - #[serde(deserialize_with = "crate::util::deserialize_from_str_opt")] - #[serde(serialize_with = "crate::util::serialize_display_opt")] + #[serde(deserialize_with = "crate::util::serde::deserialize_from_str_opt")] + #[serde(serialize_with = "crate::util::serde::serialize_display_opt")] pub signal: Option, pub depends_on: BTreeMap>, } diff --git a/appmgr/src/config/mod.rs b/appmgr/src/config/mod.rs index 46c5a1576..6bb875046 100644 --- a/appmgr/src/config/mod.rs +++ b/appmgr/src/config/mod.rs @@ -23,7 +23,8 @@ use crate::dependencies::{ }; use crate::install::cleanup::remove_current_dependents; use crate::s9pk::manifest::{Manifest, PackageId}; -use crate::util::{display_none, display_serializable, parse_stdin_deserializable, IoFormat}; +use crate::util::display_none; +use crate::util::serde::{display_serializable, parse_stdin_deserializable, IoFormat}; use crate::{Error, ResultExt as _}; pub mod action; @@ -188,7 +189,7 @@ pub fn set( #[allow(unused_variables)] #[arg(long = "format")] format: Option, - #[arg(long = "timeout")] timeout: Option, + #[arg(long = "timeout")] timeout: Option, #[arg(stdin, parse(parse_stdin_deserializable))] config: Option, #[arg(rename = "expire-id", long = "expire-id")] expire_id: Option, ) -> Result<(PackageId, Option, Option, Option), Error> { diff --git a/appmgr/src/context/cli.rs b/appmgr/src/context/cli.rs index 3da513791..0996f87f8 100644 --- a/appmgr/src/context/cli.rs +++ b/appmgr/src/context/cli.rs @@ -22,7 +22,7 @@ use crate::ResultExt; pub struct CliContextConfig { pub bind_rpc: Option, pub host: Option, - #[serde(deserialize_with = "crate::util::deserialize_from_str_opt")] + #[serde(deserialize_with = "crate::util::serde::deserialize_from_str_opt")] pub proxy: Option, pub cookie_path: Option, } diff --git a/appmgr/src/context/rpc.rs b/appmgr/src/context/rpc.rs index 848d481fd..608ecb3cd 100644 --- a/appmgr/src/context/rpc.rs +++ b/appmgr/src/context/rpc.rs @@ -29,6 +29,7 @@ use crate::net::tor::os_key; use crate::net::wifi::WpaCli; use crate::net::NetController; use crate::notifications::NotificationManager; +use crate::setup::password_hash; use crate::shutdown::Shutdown; use crate::status::{MainStatus, Status}; use crate::system::launch_metrics_task; @@ -81,6 +82,7 @@ impl RpcContextConfig { get_id().await?, &get_hostname().await?, &os_key(&mut secret_store.acquire().await?).await?, + password_hash(&mut secret_store.acquire().await?).await?, ), None, ) diff --git a/appmgr/src/context/setup.rs b/appmgr/src/context/setup.rs index 57ec3c878..eb5abd348 100644 --- a/appmgr/src/context/setup.rs +++ b/appmgr/src/context/setup.rs @@ -20,7 +20,7 @@ use url::Host; use crate::db::model::Database; use crate::hostname::{get_hostname, get_id, get_product_key}; use crate::net::tor::os_key; -use crate::setup::RecoveryStatus; +use crate::setup::{password_hash, RecoveryStatus}; use crate::util::io::from_toml_async_reader; use crate::util::AsyncFileExt; use crate::{Error, ResultExt}; @@ -62,6 +62,7 @@ pub struct SetupContextSeed { pub selected_v2_drive: RwLock>, pub cached_product_key: RwLock>>, pub recovery_status: RwLock>>, + pub disk_guid: RwLock>>, } #[derive(Clone)] @@ -80,6 +81,7 @@ impl SetupContext { selected_v2_drive: RwLock::new(None), cached_product_key: RwLock::new(None), recovery_status: RwLock::new(None), + disk_guid: RwLock::new(None), }))) } #[instrument(skip(self))] @@ -95,6 +97,7 @@ impl SetupContext { get_id().await?, &get_hostname().await?, &os_key(&mut secret_store.acquire().await?).await?, + password_hash(&mut secret_store.acquire().await?).await?, ), None, ) diff --git a/appmgr/src/control.rs b/appmgr/src/control.rs index e4fcaf81b..b3868f1ae 100644 --- a/appmgr/src/control.rs +++ b/appmgr/src/control.rs @@ -1,6 +1,5 @@ use std::collections::BTreeMap; -use chrono::Utc; use color_eyre::eyre::eyre; use patch_db::DbHandle; use rpc_toolkit::command; @@ -14,7 +13,8 @@ use crate::dependencies::{ }; use crate::s9pk::manifest::PackageId; use crate::status::MainStatus; -use crate::util::{display_none, display_serializable}; +use crate::util::display_none; +use crate::util::serde::display_serializable; use crate::{Error, ResultExt}; #[command(display(display_none))] diff --git a/appmgr/src/db/mod.rs b/appmgr/src/db/mod.rs index d04a7bdb4..328de4161 100644 --- a/appmgr/src/db/mod.rs +++ b/appmgr/src/db/mod.rs @@ -26,7 +26,8 @@ pub use self::model::DatabaseModel; use self::util::WithRevision; use crate::context::RpcContext; use crate::middleware::auth::{HasValidSession, HashSessionToken}; -use crate::util::{display_serializable, GeneralGuard, IoFormat}; +use crate::util::serde::{display_serializable, IoFormat}; +use crate::util::GeneralGuard; use crate::{Error, ResultExt}; #[instrument(skip(ctx, ws_fut))] diff --git a/appmgr/src/db/model.rs b/appmgr/src/db/model.rs index 22c330026..2761405ef 100644 --- a/appmgr/src/db/model.rs +++ b/appmgr/src/db/model.rs @@ -32,7 +32,12 @@ pub struct Database { pub ui: Value, } impl Database { - pub fn init(id: String, hostname: &str, tor_key: &TorSecretKeyV3) -> Self { + pub fn init( + id: String, + hostname: &str, + tor_key: &TorSecretKeyV3, + password_hash: String, + ) -> Self { // TODO Database { server_info: ServerInfo { @@ -59,6 +64,7 @@ impl Database { }, share_stats: false, update_progress: None, + password_hash, }, package_data: AllPackageData::default(), recovered_packages: BTreeMap::new(), @@ -91,6 +97,7 @@ pub struct ServerInfo { pub share_stats: bool, #[model] pub update_progress: Option, + pub password_hash: String, } #[derive(Debug, Deserialize, Serialize)] diff --git a/appmgr/src/dependencies.rs b/appmgr/src/dependencies.rs index 5aa2901b1..9596e0ff8 100644 --- a/appmgr/src/dependencies.rs +++ b/appmgr/src/dependencies.rs @@ -21,7 +21,8 @@ use crate::error::ResultExt; use crate::s9pk::manifest::{Manifest, PackageId}; use crate::status::health_check::{HealthCheckId, HealthCheckResult}; use crate::status::{MainStatus, Status}; -use crate::util::{display_none, display_serializable, Version}; +use crate::util::serde::display_serializable; +use crate::util::{display_none, Version}; use crate::volume::Volumes; use crate::Error; diff --git a/appmgr/src/disk/main.rs b/appmgr/src/disk/main.rs index c891e4028..7d3d0a623 100644 --- a/appmgr/src/disk/main.rs +++ b/appmgr/src/disk/main.rs @@ -4,7 +4,8 @@ use std::path::{Path, PathBuf}; use tokio::process::Command; use tracing::instrument; -use crate::disk::util::{mount, unmount}; +use crate::disk::mount::filesystem::block_dev::mount; +use crate::disk::mount::util::unmount; use crate::util::Invoke; use crate::{Error, ResultExt}; diff --git a/appmgr/src/disk/mod.rs b/appmgr/src/disk/mod.rs index 3483360e7..bc873e4c6 100644 --- a/appmgr/src/disk/mod.rs +++ b/appmgr/src/disk/mod.rs @@ -1,25 +1,18 @@ -use std::collections::BTreeMap; -use std::path::PathBuf; - -use chrono::{DateTime, Utc}; use clap::ArgMatches; use rpc_toolkit::command; -use serde::{Deserialize, Serialize}; -use tracing::instrument; use self::util::DiskInfo; -use crate::disk::util::{BackupMountGuard, TmpMountGuard}; -use crate::s9pk::manifest::PackageId; -use crate::util::{display_serializable, IoFormat, Version}; +use crate::util::serde::{display_serializable, IoFormat}; use crate::Error; pub mod main; +pub mod mount; pub mod quirks; pub mod util; pub const BOOT_RW_PATH: &'static str = "/media/boot-rw"; -#[command(subcommands(list, backup_info))] +#[command(subcommands(list))] pub fn disk() -> Result<(), Error> { Ok(()) } @@ -86,72 +79,3 @@ pub async fn list( ) -> Result, Error> { crate::disk::util::list().await } - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -pub struct BackupInfo { - pub version: Version, - pub timestamp: Option>, - pub package_backups: BTreeMap, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -pub struct PackageBackupInfo { - pub title: String, - pub version: Version, - pub os_version: Version, - pub timestamp: DateTime, -} - -fn display_backup_info(info: BackupInfo, matches: &ArgMatches<'_>) { - use prettytable::*; - - if matches.is_present("format") { - return display_serializable(info, matches); - } - - let mut table = Table::new(); - table.add_row(row![bc => - "ID", - "VERSION", - "OS VERSION", - "TIMESTAMP", - ]); - table.add_row(row![ - "EMBASSY OS", - info.version.as_str(), - info.version.as_str(), - &if let Some(ts) = &info.timestamp { - ts.to_string() - } else { - "N/A".to_owned() - }, - ]); - for (id, info) in info.package_backups { - let row = row![ - id.as_str(), - info.version.as_str(), - info.os_version.as_str(), - &info.timestamp.to_string(), - ]; - table.add_row(row); - } - table.print_tty(false); -} - -#[command(rename = "backup-info", display(display_backup_info))] -#[instrument(skip(password))] -pub async fn backup_info( - #[arg] logicalname: PathBuf, - #[arg] password: String, -) -> Result { - let guard = - BackupMountGuard::mount(TmpMountGuard::mount(logicalname, None).await?, &password).await?; - - let res = guard.metadata.clone(); - - guard.unmount().await?; - - Ok(res) -} diff --git a/appmgr/src/disk/mount/backup.rs b/appmgr/src/disk/mount/backup.rs new file mode 100644 index 000000000..02c8b7f0d --- /dev/null +++ b/appmgr/src/disk/mount/backup.rs @@ -0,0 +1,255 @@ +use std::path::{Path, PathBuf}; + +use color_eyre::eyre::eyre; +use tokio::io::AsyncWriteExt; +use tracing::instrument; + +use super::filesystem::ecryptfs::EcryptFS; +use super::guard::{GenericMountGuard, TmpMountGuard}; +use super::util::{bind, unmount}; +use crate::auth::check_password; +use crate::backup::target::BackupInfo; +use crate::disk::util::EmbassyOsRecoveryInfo; +use crate::middleware::encrypt::{decrypt_slice, encrypt_slice}; +use crate::s9pk::manifest::PackageId; +use crate::util::serde::IoFormat; +use crate::util::{AtomicFile, FileLock}; +use crate::volume::BACKUP_DIR; +use crate::{Error, ResultExt}; + +pub struct BackupMountGuard { + backup_disk_mount_guard: Option, + encrypted_guard: Option, + enc_key: String, + pub unencrypted_metadata: EmbassyOsRecoveryInfo, + pub metadata: BackupInfo, +} +impl BackupMountGuard { + fn backup_disk_path(&self) -> &Path { + if let Some(guard) = &self.backup_disk_mount_guard { + guard.as_ref() + } else { + unreachable!() + } + } + + #[instrument(skip(password))] + pub async fn mount(backup_disk_mount_guard: G, password: &str) -> Result { + let backup_disk_path = backup_disk_mount_guard.as_ref(); + let unencrypted_metadata_path = + backup_disk_path.join("EmbassyBackups/unencrypted-metadata.cbor"); + let mut unencrypted_metadata: EmbassyOsRecoveryInfo = + if tokio::fs::metadata(&unencrypted_metadata_path) + .await + .is_ok() + { + IoFormat::Cbor.from_slice( + &tokio::fs::read(&unencrypted_metadata_path) + .await + .with_ctx(|_| { + ( + crate::ErrorKind::Filesystem, + unencrypted_metadata_path.display().to_string(), + ) + })?, + )? + } else { + Default::default() + }; + let enc_key = if let (Some(hash), Some(wrapped_key)) = ( + unencrypted_metadata.password_hash.as_ref(), + unencrypted_metadata.wrapped_key.as_ref(), + ) { + let wrapped_key = + base32::decode(base32::Alphabet::RFC4648 { padding: true }, wrapped_key) + .ok_or_else(|| { + Error::new( + eyre!("failed to decode wrapped key"), + crate::ErrorKind::Backup, + ) + })?; + check_password(hash, password)?; + String::from_utf8(decrypt_slice(wrapped_key, password))? + } else { + base32::encode( + base32::Alphabet::RFC4648 { padding: false }, + &rand::random::<[u8; 32]>()[..], + ) + }; + + if unencrypted_metadata.password_hash.is_none() { + unencrypted_metadata.password_hash = Some( + argon2::hash_encoded( + password.as_bytes(), + &rand::random::<[u8; 16]>()[..], + &argon2::Config::default(), + ) + .with_kind(crate::ErrorKind::PasswordHashGeneration)?, + ); + } + if unencrypted_metadata.wrapped_key.is_none() { + unencrypted_metadata.wrapped_key = Some(base32::encode( + base32::Alphabet::RFC4648 { padding: true }, + &encrypt_slice(&enc_key, password), + )); + } + + let crypt_path = backup_disk_path.join("EmbassyBackups/crypt"); + if tokio::fs::metadata(&crypt_path).await.is_err() { + tokio::fs::create_dir_all(&crypt_path).await.with_ctx(|_| { + ( + crate::ErrorKind::Filesystem, + crypt_path.display().to_string(), + ) + })?; + } + let encrypted_guard = TmpMountGuard::mount(&EcryptFS::new(&crypt_path, &enc_key)).await?; + + let metadata_path = encrypted_guard.as_ref().join("metadata.cbor"); + let metadata: BackupInfo = if tokio::fs::metadata(&metadata_path).await.is_ok() { + IoFormat::Cbor.from_slice(&tokio::fs::read(&metadata_path).await.with_ctx(|_| { + ( + crate::ErrorKind::Filesystem, + metadata_path.display().to_string(), + ) + })?)? + } else { + Default::default() + }; + + Ok(Self { + backup_disk_mount_guard: Some(backup_disk_mount_guard), + encrypted_guard: Some(encrypted_guard), + enc_key, + unencrypted_metadata, + metadata, + }) + } + + pub fn change_password(&mut self, new_password: &str) -> Result<(), Error> { + self.unencrypted_metadata.password_hash = Some( + argon2::hash_encoded( + new_password.as_bytes(), + &rand::random::<[u8; 16]>()[..], + &argon2::Config::default(), + ) + .with_kind(crate::ErrorKind::PasswordHashGeneration)?, + ); + self.unencrypted_metadata.wrapped_key = Some(base32::encode( + base32::Alphabet::RFC4648 { padding: false }, + &encrypt_slice(&self.enc_key, new_password), + )); + Ok(()) + } + + #[instrument(skip(self))] + pub async fn mount_package_backup( + &self, + id: &PackageId, + ) -> Result { + let lock = FileLock::new(Path::new(BACKUP_DIR).join(format!("{}.lock", id)), false).await?; + let mountpoint = Path::new(BACKUP_DIR).join(id); + bind(self.as_ref().join(id), &mountpoint, false).await?; + Ok(PackageBackupMountGuard { + mountpoint: Some(mountpoint), + lock: Some(lock), + }) + } + + #[instrument(skip(self))] + pub async fn save(&self) -> Result<(), Error> { + let metadata_path = self.as_ref().join("metadata.cbor"); + let backup_disk_path = self.backup_disk_path(); + let mut file = AtomicFile::new(&metadata_path).await?; + file.write_all(&IoFormat::Cbor.to_vec(&self.metadata)?) + .await?; + file.save().await?; + let unencrypted_metadata_path = + backup_disk_path.join("EmbassyBackups/unencrypted-metadata.cbor"); + let mut file = AtomicFile::new(&unencrypted_metadata_path).await?; + file.write_all(&IoFormat::Cbor.to_vec(&self.unencrypted_metadata)?) + .await?; + file.save().await?; + Ok(()) + } + + #[instrument(skip(self))] + pub async fn unmount(mut self) -> Result<(), Error> { + if let Some(guard) = self.encrypted_guard.take() { + guard.unmount().await?; + } + if let Some(guard) = self.backup_disk_mount_guard.take() { + guard.unmount().await?; + } + Ok(()) + } + + #[instrument(skip(self))] + pub async fn save_and_unmount(self) -> Result<(), Error> { + self.save().await?; + self.unmount().await?; + Ok(()) + } +} +impl AsRef for BackupMountGuard { + fn as_ref(&self) -> &Path { + if let Some(guard) = &self.encrypted_guard { + guard.as_ref() + } else { + unreachable!() + } + } +} +impl Drop for BackupMountGuard { + fn drop(&mut self) { + let first = self.encrypted_guard.take(); + let second = self.backup_disk_mount_guard.take(); + tokio::spawn(async move { + if let Some(guard) = first { + guard.unmount().await.unwrap(); + } + if let Some(guard) = second { + guard.unmount().await.unwrap(); + } + }); + } +} + +pub struct PackageBackupMountGuard { + mountpoint: Option, + lock: Option, +} +impl PackageBackupMountGuard { + pub async fn unmount(mut self) -> Result<(), Error> { + if let Some(mountpoint) = self.mountpoint.take() { + unmount(&mountpoint).await?; + } + if let Some(lock) = self.lock.take() { + lock.unlock().await?; + } + Ok(()) + } +} +impl AsRef for PackageBackupMountGuard { + fn as_ref(&self) -> &Path { + if let Some(mountpoint) = &self.mountpoint { + mountpoint + } else { + unreachable!() + } + } +} +impl Drop for PackageBackupMountGuard { + fn drop(&mut self) { + let mountpoint = self.mountpoint.take(); + let lock = self.lock.take(); + tokio::spawn(async move { + if let Some(mountpoint) = mountpoint { + unmount(&mountpoint).await.unwrap(); + } + if let Some(lock) = lock { + lock.unlock().await.unwrap(); + } + }); + } +} diff --git a/appmgr/src/disk/mount/filesystem/block_dev.rs b/appmgr/src/disk/mount/filesystem/block_dev.rs new file mode 100644 index 000000000..a32ad08f3 --- /dev/null +++ b/appmgr/src/disk/mount/filesystem/block_dev.rs @@ -0,0 +1,59 @@ +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +use async_trait::async_trait; +use digest::generic_array::GenericArray; +use digest::Digest; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +use super::FileSystem; +use crate::util::Invoke; +use crate::{Error, ResultExt}; + +pub async fn mount( + logicalname: impl AsRef, + mountpoint: impl AsRef, +) -> Result<(), Error> { + tokio::fs::create_dir_all(mountpoint.as_ref()).await?; + tokio::process::Command::new("mount") + .arg(logicalname.as_ref()) + .arg(mountpoint.as_ref()) + .invoke(crate::ErrorKind::Filesystem) + .await?; + Ok(()) +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct BlockDev> { + logicalname: LogicalName, +} +impl> BlockDev { + pub fn new(logicalname: LogicalName) -> Self { + BlockDev { logicalname } + } +} +#[async_trait] +impl + Send + Sync> FileSystem for BlockDev { + async fn mount + Send + Sync>(&self, mountpoint: P) -> Result<(), Error> { + mount(self.logicalname.as_ref(), mountpoint).await + } + async fn source_hash(&self) -> Result::OutputSize>, Error> { + let mut sha = Sha256::new(); + sha.update("BlockDev"); + sha.update( + tokio::fs::canonicalize(self.logicalname.as_ref()) + .await + .with_ctx(|_| { + ( + crate::ErrorKind::Filesystem, + self.logicalname.as_ref().display().to_string(), + ) + })? + .as_os_str() + .as_bytes(), + ); + Ok(sha.finalize()) + } +} diff --git a/appmgr/src/disk/mount/filesystem/cifs.rs b/appmgr/src/disk/mount/filesystem/cifs.rs new file mode 100644 index 000000000..c7004617b --- /dev/null +++ b/appmgr/src/disk/mount/filesystem/cifs.rs @@ -0,0 +1,93 @@ +use std::net::IpAddr; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; +use digest::generic_array::GenericArray; +use digest::Digest; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use tokio::process::Command; +use tracing::instrument; + +use super::FileSystem; +use crate::disk::mount::guard::TmpMountGuard; +use crate::util::Invoke; +use crate::Error; + +#[instrument(skip(path, password, mountpoint))] +pub async fn mount_cifs( + hostname: &str, + path: impl AsRef, + username: &str, + password: Option<&str>, + mountpoint: impl AsRef, +) -> Result<(), Error> { + tokio::fs::create_dir_all(mountpoint.as_ref()).await?; + let ip: IpAddr = String::from_utf8( + Command::new("nmblookup") + .arg(hostname) + .invoke(crate::ErrorKind::Network) + .await?, + )? + .split(" ") + .next() + .unwrap() + .parse()?; + let absolute_path = Path::new("/").join(path.as_ref()); + Command::new("mount") + .arg("-t") + .arg("cifs") + .arg("-o") + .arg(format!( + "username={}{}", + username, + password + .map(|p| format!(",password={}", p)) + .unwrap_or_default() + )) + .arg(format!("//{}{}", ip, absolute_path.display())) + .arg(mountpoint.as_ref()) + .invoke(crate::ErrorKind::Filesystem) + .await?; + Ok(()) +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Cifs { + pub hostname: String, + pub path: PathBuf, + pub username: String, + pub password: Option, +} +impl Cifs { + pub async fn mountable(&self) -> Result<(), Error> { + let guard = TmpMountGuard::mount(self).await?; + guard.unmount().await?; + Ok(()) + } +} +#[async_trait] +impl FileSystem for Cifs { + async fn mount + Send + Sync>( + &self, + mountpoint: P, + ) -> Result<(), Error> { + mount_cifs( + &self.hostname, + &self.path, + &self.username, + self.password.as_ref().map(|p| p.as_str()), + mountpoint, + ) + .await + } + async fn source_hash(&self) -> Result::OutputSize>, Error> { + let mut sha = Sha256::new(); + sha.update("Cifs"); + sha.update(self.hostname.as_bytes()); + sha.update(self.path.as_os_str().as_bytes()); + Ok(sha.finalize()) + } +} diff --git a/appmgr/src/disk/mount/filesystem/ecryptfs.rs b/appmgr/src/disk/mount/filesystem/ecryptfs.rs new file mode 100644 index 000000000..16e226e32 --- /dev/null +++ b/appmgr/src/disk/mount/filesystem/ecryptfs.rs @@ -0,0 +1,79 @@ +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +use async_trait::async_trait; +use color_eyre::eyre::eyre; +use digest::generic_array::GenericArray; +use digest::Digest; +use sha2::Sha256; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use super::FileSystem; +use crate::{Error, ResultExt}; + +pub async fn mount_ecryptfs, P1: AsRef>( + src: P0, + dst: P1, + key: &str, +) -> Result<(), Error> { + tokio::fs::create_dir_all(dst.as_ref()).await?; + let mut ecryptfs = tokio::process::Command::new("mount") + .arg("-t") + .arg("ecryptfs") + .arg(src.as_ref()) + .arg(dst.as_ref()) + .arg("-o") + // for more information `man ecryptfs` + .arg(format!("key=passphrase,passwd={},ecryptfs_cipher=aes,ecryptfs_key_bytes=32,ecryptfs_passthrough=n,ecryptfs_enable_filename_crypto=y", key)) + .stdin(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + let mut stdin = ecryptfs.stdin.take().unwrap(); + let mut stderr = ecryptfs.stderr.take().unwrap(); + stdin.write_all(b"\nyes\nno").await?; + stdin.flush().await?; + stdin.shutdown().await?; + drop(stdin); + let mut err = String::new(); + stderr.read_to_string(&mut err).await?; + if !ecryptfs.wait().await?.success() { + Err(Error::new(eyre!("{}", err), crate::ErrorKind::Filesystem)) + } else { + Ok(()) + } +} + +pub struct EcryptFS, Key: AsRef> { + encrypted_dir: EncryptedDir, + key: Key, +} +impl, Key: AsRef> EcryptFS { + pub fn new(encrypted_dir: EncryptedDir, key: Key) -> Self { + EcryptFS { encrypted_dir, key } + } +} +#[async_trait] +impl + Send + Sync, Key: AsRef + Send + Sync> FileSystem + for EcryptFS +{ + async fn mount + Send + Sync>(&self, mountpoint: P) -> Result<(), Error> { + mount_ecryptfs(self.encrypted_dir.as_ref(), mountpoint, self.key.as_ref()).await + } + async fn source_hash(&self) -> Result::OutputSize>, Error> { + let mut sha = Sha256::new(); + sha.update("EcryptFS"); + sha.update( + tokio::fs::canonicalize(self.encrypted_dir.as_ref()) + .await + .with_ctx(|_| { + ( + crate::ErrorKind::Filesystem, + self.encrypted_dir.as_ref().display().to_string(), + ) + })? + .as_os_str() + .as_bytes(), + ); + Ok(sha.finalize()) + } +} diff --git a/appmgr/src/disk/mount/filesystem/label.rs b/appmgr/src/disk/mount/filesystem/label.rs new file mode 100644 index 000000000..7311ca1e1 --- /dev/null +++ b/appmgr/src/disk/mount/filesystem/label.rs @@ -0,0 +1,42 @@ +use std::path::Path; + +use async_trait::async_trait; +use digest::generic_array::GenericArray; +use digest::Digest; +use sha2::Sha256; + +use super::FileSystem; +use crate::util::Invoke; +use crate::Error; + +pub async fn mount_label(label: &str, mountpoint: impl AsRef) -> Result<(), Error> { + tokio::fs::create_dir_all(mountpoint.as_ref()).await?; + tokio::process::Command::new("mount") + .arg("-L") + .arg(label) + .arg(mountpoint.as_ref()) + .invoke(crate::ErrorKind::Filesystem) + .await?; + Ok(()) +} + +pub struct Label> { + label: S, +} +impl> Label { + pub fn new(label: S) -> Self { + Label { label } + } +} +#[async_trait] +impl + Send + Sync> FileSystem for Label { + async fn mount + Send + Sync>(&self, mountpoint: P) -> Result<(), Error> { + mount_label(self.label.as_ref(), mountpoint).await + } + async fn source_hash(&self) -> Result::OutputSize>, Error> { + let mut sha = Sha256::new(); + sha.update("Label"); + sha.update(self.label.as_ref().as_bytes()); + Ok(sha.finalize()) + } +} diff --git a/appmgr/src/disk/mount/filesystem/mod.rs b/appmgr/src/disk/mount/filesystem/mod.rs new file mode 100644 index 000000000..87771f766 --- /dev/null +++ b/appmgr/src/disk/mount/filesystem/mod.rs @@ -0,0 +1,19 @@ +use std::path::Path; + +use async_trait::async_trait; +use digest::generic_array::GenericArray; +use digest::Digest; +use sha2::Sha256; + +use crate::Error; + +pub mod block_dev; +pub mod cifs; +pub mod ecryptfs; +pub mod label; + +#[async_trait] +pub trait FileSystem { + async fn mount + Send + Sync>(&self, mountpoint: P) -> Result<(), Error>; + async fn source_hash(&self) -> Result::OutputSize>, Error>; +} diff --git a/appmgr/src/disk/mount/guard.rs b/appmgr/src/disk/mount/guard.rs new file mode 100644 index 000000000..7e2e3af9d --- /dev/null +++ b/appmgr/src/disk/mount/guard.rs @@ -0,0 +1,114 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Weak}; + +use lazy_static::lazy_static; +use tokio::sync::Mutex; +use tracing::instrument; + +use super::filesystem::FileSystem; +use super::util::unmount; +use crate::Error; + +pub const TMP_MOUNTPOINT: &'static str = "/media/embassy-os/tmp"; + +#[async_trait::async_trait] +pub trait GenericMountGuard: AsRef + std::fmt::Debug + Send + Sync + 'static { + async fn unmount(mut self) -> Result<(), Error>; +} + +#[derive(Debug)] +pub struct MountGuard { + mountpoint: PathBuf, + mounted: bool, +} +impl MountGuard { + pub async fn mount( + filesystem: &impl FileSystem, + mountpoint: impl AsRef, + ) -> Result { + let mountpoint = mountpoint.as_ref().to_owned(); + filesystem.mount(&mountpoint).await?; + Ok(MountGuard { + mountpoint, + mounted: true, + }) + } + pub async fn unmount(mut self) -> Result<(), Error> { + if self.mounted { + unmount(&self.mountpoint).await?; + self.mounted = false; + } + Ok(()) + } +} +impl AsRef for MountGuard { + fn as_ref(&self) -> &Path { + &self.mountpoint + } +} +impl Drop for MountGuard { + fn drop(&mut self) { + if self.mounted { + let mountpoint = std::mem::take(&mut self.mountpoint); + tokio::spawn(async move { unmount(mountpoint).await.unwrap() }); + } + } +} +#[async_trait::async_trait] +impl GenericMountGuard for MountGuard { + async fn unmount(mut self) -> Result<(), Error> { + MountGuard::unmount(self).await + } +} + +async fn tmp_mountpoint(source: &impl FileSystem) -> Result { + Ok(Path::new(TMP_MOUNTPOINT).join(base32::encode( + base32::Alphabet::RFC4648 { padding: false }, + &source.source_hash().await?, + ))) +} + +lazy_static! { + static ref TMP_MOUNTS: Mutex>> = Mutex::new(BTreeMap::new()); +} + +#[derive(Debug)] +pub struct TmpMountGuard { + guard: Arc, +} +impl TmpMountGuard { + #[instrument(skip(filesystem))] + pub async fn mount(filesystem: &impl FileSystem) -> Result { + let mountpoint = tmp_mountpoint(filesystem).await?; + let mut tmp_mounts = TMP_MOUNTS.lock().await; + if !tmp_mounts.contains_key(&mountpoint) { + tmp_mounts.insert(mountpoint.clone(), Weak::new()); + } + let weak_slot = tmp_mounts.get_mut(&mountpoint).unwrap(); + if let Some(guard) = weak_slot.upgrade() { + Ok(TmpMountGuard { guard }) + } else { + let guard = Arc::new(MountGuard::mount(filesystem, &mountpoint).await?); + *weak_slot = Arc::downgrade(&guard); + Ok(TmpMountGuard { guard }) + } + } + pub async fn unmount(self) -> Result<(), Error> { + if let Ok(guard) = Arc::try_unwrap(self.guard) { + guard.unmount().await?; + } + Ok(()) + } +} +impl AsRef for TmpMountGuard { + fn as_ref(&self) -> &Path { + (&*self.guard).as_ref() + } +} +#[async_trait::async_trait] +impl GenericMountGuard for TmpMountGuard { + async fn unmount(mut self) -> Result<(), Error> { + TmpMountGuard::unmount(self).await + } +} diff --git a/appmgr/src/disk/mount/mod.rs b/appmgr/src/disk/mount/mod.rs new file mode 100644 index 000000000..74e34e860 --- /dev/null +++ b/appmgr/src/disk/mount/mod.rs @@ -0,0 +1,4 @@ +pub mod backup; +pub mod filesystem; +pub mod guard; +pub mod util; diff --git a/appmgr/src/disk/mount/util.rs b/appmgr/src/disk/mount/util.rs new file mode 100644 index 000000000..a9ebc69f1 --- /dev/null +++ b/appmgr/src/disk/mount/util.rs @@ -0,0 +1,60 @@ +use std::path::Path; + +use tracing::instrument; + +use crate::util::Invoke; +use crate::{Error, ResultExt}; + +#[instrument(skip(src, dst))] +pub async fn bind, P1: AsRef>( + src: P0, + dst: P1, + read_only: bool, +) -> Result<(), Error> { + tracing::info!( + "Binding {} to {}", + src.as_ref().display(), + dst.as_ref().display() + ); + let is_mountpoint = tokio::process::Command::new("mountpoint") + .arg(dst.as_ref()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await?; + if is_mountpoint.success() { + unmount(dst.as_ref()).await?; + } + tokio::fs::create_dir_all(&src).await?; + tokio::fs::create_dir_all(&dst).await?; + let mut mount_cmd = tokio::process::Command::new("mount"); + mount_cmd.arg("--bind"); + if read_only { + mount_cmd.arg("-o").arg("ro"); + } + mount_cmd + .arg(src.as_ref()) + .arg(dst.as_ref()) + .invoke(crate::ErrorKind::Filesystem) + .await?; + Ok(()) +} + +#[instrument(skip(mountpoint))] +pub async fn unmount>(mountpoint: P) -> Result<(), Error> { + tracing::debug!("Unmounting {}.", mountpoint.as_ref().display()); + tokio::process::Command::new("umount") + .arg("-l") + .arg(mountpoint.as_ref()) + .invoke(crate::ErrorKind::Filesystem) + .await?; + tokio::fs::remove_dir_all(mountpoint.as_ref()) + .await + .with_ctx(|_| { + ( + crate::ErrorKind::Filesystem, + format!("rm {}", mountpoint.as_ref().display()), + ) + })?; + Ok(()) +} diff --git a/appmgr/src/disk/util.rs b/appmgr/src/disk/util.rs index affa1a255..716b02779 100644 --- a/appmgr/src/disk/util.rs +++ b/appmgr/src/disk/util.rs @@ -1,13 +1,9 @@ use std::collections::BTreeMap; -use std::os::unix::prelude::OsStrExt; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Weak}; use color_eyre::eyre::{self, eyre}; -use digest::Digest; use futures::TryStreamExt; use indexmap::IndexSet; -use lazy_static::lazy_static; use nom::bytes::complete::{tag, take_till1}; use nom::character::complete::multispace1; use nom::character::is_space; @@ -17,23 +13,17 @@ use nom::IResult; use regex::Regex; use serde::{Deserialize, Serialize}; use tokio::fs::File; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::process::Command; -use tokio::sync::Mutex; use tracing::instrument; +use super::mount::filesystem::block_dev::BlockDev; +use super::mount::guard::TmpMountGuard; use super::quirks::{fetch_quirks, save_quirks, update_quirks}; -use super::BackupInfo; -use crate::auth::check_password; -use crate::middleware::encrypt::{decrypt_slice, encrypt_slice}; -use crate::s9pk::manifest::PackageId; use crate::util::io::from_yaml_async_reader; -use crate::util::{AtomicFile, FileLock, Invoke, IoFormat, Version}; -use crate::volume::BACKUP_DIR; +use crate::util::serde::IoFormat; +use crate::util::{Invoke, Version}; use crate::{Error, ResultExt as _}; -pub const TMP_MOUNTPOINT: &'static str = "/media/embassy-os/tmp"; - #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct DiskInfo { @@ -205,6 +195,33 @@ pub async fn pvscan() -> Result>, Error> { Ok(parse_pvscan_output(pvscan_out_str)) } +pub async fn recovery_info( + mountpoint: impl AsRef, +) -> Result, Error> { + let backup_unencrypted_metadata_path = mountpoint + .as_ref() + .join("EmbassyBackups/unencrypted-metadata.cbor"); + if tokio::fs::metadata(&backup_unencrypted_metadata_path) + .await + .is_ok() + { + Ok(Some( + IoFormat::Cbor.from_slice( + &tokio::fs::read(&backup_unencrypted_metadata_path) + .await + .with_ctx(|_| { + ( + crate::ErrorKind::Filesystem, + backup_unencrypted_metadata_path.display().to_string(), + ) + })?, + )?, + )) + } else { + Ok(None) + } +} + #[instrument] pub async fn list() -> Result, Error> { let mut quirks = fetch_quirks().await?; @@ -292,7 +309,7 @@ pub async fn list() -> Result, Error> { .unwrap_or_default(); let mut used = None; - match TmpMountGuard::mount(&part, None).await { + match TmpMountGuard::mount(&BlockDev::new(&part)).await { Err(e) => tracing::warn!("Could not collect usage information: {}", e.source), Ok(mount_guard) => { used = get_used(&mount_guard) @@ -305,38 +322,17 @@ pub async fn list() -> Result, Error> { ) }) .ok(); - let backup_unencrypted_metadata_path = mount_guard - .as_ref() - .join("EmbassyBackups/unencrypted-metadata.cbor"); - if tokio::fs::metadata(&backup_unencrypted_metadata_path) - .await - .is_ok() - { - embassy_os = match (|| async { - IoFormat::Cbor.from_slice( - &tokio::fs::read(&backup_unencrypted_metadata_path) - .await - .with_ctx(|_| { - ( - crate::ErrorKind::Filesystem, - backup_unencrypted_metadata_path - .display() - .to_string(), - ) - })?, - ) - })() - .await - { - Ok(a) => Some(a), - Err(e) => { - tracing::error!( - "Error fetching unencrypted backup metadata: {}", - e - ); - None - } - }; + if let Some(recovery_info) = match recovery_info(&mount_guard).await { + Ok(a) => a, + Err(e) => { + tracing::error!( + "Error fetching unencrypted backup metadata: {}", + e + ); + None + } + } { + embassy_os = Some(recovery_info) } else if label.as_deref() == Some("rootfs") { let version_path = mount_guard.as_ref().join("root/appmgr/version"); if tokio::fs::metadata(&version_path).await.is_ok() { @@ -377,497 +373,6 @@ pub async fn list() -> Result, Error> { Ok(res) } -#[instrument(skip(logicalname, mountpoint))] -pub async fn mount( - logicalname: impl AsRef, - mountpoint: impl AsRef, -) -> Result<(), Error> { - let is_mountpoint = tokio::process::Command::new("mountpoint") - .arg(mountpoint.as_ref()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .await?; - if is_mountpoint.success() { - unmount(mountpoint.as_ref()).await?; - } - tokio::fs::create_dir_all(mountpoint.as_ref()).await?; - let mount_output = tokio::process::Command::new("mount") - .arg(logicalname.as_ref()) - .arg(mountpoint.as_ref()) - .output() - .await?; - crate::ensure_code!( - mount_output.status.success(), - crate::ErrorKind::Filesystem, - "Error Mounting {} to {}: {}", - logicalname.as_ref().display(), - mountpoint.as_ref().display(), - std::str::from_utf8(&mount_output.stderr).unwrap_or("Unknown Error") - ); - Ok(()) -} - -#[instrument(skip(src, dst, key))] -pub async fn mount_ecryptfs, P1: AsRef>( - src: P0, - dst: P1, - key: &str, -) -> Result<(), Error> { - let is_mountpoint = tokio::process::Command::new("mountpoint") - .arg(dst.as_ref()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .await?; - if is_mountpoint.success() { - unmount(dst.as_ref()).await?; - } - tokio::fs::create_dir_all(dst.as_ref()).await?; - let mut ecryptfs = tokio::process::Command::new("mount") - .arg("-t") - .arg("ecryptfs") - .arg(src.as_ref()) - .arg(dst.as_ref()) - .arg("-o") - .arg(format!("key=passphrase,passwd={},ecryptfs_cipher=aes,ecryptfs_key_bytes=32,ecryptfs_passthrough=n,ecryptfs_enable_filename_crypto=y", key)) - .stdin(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn()?; - let mut stdin = ecryptfs.stdin.take().unwrap(); - let mut stderr = ecryptfs.stderr.take().unwrap(); - stdin.write_all(b"\nyes\nno").await?; - stdin.flush().await?; - stdin.shutdown().await?; - drop(stdin); - let mut err = String::new(); - stderr.read_to_string(&mut err).await?; - if !ecryptfs.wait().await?.success() { - Err(Error::new(eyre!("{}", err), crate::ErrorKind::Filesystem)) - } else { - Ok(()) - } -} - -#[instrument(skip(src, dst))] -pub async fn bind, P1: AsRef>( - src: P0, - dst: P1, - read_only: bool, -) -> Result<(), Error> { - tracing::info!( - "Binding {} to {}", - src.as_ref().display(), - dst.as_ref().display() - ); - let is_mountpoint = tokio::process::Command::new("mountpoint") - .arg(dst.as_ref()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .await?; - if is_mountpoint.success() { - unmount(dst.as_ref()).await?; - } - tokio::fs::create_dir_all(&src).await?; - tokio::fs::create_dir_all(&dst).await?; - let mut mount_cmd = tokio::process::Command::new("mount"); - mount_cmd.arg("--bind"); - if read_only { - mount_cmd.arg("-o").arg("ro"); - } - mount_cmd - .arg(src.as_ref()) - .arg(dst.as_ref()) - .invoke(crate::ErrorKind::Filesystem) - .await?; - Ok(()) -} - -#[instrument(skip(mountpoint))] -pub async fn unmount>(mountpoint: P) -> Result<(), Error> { - tracing::debug!("Unmounting {}.", mountpoint.as_ref().display()); - let umount_output = tokio::process::Command::new("umount") - .arg("-l") - .arg(mountpoint.as_ref()) - .output() - .await?; - crate::ensure_code!( - umount_output.status.success(), - crate::ErrorKind::Filesystem, - "Error Unmounting Drive: {}: {}", - mountpoint.as_ref().display(), - std::str::from_utf8(&umount_output.stderr).unwrap_or("Unknown Error") - ); - tokio::fs::remove_dir_all(mountpoint.as_ref()) - .await - .with_ctx(|_| { - ( - crate::ErrorKind::Filesystem, - format!("rm {}", mountpoint.as_ref().display()), - ) - })?; - Ok(()) -} - -#[async_trait::async_trait] -pub trait GenericMountGuard: AsRef + std::fmt::Debug + Send + Sync + 'static { - async fn unmount(mut self) -> Result<(), Error>; -} - -#[derive(Debug)] -pub struct MountGuard { - mountpoint: PathBuf, - mounted: bool, -} -impl MountGuard { - pub async fn mount( - logicalname: impl AsRef, - mountpoint: impl AsRef, - encryption_key: Option<&str>, - ) -> Result { - let mountpoint = mountpoint.as_ref().to_owned(); - if let Some(key) = encryption_key { - mount_ecryptfs(logicalname, &mountpoint, key).await?; - } else { - mount(logicalname, &mountpoint).await?; - } - Ok(MountGuard { - mountpoint, - mounted: true, - }) - } - pub async fn unmount(mut self) -> Result<(), Error> { - if self.mounted { - unmount(&self.mountpoint).await?; - self.mounted = false; - } - Ok(()) - } -} -impl AsRef for MountGuard { - fn as_ref(&self) -> &Path { - &self.mountpoint - } -} -impl Drop for MountGuard { - fn drop(&mut self) { - if self.mounted { - let mountpoint = std::mem::take(&mut self.mountpoint); - tokio::spawn(async move { unmount(mountpoint).await.unwrap() }); - } - } -} -#[async_trait::async_trait] -impl GenericMountGuard for MountGuard { - async fn unmount(mut self) -> Result<(), Error> { - MountGuard::unmount(self).await - } -} - -async fn tmp_mountpoint(source: impl AsRef) -> Result { - Ok(Path::new(TMP_MOUNTPOINT).join(base32::encode( - base32::Alphabet::RFC4648 { padding: false }, - &sha2::Sha256::digest( - tokio::fs::canonicalize(&source) - .await - .with_ctx(|_| { - ( - crate::ErrorKind::Filesystem, - source.as_ref().display().to_string(), - ) - })? - .as_os_str() - .as_bytes(), - ), - ))) -} - -lazy_static! { - static ref TMP_MOUNTS: Mutex>> = Mutex::new(BTreeMap::new()); -} - -#[derive(Debug)] -pub struct TmpMountGuard { - guard: Arc, -} -impl TmpMountGuard { - #[instrument(skip(logicalname, encryption_key))] - pub async fn mount( - logicalname: impl AsRef, - encryption_key: Option<&str>, - ) -> Result { - let mountpoint = tmp_mountpoint(&logicalname).await?; - let mut tmp_mounts = TMP_MOUNTS.lock().await; - if !tmp_mounts.contains_key(&mountpoint) { - tmp_mounts.insert(mountpoint.clone(), Weak::new()); - } - let weak_slot = tmp_mounts.get_mut(&mountpoint).unwrap(); - if let Some(guard) = weak_slot.upgrade() { - Ok(TmpMountGuard { guard }) - } else { - let guard = - Arc::new(MountGuard::mount(logicalname, &mountpoint, encryption_key).await?); - *weak_slot = Arc::downgrade(&guard); - Ok(TmpMountGuard { guard }) - } - } - pub async fn unmount(self) -> Result<(), Error> { - if let Ok(guard) = Arc::try_unwrap(self.guard) { - guard.unmount().await?; - } - Ok(()) - } -} -impl AsRef for TmpMountGuard { - fn as_ref(&self) -> &Path { - (&*self.guard).as_ref() - } -} -#[async_trait::async_trait] -impl GenericMountGuard for TmpMountGuard { - async fn unmount(mut self) -> Result<(), Error> { - TmpMountGuard::unmount(self).await - } -} - -pub struct BackupMountGuard { - backup_disk_mount_guard: Option, - encrypted_guard: Option, - enc_key: String, - pub unencrypted_metadata: EmbassyOsRecoveryInfo, - pub metadata: BackupInfo, -} -impl BackupMountGuard { - fn backup_disk_path(&self) -> &Path { - if let Some(guard) = &self.backup_disk_mount_guard { - guard.as_ref() - } else { - unreachable!() - } - } - - #[instrument(skip(password))] - pub async fn mount(backup_disk_mount_guard: G, password: &str) -> Result { - let backup_disk_path = backup_disk_mount_guard.as_ref(); - let unencrypted_metadata_path = - backup_disk_path.join("EmbassyBackups/unencrypted-metadata.cbor"); - let mut unencrypted_metadata: EmbassyOsRecoveryInfo = - if tokio::fs::metadata(&unencrypted_metadata_path) - .await - .is_ok() - { - IoFormat::Cbor.from_slice( - &tokio::fs::read(&unencrypted_metadata_path) - .await - .with_ctx(|_| { - ( - crate::ErrorKind::Filesystem, - unencrypted_metadata_path.display().to_string(), - ) - })?, - )? - } else { - Default::default() - }; - let enc_key = if let (Some(hash), Some(wrapped_key)) = ( - unencrypted_metadata.password_hash.as_ref(), - unencrypted_metadata.wrapped_key.as_ref(), - ) { - let wrapped_key = - base32::decode(base32::Alphabet::RFC4648 { padding: true }, wrapped_key) - .ok_or_else(|| { - Error::new( - eyre!("failed to decode wrapped key"), - crate::ErrorKind::Backup, - ) - })?; - check_password(hash, password)?; - String::from_utf8(decrypt_slice(wrapped_key, password))? - } else { - base32::encode( - base32::Alphabet::RFC4648 { padding: false }, - &rand::random::<[u8; 32]>()[..], - ) - }; - - if unencrypted_metadata.password_hash.is_none() { - unencrypted_metadata.password_hash = Some( - argon2::hash_encoded( - password.as_bytes(), - &rand::random::<[u8; 16]>()[..], - &argon2::Config::default(), - ) - .with_kind(crate::ErrorKind::PasswordHashGeneration)?, - ); - } - if unencrypted_metadata.wrapped_key.is_none() { - unencrypted_metadata.wrapped_key = Some(base32::encode( - base32::Alphabet::RFC4648 { padding: true }, - &encrypt_slice(&enc_key, password), - )); - } - - let crypt_path = backup_disk_path.join("EmbassyBackups/crypt"); - if tokio::fs::metadata(&crypt_path).await.is_err() { - tokio::fs::create_dir_all(&crypt_path).await.with_ctx(|_| { - ( - crate::ErrorKind::Filesystem, - crypt_path.display().to_string(), - ) - })?; - } - let encrypted_guard = TmpMountGuard::mount(&crypt_path, Some(&enc_key)).await?; - - let metadata_path = encrypted_guard.as_ref().join("metadata.cbor"); - let metadata: BackupInfo = if tokio::fs::metadata(&metadata_path).await.is_ok() { - IoFormat::Cbor.from_slice(&tokio::fs::read(&metadata_path).await.with_ctx(|_| { - ( - crate::ErrorKind::Filesystem, - metadata_path.display().to_string(), - ) - })?)? - } else { - Default::default() - }; - - Ok(Self { - backup_disk_mount_guard: Some(backup_disk_mount_guard), - encrypted_guard: Some(encrypted_guard), - enc_key, - unencrypted_metadata, - metadata, - }) - } - - pub fn change_password(&mut self, new_password: &str) -> Result<(), Error> { - self.unencrypted_metadata.password_hash = Some( - argon2::hash_encoded( - new_password.as_bytes(), - &rand::random::<[u8; 16]>()[..], - &argon2::Config::default(), - ) - .with_kind(crate::ErrorKind::PasswordHashGeneration)?, - ); - self.unencrypted_metadata.wrapped_key = Some(base32::encode( - base32::Alphabet::RFC4648 { padding: false }, - &encrypt_slice(&self.enc_key, new_password), - )); - Ok(()) - } - - #[instrument(skip(self))] - pub async fn mount_package_backup( - &self, - id: &PackageId, - ) -> Result { - let lock = FileLock::new(Path::new(BACKUP_DIR).join(format!("{}.lock", id)), false).await?; - let mountpoint = Path::new(BACKUP_DIR).join(id); - bind(self.as_ref().join(id), &mountpoint, false).await?; - Ok(PackageBackupMountGuard { - mountpoint: Some(mountpoint), - lock: Some(lock), - }) - } - - #[instrument(skip(self))] - pub async fn save(&self) -> Result<(), Error> { - let metadata_path = self.as_ref().join("metadata.cbor"); - let backup_disk_path = self.backup_disk_path(); - let mut file = AtomicFile::new(&metadata_path).await?; - file.write_all(&IoFormat::Cbor.to_vec(&self.metadata)?) - .await?; - file.save().await?; - let unencrypted_metadata_path = - backup_disk_path.join("EmbassyBackups/unencrypted-metadata.cbor"); - let mut file = AtomicFile::new(&unencrypted_metadata_path).await?; - file.write_all(&IoFormat::Cbor.to_vec(&self.unencrypted_metadata)?) - .await?; - file.save().await?; - Ok(()) - } - - #[instrument(skip(self))] - pub async fn unmount(mut self) -> Result<(), Error> { - if let Some(guard) = self.encrypted_guard.take() { - guard.unmount().await?; - } - if let Some(guard) = self.backup_disk_mount_guard.take() { - guard.unmount().await?; - } - Ok(()) - } - - #[instrument(skip(self))] - pub async fn save_and_unmount(self) -> Result<(), Error> { - self.save().await?; - self.unmount().await?; - Ok(()) - } -} -impl AsRef for BackupMountGuard { - fn as_ref(&self) -> &Path { - if let Some(guard) = &self.encrypted_guard { - guard.as_ref() - } else { - unreachable!() - } - } -} -impl Drop for BackupMountGuard { - fn drop(&mut self) { - let first = self.encrypted_guard.take(); - let second = self.backup_disk_mount_guard.take(); - tokio::spawn(async move { - if let Some(guard) = first { - guard.unmount().await.unwrap(); - } - if let Some(guard) = second { - guard.unmount().await.unwrap(); - } - }); - } -} - -pub struct PackageBackupMountGuard { - mountpoint: Option, - lock: Option, -} -impl PackageBackupMountGuard { - pub async fn unmount(mut self) -> Result<(), Error> { - if let Some(mountpoint) = self.mountpoint.take() { - unmount(&mountpoint).await?; - } - if let Some(lock) = self.lock.take() { - lock.unlock().await?; - } - Ok(()) - } -} -impl AsRef for PackageBackupMountGuard { - fn as_ref(&self) -> &Path { - if let Some(mountpoint) = &self.mountpoint { - mountpoint - } else { - unreachable!() - } - } -} -impl Drop for PackageBackupMountGuard { - fn drop(&mut self) { - let mountpoint = self.mountpoint.take(); - let lock = self.lock.take(); - tokio::spawn(async move { - if let Some(mountpoint) = mountpoint { - unmount(&mountpoint).await.unwrap(); - } - if let Some(lock) = lock { - lock.unlock().await.unwrap(); - } - }); - } -} - fn parse_pvscan_output(pvscan_output: &str) -> BTreeMap> { fn parse_line(line: &str) -> IResult<&str, (&str, Option<&str>)> { let pv_parse = preceded( diff --git a/appmgr/src/error.rs b/appmgr/src/error.rs index 6f88d06b2..710e6703e 100644 --- a/appmgr/src/error.rs +++ b/appmgr/src/error.rs @@ -61,6 +61,7 @@ pub enum ErrorKind { Duplicate = 53, MultipleErrors = 54, Incoherent = 55, + InvalidBackupTargetId = 56, } impl ErrorKind { pub fn as_str(&self) -> &'static str { @@ -121,6 +122,7 @@ impl ErrorKind { Duplicate => "Duplication Error", MultipleErrors => "Multiple Errors", Incoherent => "Incoherent", + InvalidBackupTargetId => "Invalid Backup Target ID", } } } @@ -336,7 +338,7 @@ where macro_rules! ensure_code { ($x:expr, $c:expr, $fmt:expr $(, $arg:expr)*) => { if !($x) { - return Err(crate::Error::new(eyre!($fmt, $($arg, )*), $c)); + return Err(crate::Error::new(color_eyre::eyre::eyre!($fmt, $($arg, )*), $c)); } }; } diff --git a/appmgr/src/init.rs b/appmgr/src/init.rs index 51bc4ea99..fb4ab55f5 100644 --- a/appmgr/src/init.rs +++ b/appmgr/src/init.rs @@ -12,7 +12,7 @@ pub async fn init(cfg: &RpcContextConfig) -> Result<(), Error> { if tokio::fs::metadata(&log_dir).await.is_err() { tokio::fs::create_dir_all(&log_dir).await?; } - crate::disk::util::bind(&log_dir, "/var/log/journal", false).await?; + crate::disk::mount::util::bind(&log_dir, "/var/log/journal", false).await?; Command::new("systemctl") .arg("restart") .arg("systemd-journald") @@ -38,7 +38,7 @@ pub async fn init(cfg: &RpcContextConfig) -> Result<(), Error> { .arg("docker") .invoke(crate::ErrorKind::Docker) .await?; - crate::disk::util::bind(&tmp_docker, "/var/lib/docker", false).await?; + crate::disk::mount::util::bind(&tmp_docker, "/var/lib/docker", false).await?; Command::new("systemctl") .arg("reset-failed") .arg("docker") @@ -60,8 +60,6 @@ pub async fn init(cfg: &RpcContextConfig) -> Result<(), Error> { crate::ssh::sync_keys_from_db(&secret_store, "/root/.ssh/authorized_keys").await?; tracing::info!("Synced SSH Keys"); - crate::hostname::sync_hostname().await?; - tracing::info!("Synced Hostname"); crate::net::wifi::synchronize_wpa_supplicant_conf(&cfg.datadir().join("main")).await?; tracing::info!("Synchronized wpa_supplicant.conf"); diff --git a/appmgr/src/inspect.rs b/appmgr/src/inspect.rs index 6366230b0..cd27bbb2d 100644 --- a/appmgr/src/inspect.rs +++ b/appmgr/src/inspect.rs @@ -4,7 +4,8 @@ use rpc_toolkit::command; use crate::s9pk::manifest::Manifest; use crate::s9pk::reader::S9pkReader; -use crate::util::{display_none, display_serializable, IoFormat}; +use crate::util::display_none; +use crate::util::serde::{display_serializable, IoFormat}; use crate::Error; #[command(subcommands(hash, manifest, license, icon, instructions, docker_images))] diff --git a/appmgr/src/install/mod.rs b/appmgr/src/install/mod.rs index 457d1a747..deefb8590 100644 --- a/appmgr/src/install/mod.rs +++ b/appmgr/src/install/mod.rs @@ -42,7 +42,8 @@ use crate::s9pk::manifest::{Manifest, PackageId}; use crate::s9pk::reader::S9pkReader; use crate::status::{MainStatus, Status}; use crate::util::io::copy_and_shutdown; -use crate::util::{display_none, display_serializable, AsyncFileExt, IoFormat, Version}; +use crate::util::serde::{display_serializable, IoFormat}; +use crate::util::{display_none, AsyncFileExt, Version}; use crate::version::{Current, VersionT}; use crate::volume::asset_dir; use crate::{Error, ErrorKind, ResultExt}; diff --git a/appmgr/src/install/update.rs b/appmgr/src/install/update.rs index 585d99d98..9e3a35d62 100644 --- a/appmgr/src/install/update.rs +++ b/appmgr/src/install/update.rs @@ -6,7 +6,8 @@ use rpc_toolkit::command; use crate::context::RpcContext; use crate::dependencies::{break_transitive, BreakageRes, DependencyError}; use crate::s9pk::manifest::PackageId; -use crate::util::{display_serializable, Version}; +use crate::util::serde::display_serializable; +use crate::util::Version; use crate::Error; #[command(subcommands(dry))] diff --git a/appmgr/src/logs.rs b/appmgr/src/logs.rs index 757142d62..4d729099e 100644 --- a/appmgr/src/logs.rs +++ b/appmgr/src/logs.rs @@ -15,7 +15,7 @@ use tracing::instrument; use crate::action::docker::DockerAction; use crate::error::ResultExt; use crate::s9pk::manifest::PackageId; -use crate::util::Reversible; +use crate::util::serde::Reversible; use crate::Error; #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] diff --git a/appmgr/src/net/interface.rs b/appmgr/src/net/interface.rs index 1494d9c94..b97f0cada 100644 --- a/appmgr/src/net/interface.rs +++ b/appmgr/src/net/interface.rs @@ -13,7 +13,7 @@ use tracing::instrument; use crate::db::model::{InterfaceAddressMap, InterfaceAddresses}; use crate::id::Id; use crate::s9pk::manifest::PackageId; -use crate::util::Port; +use crate::util::serde::Port; use crate::Error; #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/appmgr/src/net/nginx-standard.conf.template b/appmgr/src/net/nginx-standard.conf.template deleted file mode 100644 index 1cf097f50..000000000 --- a/appmgr/src/net/nginx-standard.conf.template +++ /dev/null @@ -1,28 +0,0 @@ -map $http_x_forwarded_proto $real_proto {{ - ext+onions ext+onions; - ext+onion ext+onion; - https https; - http http; - default $scheme; -}} -server {{ - listen 443 ssl; - server_name .{hostname}.local; - ssl_certificate /root/appmgr/apps/{app_id}/cert-local.fullchain.crt.pem; - ssl_certificate_key /root/appmgr/apps/{app_id}/cert-local.key.pem; - location / {{ - proxy_pass http://{app_ip}:{internal_port}/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $real_proto; - client_max_body_size 0; - proxy_request_buffering off; - proxy_buffering off; - }} -}} -server {{ - listen 80; - server_name .{hostname}.local; - return 301 https://$host$request_uri; -}} diff --git a/appmgr/src/net/nginx.rs b/appmgr/src/net/nginx.rs index ca1a6a47f..fe6b058f0 100644 --- a/appmgr/src/net/nginx.rs +++ b/appmgr/src/net/nginx.rs @@ -11,7 +11,8 @@ use super::interface::{InterfaceId, LanPortConfig}; use super::ssl::SslManager; use crate::hostname::get_hostname; use crate::s9pk::manifest::PackageId; -use crate::util::{Invoke, Port}; +use crate::util::serde::Port; +use crate::util::Invoke; use crate::{Error, ErrorKind, ResultExt}; pub struct NginxController { diff --git a/appmgr/src/net/tor.rs b/appmgr/src/net/tor.rs index 401fd86ec..f0dedfe95 100644 --- a/appmgr/src/net/tor.rs +++ b/appmgr/src/net/tor.rs @@ -19,7 +19,7 @@ use tracing::instrument; use super::interface::{InterfaceId, TorConfig}; use crate::context::RpcContext; use crate::s9pk::manifest::PackageId; -use crate::util::{display_serializable, IoFormat}; +use crate::util::serde::{display_serializable, IoFormat}; use crate::{Error, ErrorKind, ResultExt as _}; #[test] diff --git a/appmgr/src/net/wifi.rs b/appmgr/src/net/wifi.rs index a140b6aef..b45e7fc50 100644 --- a/appmgr/src/net/wifi.rs +++ b/appmgr/src/net/wifi.rs @@ -11,7 +11,8 @@ use tokio::sync::RwLock; use tracing::instrument; use crate::context::RpcContext; -use crate::util::{display_none, display_serializable, Invoke, IoFormat}; +use crate::util::serde::{display_serializable, IoFormat}; +use crate::util::{display_none, Invoke}; use crate::{Error, ErrorKind}; #[command(subcommands(add, connect, delete, get, set_country))] diff --git a/appmgr/src/notifications.rs b/appmgr/src/notifications.rs index 34e09e327..748b1b0f1 100644 --- a/appmgr/src/notifications.rs +++ b/appmgr/src/notifications.rs @@ -14,7 +14,8 @@ use crate::backup::BackupReport; use crate::context::RpcContext; use crate::db::util::WithRevision; use crate::s9pk::manifest::PackageId; -use crate::util::{display_none, display_serializable}; +use crate::util::display_none; +use crate::util::serde::display_serializable; use crate::{Error, ErrorKind, ResultExt}; #[command(subcommands(list, delete, delete_before, create))] diff --git a/appmgr/src/setup.rs b/appmgr/src/setup.rs index 82a33f662..7ddfca013 100644 --- a/appmgr/src/setup.rs +++ b/appmgr/src/setup.rs @@ -11,17 +11,23 @@ use openssl::x509::X509; use rpc_toolkit::command; use rpc_toolkit::yajrc::RpcError; use serde::{Deserialize, Serialize}; +use sqlx::{Executor, Sqlite}; use tokio::fs::File; use tokio::io::AsyncWriteExt; use torut::onion::{OnionAddressV3, TorSecretKeyV3}; use tracing::instrument; use crate::backup::restore::recover_full_embassy; +use crate::backup::target::BackupTargetFS; use crate::context::rpc::RpcContextConfig; use crate::context::SetupContext; use crate::db::model::RecoveredPackageInfo; use crate::disk::main::DEFAULT_PASSWORD; -use crate::disk::util::{pvscan, DiskInfo, PartitionInfo, TmpMountGuard}; +use crate::disk::mount::filesystem::block_dev::BlockDev; +use crate::disk::mount::filesystem::cifs::Cifs; +use crate::disk::mount::guard::TmpMountGuard; +use crate::disk::util::{pvscan, recovery_info, DiskInfo, EmbassyOsRecoveryInfo}; +use crate::hostname::PRODUCT_KEY_PATH; use crate::id::Id; use crate::init::init; use crate::install::PKG_PUBLIC_DIR; @@ -33,7 +39,20 @@ use crate::util::Version; use crate::volume::{data_dir, VolumeId}; use crate::{ensure_code, Error, ResultExt}; -#[command(subcommands(status, disk, execute, recovery))] +#[instrument(skip(secrets))] +pub async fn password_hash(secrets: &mut Ex) -> Result +where + for<'a> &'a mut Ex: Executor<'a, Database = Sqlite>, +{ + let password = sqlx::query!("SELECT password FROM account") + .fetch_one(secrets) + .await? + .password; + + Ok(password) +} + +#[command(subcommands(status, disk, execute, recovery, cifs, complete))] pub fn setup() -> Result<(), Error> { Ok(()) } @@ -51,7 +70,7 @@ pub async fn status(#[context] ctx: SetupContext) -> Result { product_key: tokio::fs::metadata("/embassy-os/product_key.txt") .await .is_ok(), - migrating: ctx.recovery_status.read().await.is_some(), // TODO + migrating: ctx.recovery_status.read().await.is_some(), }) } @@ -65,16 +84,35 @@ pub async fn list_disks() -> Result, Error> { crate::disk::list(None).await } -#[command(subcommands(recovery_status))] +#[command(subcommands(v2, recovery_status))] pub fn recovery() -> Result<(), Error> { 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)).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)] #[serde(rename_all = "kebab-case")] pub struct RecoveryStatus { pub bytes_transferred: u64, pub total_bytes: u64, + pub complete: bool, } #[command(rename = "status", rpc_only, metadata(authenticated = false))] @@ -84,6 +122,30 @@ pub async fn recovery_status( ctx.recovery_status.read().await.clone().transpose() } +#[command(subcommands(verify_cifs))] +pub fn cifs() -> Result<(), Error> { + Ok(()) +} + +#[command(rename = "verify", rpc_only)] +pub async fn verify_cifs( + #[arg] hostname: String, + #[arg] path: PathBuf, + #[arg] username: String, + #[arg] password: Option, +) -> Result { + let guard = TmpMountGuard::mount(&Cifs { + hostname, + path, + username, + password, + }) + .await?; + let embassy_os = recovery_info(&guard).await?; + guard.unmount().await?; + embassy_os.ok_or_else(|| Error::new(eyre!("No Backup Found"), crate::ErrorKind::NotFound)) +} + #[derive(Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct SetupResult { @@ -97,14 +159,17 @@ pub async fn execute( #[context] ctx: SetupContext, #[arg(rename = "embassy-logicalname")] embassy_logicalname: PathBuf, #[arg(rename = "embassy-password")] embassy_password: String, - #[arg(rename = "recovery-partition")] recovery_partition: Option, + #[arg(rename = "recovery-source")] mut recovery_source: Option, #[arg(rename = "recovery-password")] recovery_password: Option, ) -> Result { + if let Some(v2_drive) = &*ctx.selected_v2_drive.read().await { + recovery_source = Some(BackupTargetFS::Disk(BlockDev::new(v2_drive.clone()))) + } match execute_inner( ctx, embassy_logicalname, embassy_password, - recovery_partition, + recovery_source, recovery_password, ) .await @@ -126,7 +191,23 @@ pub async fn execute( } #[instrument(skip(ctx))] -pub async fn complete_setup(ctx: SetupContext, guid: Arc) -> Result<(), Error> { +#[command(rpc_only)] +pub async fn complete(#[context] ctx: SetupContext) -> Result<(), Error> { + let guid = if let Some(guid) = &*ctx.disk_guid.read().await { + guid.clone() + } else { + return Err(Error::new( + eyre!("setup.execute has not completed successfully"), + crate::ErrorKind::InvalidRequest, + )); + }; + if tokio::fs::metadata(PRODUCT_KEY_PATH).await.is_err() { + let mut pkey_file = File::create(PRODUCT_KEY_PATH).await?; + pkey_file + .write_all(ctx.product_key().await?.as_bytes()) + .await?; + pkey_file.sync_all().await?; + } let mut guid_file = File::create("/embassy-os/disk.guid").await?; guid_file.write_all(guid.as_bytes()).await?; guid_file.sync_all().await?; @@ -139,7 +220,7 @@ pub async fn execute_inner( ctx: SetupContext, embassy_logicalname: PathBuf, embassy_password: String, - recovery_partition: Option, + recovery_source: Option, recovery_password: Option, ) -> Result<(OnionAddressV3, X509), Error> { if ctx.recovery_status.read().await.is_some() { @@ -159,27 +240,25 @@ pub async fn execute_inner( ); crate::disk::main::import(&*guid, &ctx.datadir, DEFAULT_PASSWORD).await?; - let res = if let Some(recovery_partition) = recovery_partition { - if recovery_partition - .embassy_os - .as_ref() - .map(|v| &*v.version < &emver::Version::new(0, 2, 8, 0)) - .unwrap_or(true) - { - return Err(Error::new(eyre!("Unsupported version of EmbassyOS. Please update to at least 0.2.8 before recovering."), crate::ErrorKind::VersionIncompatible)); - } + let res = if let Some(recovery_source) = recovery_source { let (tor_addr, root_ca, recover_fut) = recover( ctx.clone(), guid.clone(), embassy_password, - recovery_partition, + recovery_source, recovery_password, ) .await?; init(&RpcContextConfig::load(ctx.config_path.as_ref()).await?).await?; tokio::spawn(async move { if let Err(e) = recover_fut - .and_then(|_| complete_setup(ctx.clone(), guid)) + .and_then(|_| async { + *ctx.disk_guid.write().await = Some(guid); + 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 @@ -192,7 +271,7 @@ pub async fn execute_inner( } else { let res = fresh_setup(&ctx, &embassy_password).await?; init(&RpcContextConfig::load(ctx.config_path.as_ref()).await?).await?; - complete_setup(ctx, guid).await?; + *ctx.disk_guid.write().await = Some(guid); res }; @@ -233,11 +312,12 @@ async fn recover( ctx: SetupContext, guid: Arc, embassy_password: String, - recovery_partition: PartitionInfo, + recovery_source: BackupTargetFS, recovery_password: Option, ) -> Result<(OnionAddressV3, X509, BoxFuture<'static, Result<(), Error>>), Error> { - let recovery_version = recovery_partition - .embassy_os + let recovery_source = TmpMountGuard::mount(&recovery_source).await?; + let recovery_version = recovery_info(&recovery_source) + .await? .as_ref() .map(|i| i.version.clone()) .unwrap_or_default(); @@ -246,14 +326,14 @@ async fn recover( ( tor_addr, root_ca, - recover_v2(ctx.clone(), recovery_partition).boxed(), + recover_v2(ctx.clone(), recovery_source).boxed(), ) } else if recovery_version.major() == 0 && recovery_version.minor() == 3 { recover_full_embassy( ctx.clone(), guid.clone(), embassy_password, - recovery_partition, + recovery_source, recovery_password, ) .await? @@ -332,14 +412,12 @@ fn dir_copy<'a, P0: AsRef + 'a + Send + Sync, P1: AsRef + 'a + Send } #[instrument(skip(ctx))] -async fn recover_v2(ctx: SetupContext, recovery_partition: PartitionInfo) -> Result<(), Error> { - let recovery = TmpMountGuard::mount(&recovery_partition.logicalname, None).await?; - +async fn recover_v2(ctx: SetupContext, recovery_source: TmpMountGuard) -> Result<(), Error> { let secret_store = ctx.secret_store().await?; let db = ctx.db(&secret_store).await?; let mut handle = db.handle(); - let apps_yaml_path = recovery + let apps_yaml_path = recovery_source .as_ref() .join("root") .join("appmgr") @@ -358,7 +436,7 @@ async fn recover_v2(ctx: SetupContext, recovery_partition: PartitionInfo) -> Res })?) .await?; - let volume_path = recovery.as_ref().join("root/volumes"); + 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); @@ -372,6 +450,7 @@ async fn recover_v2(ctx: SetupContext, recovery_partition: PartitionInfo) -> Res *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())?); @@ -398,11 +477,12 @@ async fn recover_v2(ctx: SetupContext, recovery_partition: PartitionInfo) -> Res *ctx.recovery_status.write().await = Some(Ok(RecoveryStatus { bytes_transferred: bytes_transferred.load(Ordering::Relaxed), total_bytes, + complete: false })); } } => (), ); - let tor_src_path = recovery + let tor_src_path = recovery_source .as_ref() .join("var/lib/tor") .join(format!("app-{}", pkg_id)) @@ -430,7 +510,7 @@ async fn recover_v2(ctx: SetupContext, recovery_partition: PartitionInfo) -> Res let icon_leaf = AsRef::::as_ref(&pkg_id) .join(info.version.as_str()) .join("icon.png"); - let icon_src_path = recovery + let icon_src_path = recovery_source .as_ref() .join("root/agent/icons") .join(format!("{}.png", pkg_id)); @@ -468,6 +548,6 @@ async fn recover_v2(ctx: SetupContext, recovery_partition: PartitionInfo) -> Res } secret_store.close().await; - recovery.unmount().await?; + recovery_source.unmount().await?; Ok(()) } diff --git a/appmgr/src/ssh.rs b/appmgr/src/ssh.rs index 32eb067c9..8a23e63de 100644 --- a/appmgr/src/ssh.rs +++ b/appmgr/src/ssh.rs @@ -8,15 +8,16 @@ use sqlx::{Pool, Sqlite}; use tracing::instrument; use crate::context::RpcContext; -use crate::util::{display_none, display_serializable, IoFormat}; +use crate::util::display_none; +use crate::util::serde::{display_serializable, IoFormat}; use crate::{Error, ErrorKind}; static SSH_AUTHORIZED_KEYS_FILE: &str = "/root/.ssh/authorized_keys"; #[derive(Debug, serde::Deserialize, serde::Serialize)] pub struct PubKey( - #[serde(serialize_with = "crate::util::serialize_display")] - #[serde(deserialize_with = "crate::util::deserialize_from_str")] + #[serde(serialize_with = "crate::util::serde::serialize_display")] + #[serde(deserialize_with = "crate::util::serde::deserialize_from_str")] openssh_keys::PublicKey, ); diff --git a/appmgr/src/status/health_check.rs b/appmgr/src/status/health_check.rs index 6351e9379..12b06aa0a 100644 --- a/appmgr/src/status/health_check.rs +++ b/appmgr/src/status/health_check.rs @@ -9,7 +9,8 @@ use crate::action::{ActionImplementation, NoOutput}; use crate::context::RpcContext; use crate::id::Id; use crate::s9pk::manifest::PackageId; -use crate::util::{Duration, Version}; +use crate::util::serde::Duration; +use crate::util::Version; use crate::volume::Volumes; use crate::Error; diff --git a/appmgr/src/system.rs b/appmgr/src/system.rs index 513c8d4e8..408301b95 100644 --- a/appmgr/src/system.rs +++ b/appmgr/src/system.rs @@ -12,7 +12,8 @@ use crate::db::util::WithRevision; use crate::disk::util::{get_available, get_percentage, get_used}; use crate::logs::{display_logs, fetch_logs, LogResponse, LogSource}; use crate::shutdown::Shutdown; -use crate::util::{display_none, display_serializable, IoFormat}; +use crate::util::display_none; +use crate::util::serde::{display_serializable, IoFormat}; use crate::{Error, ErrorKind}; pub const SYSTEMD_UNIT: &'static str = "embassyd"; diff --git a/appmgr/src/update/mod.rs b/appmgr/src/update/mod.rs index 5597a1383..4f3118a68 100644 --- a/appmgr/src/update/mod.rs +++ b/appmgr/src/update/mod.rs @@ -25,12 +25,18 @@ use tracing::instrument; use crate::context::RpcContext; use crate::db::model::{ServerStatus, UpdateProgress}; use crate::db::util::WithRevision; +use crate::disk::mount::filesystem::label::Label; +use crate::disk::mount::filesystem::FileSystem; +use crate::disk::mount::guard::TmpMountGuard; +use crate::disk::BOOT_RW_PATH; use crate::notifications::NotificationLevel; use crate::update::latest_information::LatestInformation; use crate::util::Invoke; use crate::version::{Current, VersionT}; use crate::{Error, ErrorKind, ResultExt}; +mod latest_information; + lazy_static! { static ref UPDATED: AtomicBool = AtomicBool::new(false); } @@ -78,89 +84,24 @@ fn display_update_result(status: WithRevision, _: &ArgMatches<'_>) } const HEADER_KEY: &str = "x-eos-hash"; -mod latest_information; #[derive(Debug, Clone, Copy)] enum WritableDrives { Green, Blue, } - -#[derive(Debug, Clone, Copy)] -struct Boot; - -/// We are going to be creating some folders and mounting so -/// we need to know the labels for those types. These labels -/// are the labels that are shipping with the embassy, blue/ green -/// are where the os sits and will do a swap during update. -trait FileType: std::fmt::Debug + Copy + Send + Sync + 'static { - fn mount_folder(&self) -> PathBuf { - Path::new("/media").join(self.label()) - } - fn label(&self) -> &'static str; - fn block_dev(&self) -> &'static Path; -} -impl FileType for WritableDrives { +impl WritableDrives { fn label(&self) -> &'static str { match self { - WritableDrives::Green => "green", - WritableDrives::Blue => "blue", + Self::Green => "green", + Self::Blue => "blue", } } - fn block_dev(&self) -> &'static Path { - Path::new(match self { - WritableDrives::Green => "/dev/mmcblk0p3", - WritableDrives::Blue => "/dev/mmcblk0p4", - }) + fn block_dev(&self) -> PathBuf { + Path::new("/dev/disk/by-label").join(self.label()) } -} -impl FileType for Boot { - fn label(&self) -> &'static str { - "system-boot" - } - fn block_dev(&self) -> &'static Path { - Path::new("/dev/mmcblk0p1") - } -} - -/// Proven data that this is mounted, should be consumed in an unmount -#[derive(Debug)] -struct MountedResource { - value: X, - mounted: bool, -} -impl MountedResource { - fn new(value: X) -> Self { - MountedResource { - value, - mounted: true, - } - } - #[instrument] - async fn unmount(value: X) -> Result<(), Error> { - let folder = value.mount_folder(); - Command::new("umount") - .arg(&folder) - .invoke(crate::ErrorKind::Filesystem) - .await?; - tokio::fs::remove_dir_all(&folder) - .await - .with_ctx(|_| (crate::ErrorKind::Filesystem, folder.display().to_string()))?; - Ok(()) - } - #[instrument] - async fn unmount_label(&mut self) -> Result<(), Error> { - Self::unmount(self.value).await?; - self.mounted = false; - Ok(()) - } -} -impl Drop for MountedResource { - fn drop(&mut self) { - if self.mounted { - let value = self.value; - tokio::spawn(async move { Self::unmount(value).await.expect("failed to unmount") }); - } + fn as_fs(&self) -> impl FileSystem { + Label::new(self.label()) } } @@ -222,7 +163,6 @@ async fn maybe_do_update(ctx: RpcContext) -> Result>, Error ServerStatus::Running => (), } - let mounted_boot = mount_label(Boot).await?; let (new_label, _current_label) = query_mounted_label().await?; let (size, download) = download_file( ctx.db.handle(), @@ -243,7 +183,7 @@ async fn maybe_do_update(ctx: RpcContext) -> Result>, Error tokio::spawn(async move { let mut db = ctx.db.handle(); - let res = do_update(download, new_label, mounted_boot).await; + let res = do_update(download, new_label).await; let mut info = crate::db::DatabaseModel::new() .server_info() .get_mut(&mut db) @@ -280,12 +220,9 @@ async fn maybe_do_update(ctx: RpcContext) -> Result>, Error async fn do_update( download: impl Future>, new_label: NewLabel, - mut mounted_boot: MountedResource, ) -> Result<(), Error> { download.await?; - swap_boot_label(new_label, &mounted_boot).await?; - - mounted_boot.unmount_label().await?; + swap_boot_label(new_label).await?; Ok(()) } @@ -354,13 +291,13 @@ async fn download_file<'a, Db: DbHandle + 'a>( .map(|l| l.parse()) .transpose()?; Ok((size, async move { - let hash_from_header: String = "".to_owned(); // download_request - // .headers() - // .get(HEADER_KEY) - // .ok_or_else(|| Error::new(eyre!("No {} in headers", HEADER_KEY), ErrorKind::Network))? - // .to_str() - // .with_kind(ErrorKind::InvalidRequest)? - // .to_owned(); + let hash_from_header: String = download_request + .headers() + .get(HEADER_KEY) + .ok_or_else(|| Error::new(eyre!("No {} in headers", HEADER_KEY), ErrorKind::Network))? + .to_str() + .with_kind(ErrorKind::InvalidRequest)? + .to_owned(); let stream_download = download_request.bytes_stream(); let file_sum = write_stream_to_label(&mut db, size, stream_download, new_label).await?; check_download(&hash_from_header, file_sum).await?; @@ -408,39 +345,36 @@ async fn write_stream_to_label( #[instrument] async fn check_download(hash_from_header: &str, file_digest: Vec) -> Result<(), Error> { - // if hex::decode(hash_from_header).with_kind(ErrorKind::Network)? != file_digest { - // return Err(Error::new( - // eyre!("Hash sum does not match source"), - // ErrorKind::Network, - // )); - // } + if hex::decode(hash_from_header).with_kind(ErrorKind::Network)? != file_digest { + return Err(Error::new( + eyre!("Hash sum does not match source"), + ErrorKind::Network, + )); + } Ok(()) } #[instrument] -async fn swap_boot_label( - new_label: NewLabel, - mounted_boot: &MountedResource, -) -> Result<(), Error> { +async fn swap_boot_label(new_label: NewLabel) -> Result<(), Error> { let block_dev = new_label.0.block_dev(); Command::new("e2label") .arg(block_dev) .arg(new_label.0.label()) .invoke(crate::ErrorKind::BlockDevice) .await?; - let mut mounted = mount_label(new_label.0).await?; + let mounted = TmpMountGuard::mount(&new_label.0.as_fs()).await?; let sedcmd = format!("s/LABEL=\\(blue\\|green\\)/LABEL={}/g", new_label.0.label()); Command::new("sed") .arg("-i") .arg(&sedcmd) - .arg(mounted.value.mount_folder().join("etc/fstab")) + .arg(mounted.as_ref().join("etc/fstab")) .output() .await?; - mounted.unmount_label().await?; + mounted.unmount().await?; Command::new("sed") .arg("-i") .arg(&sedcmd) - .arg(mounted_boot.value.mount_folder().join("cmdline.txt")) + .arg(Path::new(BOOT_RW_PATH).join("cmdline.txt")) .output() .await?; @@ -448,24 +382,6 @@ async fn swap_boot_label( Ok(()) } -#[instrument] -async fn mount_label(file_type: F) -> Result, Error> -where - F: FileType, -{ - let label = file_type.label(); - let folder = file_type.mount_folder(); - tokio::fs::create_dir_all(&folder) - .await - .with_ctx(|_| (crate::ErrorKind::Filesystem, folder.display().to_string()))?; - Command::new("mount") - .arg("-L") - .arg(label) - .arg(folder) - .invoke(crate::ErrorKind::Filesystem) - .await?; - Ok(MountedResource::new(file_type)) -} /// Captured from doing an fstab with an embassy box and the cat from the /etc/fstab #[test] fn test_capture() { diff --git a/appmgr/src/util/mod.rs b/appmgr/src/util/mod.rs index 33eba023d..4e061d0bb 100644 --- a/appmgr/src/util/mod.rs +++ b/appmgr/src/util/mod.rs @@ -4,10 +4,11 @@ use std::hash::{Hash, Hasher}; use std::marker::PhantomData; use std::ops::Deref; use std::path::{Path, PathBuf}; -use std::process::{exit, Stdio}; +use std::process::Stdio; use std::str::FromStr; use std::sync::Arc; +use ::serde::{Deserialize, Deserializer, Serialize, Serializer}; use async_trait::async_trait; use clap::ArgMatches; use color_eyre::eyre::{self, eyre}; @@ -17,8 +18,6 @@ use futures::future::BoxFuture; use futures::FutureExt; use lazy_static::lazy_static; use patch_db::{HasModel, Model}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use serde_json::Value; use tokio::fs::File; use tokio::sync::{Mutex, OwnedMutexGuard, RwLock}; use tokio::task::{JoinError, JoinHandle}; @@ -29,6 +28,7 @@ use crate::{Error, ResultExt as _}; pub mod io; pub mod logger; +pub mod serde; #[derive(Clone, Copy, Debug)] pub enum Never {} @@ -84,90 +84,6 @@ pub trait ApplyRef { impl Apply for T {} impl ApplyRef for T {} -pub fn deserialize_from_str< - 'de, - D: serde::de::Deserializer<'de>, - T: FromStr, - E: std::fmt::Display, ->( - deserializer: D, -) -> std::result::Result { - struct Visitor, E>(std::marker::PhantomData); - impl<'de, T: FromStr, Err: std::fmt::Display> serde::de::Visitor<'de> - for Visitor - { - type Value = T; - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "a parsable string") - } - fn visit_str(self, v: &str) -> Result - where - E: serde::de::Error, - { - v.parse().map_err(|e| serde::de::Error::custom(e)) - } - } - deserializer.deserialize_str(Visitor(std::marker::PhantomData)) -} - -pub fn deserialize_from_str_opt< - 'de, - D: serde::de::Deserializer<'de>, - T: FromStr, - E: std::fmt::Display, ->( - deserializer: D, -) -> std::result::Result, D::Error> { - struct Visitor, E>(std::marker::PhantomData); - impl<'de, T: FromStr, Err: std::fmt::Display> serde::de::Visitor<'de> - for Visitor - { - type Value = Option; - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "a parsable string") - } - fn visit_str(self, v: &str) -> Result - where - E: serde::de::Error, - { - v.parse().map(Some).map_err(|e| serde::de::Error::custom(e)) - } - fn visit_some(self, deserializer: D) -> Result - where - D: serde::de::Deserializer<'de>, - { - deserializer.deserialize_str(Visitor(std::marker::PhantomData)) - } - fn visit_none(self) -> Result - where - E: serde::de::Error, - { - Ok(None) - } - fn visit_unit(self) -> Result - where - E: serde::de::Error, - { - Ok(None) - } - } - deserializer.deserialize_any(Visitor(std::marker::PhantomData)) -} - -pub fn serialize_display( - t: &T, - serializer: S, -) -> Result { - String::serialize(&t.to_string(), serializer) -} - -pub fn serialize_display_opt( - t: &Option, - serializer: S, -) -> Result { - Option::::serialize(&t.as_ref().map(|t| t.to_string()), serializer) -} - pub async fn daemon Fut, Fut: Future + Send + 'static>( mut f: F, cooldown: std::time::Duration, @@ -206,134 +122,6 @@ impl SNone { } impl SOption for SNone {} -#[derive(Debug, Serialize)] -#[serde(untagged)] -pub enum ValuePrimative { - Null, - Boolean(bool), - String(String), - Number(serde_json::Number), -} -impl<'de> serde::de::Deserialize<'de> for ValuePrimative { - fn deserialize(deserializer: D) -> Result - where - D: serde::de::Deserializer<'de>, - { - struct Visitor; - impl<'de> serde::de::Visitor<'de> for Visitor { - type Value = ValuePrimative; - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(formatter, "a JSON primative value") - } - fn visit_unit(self) -> Result - where - E: serde::de::Error, - { - Ok(ValuePrimative::Null) - } - fn visit_none(self) -> Result - where - E: serde::de::Error, - { - Ok(ValuePrimative::Null) - } - fn visit_bool(self, v: bool) -> Result - where - E: serde::de::Error, - { - Ok(ValuePrimative::Boolean(v)) - } - fn visit_str(self, v: &str) -> Result - where - E: serde::de::Error, - { - Ok(ValuePrimative::String(v.to_owned())) - } - fn visit_string(self, v: String) -> Result - where - E: serde::de::Error, - { - Ok(ValuePrimative::String(v)) - } - fn visit_f32(self, v: f32) -> Result - where - E: serde::de::Error, - { - Ok(ValuePrimative::Number( - serde_json::Number::from_f64(v as f64).ok_or_else(|| { - serde::de::Error::invalid_value( - serde::de::Unexpected::Float(v as f64), - &"a finite number", - ) - })?, - )) - } - fn visit_f64(self, v: f64) -> Result - where - E: serde::de::Error, - { - Ok(ValuePrimative::Number( - serde_json::Number::from_f64(v).ok_or_else(|| { - serde::de::Error::invalid_value( - serde::de::Unexpected::Float(v), - &"a finite number", - ) - })?, - )) - } - fn visit_u8(self, v: u8) -> Result - where - E: serde::de::Error, - { - Ok(ValuePrimative::Number(v.into())) - } - fn visit_u16(self, v: u16) -> Result - where - E: serde::de::Error, - { - Ok(ValuePrimative::Number(v.into())) - } - fn visit_u32(self, v: u32) -> Result - where - E: serde::de::Error, - { - Ok(ValuePrimative::Number(v.into())) - } - fn visit_u64(self, v: u64) -> Result - where - E: serde::de::Error, - { - Ok(ValuePrimative::Number(v.into())) - } - fn visit_i8(self, v: i8) -> Result - where - E: serde::de::Error, - { - Ok(ValuePrimative::Number(v.into())) - } - fn visit_i16(self, v: i16) -> Result - where - E: serde::de::Error, - { - Ok(ValuePrimative::Number(v.into())) - } - fn visit_i32(self, v: i32) -> Result - where - E: serde::de::Error, - { - Ok(ValuePrimative::Number(v.into())) - } - fn visit_i64(self, v: i64) -> Result - where - E: serde::de::Error, - { - Ok(ValuePrimative::Number(v.into())) - } - } - deserializer.deserialize_any(Visitor) - } -} - #[derive(Debug, Clone)] pub struct Version { version: emver::Version, @@ -419,7 +207,7 @@ impl<'de> Deserialize<'de> for Version { D: Deserializer<'de>, { let string = String::deserialize(deserializer)?; - let version = emver::Version::from_str(&string).map_err(serde::de::Error::custom)?; + let version = emver::Version::from_str(&string).map_err(::serde::de::Error::custom)?; Ok(Self { string, version }) } } @@ -478,273 +266,10 @@ impl std::io::Write for FmtWriter { } } -#[derive(Clone, Copy, Debug, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum IoFormat { - Json, - JsonPretty, - Yaml, - Cbor, - Toml, - TomlPretty, -} -impl Default for IoFormat { - fn default() -> Self { - IoFormat::JsonPretty - } -} -impl std::fmt::Display for IoFormat { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - use IoFormat::*; - match self { - Json => write!(f, "JSON"), - JsonPretty => write!(f, "JSON (pretty)"), - Yaml => write!(f, "YAML"), - Cbor => write!(f, "CBOR"), - Toml => write!(f, "TOML"), - TomlPretty => write!(f, "TOML (pretty)"), - } - } -} -impl std::str::FromStr for IoFormat { - type Err = Error; - fn from_str(s: &str) -> Result { - serde_json::from_value(Value::String(s.to_owned())) - .with_kind(crate::ErrorKind::Deserialization) - } -} -impl IoFormat { - pub fn to_writer( - &self, - mut writer: W, - value: &T, - ) -> Result<(), Error> { - match self { - IoFormat::Json => { - serde_json::to_writer(writer, value).with_kind(crate::ErrorKind::Serialization) - } - IoFormat::JsonPretty => serde_json::to_writer_pretty(writer, value) - .with_kind(crate::ErrorKind::Serialization), - IoFormat::Yaml => { - serde_yaml::to_writer(writer, value).with_kind(crate::ErrorKind::Serialization) - } - IoFormat::Cbor => serde_cbor::ser::into_writer(value, writer) - .with_kind(crate::ErrorKind::Serialization), - IoFormat::Toml => writer - .write_all( - &serde_toml::to_vec( - &serde_toml::Value::try_from(value) - .with_kind(crate::ErrorKind::Serialization)?, - ) - .with_kind(crate::ErrorKind::Serialization)?, - ) - .with_kind(crate::ErrorKind::Serialization), - IoFormat::TomlPretty => writer - .write_all( - serde_toml::to_string_pretty( - &serde_toml::Value::try_from(value) - .with_kind(crate::ErrorKind::Serialization)?, - ) - .with_kind(crate::ErrorKind::Serialization)? - .as_bytes(), - ) - .with_kind(crate::ErrorKind::Serialization), - } - } - pub fn to_vec(&self, value: &T) -> Result, Error> { - match self { - IoFormat::Json => serde_json::to_vec(value).with_kind(crate::ErrorKind::Serialization), - IoFormat::JsonPretty => { - serde_json::to_vec_pretty(value).with_kind(crate::ErrorKind::Serialization) - } - IoFormat::Yaml => serde_yaml::to_vec(value).with_kind(crate::ErrorKind::Serialization), - IoFormat::Cbor => { - let mut res = Vec::new(); - serde_cbor::ser::into_writer(value, &mut res) - .with_kind(crate::ErrorKind::Serialization)?; - Ok(res) - } - IoFormat::Toml => serde_toml::to_vec( - &serde_toml::Value::try_from(value).with_kind(crate::ErrorKind::Serialization)?, - ) - .with_kind(crate::ErrorKind::Serialization), - IoFormat::TomlPretty => serde_toml::to_string_pretty( - &serde_toml::Value::try_from(value).with_kind(crate::ErrorKind::Serialization)?, - ) - .map(|s| s.into_bytes()) - .with_kind(crate::ErrorKind::Serialization), - } - } - /// BLOCKING - pub fn from_reader Deserialize<'de>>( - &self, - mut reader: R, - ) -> Result { - match self { - IoFormat::Json | IoFormat::JsonPretty => { - serde_json::from_reader(reader).with_kind(crate::ErrorKind::Deserialization) - } - IoFormat::Yaml => { - serde_yaml::from_reader(reader).with_kind(crate::ErrorKind::Deserialization) - } - IoFormat::Cbor => { - serde_cbor::de::from_reader(reader).with_kind(crate::ErrorKind::Deserialization) - } - IoFormat::Toml | IoFormat::TomlPretty => { - let mut s = String::new(); - reader - .read_to_string(&mut s) - .with_kind(crate::ErrorKind::Deserialization)?; - serde_toml::from_str(&s).with_kind(crate::ErrorKind::Deserialization) - } - } - } - pub fn from_slice Deserialize<'de>>(&self, slice: &[u8]) -> Result { - match self { - IoFormat::Json | IoFormat::JsonPretty => { - serde_json::from_slice(slice).with_kind(crate::ErrorKind::Deserialization) - } - IoFormat::Yaml => { - serde_yaml::from_slice(slice).with_kind(crate::ErrorKind::Deserialization) - } - IoFormat::Cbor => { - serde_cbor::de::from_reader(slice).with_kind(crate::ErrorKind::Deserialization) - } - IoFormat::Toml | IoFormat::TomlPretty => { - serde_toml::from_slice(slice).with_kind(crate::ErrorKind::Deserialization) - } - } - } -} - -pub fn display_serializable(t: T, matches: &ArgMatches<'_>) { - let format = match matches.value_of("format").map(|f| f.parse()) { - Some(Ok(f)) => f, - Some(Err(_)) => { - eprintln!("unrecognized formatter"); - exit(1) - } - None => IoFormat::default(), - }; - format - .to_writer(std::io::stdout(), &t) - .expect("Error serializing result to stdout") -} - pub fn display_none(_: T, _: &ArgMatches) { () } -pub fn parse_stdin_deserializable Deserialize<'de>>( - stdin: &mut std::io::Stdin, - matches: &ArgMatches<'_>, -) -> Result { - let format = match matches.value_of("format").map(|f| f.parse()) { - Some(Ok(f)) => f, - Some(Err(_)) => { - eprintln!("unrecognized formatter"); - exit(1) - } - None => IoFormat::default(), - }; - format.from_reader(stdin) -} - -#[derive(Debug, Clone, Copy)] -pub struct Duration(std::time::Duration); -impl Deref for Duration { - type Target = std::time::Duration; - fn deref(&self) -> &Self::Target { - &self.0 - } -} -impl From for Duration { - fn from(t: std::time::Duration) -> Self { - Duration(t) - } -} -impl std::str::FromStr for Duration { - type Err = Error; - fn from_str(s: &str) -> Result { - let units_idx = s.find(|c: char| c.is_alphabetic()).ok_or_else(|| { - Error::new( - eyre!("Must specify units for duration"), - crate::ErrorKind::Deserialization, - ) - })?; - let (num, units) = s.split_at(units_idx); - use std::time::Duration; - Ok(Duration(match units { - "d" if num.contains(".") => Duration::from_secs_f64(num.parse::()? * 86_400_f64), - "d" => Duration::from_secs(num.parse::()? * 86_400), - "h" if num.contains(".") => Duration::from_secs_f64(num.parse::()? * 3_600_f64), - "h" => Duration::from_secs(num.parse::()? * 3_600), - "m" if num.contains(".") => Duration::from_secs_f64(num.parse::()? * 60_f64), - "m" => Duration::from_secs(num.parse::()? * 60), - "s" if num.contains(".") => Duration::from_secs_f64(num.parse()?), - "s" => Duration::from_secs(num.parse()?), - "ms" if num.contains(".") => Duration::from_secs_f64(num.parse::()? / 1_000_f64), - "ms" => { - let millis: u128 = num.parse()?; - Duration::new((millis / 1_000) as u64, (millis % 1_000) as u32) - } - "us" | "µs" if num.contains(".") => { - Duration::from_secs_f64(num.parse::()? / 1_000_000_f64) - } - "us" | "µs" => { - let micros: u128 = num.parse()?; - Duration::new((micros / 1_000_000) as u64, (micros % 1_000_000) as u32) - } - "ns" if num.contains(".") => { - Duration::from_secs_f64(num.parse::()? / 1_000_000_000_f64) - } - "ns" => { - let nanos: u128 = num.parse()?; - Duration::new( - (nanos / 1_000_000_000) as u64, - (nanos % 1_000_000_000) as u32, - ) - } - _ => { - return Err(Error::new( - eyre!("Invalid units for duration"), - crate::ErrorKind::Deserialization, - )) - } - })) - } -} -impl std::fmt::Display for Duration { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let nanos = self.as_nanos(); - match () { - _ if nanos % 86_400_000_000_000 == 0 => write!(f, "{}d", nanos / 86_400_000_000_000), - _ if nanos % 3_600_000_000_000 == 0 => write!(f, "{}h", nanos / 3_600_000_000_000), - _ if nanos % 60_000_000_000 == 0 => write!(f, "{}m", nanos / 60_000_000_000), - _ if nanos % 1_000_000_000 == 0 => write!(f, "{}s", nanos / 1_000_000_000), - _ if nanos % 1_000_000 == 0 => write!(f, "{}ms", nanos / 1_000_000), - _ if nanos % 1_000 == 0 => write!(f, "{}µs", nanos / 1_000), - _ => write!(f, "{}ns", nanos), - } - } -} -impl<'de> Deserialize<'de> for Duration { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - deserialize_from_str(deserializer) - } -} -impl Serialize for Duration { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serialize_display(self, serializer) - } -} - pub struct Container(RwLock>); impl Container { pub fn new(value: Option) -> Self { @@ -793,85 +318,6 @@ impl std::io::Write for HashWriter { } } -pub fn deserialize_number_permissive< - 'de, - D: serde::de::Deserializer<'de>, - T: FromStr + num::cast::FromPrimitive, - E: std::fmt::Display, ->( - deserializer: D, -) -> std::result::Result { - struct Visitor + num::cast::FromPrimitive, E>(std::marker::PhantomData); - impl<'de, T: FromStr + num::cast::FromPrimitive, Err: std::fmt::Display> - serde::de::Visitor<'de> for Visitor - { - type Value = T; - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "a parsable string") - } - fn visit_str(self, v: &str) -> Result - where - E: serde::de::Error, - { - v.parse().map_err(|e| serde::de::Error::custom(e)) - } - fn visit_f64(self, v: f64) -> Result - where - E: serde::de::Error, - { - T::from_f64(v).ok_or_else(|| { - serde::de::Error::custom(format!( - "{} cannot be represented by the requested type", - v - )) - }) - } - fn visit_u64(self, v: u64) -> Result - where - E: serde::de::Error, - { - T::from_u64(v).ok_or_else(|| { - serde::de::Error::custom(format!( - "{} cannot be represented by the requested type", - v - )) - }) - } - fn visit_i64(self, v: i64) -> Result - where - E: serde::de::Error, - { - T::from_i64(v).ok_or_else(|| { - serde::de::Error::custom(format!( - "{} cannot be represented by the requested type", - v - )) - }) - } - } - deserializer.deserialize_str(Visitor(std::marker::PhantomData)) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Port(pub u16); -impl<'de> Deserialize<'de> for Port { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - //TODO: if number, be permissive - deserialize_number_permissive(deserializer).map(Port) - } -} -impl Serialize for Port { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serialize_display(&self.0, serializer) - } -} - pub trait IntoDoubleEndedIterator: IntoIterator { type IntoIter: Iterator + DoubleEndedIterator; fn into_iter(self) -> >::IntoIter; @@ -887,92 +333,6 @@ where } } -#[derive(Debug, Clone)] -pub struct Reversible> -where - for<'a> &'a Container: IntoDoubleEndedIterator<&'a T>, -{ - reversed: bool, - data: Container, - phantom: PhantomData, -} -impl Reversible -where - for<'a> &'a Container: IntoDoubleEndedIterator<&'a T>, -{ - pub fn new(data: Container) -> Self { - Reversible { - reversed: false, - data, - phantom: PhantomData, - } - } - - pub fn reverse(&mut self) { - self.reversed = !self.reversed - } - - pub fn iter( - &self, - ) -> itertools::Either< - <&Container as IntoDoubleEndedIterator<&T>>::IntoIter, - std::iter::Rev<<&Container as IntoDoubleEndedIterator<&T>>::IntoIter>, - > { - let iter = IntoDoubleEndedIterator::into_iter(&self.data); - if self.reversed { - itertools::Either::Right(iter.rev()) - } else { - itertools::Either::Left(iter) - } - } -} -impl Serialize for Reversible -where - for<'a> &'a Container: IntoDoubleEndedIterator<&'a T>, - T: Serialize, -{ - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - use serde::ser::SerializeSeq; - - let iter = IntoDoubleEndedIterator::into_iter(&self.data); - let mut seq_ser = serializer.serialize_seq(match iter.size_hint() { - (lower, Some(upper)) if lower == upper => Some(upper), - _ => None, - })?; - if self.reversed { - for elem in iter.rev() { - seq_ser.serialize_element(elem)?; - } - } else { - for elem in iter { - seq_ser.serialize_element(elem)?; - } - } - seq_ser.end() - } -} -impl<'de, T, Container> Deserialize<'de> for Reversible -where - for<'a> &'a Container: IntoDoubleEndedIterator<&'a T>, - Container: Deserialize<'de>, -{ - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - Ok(Reversible::new(Deserialize::deserialize(deserializer)?)) - } - fn deserialize_in_place(deserializer: D, place: &mut Self) -> Result<(), D::Error> - where - D: Deserializer<'de>, - { - Deserialize::deserialize_in_place(deserializer, &mut place.data) - } -} - #[pin_project::pin_project(PinnedDrop)] pub struct NonDetachingJoinHandle(#[pin] JoinHandle); impl From> for NonDetachingJoinHandle { diff --git a/appmgr/src/util/serde.rs b/appmgr/src/util/serde.rs new file mode 100644 index 000000000..a8999656b --- /dev/null +++ b/appmgr/src/util/serde.rs @@ -0,0 +1,690 @@ +use std::marker::PhantomData; +use std::ops::Deref; +use std::process::exit; +use std::str::FromStr; + +use clap::ArgMatches; +use color_eyre::eyre::eyre; +use serde::ser::{SerializeMap, SerializeSeq}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; + +use super::IntoDoubleEndedIterator; +use crate::{Error, ResultExt}; + +pub fn deserialize_from_str< + 'de, + D: serde::de::Deserializer<'de>, + T: FromStr, + E: std::fmt::Display, +>( + deserializer: D, +) -> std::result::Result { + struct Visitor, E>(std::marker::PhantomData); + impl<'de, T: FromStr, Err: std::fmt::Display> serde::de::Visitor<'de> + for Visitor + { + type Value = T; + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "a parsable string") + } + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + v.parse().map_err(|e| serde::de::Error::custom(e)) + } + } + deserializer.deserialize_str(Visitor(std::marker::PhantomData)) +} + +pub fn deserialize_from_str_opt< + 'de, + D: serde::de::Deserializer<'de>, + T: FromStr, + E: std::fmt::Display, +>( + deserializer: D, +) -> std::result::Result, D::Error> { + struct Visitor, E>(std::marker::PhantomData); + impl<'de, T: FromStr, Err: std::fmt::Display> serde::de::Visitor<'de> + for Visitor + { + type Value = Option; + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "a parsable string") + } + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + v.parse().map(Some).map_err(|e| serde::de::Error::custom(e)) + } + fn visit_some(self, deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + deserializer.deserialize_str(Visitor(std::marker::PhantomData)) + } + fn visit_none(self) -> Result + where + E: serde::de::Error, + { + Ok(None) + } + fn visit_unit(self) -> Result + where + E: serde::de::Error, + { + Ok(None) + } + } + deserializer.deserialize_any(Visitor(std::marker::PhantomData)) +} + +pub fn serialize_display( + t: &T, + serializer: S, +) -> Result { + String::serialize(&t.to_string(), serializer) +} + +pub fn serialize_display_opt( + t: &Option, + serializer: S, +) -> Result { + Option::::serialize(&t.as_ref().map(|t| t.to_string()), serializer) +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum ValuePrimative { + Null, + Boolean(bool), + String(String), + Number(serde_json::Number), +} +impl<'de> serde::de::Deserialize<'de> for ValuePrimative { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = ValuePrimative; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "a JSON primative value") + } + fn visit_unit(self) -> Result + where + E: serde::de::Error, + { + Ok(ValuePrimative::Null) + } + fn visit_none(self) -> Result + where + E: serde::de::Error, + { + Ok(ValuePrimative::Null) + } + fn visit_bool(self, v: bool) -> Result + where + E: serde::de::Error, + { + Ok(ValuePrimative::Boolean(v)) + } + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + Ok(ValuePrimative::String(v.to_owned())) + } + fn visit_string(self, v: String) -> Result + where + E: serde::de::Error, + { + Ok(ValuePrimative::String(v)) + } + fn visit_f32(self, v: f32) -> Result + where + E: serde::de::Error, + { + Ok(ValuePrimative::Number( + serde_json::Number::from_f64(v as f64).ok_or_else(|| { + serde::de::Error::invalid_value( + serde::de::Unexpected::Float(v as f64), + &"a finite number", + ) + })?, + )) + } + fn visit_f64(self, v: f64) -> Result + where + E: serde::de::Error, + { + Ok(ValuePrimative::Number( + serde_json::Number::from_f64(v).ok_or_else(|| { + serde::de::Error::invalid_value( + serde::de::Unexpected::Float(v), + &"a finite number", + ) + })?, + )) + } + fn visit_u8(self, v: u8) -> Result + where + E: serde::de::Error, + { + Ok(ValuePrimative::Number(v.into())) + } + fn visit_u16(self, v: u16) -> Result + where + E: serde::de::Error, + { + Ok(ValuePrimative::Number(v.into())) + } + fn visit_u32(self, v: u32) -> Result + where + E: serde::de::Error, + { + Ok(ValuePrimative::Number(v.into())) + } + fn visit_u64(self, v: u64) -> Result + where + E: serde::de::Error, + { + Ok(ValuePrimative::Number(v.into())) + } + fn visit_i8(self, v: i8) -> Result + where + E: serde::de::Error, + { + Ok(ValuePrimative::Number(v.into())) + } + fn visit_i16(self, v: i16) -> Result + where + E: serde::de::Error, + { + Ok(ValuePrimative::Number(v.into())) + } + fn visit_i32(self, v: i32) -> Result + where + E: serde::de::Error, + { + Ok(ValuePrimative::Number(v.into())) + } + fn visit_i64(self, v: i64) -> Result + where + E: serde::de::Error, + { + Ok(ValuePrimative::Number(v.into())) + } + } + deserializer.deserialize_any(Visitor) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum IoFormat { + Json, + JsonPretty, + Yaml, + Cbor, + Toml, + TomlPretty, +} +impl Default for IoFormat { + fn default() -> Self { + IoFormat::JsonPretty + } +} +impl std::fmt::Display for IoFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use IoFormat::*; + match self { + Json => write!(f, "JSON"), + JsonPretty => write!(f, "JSON (pretty)"), + Yaml => write!(f, "YAML"), + Cbor => write!(f, "CBOR"), + Toml => write!(f, "TOML"), + TomlPretty => write!(f, "TOML (pretty)"), + } + } +} +impl std::str::FromStr for IoFormat { + type Err = Error; + fn from_str(s: &str) -> Result { + serde_json::from_value(Value::String(s.to_owned())) + .with_kind(crate::ErrorKind::Deserialization) + } +} +impl IoFormat { + pub fn to_writer( + &self, + mut writer: W, + value: &T, + ) -> Result<(), Error> { + match self { + IoFormat::Json => { + serde_json::to_writer(writer, value).with_kind(crate::ErrorKind::Serialization) + } + IoFormat::JsonPretty => serde_json::to_writer_pretty(writer, value) + .with_kind(crate::ErrorKind::Serialization), + IoFormat::Yaml => { + serde_yaml::to_writer(writer, value).with_kind(crate::ErrorKind::Serialization) + } + IoFormat::Cbor => serde_cbor::ser::into_writer(value, writer) + .with_kind(crate::ErrorKind::Serialization), + IoFormat::Toml => writer + .write_all( + &serde_toml::to_vec( + &serde_toml::Value::try_from(value) + .with_kind(crate::ErrorKind::Serialization)?, + ) + .with_kind(crate::ErrorKind::Serialization)?, + ) + .with_kind(crate::ErrorKind::Serialization), + IoFormat::TomlPretty => writer + .write_all( + serde_toml::to_string_pretty( + &serde_toml::Value::try_from(value) + .with_kind(crate::ErrorKind::Serialization)?, + ) + .with_kind(crate::ErrorKind::Serialization)? + .as_bytes(), + ) + .with_kind(crate::ErrorKind::Serialization), + } + } + pub fn to_vec(&self, value: &T) -> Result, Error> { + match self { + IoFormat::Json => serde_json::to_vec(value).with_kind(crate::ErrorKind::Serialization), + IoFormat::JsonPretty => { + serde_json::to_vec_pretty(value).with_kind(crate::ErrorKind::Serialization) + } + IoFormat::Yaml => serde_yaml::to_vec(value).with_kind(crate::ErrorKind::Serialization), + IoFormat::Cbor => { + let mut res = Vec::new(); + serde_cbor::ser::into_writer(value, &mut res) + .with_kind(crate::ErrorKind::Serialization)?; + Ok(res) + } + IoFormat::Toml => serde_toml::to_vec( + &serde_toml::Value::try_from(value).with_kind(crate::ErrorKind::Serialization)?, + ) + .with_kind(crate::ErrorKind::Serialization), + IoFormat::TomlPretty => serde_toml::to_string_pretty( + &serde_toml::Value::try_from(value).with_kind(crate::ErrorKind::Serialization)?, + ) + .map(|s| s.into_bytes()) + .with_kind(crate::ErrorKind::Serialization), + } + } + /// BLOCKING + pub fn from_reader Deserialize<'de>>( + &self, + mut reader: R, + ) -> Result { + match self { + IoFormat::Json | IoFormat::JsonPretty => { + serde_json::from_reader(reader).with_kind(crate::ErrorKind::Deserialization) + } + IoFormat::Yaml => { + serde_yaml::from_reader(reader).with_kind(crate::ErrorKind::Deserialization) + } + IoFormat::Cbor => { + serde_cbor::de::from_reader(reader).with_kind(crate::ErrorKind::Deserialization) + } + IoFormat::Toml | IoFormat::TomlPretty => { + let mut s = String::new(); + reader + .read_to_string(&mut s) + .with_kind(crate::ErrorKind::Deserialization)?; + serde_toml::from_str(&s).with_kind(crate::ErrorKind::Deserialization) + } + } + } + pub fn from_slice Deserialize<'de>>(&self, slice: &[u8]) -> Result { + match self { + IoFormat::Json | IoFormat::JsonPretty => { + serde_json::from_slice(slice).with_kind(crate::ErrorKind::Deserialization) + } + IoFormat::Yaml => { + serde_yaml::from_slice(slice).with_kind(crate::ErrorKind::Deserialization) + } + IoFormat::Cbor => { + serde_cbor::de::from_reader(slice).with_kind(crate::ErrorKind::Deserialization) + } + IoFormat::Toml | IoFormat::TomlPretty => { + serde_toml::from_slice(slice).with_kind(crate::ErrorKind::Deserialization) + } + } + } +} + +pub fn display_serializable(t: T, matches: &ArgMatches<'_>) { + let format = match matches.value_of("format").map(|f| f.parse()) { + Some(Ok(f)) => f, + Some(Err(_)) => { + eprintln!("unrecognized formatter"); + exit(1) + } + None => IoFormat::default(), + }; + format + .to_writer(std::io::stdout(), &t) + .expect("Error serializing result to stdout") +} + +pub fn parse_stdin_deserializable Deserialize<'de>>( + stdin: &mut std::io::Stdin, + matches: &ArgMatches<'_>, +) -> Result { + let format = match matches.value_of("format").map(|f| f.parse()) { + Some(Ok(f)) => f, + Some(Err(_)) => { + eprintln!("unrecognized formatter"); + exit(1) + } + None => IoFormat::default(), + }; + format.from_reader(stdin) +} + +#[derive(Debug, Clone, Copy)] +pub struct Duration(std::time::Duration); +impl Deref for Duration { + type Target = std::time::Duration; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl From for Duration { + fn from(t: std::time::Duration) -> Self { + Duration(t) + } +} +impl std::str::FromStr for Duration { + type Err = Error; + fn from_str(s: &str) -> Result { + let units_idx = s.find(|c: char| c.is_alphabetic()).ok_or_else(|| { + Error::new( + eyre!("Must specify units for duration"), + crate::ErrorKind::Deserialization, + ) + })?; + let (num, units) = s.split_at(units_idx); + use std::time::Duration; + Ok(Duration(match units { + "d" if num.contains(".") => Duration::from_secs_f64(num.parse::()? * 86_400_f64), + "d" => Duration::from_secs(num.parse::()? * 86_400), + "h" if num.contains(".") => Duration::from_secs_f64(num.parse::()? * 3_600_f64), + "h" => Duration::from_secs(num.parse::()? * 3_600), + "m" if num.contains(".") => Duration::from_secs_f64(num.parse::()? * 60_f64), + "m" => Duration::from_secs(num.parse::()? * 60), + "s" if num.contains(".") => Duration::from_secs_f64(num.parse()?), + "s" => Duration::from_secs(num.parse()?), + "ms" if num.contains(".") => Duration::from_secs_f64(num.parse::()? / 1_000_f64), + "ms" => { + let millis: u128 = num.parse()?; + Duration::new((millis / 1_000) as u64, (millis % 1_000) as u32) + } + "us" | "µs" if num.contains(".") => { + Duration::from_secs_f64(num.parse::()? / 1_000_000_f64) + } + "us" | "µs" => { + let micros: u128 = num.parse()?; + Duration::new((micros / 1_000_000) as u64, (micros % 1_000_000) as u32) + } + "ns" if num.contains(".") => { + Duration::from_secs_f64(num.parse::()? / 1_000_000_000_f64) + } + "ns" => { + let nanos: u128 = num.parse()?; + Duration::new( + (nanos / 1_000_000_000) as u64, + (nanos % 1_000_000_000) as u32, + ) + } + _ => { + return Err(Error::new( + eyre!("Invalid units for duration"), + crate::ErrorKind::Deserialization, + )) + } + })) + } +} +impl std::fmt::Display for Duration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let nanos = self.as_nanos(); + match () { + _ if nanos % 86_400_000_000_000 == 0 => write!(f, "{}d", nanos / 86_400_000_000_000), + _ if nanos % 3_600_000_000_000 == 0 => write!(f, "{}h", nanos / 3_600_000_000_000), + _ if nanos % 60_000_000_000 == 0 => write!(f, "{}m", nanos / 60_000_000_000), + _ if nanos % 1_000_000_000 == 0 => write!(f, "{}s", nanos / 1_000_000_000), + _ if nanos % 1_000_000 == 0 => write!(f, "{}ms", nanos / 1_000_000), + _ if nanos % 1_000 == 0 => write!(f, "{}µs", nanos / 1_000), + _ => write!(f, "{}ns", nanos), + } + } +} +impl<'de> Deserialize<'de> for Duration { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_from_str(deserializer) + } +} +impl Serialize for Duration { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serialize_display(self, serializer) + } +} + +pub fn deserialize_number_permissive< + 'de, + D: serde::de::Deserializer<'de>, + T: FromStr + num::cast::FromPrimitive, + E: std::fmt::Display, +>( + deserializer: D, +) -> std::result::Result { + struct Visitor + num::cast::FromPrimitive, E>(std::marker::PhantomData); + impl<'de, T: FromStr + num::cast::FromPrimitive, Err: std::fmt::Display> + serde::de::Visitor<'de> for Visitor + { + type Value = T; + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "a parsable string") + } + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + v.parse().map_err(|e| serde::de::Error::custom(e)) + } + fn visit_f64(self, v: f64) -> Result + where + E: serde::de::Error, + { + T::from_f64(v).ok_or_else(|| { + serde::de::Error::custom(format!( + "{} cannot be represented by the requested type", + v + )) + }) + } + fn visit_u64(self, v: u64) -> Result + where + E: serde::de::Error, + { + T::from_u64(v).ok_or_else(|| { + serde::de::Error::custom(format!( + "{} cannot be represented by the requested type", + v + )) + }) + } + fn visit_i64(self, v: i64) -> Result + where + E: serde::de::Error, + { + T::from_i64(v).ok_or_else(|| { + serde::de::Error::custom(format!( + "{} cannot be represented by the requested type", + v + )) + }) + } + } + deserializer.deserialize_str(Visitor(std::marker::PhantomData)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Port(pub u16); +impl<'de> Deserialize<'de> for Port { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + //TODO: if number, be permissive + deserialize_number_permissive(deserializer).map(Port) + } +} +impl Serialize for Port { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serialize_display(&self.0, serializer) + } +} + +#[derive(Debug, Clone)] +pub struct Reversible> +where + for<'a> &'a Container: IntoDoubleEndedIterator<&'a T>, +{ + reversed: bool, + data: Container, + phantom: PhantomData, +} +impl Reversible +where + for<'a> &'a Container: IntoDoubleEndedIterator<&'a T>, +{ + pub fn new(data: Container) -> Self { + Reversible { + reversed: false, + data, + phantom: PhantomData, + } + } + + pub fn reverse(&mut self) { + self.reversed = !self.reversed + } + + pub fn iter( + &self, + ) -> itertools::Either< + <&Container as IntoDoubleEndedIterator<&T>>::IntoIter, + std::iter::Rev<<&Container as IntoDoubleEndedIterator<&T>>::IntoIter>, + > { + let iter = IntoDoubleEndedIterator::into_iter(&self.data); + if self.reversed { + itertools::Either::Right(iter.rev()) + } else { + itertools::Either::Left(iter) + } + } +} +impl Serialize for Reversible +where + for<'a> &'a Container: IntoDoubleEndedIterator<&'a T>, + T: Serialize, +{ + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let iter = IntoDoubleEndedIterator::into_iter(&self.data); + let mut seq_ser = serializer.serialize_seq(match iter.size_hint() { + (lower, Some(upper)) if lower == upper => Some(upper), + _ => None, + })?; + if self.reversed { + for elem in iter.rev() { + seq_ser.serialize_element(elem)?; + } + } else { + for elem in iter { + seq_ser.serialize_element(elem)?; + } + } + seq_ser.end() + } +} +impl<'de, T, Container> Deserialize<'de> for Reversible +where + for<'a> &'a Container: IntoDoubleEndedIterator<&'a T>, + Container: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(Reversible::new(Deserialize::deserialize(deserializer)?)) + } + fn deserialize_in_place(deserializer: D, place: &mut Self) -> Result<(), D::Error> + where + D: Deserializer<'de>, + { + Deserialize::deserialize_in_place(deserializer, &mut place.data) + } +} + +pub struct KeyVal { + pub key: K, + pub value: V, +} +impl Serialize for KeyVal { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry(&self.key, &self.value)?; + map.end() + } +} +impl<'de, K: Deserialize<'de>, V: Deserialize<'de>> Deserialize<'de> for KeyVal { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor(PhantomData<(K, V)>); + impl<'de, K: Deserialize<'de>, V: Deserialize<'de>> serde::de::Visitor<'de> for Visitor { + type Value = KeyVal; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "A map with a single element") + } + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let (key, value) = map + .next_entry()? + .ok_or_else(|| serde::de::Error::invalid_length(0, &"1"))?; + Ok(KeyVal { key, value }) + } + } + deserializer.deserialize_map(Visitor(PhantomData)) + } +} diff --git a/build/fstab b/build/fstab index 53d09249f..2a345b7af 100644 --- a/build/fstab +++ b/build/fstab @@ -1,3 +1,4 @@ LABEL=green / ext4 discard,errors=remount-ro 0 1 LABEL=system-boot /media/boot-rw vfat defaults 0 1 /media/boot-rw /boot/firmware none defaults,bind,ro 0 0 +LABEL=EMBASSY /embassy-os vfat defaults 0 1 diff --git a/build/initialization.sh b/build/initialization.sh index ef4afbda0..991af0a1a 100755 --- a/build/initialization.sh +++ b/build/initialization.sh @@ -18,7 +18,9 @@ apt install -y \ sqlite3 \ wireless-tools \ net-tools \ - ecryptfs-utils + ecryptfs-utils \ + cifs-utils \ + samba-common-bin sed -i 's/"1"/"0"/g' /etc/apt/apt.conf.d/20auto-upgrades sed -i 's/Restart=on-failure/Restart=always/g' /lib/systemd/system/tor@default.service sed -i '/}/i \ \ \ \ application\/wasm \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ wasm;' /etc/nginx/mime.types diff --git a/build/write-image.sh b/build/write-image.sh index 06f847c7f..14fe3a7e1 100755 --- a/build/write-image.sh +++ b/build/write-image.sh @@ -39,6 +39,7 @@ sudo umount /tmp/eos-mnt sudo mount ${OUTPUT_DEVICE}p3 /tmp/eos-mnt sudo mkdir /tmp/eos-mnt/media/boot-rw +sudo mkdir /tmp/eos-mnt/embassy-os sudo cp build/fstab /tmp/eos-mnt/etc/fstab # Enter the appmgr directory, copy over the built EmbassyOS binaries and systemd services, edit the nginx config, then create the .ssh directory cd appmgr/ diff --git a/setup-wizard/README.md b/setup-wizard/README.md index 2463dc259..2857363c7 100644 --- a/setup-wizard/README.md +++ b/setup-wizard/README.md @@ -1,25 +1,31 @@ # Embassy Setup Wizard -## Instructions for running locally +## Development Environment Setup -**Make sure you have git, node, and npm installed** +**Make sure you have git, nvm (node, npm), and rust installed** -Install Ionic +``` +node --version +v16.11.0 -`npm i -g @ionic/cli` +npm --version +v8.0.0 +``` -Clone this repository +### Building Embassy UI `git clone https://github.com/Start9Labs/embassy-os.git` -`cd embassy-os/setup-wizard` +`cd embassy-os` -Install dependencies +`cd setup-wizard/` -`npm i` +`npm --prefix . install @ionic/cli` -Copy `config-sample.json` to new file `config.json` +`npm --prefix . install` -Start the server +Copy `config-sample.json` and contents to new file `config.json` -`ionic serve` \ No newline at end of file +**Start the development server** + +`ionic serve` diff --git a/setup-wizard/src/app/app.module.ts b/setup-wizard/src/app/app.module.ts index 332d28b61..f7fcc5af3 100644 --- a/setup-wizard/src/app/app.module.ts +++ b/setup-wizard/src/app/app.module.ts @@ -14,7 +14,7 @@ import { SuccessPageModule } from './pages/success/success.module' import { InitPageModule } from './pages/init/init.module' import { HomePageModule } from './pages/home/home.module' import { LoadingPageModule } from './pages/loading/loading.module' -import { ProdKeyModalModule } from './pages/prod-key-modal/prod-key-modal.module' +import { ProdKeyModalModule } from './modals/prod-key-modal/prod-key-modal.module' import { ProductKeyPageModule } from './pages/product-key/product-key.module' import { RecoverPageModule } from './pages/recover/recover.module' diff --git a/setup-wizard/src/app/modals/cifs-modal/cifs-modal.module.ts b/setup-wizard/src/app/modals/cifs-modal/cifs-modal.module.ts new file mode 100644 index 000000000..f10455e0c --- /dev/null +++ b/setup-wizard/src/app/modals/cifs-modal/cifs-modal.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core' +import { CommonModule } from '@angular/common' +import { IonicModule } from '@ionic/angular' +import { FormsModule } from '@angular/forms' +import { CifsModal } from './cifs-modal.page' + +@NgModule({ + declarations: [ + CifsModal, + ], + imports: [ + CommonModule, + FormsModule, + IonicModule, + ], + exports: [ + CifsModal, + ], +}) +export class CifsModalModule { } diff --git a/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.html b/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.html new file mode 100644 index 000000000..7b1f37860 --- /dev/null +++ b/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.html @@ -0,0 +1,81 @@ + + + + Connect Shared Folder + + + + + +
+

Hostname *

+ + + +

+ Hostname is required +

+ +

Path *

+ + + +

+ Path is required +

+ +

Username *

+ + + +

+ Username is required +

+ +

Password

+ + + + + +
+
+ + + + + Cancel + + + Verify + + + + diff --git a/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.scss b/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.scss new file mode 100644 index 000000000..ac7528a0e --- /dev/null +++ b/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.scss @@ -0,0 +1,3 @@ +ion-content { + --ion-text-color: var(--ion-color-dark); +} \ No newline at end of file diff --git a/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.ts b/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.ts new file mode 100644 index 000000000..a6c2012dd --- /dev/null +++ b/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.ts @@ -0,0 +1,85 @@ +import { Component } from '@angular/core' +import { AlertController, LoadingController, ModalController } from '@ionic/angular' +import { ApiService, BackupTarget, CifsBackupTarget, EmbassyOSRecoveryInfo } from 'src/app/services/api/api.service' +import { PasswordPage } from '../password/password.page' + +@Component({ + selector: 'cifs-modal', + templateUrl: 'cifs-modal.page.html', + styleUrls: ['cifs-modal.page.scss'], +}) +export class CifsModal { + cifs = { + hostname: '', + path: '', + username: '', + password: '', + } + + constructor ( + private readonly modalController: ModalController, + private readonly apiService: ApiService, + private readonly loadingCtrl: LoadingController, + private readonly alertCtrl: AlertController, + ) { } + + cancel () { + this.modalController.dismiss() + } + + async submit (): Promise { + const loader = await this.loadingCtrl.create({ + spinner: 'lines', + message: 'Connecting to shared folder...', + cssClass: 'loader', + }) + await loader.present() + + try { + const embassyOS = await this.apiService.verifyCifs(this.cifs) + this.presentModalPassword(embassyOS) + } catch (e) { + this.presentAlertFailed() + } finally { + loader.dismiss() + } + } + + private async presentModalPassword (embassyOS: EmbassyOSRecoveryInfo): Promise { + const target: CifsBackupTarget = { + type: 'cifs', + ...this.cifs, + mountable: true, + 'embassy-os': embassyOS, + } + + const modal = await this.modalController.create({ + component: PasswordPage, + componentProps: { target }, + cssClass: 'alertlike-modal', + }) + modal.onDidDismiss().then(res => { + if (res.role === 'success') { + this.modalController.dismiss({ + cifs: this.cifs, + recoveryPassword: res.data.password, + }, 'success') + } + }) + await modal.present() + } + + private async presentAlertFailed (): Promise { + const alert = await this.alertCtrl.create({ + header: 'Connection Failed', + message: 'Unable to connect to shared folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.', + buttons: ['OK'], + }) + alert.present() + } +} + +interface MappedCifs { + hasValidBackup: boolean + cifs: CifsBackupTarget +} diff --git a/setup-wizard/src/app/pages/password/password.module.ts b/setup-wizard/src/app/modals/password/password.module.ts similarity index 71% rename from setup-wizard/src/app/pages/password/password.module.ts rename to setup-wizard/src/app/modals/password/password.module.ts index 3bbfaa569..416c558f5 100644 --- a/setup-wizard/src/app/pages/password/password.module.ts +++ b/setup-wizard/src/app/modals/password/password.module.ts @@ -4,16 +4,17 @@ import { IonicModule } from '@ionic/angular' import { FormsModule } from '@angular/forms' import { PasswordPage } from './password.page' -import { PasswordPageRoutingModule } from './password-routing.module' - - @NgModule({ + declarations: [ + PasswordPage, + ], imports: [ CommonModule, FormsModule, IonicModule, - PasswordPageRoutingModule, ], - declarations: [PasswordPage], + exports: [ + PasswordPage, + ], }) export class PasswordPageModule { } diff --git a/setup-wizard/src/app/pages/password/password.page.html b/setup-wizard/src/app/modals/password/password.page.html similarity index 87% rename from setup-wizard/src/app/pages/password/password.page.html rename to setup-wizard/src/app/modals/password/password.page.html index e4d177214..22b9b8cba 100644 --- a/setup-wizard/src/app/pages/password/password.page.html +++ b/setup-wizard/src/app/modals/password/password.page.html @@ -17,13 +17,8 @@
-

- Password: -

- +

Password

+ {{ pwError }}

-

- Confirm Password: -

- +

Confirm Password

+

Verify the product key for the chosen recovery drive.

- + - - + +
@@ -14,29 +14,19 @@ - -

No drives found

-

Please connect a storage drive to your Embassy and refresh the page.

- - Refresh - -
+ + - - - - - - - - - - - + + + +

No drives found

+

Please connect an storage drive to your Embassy and click "Refresh".

- - - + + + +

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

{{ drive.logicalname }} - {{ drive.capacity | convertBytes }}

@@ -47,8 +37,8 @@

-
-
+ +
diff --git a/setup-wizard/src/app/pages/embassy/embassy.page.ts b/setup-wizard/src/app/pages/embassy/embassy.page.ts index a79612913..a4036a917 100644 --- a/setup-wizard/src/app/pages/embassy/embassy.page.ts +++ b/setup-wizard/src/app/pages/embassy/embassy.page.ts @@ -1,9 +1,9 @@ import { Component } from '@angular/core' import { AlertController, LoadingController, ModalController, NavController } from '@ionic/angular' -import { ApiService, DiskInfo } from 'src/app/services/api/api.service' +import { ApiService, DiskInfo, DiskRecoverySource } from 'src/app/services/api/api.service' import { ErrorToastService } from 'src/app/services/error-toast.service' import { StateService } from 'src/app/services/state.service' -import { PasswordPage } from '../password/password.page' +import { PasswordPage } from '../../modals/password/password.page' @Component({ selector: 'app-embassy', @@ -11,8 +11,7 @@ import { PasswordPage } from '../password/password.page' styleUrls: ['embassy.page.scss'], }) export class EmbassyPage { - storageDrives = [] - selectedDrive: DiskInfo = null + storageDrives: DiskInfo[] = [] loading = true constructor ( @@ -30,15 +29,14 @@ export class EmbassyPage { } async refresh () { - this.storageDrives = [] - this.selectedDrive = null this.loading = true await this.getDrives() } async getDrives () { try { - this.storageDrives = (await this.apiService.getDrives()).filter(d => !d.partitions.map(p => p.logicalname).includes(this.stateService.recoveryPartition?.logicalname)) + const drives = await this.apiService.getDrives() + this.storageDrives = drives.filter(d => !d.partitions.map(p => p.logicalname).includes((this.stateService.recoverySource as DiskRecoverySource)?.logicalname)) } catch (e) { this.errorToastService.present(e.message) } finally { @@ -60,14 +58,22 @@ export class EmbassyPage { { text: 'Continue', handler: () => { - this.presentModalPassword(drive) + if (this.stateService.recoveryPassword) { + this.setupEmbassy(drive, this.stateService.recoveryPassword) + } else { + this.presentModalPassword(drive) + } }, }, ], }) await alert.present() } else { - this.presentModalPassword(drive) + if (this.stateService.recoveryPassword) { + this.setupEmbassy(drive, this.stateService.recoveryPassword) + } else { + this.presentModalPassword(drive) + } } } @@ -80,31 +86,30 @@ export class EmbassyPage { }) modal.onDidDismiss().then(async ret => { if (!ret.data || !ret.data.password) return - - const loader = await this.loadingCtrl.create({ - message: 'Transferring encrypted data', - }) - - await loader.present() - - this.stateService.storageDrive = drive - this.stateService.embassyPassword = ret.data.password - - try { - await this.stateService.setupEmbassy() - if (!!this.stateService.recoveryPartition) { - await this.navCtrl.navigateForward(`/loading`) - } else { - await this.navCtrl.navigateForward(`/init`) - } - } catch (e) { - this.errorToastService.present(`${e.message}: ${e.details}`) - console.error(e.message) - console.error(e.details) - } finally { - loader.dismiss() - } + this.setupEmbassy(drive, ret.data.password) }) await modal.present() } + + private async setupEmbassy (drive: DiskInfo, password: string): Promise { + const loader = await this.loadingCtrl.create({ + message: 'Transferring encrypted data. This could take a while...', + }) + + await loader.present() + + try { + await this.stateService.setupEmbassy(drive.logicalname, password) + if (!!this.stateService.recoverySource) { + await this.navCtrl.navigateForward(`/loading`) + } else { + await this.navCtrl.navigateForward(`/init`) + } + } catch (e) { + this.errorToastService.present(`${e.message}: ${e.details}. Restart Embassy to try again.`) + console.error(e) + } finally { + loader.dismiss() + } + } } diff --git a/setup-wizard/src/app/pages/home/home.module.ts b/setup-wizard/src/app/pages/home/home.module.ts index 517799bdb..e1ab32d7e 100644 --- a/setup-wizard/src/app/pages/home/home.module.ts +++ b/setup-wizard/src/app/pages/home/home.module.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common' import { IonicModule } from '@ionic/angular' import { FormsModule } from '@angular/forms' import { HomePage } from './home.page' -import { PasswordPageModule } from '../password/password.module' +import { PasswordPageModule } from '../../modals/password/password.module' import { HomePageRoutingModule } from './home-routing.module' diff --git a/setup-wizard/src/app/pages/home/home.page.html b/setup-wizard/src/app/pages/home/home.page.html index e2ab9a9d0..0ff1bb5c4 100644 --- a/setup-wizard/src/app/pages/home/home.page.html +++ b/setup-wizard/src/app/pages/home/home.page.html @@ -1,6 +1,6 @@ - - + +
diff --git a/setup-wizard/src/app/pages/init/init.page.html b/setup-wizard/src/app/pages/init/init.page.html index bb97e87e5..fff8d7e83 100644 --- a/setup-wizard/src/app/pages/init/init.page.html +++ b/setup-wizard/src/app/pages/init/init.page.html @@ -1,6 +1,6 @@ - - + +
diff --git a/setup-wizard/src/app/pages/init/init.page.ts b/setup-wizard/src/app/pages/init/init.page.ts index cad4b99ea..1a250a650 100644 --- a/setup-wizard/src/app/pages/init/init.page.ts +++ b/setup-wizard/src/app/pages/init/init.page.ts @@ -1,6 +1,7 @@ import { Component } from '@angular/core' -import { interval, Observable, Subscription } from 'rxjs' -import { delay, finalize, take, tap } from 'rxjs/operators' +import { interval, Subscription } from 'rxjs' +import { finalize, take, tap } from 'rxjs/operators' +import { ApiService } from 'src/app/services/api/api.service' import { StateService } from 'src/app/services/state.service' @Component({ @@ -9,14 +10,18 @@ import { StateService } from 'src/app/services/state.service' styleUrls: ['init.page.scss'], }) export class InitPage { - progress: number + progress = 0 sub: Subscription constructor ( + private readonly apiService: ApiService, public readonly stateService: StateService, ) { } ngOnInit () { + // call setup.complete to tear down embassy.local and spin up embassy-[id].local + this.apiService.setupComplete() + this.sub = interval(130) .pipe( take(101), diff --git a/setup-wizard/src/app/pages/loading/loading.page.html b/setup-wizard/src/app/pages/loading/loading.page.html index e26e1032c..288f0ce6a 100644 --- a/setup-wizard/src/app/pages/loading/loading.page.html +++ b/setup-wizard/src/app/pages/loading/loading.page.html @@ -1,6 +1,6 @@ - - + +
diff --git a/setup-wizard/src/app/pages/loading/loading.page.ts b/setup-wizard/src/app/pages/loading/loading.page.ts index 84b046889..9803c4405 100644 --- a/setup-wizard/src/app/pages/loading/loading.page.ts +++ b/setup-wizard/src/app/pages/loading/loading.page.ts @@ -15,10 +15,10 @@ export class LoadingPage { ngOnInit () { this.stateService.pollDataTransferProgress() - const progSub = this.stateService.dataProgSubject.subscribe(async progress => { - if (progress === 1) { + const progSub = this.stateService.dataCompletionSubject.subscribe(async complete => { + if (complete) { progSub.unsubscribe() - await this.navCtrl.navigateForward(`/success`) + await this.navCtrl.navigateForward(`/init`) } }) } diff --git a/setup-wizard/src/app/pages/password/password-routing.module.ts b/setup-wizard/src/app/pages/password/password-routing.module.ts deleted file mode 100644 index e5c24ed74..000000000 --- a/setup-wizard/src/app/pages/password/password-routing.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NgModule } from '@angular/core' -import { RouterModule, Routes } from '@angular/router' -import { PasswordPage } from './password.page' - -const routes: Routes = [ - { - path: '', - component: PasswordPage, - }, -] - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule], -}) -export class PasswordPageRoutingModule { } diff --git a/setup-wizard/src/app/pages/password/password.page.scss b/setup-wizard/src/app/pages/password/password.page.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/setup-wizard/src/app/pages/prod-key-modal/prod-key-modal-routing.module.ts b/setup-wizard/src/app/pages/prod-key-modal/prod-key-modal-routing.module.ts deleted file mode 100644 index cfc050421..000000000 --- a/setup-wizard/src/app/pages/prod-key-modal/prod-key-modal-routing.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NgModule } from '@angular/core' -import { RouterModule, Routes } from '@angular/router' -import { ProdKeyModal } from './prod-key-modal.page' - -const routes: Routes = [ - { - path: '', - component: ProdKeyModal, - }, -] - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule], -}) -export class ProdKeyModalRoutingModule { } diff --git a/setup-wizard/src/app/pages/prod-key-modal/prod-key-modal.page.scss b/setup-wizard/src/app/pages/prod-key-modal/prod-key-modal.page.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/setup-wizard/src/app/pages/product-key/product-key.module.ts b/setup-wizard/src/app/pages/product-key/product-key.module.ts index 47750eced..fc5680353 100644 --- a/setup-wizard/src/app/pages/product-key/product-key.module.ts +++ b/setup-wizard/src/app/pages/product-key/product-key.module.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common' import { IonicModule } from '@ionic/angular' import { FormsModule } from '@angular/forms' import { ProductKeyPage } from './product-key.page' -import { PasswordPageModule } from '../password/password.module' +import { PasswordPageModule } from '../../modals/password/password.module' import { ProductKeyPageRoutingModule } from './product-key-routing.module' @NgModule({ diff --git a/setup-wizard/src/app/pages/product-key/product-key.page.html b/setup-wizard/src/app/pages/product-key/product-key.page.html index 045bdeb95..10b2ad82f 100644 --- a/setup-wizard/src/app/pages/product-key/product-key.page.html +++ b/setup-wizard/src/app/pages/product-key/product-key.page.html @@ -1,6 +1,6 @@ - - + +
@@ -8,14 +8,14 @@
- + Enter Product Key -

Product Key

+

Product Key

+

+ + Embassy backup detected +

+

+ + No Embassy backup +

+
\ No newline at end of file diff --git a/setup-wizard/src/app/pages/recover/recover.module.ts b/setup-wizard/src/app/pages/recover/recover.module.ts index 2ea24b4a7..a1f46afd8 100644 --- a/setup-wizard/src/app/pages/recover/recover.module.ts +++ b/setup-wizard/src/app/pages/recover/recover.module.ts @@ -2,14 +2,15 @@ import { NgModule } from '@angular/core' import { CommonModule } from '@angular/common' import { IonicModule } from '@ionic/angular' import { FormsModule } from '@angular/forms' -import { RecoverPage } from './recover.page' -import { PasswordPageModule } from '../password/password.module' -import { ProdKeyModalModule } from '../prod-key-modal/prod-key-modal.module' +import { DriveStatusComponent, RecoverPage } from './recover.page' +import { PasswordPageModule } from '../../modals/password/password.module' +import { ProdKeyModalModule } from '../../modals/prod-key-modal/prod-key-modal.module' import { RecoverPageRoutingModule } from './recover-routing.module' import { PipesModule } from 'src/app/pipes/pipe.module' - +import { CifsModalModule } from 'src/app/modals/cifs-modal/cifs-modal.module' @NgModule({ + declarations: [RecoverPage, DriveStatusComponent], imports: [ CommonModule, FormsModule, @@ -18,7 +19,7 @@ import { PipesModule } from 'src/app/pipes/pipe.module' PasswordPageModule, ProdKeyModalModule, PipesModule, + CifsModalModule, ], - declarations: [RecoverPage], }) export class RecoverPageModule { } diff --git a/setup-wizard/src/app/pages/recover/recover.page.html b/setup-wizard/src/app/pages/recover/recover.page.html index 2f2e25319..efaae27fe 100644 --- a/setup-wizard/src/app/pages/recover/recover.page.html +++ b/setup-wizard/src/app/pages/recover/recover.page.html @@ -1,6 +1,6 @@ - - + +
@@ -9,66 +9,53 @@ - Select Recovery Drive - Select the drive containing the Embassy you want to recover. + Restore from Backup + Select the shared folder or physical drive containing your Embassy backup - - -

No recovery drives found

-

Please connect a recovery drive to your Embassy and refresh the page.

- - Refresh - -
+ - - - - - - - + + + +

+ Shared Network Folder +

+

+ Using a shared folder is the recommended way to recover from backup, since it works with all Embassy hardware configurations. + To restore from a shared folder, please follow the instructions. +

+ + + + + Open Shared Folder + + +
+
+ + +

+ Physical Drives +

+

+ Warning! Plugging in more than one physical drive to Embassy can lead to power failure and data corruption. + To restore from a physical drive, please follow the instructions. +

+ + + + - - - +

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

+ +

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

+

Capacity: {{ drive.capacity | convertBytes }}

- - -
-

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

- - - -

{{ partition.label || partition.logicalname }}

-

{{ partition.capacity | convertBytes }}

-

- - Embassy backup detected - -

-

- - No Embassy backup detected - -

-
-
- - -
-
-
diff --git a/setup-wizard/src/app/pages/recover/recover.page.scss b/setup-wizard/src/app/pages/recover/recover.page.scss index 3c60ba1c0..687b91ecf 100644 --- a/setup-wizard/src/app/pages/recover/recover.page.scss +++ b/setup-wizard/src/app/pages/recover/recover.page.scss @@ -1,20 +1,4 @@ -.selected { - border: 4px solid var(--ion-color-secondary); - box-shadow: 4px 4px 16px var(--ion-color-light); -} - -.drive-label { +.target-label { font-weight: bold; padding-bottom: 6px; } - -.skeleton-header { - width: 180px; - height: 18px; - --ion-text-color-rgb: var(--ion-color-light-rgb); - margin-bottom: 6px; -} - -ion-item { - padding-bottom: 6px; -} diff --git a/setup-wizard/src/app/pages/recover/recover.page.ts b/setup-wizard/src/app/pages/recover/recover.page.ts index 6eadb37bb..ce6cf7303 100644 --- a/setup-wizard/src/app/pages/recover/recover.page.ts +++ b/setup-wizard/src/app/pages/recover/recover.page.ts @@ -1,10 +1,12 @@ -import { Component } from '@angular/core' +import { Component, Input } from '@angular/core' import { AlertController, LoadingController, ModalController, NavController } from '@ionic/angular' -import { ApiService, DiskInfo, PartitionInfo } from 'src/app/services/api/api.service' +import { CifsModal } from 'src/app/modals/cifs-modal/cifs-modal.page' +import { ApiService, CifsBackupTarget, DiskBackupTarget, DiskRecoverySource, RecoverySource } from 'src/app/services/api/api.service' import { ErrorToastService } from 'src/app/services/error-toast.service' import { StateService } from 'src/app/services/state.service' -import { PasswordPage } from '../password/password.page' -import { ProdKeyModal } from '../prod-key-modal/prod-key-modal.page' +import { MappedDisk } from 'src/app/util/misc.util' +import { PasswordPage } from '../../modals/password/password.page' +import { ProdKeyModal } from '../../modals/prod-key-modal/prod-key-modal.page' @Component({ selector: 'app-recover', @@ -12,14 +14,14 @@ import { ProdKeyModal } from '../prod-key-modal/prod-key-modal.page' styleUrls: ['recover.page.scss'], }) export class RecoverPage { - selectedPartition: PartitionInfo = null loading = true - drives: DiskInfo[] = [] + driveTargets: MappedDisk[] = [] hasShownGuidAlert = false constructor ( private readonly apiService: ApiService, private readonly navCtrl: NavController, + private readonly modalCtrl: ModalController, private readonly modalController: ModalController, private readonly alertCtrl: AlertController, private readonly loadingCtrl: LoadingController, @@ -32,25 +34,43 @@ export class RecoverPage { } async refresh () { - this.selectedPartition = null this.loading = true await this.getDrives() } - partitionClickable (partition: PartitionInfo) { - return partition['embassy-os']?.full && (this.stateService.hasProductKey || this.is02x(partition)) + driveClickable (drive: DiskBackupTarget) { + return drive['embassy-os']?.full && (this.stateService.hasProductKey || this.is02x(drive)) } async getDrives () { + this.driveTargets = [] try { const drives = await this.apiService.getDrives() - this.drives = drives.filter(d => d.partitions.length) + drives.filter(d => d.partitions.length).forEach(d => { + d.partitions.forEach(p => { + this.driveTargets.push( + { + hasValidBackup: p['embassy-os']?.full, + drive: { + type: 'disk', + vendor: d.vendor, + model: d.model, + logicalname: p.logicalname, + label: p.label, + capacity: p.capacity, + used: p.used, + 'embassy-os': p['embassy-os'], + }, + }, + ) + }) + }) const importableDrive = drives.find(d => !!d.guid) if (!!importableDrive && !this.hasShownGuidAlert) { const alert = await this.alertCtrl.create({ header: 'Embassy Drive Detected', - message: 'A valid EmbassyOS data drive has been detected. To use this drive in its current state, simply click "Use Drive" below.', + message: 'A valid EmbassyOS data drive has been detected. To use this drive as-is, simply click "Use Drive" below.', buttons: [ { role: 'cancel', @@ -74,14 +94,69 @@ export class RecoverPage { } } - async importDrive (guid: string) { + async presentModalCifs (): Promise { + const modal = await this.modalCtrl.create({ + component: CifsModal, + }) + modal.onDidDismiss().then(res => { + if (res.role === 'success') { + const { hostname, path, username, password } = res.data.cifs + this.stateService.recoverySource = { + type: 'cifs', + hostname, + path, + username, + password, + } + this.stateService.recoveryPassword = res.data.recoveryPassword + this.navCtrl.navigateForward('/embassy') + } + }) + await modal.present() + } + + async select (target: DiskBackupTarget) { + if (target['embassy-os'].version.startsWith('0.2')) { + return this.selectRecoverySource(target.logicalname) + } + + if (this.stateService.hasProductKey) { + const modal = await this.modalController.create({ + component: PasswordPage, + componentProps: { target }, + cssClass: 'alertlike-modal', + }) + modal.onDidDismiss().then(res => { + if (res.data && res.data.password) { + this.selectRecoverySource(target.logicalname, res.data.password) + } + }) + await modal.present() + // if no product key, it means they are an upgrade kit user + } else { + const modal = await this.modalController.create({ + component: ProdKeyModal, + componentProps: { target }, + cssClass: 'alertlike-modal', + }) + modal.onDidDismiss().then(res => { + if (res.data && res.data.productKey) { + this.selectRecoverySource(target.logicalname) + } + + }) + await modal.present() + } + } + + private async importDrive (guid: string) { const loader = await this.loadingCtrl.create({ message: 'Importing Drive', }) await loader.present() try { await this.stateService.importDrive(guid) - await this.navCtrl.navigateForward(`/success`) + await this.navCtrl.navigateForward(`/init`) } catch (e) { this.errorToastService.present(`${e.message}: ${e.data}`) } finally { @@ -89,60 +164,26 @@ export class RecoverPage { } } - async choosePartition (partition: PartitionInfo) { - this.selectedPartition = partition - - if (partition['embassy-os'].version.startsWith('0.2')) { - return this.selectRecoveryPartition() - } - - if (this.stateService.hasProductKey) { - const modal = await this.modalController.create({ - component: PasswordPage, - componentProps: { - recoveryPartition: this.selectedPartition, - }, - cssClass: 'alertlike-modal', - }) - modal.onDidDismiss().then(async ret => { - if (!ret.data) { - this.selectedPartition = null - } else if (ret.data.password) { - this.selectRecoveryPartition(ret.data.password) - } - - }) - await modal.present() - // if no product key, it means they are an upgrade kit user - } else { - const modal = await this.modalController.create({ - component: ProdKeyModal, - componentProps: { - recoveryPartition: this.selectedPartition, - }, - cssClass: 'alertlike-modal', - }) - modal.onDidDismiss().then(async ret => { - if (!ret.data) { - this.selectedPartition = null - } else if (ret.data.productKey) { - this.selectRecoveryPartition() - } - - }) - await modal.present() + private async selectRecoverySource (logicalname: string, password?: string) { + this.stateService.recoverySource = { + type: 'disk', + logicalname, } + this.stateService.recoveryPassword = password + this.navCtrl.navigateForward(`/embassy`) } - async selectRecoveryPartition (password?: string) { - this.stateService.recoveryPartition = this.selectedPartition - if (password) { - this.stateService.recoveryPassword = password - } - await this.navCtrl.navigateForward(`/embassy`) - } - - private is02x (partition: PartitionInfo): boolean { - return !this.stateService.hasProductKey && partition['embassy-os']?.version.startsWith('0.2') + private is02x (drive: DiskBackupTarget): boolean { + return !this.stateService.hasProductKey && drive['embassy-os']?.version.startsWith('0.2') } } + + +@Component({ + selector: 'drive-status', + templateUrl: './drive-status.component.html', + styleUrls: ['./recover.page.scss'], +}) +export class DriveStatusComponent { + @Input() hasValidBackup: boolean +} diff --git a/setup-wizard/src/app/pages/success/success-routing.module.ts b/setup-wizard/src/app/pages/success/success-routing.module.ts deleted file mode 100644 index de04b5517..000000000 --- a/setup-wizard/src/app/pages/success/success-routing.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NgModule } from '@angular/core' -import { RouterModule, Routes } from '@angular/router' -import { SuccessPage } from './success.page' - -const routes: Routes = [ - { - path: '', - component: SuccessPage, - }, -] - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule], -}) -export class SuccessPageRoutingModule { } diff --git a/setup-wizard/src/app/pages/success/success.module.ts b/setup-wizard/src/app/pages/success/success.module.ts index 38820b476..a00510e8d 100644 --- a/setup-wizard/src/app/pages/success/success.module.ts +++ b/setup-wizard/src/app/pages/success/success.module.ts @@ -3,17 +3,13 @@ import { CommonModule } from '@angular/common' import { IonicModule } from '@ionic/angular' import { FormsModule } from '@angular/forms' import { SuccessPage } from './success.page' -import { PasswordPageModule } from '../password/password.module' - -import { SuccessPageRoutingModule } from './success-routing.module' - +import { PasswordPageModule } from '../../modals/password/password.module' @NgModule({ imports: [ CommonModule, FormsModule, IonicModule, - SuccessPageRoutingModule, PasswordPageModule, ], declarations: [SuccessPage], diff --git a/setup-wizard/src/app/pages/success/success.page.html b/setup-wizard/src/app/pages/success/success.page.html index 1b4b5b3b6..1f57efc09 100644 --- a/setup-wizard/src/app/pages/success/success.page.html +++ b/setup-wizard/src/app/pages/success/success.page.html @@ -31,7 +31,7 @@ For a list of recommended browsers, click here.


-

Tor Address

+

Tor Address

{{ stateService.torAddress }} @@ -81,7 +81,7 @@ -

LAN Address

+

LAN Address

{{ stateService.lanAddress }} diff --git a/setup-wizard/src/app/services/api/api.service.ts b/setup-wizard/src/app/services/api/api.service.ts index 02e41f00e..93edc91e4 100644 --- a/setup-wizard/src/app/services/api/api.service.ts +++ b/setup-wizard/src/app/services/api/api.service.ts @@ -6,10 +6,11 @@ export abstract class ApiService { abstract getRecoveryStatus (): Promise // setup.recovery.status // encrypted + abstract verifyCifs (cifs: VerifyCifs): Promise // setup.cifs.verify abstract verifyProductKey (): Promise // echo - throws error if invalid - abstract verify03XPassword (logicalname: string, password: string): Promise // setup.recovery.test-password abstract importDrive (guid: string): Promise // setup.execute abstract setupEmbassy (setupInfo: SetupEmbassyReq): Promise // setup.execute + abstract setupComplete (): Promise // setup.complete } export interface GetStatusRes { @@ -17,11 +18,13 @@ export interface GetStatusRes { migrating: boolean } +export type VerifyCifs = Omit + export interface SetupEmbassyReq { 'embassy-logicalname': string 'embassy-password': string - 'recovery-partition'?: PartitionInfo - 'recovery-password'?: string + 'recovery-source': RecoverySource | null + 'recovery-password': string | null } export interface SetupEmbassyRes { @@ -30,6 +33,50 @@ export interface SetupEmbassyRes { 'root-ca': string } +export type BackupTarget = DiskBackupTarget | CifsBackupTarget + +export interface EmbassyOSRecoveryInfo { + version: string + full: boolean + 'password-hash': string | null + 'wrapped-key': string | null +} + +export interface DiskBackupTarget { + type: 'disk' + vendor: string | null + model: string | null + logicalname: string | null + label: string | null + capacity: number + used: number | null + 'embassy-os': EmbassyOSRecoveryInfo | null +} + +export interface CifsBackupTarget { + type: 'cifs' + hostname: string + path: string + username: string + mountable: boolean + 'embassy-os': EmbassyOSRecoveryInfo | null +} + +export type RecoverySource = DiskRecoverySource | CifsRecoverySource + +export interface DiskRecoverySource { + type: 'disk' + logicalname: string // partition logicalname +} + +export interface CifsRecoverySource { + type: 'cifs' + hostname: string + path: string + username: string + password: string | null +} + export interface DiskInfo { logicalname: string, vendor: string | null, @@ -42,6 +89,7 @@ export interface DiskInfo { export interface RecoveryStatusRes { 'bytes-transferred': number 'total-bytes': number + complete: boolean } export interface PartitionInfo { @@ -49,11 +97,5 @@ export interface PartitionInfo { label: string | null, capacity: number, used: number | null, - 'embassy-os': EmbassyOsRecoveryInfo | null, -} - -export interface EmbassyOsRecoveryInfo { - version: string, - full: boolean, // contains full embassy backup - 'password-hash': string | null, // null for 0.2.x + 'embassy-os': EmbassyOSRecoveryInfo | null, } diff --git a/setup-wizard/src/app/services/api/http.service.ts b/setup-wizard/src/app/services/api/http.service.ts index 1569b8b3f..35a15ac18 100644 --- a/setup-wizard/src/app/services/api/http.service.ts +++ b/setup-wizard/src/app/services/api/http.service.ts @@ -151,7 +151,6 @@ export enum Method { export interface RPCOptions { method: string - // @TODO what are valid params? object, bool? params?: { [param: string]: string | number | boolean | object | string[] | number[]; } diff --git a/setup-wizard/src/app/services/api/live-api.service.ts b/setup-wizard/src/app/services/api/live-api.service.ts index c9d05dd78..b54aee034 100644 --- a/setup-wizard/src/app/services/api/live-api.service.ts +++ b/setup-wizard/src/app/services/api/live-api.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core' -import { ApiService, DiskInfo, GetStatusRes, RecoveryStatusRes, SetupEmbassyReq, SetupEmbassyRes } from './api.service' +import { ApiService, DiskInfo, EmbassyOSRecoveryInfo, GetStatusRes, RecoverySource, RecoveryStatusRes, SetupEmbassyReq, SetupEmbassyRes, VerifyCifs } from './api.service' import { HttpService } from './http.service' @Injectable({ @@ -43,6 +43,14 @@ export class LiveApiService extends ApiService { // ** ENCRYPTED ** + async verifyCifs (params: VerifyCifs) { + params.path = params.path.replace('/\\/g', '/') + return this.http.rpcRequest({ + method: 'setup.cifs.verify', + params, + }) + } + async verifyProductKey () { return this.http.rpcRequest({ method: 'echo', @@ -50,13 +58,6 @@ export class LiveApiService extends ApiService { }) } - async verify03XPassword (logicalname: string, password: string) { - return this.http.rpcRequest({ - method: 'setup.recovery.test-password', - params: { logicalname, password }, - }) - } - async importDrive (guid: string) { const res = await this.http.rpcRequest({ method: 'setup.attach', @@ -70,6 +71,10 @@ export class LiveApiService extends ApiService { } async setupEmbassy (setupInfo: SetupEmbassyReq) { + if (setupInfo['recovery-source'].type === 'cifs') { + setupInfo['recovery-source'].path = setupInfo['recovery-source'].path.replace('/\\/g', '/') + } + const res = await this.http.rpcRequest({ method: 'setup.execute', params: setupInfo as any, @@ -80,4 +85,11 @@ export class LiveApiService extends ApiService { 'root-ca': btoa(res['root-ca']), } } + + async setupComplete () { + await this.http.rpcRequest({ + method: 'setup.complete', + params: { }, + }) + } } diff --git a/setup-wizard/src/app/services/api/mock-api.service.ts b/setup-wizard/src/app/services/api/mock-api.service.ts index 96747e808..c667e16ab 100644 --- a/setup-wizard/src/app/services/api/mock-api.service.ts +++ b/setup-wizard/src/app/services/api/mock-api.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core' import { pauseFor } from 'src/app/util/misc.util' -import { ApiService, SetupEmbassyReq } from './api.service' +import { ApiService, RecoverySource, SetupEmbassyReq, VerifyCifs } from './api.service' let tries = 0 @@ -27,8 +27,8 @@ export class MockApiService extends ApiService { await pauseFor(1000) return [ { - vendor: 'Vendor', - model: 'Model', + vendor: 'Samsung', + model: 'SATA', logicalname: '/dev/sda', guid: 'theguid', partitions: [ @@ -50,16 +50,16 @@ export class MockApiService extends ApiService { capacity: 150000, }, { - vendor: 'Vendor', - model: 'Model', + vendor: 'Samsung', + model: null, logicalname: 'dev/sdb', partitions: [], capacity: 34359738369, guid: null, }, { - vendor: 'Vendor', - model: 'Model', + vendor: 'Crucial', + model: 'MX500', logicalname: 'dev/sdc', guid: null, partitions: [ @@ -72,6 +72,7 @@ export class MockApiService extends ApiService { version: '0.3.3', full: true, 'password-hash': 'asdfasdfasdf', + 'wrapped-key': '', }, }, { @@ -84,6 +85,7 @@ export class MockApiService extends ApiService { full: true, // password is 'asdfasdf' 'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + 'wrapped-key': '', }, }, { @@ -95,14 +97,15 @@ export class MockApiService extends ApiService { version: '0.3.3', full: false, 'password-hash': 'asdfasdfasdf', + 'wrapped-key': '', }, }, ], capacity: 100000, }, { - vendor: 'Vendor', - model: 'Model', + vendor: 'Sandisk', + model: null, logicalname: '/dev/sdd', guid: null, partitions: [ @@ -115,6 +118,7 @@ export class MockApiService extends ApiService { version: '0.2.7', full: true, 'password-hash': 'asdfasdfasdf', + 'wrapped-key': '', }, }, ], @@ -133,21 +137,27 @@ export class MockApiService extends ApiService { return { 'bytes-transferred': tries, 'total-bytes': 4, + complete: tries === 4 } } // ** ENCRYPTED ** + async verifyCifs (params: VerifyCifs) { + await pauseFor(1000) + return { + version: '0.3.0', + full: true, + 'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + 'wrapped-key': '', + } + } + async verifyProductKey () { await pauseFor(1000) return } - async verify03XPassword (logicalname: string, password: string) { - await pauseFor(2000) - return password.length > 8 - } - async importDrive (guid: string) { await pauseFor(3000) return setupRes @@ -158,25 +168,13 @@ export class MockApiService extends ApiService { return setupRes } - async getRecoveryDrives () { - await pauseFor(2000) - return [ - { - logicalname: 'Name1', - version: '0.3.3', - name: 'My Embassy', - }, - { - logicalname: 'Name2', - version: '0.2.7', - name: 'My Embassy', - }, - ] + async setupComplete () { + await pauseFor(1000) } } const rootCA = -`-----BEGIN CERTIFICATE----- + `-----BEGIN CERTIFICATE----- MIIDpzCCAo+gAwIBAgIRAIIuOarlQETlUQEOZJGZYdIwDQYJKoZIhvcNAQELBQAw bTELMAkGA1UEBhMCVVMxFTATBgNVBAoMDEV4YW1wbGUgQ29ycDEOMAwGA1UECwwF U2FsZXMxCzAJBgNVBAgMAldBMRgwFgYDVQQDDA93d3cuZXhhbXBsZS5jb20xEDAO diff --git a/setup-wizard/src/app/services/state.service.ts b/setup-wizard/src/app/services/state.service.ts index 4702cbd09..1bc814a74 100644 --- a/setup-wizard/src/app/services/state.service.ts +++ b/setup-wizard/src/app/services/state.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core' import { BehaviorSubject } from 'rxjs' -import { ApiService, DiskInfo, PartitionInfo } from './api/api.service' +import { ApiService, RecoverySource } from './api/api.service' import { ErrorToastService } from './error-toast.service' import { pauseFor } from '../util/misc.util' @@ -14,13 +14,12 @@ export class StateService { polling = false embassyLoaded = false - storageDrive: DiskInfo - embassyPassword: string - recoveryPartition: PartitionInfo + recoverySource: RecoverySource recoveryPassword: string - dataTransferProgress: { bytesTransferred: number; totalBytes: number } | null + + dataTransferProgress: { bytesTransferred: number, totalBytes: number, complete: boolean } | null dataProgress = 0 - dataProgSubject = new BehaviorSubject(this.dataProgress) + dataCompletionSubject = new BehaviorSubject(false) torAddress: string lanAddress: string @@ -33,47 +32,48 @@ export class StateService { async pollDataTransferProgress () { this.polling = true - await pauseFor(1000) + await pauseFor(500) if ( - this.dataTransferProgress?.totalBytes && - this.dataTransferProgress.bytesTransferred === this.dataTransferProgress.totalBytes - ) return + this.dataTransferProgress?.complete + ) { + this.dataCompletionSubject.next(true) + return + } let progress try { progress = await this.apiService.getRecoveryStatus() } catch (e) { - this.errorToastService.present(`${e.message}: ${e.details}`) + this.errorToastService.present(`${e.message}: ${e.details}.\nRestart Embassy to try again.`) } if (progress) { this.dataTransferProgress = { bytesTransferred: progress['bytes-transferred'], totalBytes: progress['total-bytes'], + complete: progress.complete, } if (this.dataTransferProgress.totalBytes) { this.dataProgress = this.dataTransferProgress.bytesTransferred / this.dataTransferProgress.totalBytes - this.dataProgSubject.next(this.dataProgress) } } - this.pollDataTransferProgress() + setTimeout(() => this.pollDataTransferProgress(), 0) // prevent call stack from growing } - - async importDrive (guid: string) : Promise { + async importDrive (guid: string): Promise { const ret = await this.apiService.importDrive(guid) this.torAddress = ret['tor-address'] this.lanAddress = ret['lan-address'] this.cert = ret['root-ca'] } - async setupEmbassy () : Promise { + async setupEmbassy (storageLogicalname: string, password: string): Promise { const ret = await this.apiService.setupEmbassy({ - 'embassy-logicalname': this.storageDrive.logicalname, - 'embassy-password': this.embassyPassword, - 'recovery-partition': this.recoveryPartition, - 'recovery-password': this.recoveryPassword, + 'embassy-logicalname': storageLogicalname, + 'embassy-password': password, + 'recovery-source': this.recoverySource || null, + 'recovery-password': this.recoveryPassword || null, }) this.torAddress = ret['tor-address'] this.lanAddress = ret['lan-address'] diff --git a/setup-wizard/src/app/util/misc.util.ts b/setup-wizard/src/app/util/misc.util.ts index 1a21a3ab1..1a6afc405 100644 --- a/setup-wizard/src/app/util/misc.util.ts +++ b/setup-wizard/src/app/util/misc.util.ts @@ -1,3 +1,10 @@ +import { DiskBackupTarget } from '../services/api/api.service' + +export interface MappedDisk { + hasValidBackup: boolean + drive: DiskBackupTarget +} + export const pauseFor = (ms: number) => { return new Promise(resolve => setTimeout(resolve, ms)) } \ No newline at end of file diff --git a/setup-wizard/src/global.scss b/setup-wizard/src/global.scss index 748ab485f..6f9f8338e 100644 --- a/setup-wizard/src/global.scss +++ b/setup-wizard/src/global.scss @@ -25,8 +25,29 @@ @import "~@ionic/angular/css/text-transformation.css"; @import "~@ionic/angular/css/flex-utils.css"; +ion-content { + --background: var(--ion-color-medium); +} + +ion-grid { + padding-top: 32px; + height: 100%; + max-width: 600px; +} + +ion-row { + height: 100%; +} + +ion-item { + --color: var(--ion-color-light); +} + ion-toolbar { - --ion-background-color: var(--ion-color-light); + --ion-background-color: var(--ion-color-light); + ion-title { + color: var(--ion-color-dark); + } } ion-avatar { @@ -34,24 +55,11 @@ ion-avatar { height: 27px; } -ion-alert { - .alert-button { - color: var(--ion-color-dark) !important; - } -} - -ion-button { - --color: var(--ion-color-dark) !important; -} - ion-item { --highlight-color-valid: transparent; --highlight-color-invalid: transparent; --border-radius: 4px; - --border-style: solid; - --border-width: 1px; - --border-color: var(--ion-color-light); } ion-card-title { @@ -69,6 +77,11 @@ ion-toast { --color: white; } +.center-spinner { + height: 20vh; + width: 100%; +} + .inline { * { display: inline-block; @@ -93,13 +106,6 @@ ion-toast { top: 64px; } -.input-label { - text-align: left; - padding-bottom: 2px; - font-size: small; - font-weight: bold; -} - .error-border { border: 2px solid var(--ion-color-danger); border-radius: 4px; @@ -111,9 +117,8 @@ ion-toast { } .modal-wrapper.sc-ion-modal-md { - border-radius: 6px; - border: 2px solid rgba(255,255,255,.03); - box-shadow: 0 0 70px 70px black; + border-radius: 4px; + border: 1px solid rgba(255,255,255,.03); } .modal-wrapper { diff --git a/setup-wizard/src/theme/variables.scss b/setup-wizard/src/theme/variables.scss index 776843bd4..165ca3939 100644 --- a/setup-wizard/src/theme/variables.scss +++ b/setup-wizard/src/theme/variables.scss @@ -4,11 +4,6 @@ /** Ionic CSS Variables **/ :root { --ion-font-family: 'Benton Sans'; - --ion-background-color: var(--ion-color-medium); - --ion-background-color-rgb: var(--ion-color-medium-rgb); - --ion-text-color: var(--ion-color-dark); - --ion-text-color-rgb: var(--ion-color-dark-rgb); - --ion-backdrop-opacity: .75; --ion-color-primary: #0075e1; --ion-color-primary-rgb: 66,140,255; diff --git a/ui/package-lock.json b/ui/package-lock.json index af64dc07b..2b55230a5 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -16,7 +16,8 @@ "@angular/router": "^12.2.4", "@ionic/angular": "^5.7.0", "@ionic/storage-angular": "^3.0.6", - "@start9labs/emver": "0.1.5", + "@start9labs/argon2": "^0.1.0", + "@start9labs/emver": "^0.1.5", "ajv": "^6.12.6", "ansi-to-html": "^0.7.2", "core-js": "^3.17.2", @@ -3063,6 +3064,11 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, + "node_modules/@start9labs/argon2": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@start9labs/argon2/-/argon2-0.1.0.tgz", + "integrity": "sha512-Ng9Ibuj0p2drQRW013AkUz6TqWysXw/9OyoEoXQZL7kfac0LrxWIDj+xvg+orqQMxcvClWgzeQY/c+IgJtcevA==" + }, "node_modules/@start9labs/emver": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/@start9labs/emver/-/emver-0.1.5.tgz", @@ -19016,6 +19022,11 @@ } } }, + "@start9labs/argon2": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@start9labs/argon2/-/argon2-0.1.0.tgz", + "integrity": "sha512-Ng9Ibuj0p2drQRW013AkUz6TqWysXw/9OyoEoXQZL7kfac0LrxWIDj+xvg+orqQMxcvClWgzeQY/c+IgJtcevA==" + }, "@start9labs/emver": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/@start9labs/emver/-/emver-0.1.5.tgz", diff --git a/ui/package.json b/ui/package.json index 2bf7375a4..82ea45c63 100644 --- a/ui/package.json +++ b/ui/package.json @@ -21,7 +21,8 @@ "@angular/router": "^12.2.4", "@ionic/angular": "^5.7.0", "@ionic/storage-angular": "^3.0.6", - "@start9labs/emver": "0.1.5", + "@start9labs/argon2": "^0.1.0", + "@start9labs/emver": "^0.1.5", "ajv": "^6.12.6", "ansi-to-html": "^0.7.2", "core-js": "^3.17.2", diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html index 19b4fa829..3642b9229 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -62,11 +62,17 @@ + + + + + + @@ -81,6 +87,7 @@ + diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index 6f97046d6..272ceca59 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -160,10 +160,9 @@ export class AppComponent { } async presentAlertLogout () { - // @TODO warn user no way to recover Embassy if logout and forget password. Maybe require password to logout? const alert = await this.alertCtrl.create({ header: 'Caution', - message: 'Are you sure you want to logout?', + message: 'Do you know your password? If you log out and forget your password, you may permanently lose access to your Embassy.', buttons: [ { text: 'Cancel', diff --git a/ui/src/app/components/backup-drives/backup-drives-status.component.html b/ui/src/app/components/backup-drives/backup-drives-status.component.html new file mode 100644 index 000000000..f18b7f3e8 --- /dev/null +++ b/ui/src/app/components/backup-drives/backup-drives-status.component.html @@ -0,0 +1,16 @@ +
+

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

+ +

+ + Embassy backup detected +

+

+ + No Embassy backup +

+
+
\ No newline at end of file diff --git a/ui/src/app/components/backup-drives/backup-drives.component.html b/ui/src/app/components/backup-drives/backup-drives.component.html index f856a0570..23a23d43d 100644 --- a/ui/src/app/components/backup-drives/backup-drives.component.html +++ b/ui/src/app/components/backup-drives/backup-drives.component.html @@ -1,70 +1,82 @@ - - - - {{ message }} + + + + + + + + + + {{ backupService.loadingError }} + + - - - - - - - - - - - - - - - - - - - - - - - - - {{ backupService.loadingError }} - - - - - - - + + + + Shared Network Folders + - - No drives found. Insert a backup drive into your Embassy and click "Refresh" above. - +

+ Shared folders are the recommended way to create Embassy backups. +

+ + + + New shared folder + + + + + + +

{{ cifs.path.split('/').pop() }}

+ + + +

+ + Unable to connect +

+

Hostname: {{ cifs.hostname }}

+

Path: {{ cifs.path }}

+
+
+
+ + Physical Drives + + + +

+ + Warning! Plugging a 2nd physical drive directly into your Embassy can lead to data corruption. + +

+
+

+ To backup to a physical drive, please follow the instructions. +

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

{{ partition.label || partition.logicalname }}

-

{{ partition.capacity | convertBytes }}

-

- - Embassy backups detected - -

-
- -
-
-
+ + + + +

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

+ +

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

+

Capacity: {{ drive.capacity | convertBytes }}

+
+
+
-
+
- + diff --git a/ui/src/app/components/backup-drives/backup-drives.component.module.ts b/ui/src/app/components/backup-drives/backup-drives.component.module.ts index 3663ffa25..d3bc6419c 100644 --- a/ui/src/app/components/backup-drives/backup-drives.component.module.ts +++ b/ui/src/app/components/backup-drives/backup-drives.component.module.ts @@ -1,22 +1,26 @@ import { NgModule } from '@angular/core' import { CommonModule } from '@angular/common' import { IonicModule } from '@ionic/angular' -import { BackupDrivesComponent, BackupDrivesHeaderComponent } from './backup-drives.component' +import { BackupDrivesComponent, BackupDrivesHeaderComponent, BackupDrivesStatusComponent } from './backup-drives.component' import { SharingModule } from '../../modules/sharing.module' +import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module' @NgModule({ declarations: [ BackupDrivesComponent, BackupDrivesHeaderComponent, + BackupDrivesStatusComponent, ], imports: [ CommonModule, IonicModule, SharingModule, + GenericFormPageModule, ], exports: [ BackupDrivesComponent, BackupDrivesHeaderComponent, + BackupDrivesStatusComponent, ], }) export class BackupDrivesComponentModule { } diff --git a/ui/src/app/components/backup-drives/backup-drives.component.ts b/ui/src/app/components/backup-drives/backup-drives.component.ts index b3e914727..fa98a73a8 100644 --- a/ui/src/app/components/backup-drives/backup-drives.component.ts +++ b/ui/src/app/components/backup-drives/backup-drives.component.ts @@ -1,6 +1,12 @@ import { Component, EventEmitter, Input, Output } from '@angular/core' import { BackupService } from './backup.service' -import { MappedPartitionInfo } from 'src/app/util/misc.util' +import { CifsBackupTarget, DiskBackupTarget, RR } from 'src/app/services/api/api.types' +import { ActionSheetController, AlertController, LoadingController, ModalController } from '@ionic/angular' +import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page' +import { ConfigSpec } from 'src/app/pkg-config/config-types' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { ErrorToastService } from 'src/app/services/error-toast.service' +import { MappedBackupTarget } from 'src/app/util/misc.util' @Component({ selector: 'backup-drives', @@ -8,25 +14,193 @@ import { MappedPartitionInfo } from 'src/app/util/misc.util' styleUrls: ['./backup-drives.component.scss'], }) export class BackupDrivesComponent { - @Input() type: 'backup' | 'restore' - @Output() onSelect: EventEmitter = new EventEmitter() - message: string + @Input() type: 'create' | 'restore' + @Output() onSelect: EventEmitter> = new EventEmitter() + loadingText: string constructor ( + private readonly loadingCtrl: LoadingController, + private readonly actionCtrl: ActionSheetController, + private readonly alertCtrl: AlertController, + private readonly modalCtrl: ModalController, + private readonly embassyApi: ApiService, + private readonly errToast: ErrorToastService, public readonly backupService: BackupService, ) { } ngOnInit () { - if (this.type === 'backup') { - this.message = 'Select the drive where you want to create a backup of your Embassy.' - } else { - this.message = 'Select the drive containing backups you would like to restore.' - } - this.backupService.getExternalDrives() + this.loadingText = this.type === 'create' ? 'Fetching Backup Targets' : 'Fetching Backup Sources' + this.backupService.getBackupTargets() } - handleSelect (partition: MappedPartitionInfo): void { - this.onSelect.emit(partition) + select (target: MappedBackupTarget): void { + if (target.entry.type === 'cifs' && !target.entry.mountable) { + const message = 'Unable to connect to shared 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) + return + } + + if (this.type === 'restore' && !target.hasValidBackup) { + const message = `${target.entry.type === 'cifs' ? 'Shared folder' : 'Drive partition'} does not contain a valid Embassy backup.` + this.presentAlertError(message) + return + } + + this.onSelect.emit(target) + } + + async presentModalAddCifs (): Promise { + const modal = await this.modalCtrl.create({ + component: GenericFormPage, + componentProps: { + title: 'New Shared Folder', + spec: CifsSpec, + buttons: [ + { + text: 'Save', + handler: (value: RR.AddBackupTargetReq) => { + return this.addCifs(value) + }, + isSubmit: true, + }, + ], + }, + }) + await modal.present() + } + + async presentActionCifs (target: MappedBackupTarget, index: number): Promise { + const entry = target.entry as CifsBackupTarget + + const action = await this.actionCtrl.create({ + header: entry.hostname, + subHeader: 'Shared Folder', + mode: 'ios', + buttons: [ + { + text: 'Forget', + icon: 'trash', + role: 'destructive', + handler: () => { + this.deleteCifs(target.id, index) + }, + }, + { + text: 'Edit', + icon: 'pencil', + handler: () => { + this.presentModalEditCifs(target.id, entry, index) + }, + }, + { + text: this.type === 'create' ? 'Create Backup' : 'Restore From Backup', + icon: this.type === 'create' ? 'cloud-upload-outline' : 'cloud-download-outline', + handler: () => { + this.select(target) + }, + }, + ], + }) + + await action.present() + } + + private async presentAlertError (message: string): Promise { + const alert = await this.alertCtrl.create({ + header: 'Error', + message, + buttons: ['OK'], + }) + await alert.present() + } + + private async addCifs (value: RR.AddBackupTargetReq): Promise { + const loader = await this.loadingCtrl.create({ + spinner: 'lines', + message: 'Testing connectivity to shared folder...', + cssClass: 'loader', + }) + await loader.present() + + try { + const res = await this.embassyApi.addBackupTarget(value) + const [id, entry] = Object.entries(res)[0] + this.backupService.cifs.unshift({ + id, + hasValidBackup: this.backupService.hasValidBackup(entry), + entry, + }) + return true + } catch (e) { + this.errToast.present(e) + return false + } finally { + loader.dismiss() + } + } + + private async presentModalEditCifs (id: string, entry: CifsBackupTarget, index: number): Promise { + const { hostname, path, username } = entry + + const modal = await this.modalCtrl.create({ + component: GenericFormPage, + componentProps: { + title: 'Update Shared Folder', + spec: CifsSpec, + buttons: [ + { + text: 'Save', + handler: (value: RR.AddBackupTargetReq) => { + return this.editCifs({ id, ...value }, index) + }, + isSubmit: true, + }, + ], + initialValue: { + hostname, + path, + username, + }, + }, + }) + await modal.present() + } + + private async editCifs (value: RR.UpdateBackupTargetReq, index: number): Promise { + const loader = await this.loadingCtrl.create({ + spinner: 'lines', + message: 'Testing connectivity to shared folder...', + cssClass: 'loader', + }) + await loader.present() + + try { + const res = await this.embassyApi.updateBackupTarget(value) + const entry = Object.values(res)[0] + this.backupService.cifs[index].entry = entry + } catch (e) { + this.errToast.present(e) + } finally { + loader.dismiss() + } + } + + private async deleteCifs (id: string, index: number): Promise { + const loader = await this.loadingCtrl.create({ + spinner: 'lines', + message: 'Removing...', + cssClass: 'loader', + }) + await loader.present() + + try { + await this.embassyApi.removeBackupTarget({ id }) + this.backupService.cifs.splice(index, 1) + } catch (e) { + this.errToast.present(e) + } finally { + loader.dismiss() + } } } @@ -45,6 +219,54 @@ export class BackupDrivesHeaderComponent { ) { } refresh () { - this.backupService.getExternalDrives() + this.backupService.getBackupTargets() } } + + +@Component({ + selector: 'backup-drives-status', + templateUrl: './backup-drives-status.component.html', + styleUrls: ['./backup-drives.component.scss'], +}) +export class BackupDrivesStatusComponent { + @Input() type: string + @Input() hasValidBackup: boolean +} + +const CifsSpec: ConfigSpec = { + hostname: { + type: 'string', + name: 'Hostname', + description: 'The local URL of the shared folder.', + placeholder: `e.g. My Computer, Bob's Laptop`, + nullable: false, + masked: false, + copyable: false, + }, + path: { + type: 'string', + name: 'Path', + description: 'The path to the shared folder on the target device.', + placeholder: 'e.g. /Desktop/my-folder', + nullable: false, + masked: false, + copyable: false, + }, + username: { + type: 'string', + name: 'Username', + description: 'The username of the user account on your target device.', + nullable: false, + masked: false, + copyable: false, + }, + password: { + type: 'string', + name: 'Password', + description: 'The password of the user account on your target device.', + nullable: true, + masked: true, + copyable: false, + }, +} diff --git a/ui/src/app/components/backup-drives/backup.service.ts b/ui/src/app/components/backup-drives/backup.service.ts index d47596d76..bddc30b96 100644 --- a/ui/src/app/components/backup-drives/backup.service.ts +++ b/ui/src/app/components/backup-drives/backup.service.ts @@ -2,14 +2,16 @@ import { Injectable } from '@angular/core' import { IonicSafeString } from '@ionic/core' import { ApiService } from 'src/app/services/api/embassy-api.service' import { getErrorMessage } from 'src/app/services/error-toast.service' -import { MappedDriveInfo, MappedPartitionInfo } from 'src/app/util/misc.util' +import { BackupTarget, CifsBackupTarget, DiskBackupTarget } from 'src/app/services/api/api.types' import { Emver } from 'src/app/services/emver.service' +import { MappedBackupTarget } from 'src/app/util/misc.util' @Injectable({ providedIn: 'root', }) export class BackupService { - drives: MappedDriveInfo[] + cifs: MappedBackupTarget[] + drives: MappedBackupTarget[] loading = true loadingError: string | IonicSafeString @@ -18,25 +20,31 @@ export class BackupService { private readonly emver: Emver, ) { } - async getExternalDrives (): Promise { + async getBackupTargets (): Promise { this.loading = true try { - const drives = await this.embassyApi.getDrives({ }) - this.drives = drives - .filter(d => !d.guid) - .map(d => { - const partionInfo: MappedPartitionInfo[] = d.partitions.map(p => { - return { - ...p, - hasBackup: [0, 1].includes(this.emver.compare(p['embassy-os']?.version, '0.3.0')), - } - }) - return { - ...d, - partitions: partionInfo, - } - }) + const targets = await this.embassyApi.getBackupTargets({ }) + // cifs + this.cifs = Object.entries(targets) + .filter(([_, target]) => target.type === 'cifs') + .map(([id, cifs]) => { + return { + id, + hasValidBackup: this.hasValidBackup(cifs), + entry: cifs as CifsBackupTarget, + } + }) + // drives + this.drives = Object.entries(targets) + .filter(([_, target]) => target.type === 'disk') + .map(([id, drive]) => { + return { + id, + hasValidBackup: this.hasValidBackup(drive), + entry: drive as DiskBackupTarget, + } + }) } catch (e) { this.loadingError = getErrorMessage(e) } finally { @@ -44,4 +52,7 @@ export class BackupService { } } -} \ No newline at end of file + hasValidBackup (target: BackupTarget): boolean { + return [0, 1].includes(this.emver.compare(target['embassy-os']?.version, '0.3.0')) + } +} diff --git a/ui/src/app/components/form-object/form-object.component.html b/ui/src/app/components/form-object/form-object.component.html index 8e4d65ecf..7e69cf043 100644 --- a/ui/src/app/components/form-object/form-object.component.html +++ b/ui/src/app/components/form-object/form-object.component.html @@ -38,7 +38,7 @@ diff --git a/ui/src/app/components/form-object/form-object.component.ts b/ui/src/app/components/form-object/form-object.component.ts index b5a551114..e3103a702 100644 --- a/ui/src/app/components/form-object/form-object.component.ts +++ b/ui/src/app/components/form-object/form-object.component.ts @@ -100,9 +100,6 @@ export class FormObjectComponent { addListItem (key: string, markDirty = true, val?: string): void { const arr = this.formGroup.get(key) as FormArray if (markDirty) arr.markAsDirty() - // @TODO why are these commented out? - // const validators = this.formService.getListItemValidators(this.objectSpec[key] as ValueSpecList, key, arr.length) - // arr.push(new FormControl(value, validators)) const listSpec = this.objectSpec[key] as ValueSpecList const newItem = this.formService.getListItem(listSpec, val) newItem.markAllAsTouched() diff --git a/ui/src/app/guards/auth.guard.ts b/ui/src/app/guards/auth.guard.ts index c12ad3571..576664aa9 100644 --- a/ui/src/app/guards/auth.guard.ts +++ b/ui/src/app/guards/auth.guard.ts @@ -28,14 +28,11 @@ export class AuthGuard implements CanActivate, CanActivateChild { } private runAuthCheck (): boolean { - switch (this.authState){ - case AuthState.VERIFIED: - return true - case AuthState.UNVERIFIED: - // @TODO could initializing cause a loop? - case AuthState.INITIALIZING: - this.router.navigate(['/auth'], { replaceUrl: true }) - return false + if (this.authState === AuthState.VERIFIED) { + return true + } else { + this.router.navigate(['/login'], { replaceUrl: true }) + return false } } } diff --git a/ui/src/app/guards/unauth.guard.ts b/ui/src/app/guards/unauth.guard.ts index d627c3354..18552a830 100644 --- a/ui/src/app/guards/unauth.guard.ts +++ b/ui/src/app/guards/unauth.guard.ts @@ -20,15 +20,11 @@ export class UnauthGuard implements CanActivate { } canActivate (): boolean { - - switch (this.authState){ - case AuthState.VERIFIED: { - this.router.navigateByUrl('') - return false - } - case AuthState.UNVERIFIED: - case AuthState.INITIALIZING: - return true + if (this.authState === AuthState.VERIFIED) { + this.router.navigateByUrl('') + return false + } else { + return true } } } diff --git a/ui/src/app/modals/app-recover-select/app-recover-select.page.ts b/ui/src/app/modals/app-recover-select/app-recover-select.page.ts index b64ce358d..9c1778825 100644 --- a/ui/src/app/modals/app-recover-select/app-recover-select.page.ts +++ b/ui/src/app/modals/app-recover-select/app-recover-select.page.ts @@ -13,9 +13,10 @@ import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' styleUrls: ['./app-recover-select.page.scss'], }) export class AppRecoverSelectPage { - @Input() logicalname: string - @Input() password: string + @Input() id: string @Input() backupInfo: BackupInfo + @Input() password: string + @Input() oldPassword: string options: (PackageBackupInfo & { id: string checked: boolean @@ -69,7 +70,8 @@ export class AppRecoverSelectPage { try { await this.embassyApi.restorePackages({ ids, - logicalname: this.logicalname, + 'target-id': this.id, + 'old-password': this.oldPassword, password: this.password, }) this.modalCtrl.dismiss(undefined, 'success') diff --git a/ui/src/app/modals/generic-form/generic-form.page.ts b/ui/src/app/modals/generic-form/generic-form.page.ts index f106fd608..54c66d825 100644 --- a/ui/src/app/modals/generic-form/generic-form.page.ts +++ b/ui/src/app/modals/generic-form/generic-form.page.ts @@ -19,6 +19,7 @@ export class GenericFormPage { @Input() title: string @Input() spec: ConfigSpec @Input() buttons: ActionButton[] + @Input() initialValue: object = { } submitBtn: ActionButton formGroup: FormGroup @@ -28,7 +29,7 @@ export class GenericFormPage { ) { } ngOnInit () { - this.formGroup = this.formService.createForm(this.spec) + this.formGroup = this.formService.createForm(this.spec, this.initialValue) this.submitBtn = this.buttons.find(btn => btn.isSubmit) || { text: '', handler: () => Promise.resolve(true), @@ -48,6 +49,7 @@ export class GenericFormPage { return } + // @TODO make this more like generic input component dismissal const success = await handler(this.formGroup.value) if (success !== false) this.modalCtrl.dismiss() } diff --git a/ui/src/app/modals/generic-input/generic-input.component.html b/ui/src/app/modals/generic-input/generic-input.component.html index 33c50e33a..2d491f9c8 100644 --- a/ui/src/app/modals/generic-input/generic-input.component.html +++ b/ui/src/app/modals/generic-input/generic-input.component.html @@ -3,13 +3,13 @@ -

{{ title }}

+

{{ options.title }}


-

{{ message }}

- +

{{ options.message }}

+

- {{ warning }} + {{ options.warning }}

@@ -17,10 +17,10 @@
-

{{ label }}

+

{{ options.label }}

- - + + @@ -32,8 +32,8 @@ Cancel - - {{ buttonText }} + + {{ options.buttonText }}
diff --git a/ui/src/app/modals/generic-input/generic-input.component.ts b/ui/src/app/modals/generic-input/generic-input.component.ts index 9b9fc05cc..2bb205611 100644 --- a/ui/src/app/modals/generic-input/generic-input.component.ts +++ b/ui/src/app/modals/generic-input/generic-input.component.ts @@ -1,5 +1,5 @@ import { Component, Input, ViewChild } from '@angular/core' -import { ModalController, IonicSafeString, LoadingController, IonInput } from '@ionic/angular' +import { ModalController, IonicSafeString, IonInput } from '@ionic/angular' import { getErrorMessage } from 'src/app/services/error-toast.service' @Component({ @@ -9,25 +9,31 @@ import { getErrorMessage } from 'src/app/services/error-toast.service' }) export class GenericInputComponent { @ViewChild('mainInput') elem: IonInput - @Input() title: string - @Input() message: string - @Input() warning: string - @Input() label: string - @Input() buttonText = 'Submit' - @Input() placeholder = 'Enter Value' - @Input() nullable = false - @Input() useMask = false - @Input() value = '' - @Input() loadingText = '' - @Input() submitFn: (value: string) => Promise + @Input() options: GenericInputOptions + value: string unmasked = false error: string | IonicSafeString constructor ( private readonly modalCtrl: ModalController, - private readonly loadingCtrl: LoadingController, ) { } + ngOnInit () { + const defaultOptions: Partial = { + buttonText: 'Submit', + placeholder: 'Enter value', + nullable: false, + useMask: false, + initialValue: '', + } + this.options = { + ...defaultOptions, + ...this.options, + } + + this.value = this.options.initialValue + } + ngAfterViewInit () { setTimeout(() => this.elem.setFocus(), 400) } @@ -43,25 +49,28 @@ export class GenericInputComponent { async submit () { const value = this.value.trim() - if (!value && !this.nullable) { - return - } - - const loader = await this.loadingCtrl.create({ - spinner: 'lines', - cssClass: 'loader', - message: this.loadingText, - }) - await loader.present() + if (!value && !this.options.nullable) return try { - await this.submitFn(value) + await this.options.submitFn(value) this.modalCtrl.dismiss(undefined, 'success') } catch (e) { this.error = getErrorMessage(e) } - finally { - loader.dismiss() - } } } + +export interface GenericInputOptions { + // required + title: string + message: string + label: string + submitFn: (value: string) => Promise + // optional + warning?: string + buttonText?: string + placeholder?: string + nullable?: boolean + useMask?: boolean + initialValue?: string +} diff --git a/ui/src/app/pages/marketplace-routes/marketplace.service.ts b/ui/src/app/pages/marketplace-routes/marketplace.service.ts index 32db93cb8..ba10f2daa 100644 --- a/ui/src/app/pages/marketplace-routes/marketplace.service.ts +++ b/ui/src/app/pages/marketplace-routes/marketplace.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@angular/core' import { MarketplaceData, MarketplaceEOS, MarketplacePkg } from 'src/app/services/api/api.types' import { ApiService } from 'src/app/services/api/embassy-api.service' -import { ConfigService } from 'src/app/services/config.service' import { Emver } from 'src/app/services/emver.service' import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' diff --git a/ui/src/app/pages/server-routes/preferences/preferences.page.ts b/ui/src/app/pages/server-routes/preferences/preferences.page.ts index c9809226a..258d140fe 100644 --- a/ui/src/app/pages/server-routes/preferences/preferences.page.ts +++ b/ui/src/app/pages/server-routes/preferences/preferences.page.ts @@ -1,7 +1,7 @@ import { Component, ViewChild } from '@angular/core' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' -import { IonContent, ModalController } from '@ionic/angular' -import { GenericInputComponent } from 'src/app/modals/generic-input/generic-input.component' +import { IonContent, LoadingController, ModalController } from '@ionic/angular' +import { GenericInputComponent, GenericInputOptions } from 'src/app/modals/generic-input/generic-input.component' import { ConfigSpec } from 'src/app/pkg-config/config-types' import { ApiService } from 'src/app/services/api/embassy-api.service' import { ServerConfigService } from 'src/app/services/server-config.service' @@ -17,6 +17,7 @@ export class PreferencesPage { defaultName: string constructor ( + private readonly loadingCtrl: LoadingController, private readonly modalCtrl: ModalController, private readonly api: ApiService, public readonly serverConfig: ServerConfigService, @@ -32,19 +33,20 @@ export class PreferencesPage { } async presentModalName (): Promise { + const options: GenericInputOptions = { + title: 'Edit Device Name', + message: 'This is for your reference only.', + label: 'Device Name', + useMask: false, + placeholder: this.defaultName, + nullable: true, + initialValue: this.patch.getData().ui.name, + buttonText: 'Save', + submitFn: (value: string) => this.setDbValue('name', value || this.defaultName), + } + const modal = await this.modalCtrl.create({ - componentProps: { - title: 'Edit Device Name', - message: 'This is for your reference only.', - label: 'Device Name', - useMask: false, - placeholder: this.defaultName, - nullable: true, - value: this.patch.getData().ui.name, - buttonText: 'Save', - loadingText: 'Saving', - submitFn: (value: string) => this.setDbValue('name', value || this.defaultName), - }, + componentProps: { options }, cssClass: 'alertlike-modal', presentingElement: await this.modalCtrl.getTop(), component: GenericInputComponent, @@ -54,7 +56,20 @@ export class PreferencesPage { } private async setDbValue (key: string, value: string): Promise { - await this.api.setDbValue({ pointer: `/${key}`, value }) + const loader = await this.loadingCtrl.create({ + spinner: 'lines', + message: 'Saving...', + cssClass: 'loader', + }) + await loader.present() + + try { + await this.api.setDbValue({ pointer: `/${key}`, value }) + } catch (e) { + throw new Error(e) + } finally { + loader.dismiss() + } } } diff --git a/ui/src/app/pages/server-routes/restore/restore.component.html b/ui/src/app/pages/server-routes/restore/restore.component.html index d50f88003..52c30d1a7 100644 --- a/ui/src/app/pages/server-routes/restore/restore.component.html +++ b/ui/src/app/pages/server-routes/restore/restore.component.html @@ -1,5 +1,5 @@ - + diff --git a/ui/src/app/pages/server-routes/restore/restore.component.ts b/ui/src/app/pages/server-routes/restore/restore.component.ts index bf7319201..b2633eaaf 100644 --- a/ui/src/app/pages/server-routes/restore/restore.component.ts +++ b/ui/src/app/pages/server-routes/restore/restore.component.ts @@ -1,10 +1,12 @@ import { Component } from '@angular/core' import { ModalController, NavController } from '@ionic/angular' import { ApiService } from 'src/app/services/api/embassy-api.service' -import { GenericInputComponent } from 'src/app/modals/generic-input/generic-input.component' -import { MappedPartitionInfo } from 'src/app/util/misc.util' -import { BackupInfo } from 'src/app/services/api/api.types' +import { GenericInputComponent, GenericInputOptions } from 'src/app/modals/generic-input/generic-input.component' +import { MappedBackupTarget } from 'src/app/util/misc.util' +import { BackupInfo, CifsBackupTarget, DiskBackupTarget } from 'src/app/services/api/api.types' import { AppRecoverSelectPage } from 'src/app/modals/app-recover-select/app-recover-select.page' +import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' +import * as argon2 from '@start9labs/argon2' @Component({ selector: 'restore', @@ -17,20 +19,22 @@ export class RestorePage { private readonly modalCtrl: ModalController, private readonly navCtrl: NavController, private readonly embassyApi: ApiService, + private readonly patch: PatchDbService, ) { } - async presentModalPassword (partition: MappedPartitionInfo): Promise { + async presentModalPassword (target: MappedBackupTarget): Promise { + const options: GenericInputOptions = { + title: 'Master Password Required', + message: 'Enter your master password. 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', + submitFn: (password: string) => this.decryptDrive(target, password), + } + const modal = await this.modalCtrl.create({ - componentProps: { - title: 'Decryption Required', - message: 'Enter the password that was originally used to encrypt this backup. After decrypting the drive, you will select the services you want to restore.', - label: 'Password', - placeholder: 'Enter password', - useMask: true, - buttonText: 'Restore', - loadingText: 'Decrypting drive...', - submitFn: (password: string) => this.decryptDrive(partition.logicalname, password), - }, + componentProps: { options }, cssClass: 'alertlike-modal', presentingElement: await this.modalCtrl.getTop(), component: GenericInputComponent, @@ -39,20 +43,54 @@ export class RestorePage { await modal.present() } - private async decryptDrive (logicalname: string, password: string): Promise { - const backupInfo = await this.embassyApi.getBackupInfo({ - logicalname, - password, - }) - this.presentModalSelect(logicalname, password, backupInfo) + private async decryptDrive (target: MappedBackupTarget, password: string): Promise { + const passwordHash = this.patch.getData()['server-info']['password-hash'] + argon2.verify(passwordHash, password) + + try { + argon2.verify(target.entry['embassy-os']['password-hash'], password) + await this.restoreFromBackup(target, password) + } catch (e) { + setTimeout(() => this.presentModalOldPassword(target, password), 500) + } } - async presentModalSelect (logicalname: string, password: string, backupInfo: BackupInfo): Promise { + private async presentModalOldPassword (target: MappedBackupTarget, password: string): Promise { + const options: GenericInputOptions = { + title: 'Original Password Needed', + message: 'This backup was created with a different password. Enter the ORIGINAL password that was used to encrypt this backup.', + label: 'Original Password', + placeholder: 'Enter original password', + useMask: true, + buttonText: 'Restore From Backup', + submitFn: (oldPassword: string) => this.restoreFromBackup(target, password, oldPassword), + } + + const m = await this.modalCtrl.create({ + component: GenericInputComponent, + componentProps: { options }, + presentingElement: await this.modalCtrl.getTop(), + cssClass: 'alertlike-modal', + }) + + await m.present() + } + + private async restoreFromBackup (target: MappedBackupTarget, password: string, oldPassword?: string): Promise { + const backupInfo = await this.embassyApi.getBackupInfo({ + 'target-id': target.id, + password, + }) + this.presentModalSelect(target.id, backupInfo, password, oldPassword) + } + + private async presentModalSelect (id: string, backupInfo: BackupInfo, password: string, oldPassword?: string): Promise { const modal = await this.modalCtrl.create({ componentProps: { - logicalname, - password, + id, backupInfo, + password, + oldPassword, }, presentingElement: await this.modalCtrl.getTop(), component: AppRecoverSelectPage, diff --git a/ui/src/app/pages/server-routes/server-backup/server-backup.page.html b/ui/src/app/pages/server-routes/server-backup/server-backup.page.html index 14bf81aa7..3b201d3e2 100644 --- a/ui/src/app/pages/server-routes/server-backup/server-backup.page.html +++ b/ui/src/app/pages/server-routes/server-backup/server-backup.page.html @@ -1,8 +1,8 @@ - - + + @@ -38,9 +38,8 @@ + Backing up -   - diff --git a/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts b/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts index e5f42902a..34b23099e 100644 --- a/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts +++ b/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts @@ -1,12 +1,14 @@ import { Component } from '@angular/core' -import { ModalController } from '@ionic/angular' +import { LoadingController, ModalController, NavController } from '@ionic/angular' import { ApiService } from 'src/app/services/api/embassy-api.service' -import { GenericInputComponent } from 'src/app/modals/generic-input/generic-input.component' -import { MappedPartitionInfo } from 'src/app/util/misc.util' +import { GenericInputComponent, GenericInputOptions } from 'src/app/modals/generic-input/generic-input.component' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PackageDataEntry, PackageMainStatus, ServerStatus } from 'src/app/services/patch-db/data-model' import { Subscription } from 'rxjs' import { take } from 'rxjs/operators' +import { MappedBackupTarget } from 'src/app/util/misc.util' +import * as argon2 from '@start9labs/argon2' +import { CifsBackupTarget, DiskBackupTarget } from 'src/app/services/api/api.types' @Component({ selector: 'server-backup', @@ -19,22 +21,31 @@ export class ServerBackupPage { subs: Subscription[] constructor ( + private readonly loadingCtrl: LoadingController, private readonly modalCtrl: ModalController, private readonly embassyApi: ApiService, private readonly patch: PatchDbService, + private readonly navCtrl: NavController, ) { } ngOnInit () { this.subs = [ - this.patch.watch$('server-info', 'status').subscribe(status => { - if (status === ServerStatus.BackingUp) { - this.backingUp = true - this.subscribeToBackup() - } else { - this.backingUp = false - this.pkgs.forEach(pkg => pkg.sub.unsubscribe()) - } - }), + this.patch.watch$('server-info', 'status') + .pipe() + .subscribe(status => { + if (status === ServerStatus.BackingUp) { + if (!this.backingUp) { + this.backingUp = true + this.subscribeToBackup() + } + } else { + if (this.backingUp) { + this.backingUp = false + this.pkgs.forEach(pkg => pkg.sub.unsubscribe()) + this.navCtrl.navigateRoot('/embassy') + } + } + }), ] } @@ -43,34 +54,90 @@ export class ServerBackupPage { this.pkgs.forEach(pkg => pkg.sub.unsubscribe()) } - async presentModalPassword (partition: MappedPartitionInfo): Promise { - let message: string - if (partition.hasBackup) { - message = 'Enter your master password to decrypt this drive and update its backup. Depending on how much data was added or changed, this could be very fast, or it could take a while.' - } else { - message = 'Enter your master password to create an encrypted backup of your Embassy and all its installed services. Since this is a fresh backup, it could take a while. Future backups will likely be much faster.' + async presentModalPassword (target: MappedBackupTarget): Promise { + let message = 'Enter your master password to create an encrypted backup of your Embassy and all its services.' + if (!target.hasValidBackup) { + message = message + ' Since this is a fresh backup, it could take a while. Future backups will likely be much faster.' + } + + const options: GenericInputOptions = { + title: 'Master Password Needed', + message, + label: 'Master Password', + placeholder: 'Enter master password', + useMask: true, + buttonText: 'Create Backup', + submitFn: (password: string) => this.test(target, password), } const m = await this.modalCtrl.create({ - componentProps: { - title: 'Create Backup', - message, - label: 'Password', - placeholder: 'Enter password', - useMask: true, - buttonText: 'Create Backup', - loadingText: 'Beginning backup...', - submitFn: (password: string) => this.create(partition.logicalname, password), - }, - cssClass: 'alertlike-modal', component: GenericInputComponent, + componentProps: { options }, + cssClass: 'alertlike-modal', }) await m.present() } - private async create (logicalname: string, password: string): Promise { - await this.embassyApi.createBackup({ logicalname, password }) + private async test (target: MappedBackupTarget, password: string, oldPassword?: string): Promise { + const passwordHash = this.patch.getData()['server-info']['password-hash'] + argon2.verify(passwordHash, password) + + if (!target.hasValidBackup) { + await this.createBackup(target.id, password) + } else { + try { + argon2.verify(target.entry['embassy-os']['password-hash'], oldPassword || password) + await this.createBackup(target.id, password) + } catch (e) { + if (oldPassword) { + throw new Error(e) + } else { + setTimeout(() => this.presentModalOldPassword(target, password), 500) + } + } + } + } + + private async presentModalOldPassword (target: MappedBackupTarget, password: string): Promise { + const options: GenericInputOptions = { + title: 'Original Password Needed', + message: 'This backup was created with a different password. Enter the ORIGINAL password that was used to encrypt this backup.', + label: 'Original Password', + placeholder: 'Enter original password', + useMask: true, + buttonText: 'Create Backup', + submitFn: (oldPassword: string) => this.test(target, password, oldPassword), + } + + const m = await this.modalCtrl.create({ + component: GenericInputComponent, + componentProps: { options }, + cssClass: 'alertlike-modal', + }) + + await m.present() + } + + private async createBackup (id: string, password: string, oldPassword?: string): Promise { + const loader = await this.loadingCtrl.create({ + spinner: 'lines', + message: 'Beginning backup...', + cssClass: 'loader', + }) + await loader.present() + + try { + await this.embassyApi.createBackup({ + 'target-id': id, + 'old-password': oldPassword || null, + password, + }) + } catch (e) { + throw new Error(e) + } finally { + loader.dismiss() + } } private subscribeToBackup () { diff --git a/ui/src/app/pages/server-routes/server-show/server-show.page.html b/ui/src/app/pages/server-routes/server-show/server-show.page.html index f661bebd0..53233b9c1 100644 --- a/ui/src/app/pages/server-routes/server-show/server-show.page.html +++ b/ui/src/app/pages/server-routes/server-show/server-show.page.html @@ -11,15 +11,23 @@
{{ cat.key }} - +

{{ button.title }}

{{ button.description }}

- - Last Backup: {{ patch.data['server-info']['last-backup'] ? (patch.data['server-info']['last-backup'] | date: 'short') : 'never' }} - + + + Last Backup: {{ patch.data['server-info']['last-backup'] ? (patch.data['server-info']['last-backup'] | date: 'short') : 'never' }} + + + + + Backing up + + +

diff --git a/ui/src/app/pages/server-routes/server-show/server-show.page.ts b/ui/src/app/pages/server-routes/server-show/server-show.page.ts index 1d0416f56..96f70ba2a 100644 --- a/ui/src/app/pages/server-routes/server-show/server-show.page.ts +++ b/ui/src/app/pages/server-routes/server-show/server-show.page.ts @@ -4,6 +4,9 @@ import { ApiService } from 'src/app/services/api/embassy-api.service' import { ActivatedRoute } from '@angular/router' import { ErrorToastService } from 'src/app/services/error-toast.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' +import { ServerStatus } from 'src/app/services/patch-db/data-model' +import { Observable, of } from 'rxjs' +import { map } from 'rxjs/operators' @Component({ selector: 'server-show', @@ -11,7 +14,7 @@ import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' styleUrls: ['server-show.page.scss'], }) export class ServerShowPage { - settings: ServerSettings = { } + ServerStatus = ServerStatus constructor ( private readonly alertCtrl: AlertController, @@ -23,10 +26,6 @@ export class ServerShowPage { public readonly patch: PatchDbService, ) { } - ngOnInit () { - this.setButtons() - } - async presentAlertRestart () { const alert = await this.alertCtrl.create({ header: 'Confirm', @@ -49,6 +48,7 @@ export class ServerShowPage { } async presentAlertShutdown () { + const sts = this.patch.data['server-info'].status const alert = await this.alertCtrl.create({ header: 'Warning', message: 'Are you sure you want to power down your Embassy? This can take several minutes, and your Embassy will not come back online automatically. To power on again, You will need to physically unplug your Embassy and plug it back in.', @@ -103,101 +103,111 @@ export class ServerShowPage { } } - private setButtons (): void { - this.settings = { - 'Backups': [ - { - title: 'Create Backup', - description: 'Back up your Embassy and all its services', - icon: 'save-outline', - action: () => this.navCtrl.navigateForward(['backup'], { relativeTo: this.route }), - detail: true, - }, - { - title: 'Restore From Backup', - description: 'Restore one or more services from a prior backup', - icon: 'color-wand-outline', - action: () => this.navCtrl.navigateForward(['restore'], { relativeTo: this.route }), - detail: true, - }, - ], - 'Insights': [ - { - title: 'About', - description: 'Basic information about your Embassy', - icon: 'information-circle-outline', - action: () => this.navCtrl.navigateForward(['specs'], { relativeTo: this.route }), - detail: true, - }, - { - title: 'Monitor', - description: 'CPU, disk, memory, and other useful metrics', - icon: 'pulse', - action: () => this.navCtrl.navigateForward(['metrics'], { relativeTo: this.route }), - detail: true, - }, - { - title: 'Logs', - description: 'Raw, unfiltered device logs', - icon: 'newspaper-outline', - action: () => this.navCtrl.navigateForward(['logs'], { relativeTo: this.route }), - detail: true, - }, - ], - 'Settings': [ - { - title: 'Preferences', - description: 'Device name, background tasks', - icon: 'options-outline', - action: () => this.navCtrl.navigateForward(['preferences'], { relativeTo: this.route }), - detail: true, - }, - { - title: 'LAN', - description: 'Access your Embassy on the Local Area Network', - icon: 'home-outline', - action: () => this.navCtrl.navigateForward(['lan'], { relativeTo: this.route }), - detail: true, - }, - { - title: 'SSH', - description: 'Access your Embassy from the command line', - icon: 'terminal-outline', - action: () => this.navCtrl.navigateForward(['ssh'], { relativeTo: this.route }), - detail: true, - }, - { - title: 'WiFi', - description: 'Add or remove WiFi networks', - icon: 'wifi', - action: () => this.navCtrl.navigateForward(['wifi'], { relativeTo: this.route }), - detail: true, - }, - { - title: 'Active Sessions', - description: 'View and manage device access', - icon: 'desktop-outline', - action: () => this.navCtrl.navigateForward(['sessions'], { relativeTo: this.route }), - detail: true, - }, - ], - 'Power': [ - { - title: 'Restart', - description: '', - icon: 'reload', - action: () => this.presentAlertRestart(), - detail: false, - }, - { - title: 'Shutdown', - description: '', - icon: 'power', - action: () => this.presentAlertShutdown(), - detail: false, - }, - ], - } + settings: ServerSettings = { + 'Backups': [ + { + title: 'Create Backup', + description: 'Back up your Embassy and all its services', + icon: 'save-outline', + action: () => this.navCtrl.navigateForward(['backup'], { relativeTo: this.route }), + detail: true, + disabled: of(false), + }, + { + title: 'Restore From Backup', + description: 'Restore one or more services from a prior backup', + icon: 'color-wand-outline', + action: () => this.navCtrl.navigateForward(['restore'], { relativeTo: this.route }), + detail: true, + disabled: this.patch.watch$('server-info', 'status').pipe(map(status => [ServerStatus.Updated, ServerStatus.BackingUp].includes(status))), + }, + ], + 'Insights': [ + { + title: 'About', + description: 'Basic information about your Embassy', + icon: 'information-circle-outline', + action: () => this.navCtrl.navigateForward(['specs'], { relativeTo: this.route }), + detail: true, + disabled: of(false), + }, + { + title: 'Monitor', + description: 'CPU, disk, memory, and other useful metrics', + icon: 'pulse', + action: () => this.navCtrl.navigateForward(['metrics'], { relativeTo: this.route }), + detail: true, + disabled: of(false), + }, + { + title: 'Logs', + description: 'Raw, unfiltered device logs', + icon: 'newspaper-outline', + action: () => this.navCtrl.navigateForward(['logs'], { relativeTo: this.route }), + detail: true, + disabled: of(false), + }, + ], + 'Settings': [ + { + title: 'Preferences', + description: 'Device name, background tasks', + icon: 'options-outline', + action: () => this.navCtrl.navigateForward(['preferences'], { relativeTo: this.route }), + detail: true, + disabled: of(false), + }, + { + title: 'LAN', + description: 'Access your Embassy on the Local Area Network', + icon: 'home-outline', + action: () => this.navCtrl.navigateForward(['lan'], { relativeTo: this.route }), + detail: true, + disabled: of(false), + }, + { + title: 'SSH', + description: 'Access your Embassy from the command line', + icon: 'terminal-outline', + action: () => this.navCtrl.navigateForward(['ssh'], { relativeTo: this.route }), + detail: true, + disabled: of(false), + }, + { + title: 'WiFi', + description: 'Add or remove WiFi networks', + icon: 'wifi', + action: () => this.navCtrl.navigateForward(['wifi'], { relativeTo: this.route }), + detail: true, + disabled: of(false), + }, + { + title: 'Active Sessions', + description: 'View and manage device access', + icon: 'desktop-outline', + action: () => this.navCtrl.navigateForward(['sessions'], { relativeTo: this.route }), + detail: true, + disabled: of(false), + }, + ], + 'Power': [ + { + title: 'Restart', + description: '', + icon: 'reload', + action: () => this.presentAlertRestart(), + detail: false, + disabled: of(false), + }, + { + title: 'Shutdown', + description: '', + icon: 'power', + action: () => this.presentAlertShutdown(), + detail: false, + disabled: of(false), + }, + ], } asIsOrder () { @@ -212,5 +222,6 @@ interface ServerSettings { icon: string action: Function detail: boolean + disabled: Observable }[] } diff --git a/ui/src/app/pages/server-routes/ssh-keys/ssh-keys.page.ts b/ui/src/app/pages/server-routes/ssh-keys/ssh-keys.page.ts index 9eddf4967..e79b8560c 100644 --- a/ui/src/app/pages/server-routes/ssh-keys/ssh-keys.page.ts +++ b/ui/src/app/pages/server-routes/ssh-keys/ssh-keys.page.ts @@ -3,7 +3,7 @@ import { AlertController, LoadingController, ModalController } from '@ionic/angu import { SSHKey } from 'src/app/services/api/api.types' import { ErrorToastService } from 'src/app/services/error-toast.service' import { ApiService } from 'src/app/services/api/embassy-api.service' -import { GenericInputComponent } from 'src/app/modals/generic-input/generic-input.component' +import { GenericInputComponent, GenericInputOptions } from 'src/app/modals/generic-input/generic-input.component' @Component({ selector: 'ssh-keys', @@ -40,23 +40,37 @@ export class SSHKeysPage { async presentModalAdd () { const { name, description } = sshSpec + const options: GenericInputOptions = { + title: name, + message: description, + label: name, + submitFn: (pk: string) => this.add(pk), + } + const modal = await this.modalCtrl.create({ component: GenericInputComponent, - componentProps: { - title: name, - message: description, - label: name, - loadingText: 'Saving', - submitFn: (pk: string) => this.add(pk), - }, + componentProps: { options }, cssClass: 'alertlike-modal', }) await modal.present() } async add (pubkey: string): Promise { - const key = await this.embassyApi.addSshKey({ key: pubkey }) - this.sshKeys.push(key) + const loader = await this.loadingCtrl.create({ + spinner: 'lines', + message: 'Saving...', + cssClass: 'loader', + }) + await loader.present() + + try { + const key = await this.embassyApi.addSshKey({ key: pubkey }) + this.sshKeys.push(key) + } catch (e) { + throw new Error(e) + } finally { + loader.dismiss() + } } async presentAlertDelete (i: number) { diff --git a/ui/src/app/pages/server-routes/wifi/wifi.page.ts b/ui/src/app/pages/server-routes/wifi/wifi.page.ts index 5aa6fe917..94d0125ca 100644 --- a/ui/src/app/pages/server-routes/wifi/wifi.page.ts +++ b/ui/src/app/pages/server-routes/wifi/wifi.page.ts @@ -109,6 +109,7 @@ export class WifiPage { { text: 'Forget', icon: 'trash', + role: 'destructive', handler: () => { this.delete(ssid, i) }, diff --git a/ui/src/app/pkg-config/config-types.ts b/ui/src/app/pkg-config/config-types.ts index 83f3ac322..e6387739f 100644 --- a/ui/src/app/pkg-config/config-types.ts +++ b/ui/src/app/pkg-config/config-types.ts @@ -93,12 +93,14 @@ export interface ListValueSpecString { 'pattern-description'?: string masked: boolean copyable: boolean + placeholder?: string } export interface ListValueSpecNumber { range: string integral: boolean units?: string + placeholder?: string } export interface ListValueSpecEnum { diff --git a/ui/src/app/services/api/api.fixures.ts b/ui/src/app/services/api/api.fixures.ts index 078a80dc4..929cf5e27 100644 --- a/ui/src/app/services/api/api.fixures.ts +++ b/ui/src/app/services/api/api.fixures.ts @@ -975,50 +975,54 @@ export module Mock { 'signal-strength': 50, } - export const Drives: RR.GetDrivesRes = [ - { - logicalname: '/dev/sda', - model: null, - vendor: 'SSK', - partitions: [ - { - logicalname: 'sdba1', - label: 'Matt Stuff', - capacity: 1000000000000, - used: 0, - 'embassy-os': null, - }, - ], - capacity: 1000000000000, - guid: 'asdfasdf', + export const BackupTargets: RR.GetBackupTargetsRes = { + 'hsbdjhasbasda': { + type: 'cifs', + hostname: 'smb://192.169.10.0', + path: '/Desktop/embassy-backups', + username: 'TestUser', + mountable: false, + 'embassy-os': { + version: '0.3.0', + full: true, + 'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNK', + 'wrapped-key': '', + }, }, - { - logicalname: '/dev/sdb', - model: 'JMS567 SATA 6Gb/s bridge', - vendor: 'Samsung', - partitions: [ - { - logicalname: 'sdba1', - label: 'Partition 1', - capacity: 1000000000, - used: 1000000000, - 'embassy-os': { - version: '0.3.0', - full: true, - }, - }, - { - logicalname: 'sdba2', - label: 'Partition 2', - capacity: 900000000, - used: 300000000, - 'embassy-os': null, - }, - ], - capacity: 10000000000, - guid: null, + // 'ftcvewdnkemfksdm': { + // type: 'disk', + // logicalname: 'sdba1', + // label: 'Matt Stuff', + // capacity: 1000000000000, + // used: 0, + // model: 'Evo SATA 2.5', + // vendor: 'Samsung', + // 'embassy-os': null, + // }, + 'csgashbdjkasnd': { + type: 'cifs', + hostname: 'smb://192.169.10.0', + path: '/Desktop/embassy-backups-2', + username: 'TestUser', + mountable: true, + 'embassy-os': null, }, - ] + // 'powjefhjbnwhdva': { + // type: 'disk', + // logicalname: 'sdba1', + // label: 'Another Drive', + // capacity: 2000000000000, + // used: 100000000000, + // model: null, + // vendor: 'SSK', + // 'embassy-os': { + // version: '0.3.0', + // full: true, + // 'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + // 'wrapped-key': '', + // }, + // }, + } export const BackupInfo: RR.GetBackupInfoRes = { version: '0.3.0', diff --git a/ui/src/app/services/api/api.types.ts b/ui/src/app/services/api/api.types.ts index 5ba73ed9a..c8ffb1d79 100644 --- a/ui/src/app/services/api/api.types.ts +++ b/ui/src/app/services/api/api.types.ts @@ -118,17 +118,33 @@ export module RR { // backup - export type CreateBackupReq = WithExpire<{ logicalname: string, password: string }> // backup.create - export type CreateBackupRes = WithRevision + export type GetBackupTargetsReq = { } // backup.target.list + export type GetBackupTargetsRes = { [id: string]: BackupTarget } - // drive + export type AddBackupTargetReq = { // backup.target.cifs.add + hostname: string + path: string + username: string + password: string | null + } + export type AddBackupTargetRes = { [id: string]: CifsBackupTarget } - export type GetDrivesReq = { } // disk.list - export type GetDrivesRes = DriveInfo[] + export type UpdateBackupTargetReq = AddBackupTargetReq & { id: string } // backup.target.cifs.update + export type UpdateBackupTargetRes = AddBackupTargetRes - export type GetBackupInfoReq = { logicalname: string, password: string } // disk.backup-info + export type RemoveBackupTargetReq = { id: string } // backup.target.cifs.remove + export type RemoveBackupTargetRes = null + + export type GetBackupInfoReq = { 'target-id': string, password: string } // backup.target.info export type GetBackupInfoRes = BackupInfo + export type CreateBackupReq = WithExpire<{ // backup.create + 'target-id': string + 'old-password': string | null + password: string + }> + export type CreateBackupRes = WithRevision + // package export type GetPackagePropertiesReq = { id: string } // package.properties @@ -157,7 +173,12 @@ export module RR { export type SetPackageConfigReq = WithExpire // package.config.set export type SetPackageConfigRes = WithRevision - export type RestorePackagesReq = WithExpire<{ ids: string[], logicalname: string, password: string }> // package.backup.restore + export type RestorePackagesReq = WithExpire<{ // package.backup.restore + ids: string[] + 'target-id': string + 'old-password': string | null, + password: string + }> export type RestorePackagesRes = WithRevision export type ExecutePackageActionReq = { id: string, 'action-id': string, input?: object } // package.action @@ -200,8 +221,8 @@ export module RR { export type GetMarketplacePackagesReq = { ids?: { id: string, version: string }[] - // iff !id 'eos-version-compat': string + // iff !ids category?: string query?: string page?: string @@ -294,7 +315,51 @@ export interface SessionMetadata { export type PlatformType = 'cli' | 'ios' | 'ipad' | 'iphone' | 'android' | 'phablet' | 'tablet' | 'cordova' | 'capacitor' | 'electron' | 'pwa' | 'mobile' | 'mobileweb' | 'desktop' | 'hybrid' -export interface DriveInfo { +export type BackupTarget = DiskBackupTarget | CifsBackupTarget + +export interface EmbassyOSRecoveryInfo { + version: string + full: boolean + 'password-hash': string | null + 'wrapped-key': string | null +} + +export interface DiskBackupTarget { + type: 'disk' + vendor: string | null + model: string | null + logicalname: string | null + label: string | null + capacity: number + used: number | null + 'embassy-os': EmbassyOSRecoveryInfo | null +} + +export interface CifsBackupTarget { + type: 'cifs' + hostname: string + path: string + username: string + mountable: boolean + 'embassy-os': EmbassyOSRecoveryInfo | null +} + +export type RecoverySource = DiskRecoverySource | CifsRecoverySource + +export interface DiskRecoverySource { + type: 'disk' + logicalname: string // partition logicalname +} + +export interface CifsRecoverySource { + type: 'cifs' + hostname: string + path: string + username: string + password: string +} + +export interface DiskInfo { logicalname: string vendor: string | null model: string | null @@ -308,10 +373,10 @@ export interface PartitionInfo { label: string | null capacity: number used: number | null - 'embassy-os': EmbassyOsDriveInfo | null + 'embassy-os': EmbassyOsDiskInfo | null } -export interface EmbassyOsDriveInfo { +export interface EmbassyOsDiskInfo { version: string full: boolean } diff --git a/ui/src/app/services/api/embassy-api.service.ts b/ui/src/app/services/api/embassy-api.service.ts index bb0cf479b..11640d299 100644 --- a/ui/src/app/services/api/embassy-api.service.ts +++ b/ui/src/app/services/api/embassy-api.service.ts @@ -121,17 +121,21 @@ export abstract class ApiService implements Source, Http { // backup + abstract getBackupTargets (params: RR.GetBackupTargetsReq): Promise + + abstract addBackupTarget (params: RR.AddBackupTargetReq): Promise + + abstract updateBackupTarget (params: RR.UpdateBackupTargetReq): Promise + + abstract removeBackupTarget (params: RR.RemoveBackupTargetReq): Promise + + abstract getBackupInfo (params: RR.GetBackupInfoReq): Promise + protected abstract createBackupRaw (params: RR.CreateBackupReq): Promise createBackup = (params: RR.CreateBackupReq) => this.syncResponse( () => this.createBackupRaw(params), )() - // drive - - abstract getDrives (params: RR.GetDrivesReq): Promise - - abstract getBackupInfo (params: RR.GetBackupInfoReq): Promise - // package abstract getPackageProperties (params: RR.GetPackagePropertiesReq): Promise['data']> diff --git a/ui/src/app/services/api/embassy-live-api.service.ts b/ui/src/app/services/api/embassy-live-api.service.ts index 40d98c128..292cbd77b 100644 --- a/ui/src/app/services/api/embassy-live-api.service.ts +++ b/ui/src/app/services/api/embassy-live-api.service.ts @@ -90,7 +90,7 @@ export class LiveApiService extends ApiService { }) } - async getMarketplaceData (params: RR.GetMarketplaceDataReq): Promise { + async getMarketplaceData (params: RR.GetMarketplaceDataReq): Promise { return this.http.httpRequest({ method: Method.GET, url: '/marketplace/package/data', @@ -98,7 +98,7 @@ export class LiveApiService extends ApiService { }) } - async getMarketplacePkgs (params: RR.GetMarketplacePackagesReq): Promise { + async getMarketplacePkgs (params: RR.GetMarketplacePackagesReq): Promise { if (params.query) params.category = undefined return this.http.httpRequest({ method: Method.GET, @@ -110,7 +110,7 @@ export class LiveApiService extends ApiService { }) } - async getReleaseNotes (params: RR.GetReleaseNotesReq): Promise { + async getReleaseNotes (params: RR.GetReleaseNotesReq): Promise { return this.http.httpRequest({ method: Method.GET, url: '/marketplace/package/release-notes', @@ -118,7 +118,7 @@ export class LiveApiService extends ApiService { }) } - async getLatestVersion (params: RR.GetLatestVersionReq): Promise { + async getLatestVersion (params: RR.GetLatestVersionReq): Promise { return this.http.httpRequest({ method: Method.GET, url: '/marketplace/latest-version', @@ -137,138 +137,149 @@ export class LiveApiService extends ApiService { // notification - async getNotificationsRaw (params: RR.GetNotificationsReq): Promise { + async getNotificationsRaw (params: RR.GetNotificationsReq): Promise { return this.http.rpcRequest({ method: 'notification.list', params }) } - async deleteNotification (params: RR.DeleteNotificationReq): Promise { + async deleteNotification (params: RR.DeleteNotificationReq): Promise { return this.http.rpcRequest({ method: 'notification.delete', params }) } - async deleteAllNotifications (params: RR.DeleteAllNotificationsReq): Promise { + async deleteAllNotifications (params: RR.DeleteAllNotificationsReq): Promise { return this.http.rpcRequest({ method: 'notification.delete-before', params }) } // wifi - async getWifi (params: RR.GetWifiReq, timeout?: number): Promise { + async getWifi (params: RR.GetWifiReq, timeout?: number): Promise { return this.http.rpcRequest({ method: 'wifi.get', params, timeout }) } - async setWifiCountry (params: RR.SetWifiCountryReq): Promise { + async setWifiCountry (params: RR.SetWifiCountryReq): Promise { return this.http.rpcRequest({ method: 'wifi.country.set', params }) } - async addWifi (params: RR.AddWifiReq): Promise { + async addWifi (params: RR.AddWifiReq): Promise { return this.http.rpcRequest({ method: 'wifi.add', params }) } - async connectWifi (params: RR.ConnectWifiReq): Promise { + async connectWifi (params: RR.ConnectWifiReq): Promise { return this.http.rpcRequest({ method: 'wifi.connect', params }) } - async deleteWifi (params: RR.DeleteWifiReq): Promise { + async deleteWifi (params: RR.DeleteWifiReq): Promise { return this.http.rpcRequest({ method: 'wifi.delete', params }) } // ssh - async getSshKeys (params: RR.GetSSHKeysReq): Promise { + async getSshKeys (params: RR.GetSSHKeysReq): Promise { return this.http.rpcRequest({ method: 'ssh.list', params }) } - async addSshKey (params: RR.AddSSHKeyReq): Promise { + async addSshKey (params: RR.AddSSHKeyReq): Promise { return this.http.rpcRequest({ method: 'ssh.add', params }) } - async deleteSshKey (params: RR.DeleteSSHKeyReq): Promise { + async deleteSshKey (params: RR.DeleteSSHKeyReq): Promise { return this.http.rpcRequest({ method: 'ssh.delete', params }) } // backup + async getBackupTargets (params: RR.GetBackupTargetsReq): Promise { + return this.http.rpcRequest({ method: 'backup.target.list', params }) + } + + async addBackupTarget (params: RR.AddBackupTargetReq): Promise { + params.path = params.path.replace('/\\/g', '/') + return this.http.rpcRequest({ method: 'backup.target.cifs.add', params }) + } + + async updateBackupTarget (params: RR.UpdateBackupTargetReq): Promise { + return this.http.rpcRequest({ method: 'backup.target.cifs.update', params }) + } + + async removeBackupTarget (params: RR.RemoveBackupTargetReq): Promise { + return this.http.rpcRequest({ method: 'backup.target.cifs.remove', params }) + } + + async getBackupInfo (params: RR.GetBackupInfoReq): Promise { + return this.http.rpcRequest({ method: 'backup.target.info', params }) + } + async createBackupRaw (params: RR.CreateBackupReq): Promise { return this.http.rpcRequest({ method: 'backup.create', params }) } - // drives - - getDrives (params: RR.GetDrivesReq): Promise { - return this.http.rpcRequest({ method: 'disk.list', params }) - } - - getBackupInfo (params: RR.GetBackupInfoReq): Promise { - return this.http.rpcRequest({ method: 'disk.backup-info', params }) - } - // package - async getPackageProperties (params: RR.GetPackagePropertiesReq): Promise ['data'] > { + async getPackageProperties (params: RR.GetPackagePropertiesReq): Promise ['data'] > { return this.http.rpcRequest({ method: 'package.properties', params }) .then(parsePropertiesPermissive) } - async getPackageLogs (params: RR.GetPackageLogsReq): Promise { + async getPackageLogs (params: RR.GetPackageLogsReq): Promise { return this.http.rpcRequest( { method: 'package.logs', params }) } - async getPkgMetrics (params: RR.GetPackageMetricsReq): Promise { + async getPkgMetrics (params: RR.GetPackageMetricsReq): Promise { return this.http.rpcRequest({ method: 'package.metrics', params }) } - async installPackageRaw (params: RR.InstallPackageReq): Promise { + async installPackageRaw (params: RR.InstallPackageReq): Promise { return this.http.rpcRequest({ method: 'package.install', params }) } - async dryUpdatePackage (params: RR.DryUpdatePackageReq): Promise { + async dryUpdatePackage (params: RR.DryUpdatePackageReq): Promise { return this.http.rpcRequest({ method: 'package.update.dry', params }) } - async getPackageConfig (params: RR.GetPackageConfigReq): Promise { + async getPackageConfig (params: RR.GetPackageConfigReq): Promise { return this.http.rpcRequest({ method: 'package.config.get', params }) } - async drySetPackageConfig (params: RR.DrySetPackageConfigReq): Promise { + async drySetPackageConfig (params: RR.DrySetPackageConfigReq): Promise { return this.http.rpcRequest({ method: 'package.config.set.dry', params }) } - async setPackageConfigRaw (params: RR.SetPackageConfigReq): Promise { + async setPackageConfigRaw (params: RR.SetPackageConfigReq): Promise { return this.http.rpcRequest({ method: 'package.config.set', params }) } - async restorePackagesRaw (params: RR.RestorePackagesReq): Promise { + async restorePackagesRaw (params: RR.RestorePackagesReq): Promise { return this.http.rpcRequest({ method: 'package.backup.restore', params }) } - async executePackageAction (params: RR.ExecutePackageActionReq): Promise { + async executePackageAction (params: RR.ExecutePackageActionReq): Promise { return this.http.rpcRequest({ method: 'package.action', params }) } - async startPackageRaw (params: RR.StartPackageReq): Promise { + async startPackageRaw (params: RR.StartPackageReq): Promise { return this.http.rpcRequest({ method: 'package.start', params }) } - async dryStopPackage (params: RR.DryStopPackageReq): Promise { + async dryStopPackage (params: RR.DryStopPackageReq): Promise { return this.http.rpcRequest({ method: 'package.stop.dry', params }) } - async stopPackageRaw (params: RR.StopPackageReq): Promise { + async stopPackageRaw (params: RR.StopPackageReq): Promise { return this.http.rpcRequest({ method: 'package.stop', params }) } - async dryUninstallPackage (params: RR.DryUninstallPackageReq): Promise { + async dryUninstallPackage (params: RR.DryUninstallPackageReq): Promise { return this.http.rpcRequest({ method: 'package.uninstall.dry', params }) } - async deleteRecoveredPackageRaw (params: RR.DeleteRecoveredPackageReq): Promise { + async deleteRecoveredPackageRaw (params: RR.DeleteRecoveredPackageReq): Promise { return this.http.rpcRequest({ method: 'package.delete-recovered', params }) } - async uninstallPackageRaw (params: RR.UninstallPackageReq): Promise { + async uninstallPackageRaw (params: RR.UninstallPackageReq): Promise { return this.http.rpcRequest({ method: 'package.uninstall', params }) } - async dryConfigureDependency (params: RR.DryConfigureDependencyReq): Promise { + async dryConfigureDependency (params: RR.DryConfigureDependencyReq): Promise { return this.http.rpcRequest({ method: 'package.dependency.configure.dry', params }) } } diff --git a/ui/src/app/services/api/embassy-mock-api.service.ts b/ui/src/app/services/api/embassy-mock-api.service.ts index 4670f8431..b73ede22f 100644 --- a/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/ui/src/app/services/api/embassy-mock-api.service.ts @@ -3,7 +3,7 @@ import { pauseFor } from '../../util/misc.util' import { ApiService } from './embassy-api.service' import { PatchOp, Update, Operation, RemoveOperation } from 'patch-db-client' import { DataModel, DependencyErrorType, InstallProgress, PackageDataEntry, PackageMainStatus, PackageState, ServerStatus } from 'src/app/services/patch-db/data-model' -import { Log, RR, WithRevision } from './api.types' +import { CifsBackupTarget, Log, RR, WithRevision } from './api.types' import { parsePropertiesPermissive } from 'src/app/util/properties.util' import { Mock } from './api.fixures' import markdown from 'raw-loader!src/assets/markdown/md-sample.md' @@ -277,6 +277,49 @@ export class MockApiService extends ApiService { // backup + async getBackupTargets (params: RR.GetBackupTargetsReq): Promise { + await pauseFor(2000) + return Mock.BackupTargets + } + + async addBackupTarget (params: RR.AddBackupTargetReq): Promise { + await pauseFor(2000) + const { hostname, path, username } = params + return { + 'latfgvwdbhjsndmk': { + type: 'cifs', + hostname, + path: path.replace(/\\/g, '/'), + username, + mountable: true, + 'embassy-os': null, + }, + } + } + + async updateBackupTarget (params: RR.UpdateBackupTargetReq): Promise { + await pauseFor(2000) + const { id, hostname, path, username } = params + return { + [id]: { + ...Mock.BackupTargets[id] as CifsBackupTarget, + hostname, + path, + username, + }, + } + } + + async removeBackupTarget (params: RR.RemoveBackupTargetReq): Promise { + await pauseFor(2000) + return null + } + + async getBackupInfo (params: RR.GetBackupInfoReq): Promise { + await pauseFor(2000) + return Mock.BackupInfo + } + async createBackupRaw (params: RR.CreateBackupReq): Promise { await pauseFor(2000) const path = '/server-info/status' @@ -324,18 +367,6 @@ export class MockApiService extends ApiService { return this.withRevision(originalPatch) } - // drives - - async getDrives (params: RR.GetDrivesReq): Promise { - await pauseFor(2000) - return Mock.Drives - } - - async getBackupInfo (params: RR.GetBackupInfoReq): Promise { - await pauseFor(2000) - return Mock.BackupInfo - } - // package async getPackageProperties (params: RR.GetPackagePropertiesReq): Promise['data']> { diff --git a/ui/src/app/services/api/mock-patch.ts b/ui/src/app/services/api/mock-patch.ts index d5d2cf219..4b0c23a34 100644 --- a/ui/src/app/services/api/mock-patch.ts +++ b/ui/src/app/services/api/mock-patch.ts @@ -19,7 +19,7 @@ export const mockPatchData: DataModel = { 'package-marketplace': null, 'share-stats': false, 'unread-notification-count': 4, - // 'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + 'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', 'eos-version-compat': '>=0.3.0', }, 'recovered-packages': { diff --git a/ui/src/app/services/auth.service.ts b/ui/src/app/services/auth.service.ts index 09563bc97..84a5cf308 100644 --- a/ui/src/app/services/auth.service.ts +++ b/ui/src/app/services/auth.service.ts @@ -6,14 +6,13 @@ import { Storage } from '@ionic/storage-angular' export enum AuthState { UNVERIFIED, VERIFIED, - INITIALIZING, } @Injectable({ providedIn: 'root', }) export class AuthService { private readonly LOGGED_IN_KEY = 'loggedInKey' - private readonly authState$: BehaviorSubject = new BehaviorSubject(AuthState.INITIALIZING) + private readonly authState$: BehaviorSubject = new BehaviorSubject(undefined) constructor ( private readonly storage: Storage, diff --git a/ui/src/app/services/http.service.ts b/ui/src/app/services/http.service.ts index b6caad0e6..630b8cd0d 100644 --- a/ui/src/app/services/http.service.ts +++ b/ui/src/app/services/http.service.ts @@ -133,10 +133,7 @@ export enum Method { export interface RPCOptions { method: string - // @TODO what are valid params? object, bool? - params?: { - [param: string]: string | number | boolean | object | string[] | number[]; - } + params?: object timeout?: number } diff --git a/ui/src/app/services/patch-db/data-model.ts b/ui/src/app/services/patch-db/data-model.ts index 2051345bf..5e2f31fab 100644 --- a/ui/src/app/services/patch-db/data-model.ts +++ b/ui/src/app/services/patch-db/data-model.ts @@ -31,6 +31,7 @@ export interface ServerInfo { downloaded: number } 'eos-version-compat': string + 'password-hash': string } export enum ServerStatus { @@ -129,7 +130,7 @@ export interface Manifest { backup: BackupActions migrations: Migrations actions: { [id: string]: Action } - permissions: any // @TODO + permissions: any // @TODO 0.3.1 dependencies: DependencyInfo } diff --git a/ui/src/app/services/server-config.service.ts b/ui/src/app/services/server-config.service.ts index 7a53fbaef..d9a918b3e 100644 --- a/ui/src/app/services/server-config.service.ts +++ b/ui/src/app/services/server-config.service.ts @@ -145,7 +145,7 @@ export const serverConfig: ConfigSpec = { 'share-stats': { type: 'boolean', name: 'Report Bugs', - description: new IonicSafeString(`If enabled, generic error codes will be anonymously transmitted over Tor to the Start9 team. This helps us identify and fix bugs quickly. Read more `) as any, // @TODO get actual link + description: new IonicSafeString(`If enabled, generic error codes will be anonymously transmitted over Tor to the Start9 team. This helps us identify and fix bugs quickly. Read more `) as any, default: false, }, // password: { diff --git a/ui/src/app/util/misc.util.ts b/ui/src/app/util/misc.util.ts index 92cbf11ca..060c8599a 100644 --- a/ui/src/app/util/misc.util.ts +++ b/ui/src/app/util/misc.util.ts @@ -1,10 +1,15 @@ import { OperatorFunction } from 'rxjs' import { map } from 'rxjs/operators' -import { DriveInfo, PartitionInfo } from '../services/api/api.types' export type Omit = Pick> export type PromiseRes = { result: 'resolve', value: T } | { result: 'reject', value: Error } +export interface MappedBackupTarget { + id: string + hasValidBackup: boolean + entry: T +} + export interface DependentInfo { id: string title: string @@ -190,11 +195,3 @@ export function debounce (delay: number = 300): MethodDecorator { return descriptor } } - -export interface MappedDriveInfo extends DriveInfo { - partitions: MappedPartitionInfo[] -} - -export interface MappedPartitionInfo extends PartitionInfo { - hasBackup: boolean -} \ No newline at end of file