feat: encrypted profile sync through a shared folder
Profiles can be kept in step across machines through any folder they share (cloud drive, network share, git checkout): one file, nexus-profiles.nexus, in the encrypted-export format under a sync password kept in the keychain/vault. Each sync merges profile by profile (newest updated_at wins), propagates deletions as dated tombstones (an edit after a deletion brings the profile back; tombstones expire after 180 days), validates every incoming profile, writes the file only when it changed, and never syncs credentials. Settings → Security → Profile sync picks the folder and sets the password; syncs run at launch, 3 s after a profile change, every 10 minutes and on Sync Now (automatic runs stay quiet unless something changed). An end-to-end test drives create, edit and delete between two data directories sharing a folder.
This commit is contained in:
@@ -41,6 +41,7 @@ Use Nexus Terminal when you need to:
|
|||||||
- Multi-select profiles with Ctrl/Cmd-click for bulk connect, disconnect, tagging, moving between groups and deleting.
|
- Multi-select profiles with Ctrl/Cmd-click for bulk connect, disconnect, tagging, moving between groups and deleting.
|
||||||
- Test basic host and port reachability before connecting.
|
- Test basic host and port reachability before connecting.
|
||||||
- Import existing OpenSSH config entries from `~/.ssh/config`, including hostnames, usernames, ports, identity files and jump hosts.
|
- Import existing OpenSSH config entries from `~/.ssh/config`, including hostnames, usernames, ports, identity files and jump hosts.
|
||||||
|
- Sync profiles between machines through any shared folder, as one encrypted file (newest change wins; deletions propagate; credentials stay local).
|
||||||
- Export and import profiles using encrypted `.nexus` bundles.
|
- Export and import profiles using encrypted `.nexus` bundles.
|
||||||
- Plain JSON export and import, with secrets redacted unless you choose to include them.
|
- Plain JSON export and import, with secrets redacted unless you choose to include them.
|
||||||
- Choose import behaviour for duplicates: skip, overwrite or duplicate.
|
- Choose import behaviour for duplicates: skip, overwrite or duplicate.
|
||||||
|
|||||||
@@ -428,6 +428,15 @@ Connections, disconnections, profile changes, credential access, imports and exp
|
|||||||
|
|
||||||
## 13. Import and export
|
## 13. Import and export
|
||||||
|
|
||||||
|
### Profile sync
|
||||||
|
|
||||||
|
**Settings → Security → Profile sync** keeps connection profiles the same on several machines. Choose a folder all of them can reach (a Dropbox, iCloud Drive, OneDrive or Nextcloud folder, a network share, or a git checkout you commit), and set the same **sync password** on each. Nexus Terminal keeps one encrypted file there, `nexus-profiles.nexus` (Argon2id + AES-256-GCM, like an encrypted export), and merges it at launch, a few seconds after you change a profile, every ten minutes and when you click **Sync Now**:
|
||||||
|
|
||||||
|
- for a profile changed on both sides, the more recent change wins;
|
||||||
|
- a profile deleted on one machine is deleted on the others (unless it was edited after the deletion);
|
||||||
|
- passwords, keys and other credentials are never synced; each machine keeps its own in its keychain or vault.
|
||||||
|
|
||||||
|
|
||||||
- **Encrypted export**: **File → Export profiles…**, the sidebar footer, the command palette, or **Export…** on a profile's right-click menu. Choose the profiles, set a password and save a `.nexus` file. Credentials saved in the keychain or vault are not included.
|
- **Encrypted export**: **File → Export profiles…**, the sidebar footer, the command palette, or **Export…** on a profile's right-click menu. Choose the profiles, set a password and save a `.nexus` file. Credentials saved in the keychain or vault are not included.
|
||||||
- **Encrypted import**: **File → Import profiles…** or the sidebar footer. Choose a `.nexus` file, enter its password, and choose what happens when a profile already exists: **Skip existing**, **Overwrite existing** or **Create duplicates**.
|
- **Encrypted import**: **File → Import profiles…** or the sidebar footer. Choose a `.nexus` file, enter its password, and choose what happens when a profile already exists: **Skip existing**, **Overwrite existing** or **Create duplicates**.
|
||||||
- **JSON export and import**: **Export Profiles (JSON)** and **Import Profiles (JSON)** in the command palette. JSON export includes every profile; passwords in login-script `send` steps that answer a password prompt are blanked unless you tick **Include secrets**. Proxy passwords and TOTP secrets live in the keychain or vault, never in the profile, so no export carries them.
|
- **JSON export and import**: **Export Profiles (JSON)** and **Import Profiles (JSON)** in the command palette. JSON export includes every profile; passwords in login-script `send` steps that answer a password prompt are blanked unless you tick **Include secrets**. Proxy passwords and TOTP secrets live in the keychain or vault, never in the profile, so no export carries them.
|
||||||
|
|||||||
+53
-1
@@ -124,6 +124,10 @@ pub struct App {
|
|||||||
/// The tab layout as last saved for restoring (see restore.rs), and when it was last checked.
|
/// The tab layout as last saved for restoring (see restore.rs), and when it was last checked.
|
||||||
pub saved_layout: Option<String>,
|
pub saved_layout: Option<String>,
|
||||||
layout_checked: f64,
|
layout_checked: f64,
|
||||||
|
/// A profile sync in flight; when the next automatic one is due; whether it was asked for.
|
||||||
|
sync_job: Option<crate::settings::Job<Result<nexus_core::service::SyncReport, String>>>,
|
||||||
|
sync_due: f64,
|
||||||
|
sync_manual: bool,
|
||||||
rx: mpsc::Receiver<Event>,
|
rx: mpsc::Receiver<Event>,
|
||||||
pub theme: ThemeKind,
|
pub theme: ThemeKind,
|
||||||
pub editor: profile_editor::ProfileEditor,
|
pub editor: profile_editor::ProfileEditor,
|
||||||
@@ -200,6 +204,9 @@ impl App {
|
|||||||
resize_sidebar: false,
|
resize_sidebar: false,
|
||||||
saved_layout: None,
|
saved_layout: None,
|
||||||
layout_checked: 0.0,
|
layout_checked: 0.0,
|
||||||
|
sync_job: None,
|
||||||
|
sync_due: 0.0,
|
||||||
|
sync_manual: false,
|
||||||
};
|
};
|
||||||
let restore = opts.connect.is_empty() && app.run_local.is_none() && opts.open.is_none() && app.s.prefs.restore_sessions;
|
let restore = opts.connect.is_empty() && app.run_local.is_none() && opts.open.is_none() && app.s.prefs.restore_sessions;
|
||||||
if restore {
|
if restore {
|
||||||
@@ -614,7 +621,15 @@ impl App {
|
|||||||
Err(e) => self.s.error(format!("Can't connect to {name}: {e}")),
|
Err(e) => self.s.error(format!("Can't connect to {name}: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Request::ReloadProfiles => self.s.reload_profiles(),
|
Request::ReloadProfiles => {
|
||||||
|
self.s.reload_profiles();
|
||||||
|
// A profile changed: sync it out shortly (batched with any further edits).
|
||||||
|
self.sync_due = self.sync_due.min(self.s.now + 3.0);
|
||||||
|
}
|
||||||
|
Request::SyncProfiles => {
|
||||||
|
self.sync_manual = true;
|
||||||
|
self.sync_due = 0.0;
|
||||||
|
}
|
||||||
Request::ReloadPreferences => {
|
Request::ReloadPreferences => {
|
||||||
self.s.prefs = self.s.svc.preferences();
|
self.s.prefs = self.s.svc.preferences();
|
||||||
self.resize_sidebar = true;
|
self.resize_sidebar = true;
|
||||||
@@ -784,6 +799,42 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Profile sync: at launch, a few seconds after a profile changes, every 10 minutes and on
|
||||||
|
/// request, when a folder and password are set. Automatic runs stay quiet unless something
|
||||||
|
/// changed or a manual one failed.
|
||||||
|
fn drive_sync(&mut self, ctx: &egui::Context) {
|
||||||
|
if let Some(r) = crate::settings::poll(&mut self.sync_job) {
|
||||||
|
let manual = std::mem::take(&mut self.sync_manual);
|
||||||
|
match r {
|
||||||
|
Ok(rep) => {
|
||||||
|
if rep.pulled + rep.removed > 0 {
|
||||||
|
self.s.reload_profiles();
|
||||||
|
self.s.info(format!("Profile sync: {} updated, {} removed", rep.pulled, rep.removed));
|
||||||
|
} else if manual {
|
||||||
|
self.s.success(if rep.pushed { "Profiles synced" } else { "Profiles already in sync" });
|
||||||
|
}
|
||||||
|
if rep.skipped > 0 {
|
||||||
|
self.s.error(format!("Profile sync skipped {} invalid profile(s) from the shared file", rep.skipped));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) if manual => self.s.error(format!("Profile sync failed: {e}")),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "profile sync failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.screenshot.is_some() || self.sync_job.is_some() || self.s.now < self.sync_due {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.sync_due = self.s.now + 600.0;
|
||||||
|
if self.s.prefs.sync_folder.trim().is_empty() {
|
||||||
|
if std::mem::take(&mut self.sync_manual) {
|
||||||
|
self.s.error("Choose a sync folder first");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let svc = self.s.svc.clone();
|
||||||
|
self.sync_job = Some(crate::settings::Job::spawn(ctx, move || svc.sync_profiles().map_err(|e| e.to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn toggle_broadcast(&mut self) {
|
pub fn toggle_broadcast(&mut self) {
|
||||||
self.s.broadcast = !self.s.broadcast;
|
self.s.broadcast = !self.s.broadcast;
|
||||||
if !self.s.broadcast {
|
if !self.s.broadcast {
|
||||||
@@ -1012,6 +1063,7 @@ impl eframe::App for App {
|
|||||||
chrome::palette(self, &ctx);
|
chrome::palette(self, &ctx);
|
||||||
chrome::toasts(self, &ctx);
|
chrome::toasts(self, &ctx);
|
||||||
self.screenshot(&ctx);
|
self.screenshot(&ctx);
|
||||||
|
self.drive_sync(&ctx);
|
||||||
if self.screenshot.is_none() && self.s.prefs.restore_sessions && self.s.now - self.layout_checked > 1.0 {
|
if self.screenshot.is_none() && self.s.prefs.restore_sessions && self.s.now - self.layout_checked > 1.0 {
|
||||||
self.layout_checked = self.s.now;
|
self.layout_checked = self.s.now;
|
||||||
crate::restore::save_if_changed(self);
|
crate::restore::save_if_changed(self);
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ pub struct Settings {
|
|||||||
known: Vec<KnownHost>,
|
known: Vec<KnownHost>,
|
||||||
/// Add-CA form: (host pattern, public key), shown when Some.
|
/// Add-CA form: (host pattern, public key), shown when Some.
|
||||||
new_ca: Option<(String, String)>,
|
new_ca: Option<(String, String)>,
|
||||||
|
sync_password: String,
|
||||||
default_shell: String,
|
default_shell: String,
|
||||||
help_topic: usize,
|
help_topic: usize,
|
||||||
master: Option<MasterPrompt>,
|
master: Option<MasterPrompt>,
|
||||||
@@ -381,6 +382,69 @@ impl Settings {
|
|||||||
s.info("Secret store locked");
|
s.info("Secret store locked");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
self.sync_section(ui, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_section(&mut self, ui: &mut Ui, s: &mut Shared) {
|
||||||
|
ui.add_space(12.0);
|
||||||
|
widgets::section(ui, "Profile sync");
|
||||||
|
widgets::hint(
|
||||||
|
ui,
|
||||||
|
"Keep connection profiles in step across machines through a folder they share (a cloud drive, a network share, a git checkout). \
|
||||||
|
Profiles are stored there encrypted with the sync password; passwords and keys are never synced.",
|
||||||
|
);
|
||||||
|
form(ui, "sync-form", |ui| {
|
||||||
|
field(ui, "Folder");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let shown = if self.prefs.sync_folder.is_empty() { "Not set".to_string() } else { self.prefs.sync_folder.clone() };
|
||||||
|
ui.label(RichText::new(shown).font(theme::mono(12.0)));
|
||||||
|
if widgets::button(ui, icon::FOLDER_OPEN, "Choose…").clicked()
|
||||||
|
&& let Some(dir) = rfd::FileDialog::new().set_title("Folder to sync profiles through").pick_folder()
|
||||||
|
{
|
||||||
|
self.set_sync_folder(s, dir.to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
if !self.prefs.sync_folder.is_empty() && ui.button("Turn Off").clicked() {
|
||||||
|
self.set_sync_folder(s, String::new());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.end_row();
|
||||||
|
field(ui, "Sync password");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut self.sync_password)
|
||||||
|
.password(true)
|
||||||
|
.hint_text(if s.svc.has_sync_password() { "Stored — type to replace" } else { "Same on every machine" })
|
||||||
|
.desired_width(220.0),
|
||||||
|
);
|
||||||
|
if ui.add_enabled(!self.sync_password.is_empty(), egui::Button::new("Save")).clicked() {
|
||||||
|
match s.svc.set_sync_password(&self.sync_password) {
|
||||||
|
Ok(()) => s.success("Sync password stored"),
|
||||||
|
Err(e) => s.error(format!("Couldn't store the sync password: {e}")),
|
||||||
|
}
|
||||||
|
self.sync_password.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.end_row();
|
||||||
|
field(ui, "");
|
||||||
|
if widgets::button(ui, icon::ARROWS_CLOCKWISE, "Sync Now").clicked() {
|
||||||
|
s.request(Request::SyncProfiles);
|
||||||
|
}
|
||||||
|
ui.end_row();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves just the sync folder (the rest of the form keeps its unsaved edits).
|
||||||
|
fn set_sync_folder(&mut self, s: &mut Shared, folder: String) {
|
||||||
|
let mut stored = s.svc.preferences();
|
||||||
|
stored.sync_folder = folder.clone();
|
||||||
|
match s.svc.save_preferences(&stored) {
|
||||||
|
Ok(()) => {
|
||||||
|
self.prefs.sync_folder = folder.clone();
|
||||||
|
s.prefs.sync_folder = folder;
|
||||||
|
s.request(Request::SyncProfiles);
|
||||||
|
}
|
||||||
|
Err(e) => s.error(format!("Failed to save: {e}")),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn master_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) {
|
fn master_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ use std::sync::Arc;
|
|||||||
/// Things a feature asks the main window to do (handled once per frame, in order).
|
/// Things a feature asks the main window to do (handled once per frame, in order).
|
||||||
#[allow(clippy::large_enum_variant)] // a per-frame queue; boxing buys nothing
|
#[allow(clippy::large_enum_variant)] // a per-frame queue; boxing buys nothing
|
||||||
pub enum Request {
|
pub enum Request {
|
||||||
|
/// Merge profiles with the sync folder (manual, or after a change).
|
||||||
|
SyncProfiles,
|
||||||
/// Open the profile editor for a new profile (optionally pre-set protocol / folder).
|
/// Open the profile editor for a new profile (optionally pre-set protocol / folder).
|
||||||
NewProfile {
|
NewProfile {
|
||||||
protocol: Option<Protocol>,
|
protocol: Option<Protocol>,
|
||||||
|
|||||||
@@ -36,16 +36,33 @@ pub fn encrypt_and_export(profiles: &[Profile], password: &str, path: &Path) ->
|
|||||||
profiles: profiles.to_vec(),
|
profiles: profiles.to_vec(),
|
||||||
count: profiles.len(),
|
count: profiles.len(),
|
||||||
};
|
};
|
||||||
let plain = serde_json::to_vec_pretty(&bundle)?;
|
crate::secrets::write_private(path, &seal(&serde_json::to_vec_pretty(&bundle)?, password)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[version][salt 32][nonce 12][AES-256-GCM ciphertext]` under an Argon2id key from `password`.
|
||||||
|
pub fn seal(plain: &[u8], password: &str) -> Result<Vec<u8>> {
|
||||||
let salt = random_bytes::<32>();
|
let salt = random_bytes::<32>();
|
||||||
let nonce = random_bytes::<12>();
|
let nonce = random_bytes::<12>();
|
||||||
let key = derive_key(password, &salt)?;
|
let key = derive_key(password, &salt)?;
|
||||||
let ct = Aes256Gcm::new((&*key).into()).encrypt(Nonce::from_slice(&nonce), plain.as_slice()).map_err(|e| e.to_string())?;
|
let ct = Aes256Gcm::new((&*key).into()).encrypt(Nonce::from_slice(&nonce), plain).map_err(|e| e.to_string())?;
|
||||||
let mut out = vec![VERSION];
|
let mut out = vec![VERSION];
|
||||||
out.extend(salt);
|
out.extend(salt);
|
||||||
out.extend(nonce);
|
out.extend(nonce);
|
||||||
out.extend(ct);
|
out.extend(ct);
|
||||||
crate::secrets::write_private(path, &out)
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open(data: &[u8], password: &str) -> Result<Vec<u8>> {
|
||||||
|
if data.len() < 1 + 32 + 12 + 1 + 16 {
|
||||||
|
return Err("exportimport: file is too small or corrupt".into());
|
||||||
|
}
|
||||||
|
if data[0] != VERSION {
|
||||||
|
return Err(format!("exportimport: unsupported format version {}", data[0]).into());
|
||||||
|
}
|
||||||
|
let key = derive_key(password, &data[1..33])?;
|
||||||
|
Ok(Aes256Gcm::new((&*key).into())
|
||||||
|
.decrypt(Nonce::from_slice(&data[33..45]), &data[45..])
|
||||||
|
.map_err(|_| "exportimport: decryption failed — wrong password or corrupt file")?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decrypt_and_import(path: &Path, password: &str) -> Result<Vec<Profile>> {
|
pub fn decrypt_and_import(path: &Path, password: &str) -> Result<Vec<Profile>> {
|
||||||
@@ -55,17 +72,7 @@ pub fn decrypt_and_import(path: &Path, password: &str) -> Result<Vec<Profile>> {
|
|||||||
if std::fs::metadata(path)?.len() > MAX_IMPORT_BYTES {
|
if std::fs::metadata(path)?.len() > MAX_IMPORT_BYTES {
|
||||||
return Err("exportimport: file is too large (max 10 MiB)".into());
|
return Err("exportimport: file is too large (max 10 MiB)".into());
|
||||||
}
|
}
|
||||||
let data = std::fs::read(path)?;
|
let plain = open(&std::fs::read(path)?, password)?;
|
||||||
if data.len() < 1 + 32 + 12 + 1 + 16 {
|
|
||||||
return Err("exportimport: file is too small or corrupt".into());
|
|
||||||
}
|
|
||||||
if data[0] != VERSION {
|
|
||||||
return Err(format!("exportimport: unsupported format version {}", data[0]).into());
|
|
||||||
}
|
|
||||||
let key = derive_key(password, &data[1..33])?;
|
|
||||||
let plain = Aes256Gcm::new((&*key).into())
|
|
||||||
.decrypt(Nonce::from_slice(&data[33..45]), &data[45..])
|
|
||||||
.map_err(|_| "exportimport: decryption failed — wrong password or corrupt file")?;
|
|
||||||
let bundle: Bundle = serde_json::from_slice(&plain).map_err(|e| format!("exportimport: invalid bundle JSON: {e}"))?;
|
let bundle: Bundle = serde_json::from_slice(&plain).map_err(|e| format!("exportimport: invalid bundle JSON: {e}"))?;
|
||||||
Ok(bundle.profiles)
|
Ok(bundle.profiles)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub mod service;
|
|||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod sshconfig;
|
pub mod sshconfig;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
pub mod sync;
|
||||||
pub mod terminalenv;
|
pub mod terminalenv;
|
||||||
pub mod updater;
|
pub mod updater;
|
||||||
pub mod validation;
|
pub mod validation;
|
||||||
|
|||||||
@@ -628,6 +628,8 @@ pub struct AppPreferences {
|
|||||||
pub confirm_multiline_paste: bool,
|
pub confirm_multiline_paste: bool,
|
||||||
/// Reopen the previous session's tabs and splits at launch.
|
/// Reopen the previous session's tabs and splits at launch.
|
||||||
pub restore_sessions: bool,
|
pub restore_sessions: bool,
|
||||||
|
/// Folder holding the encrypted profile sync file (empty: sync off).
|
||||||
|
pub sync_folder: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppPreferences {
|
impl Default for AppPreferences {
|
||||||
@@ -650,6 +652,7 @@ impl Default for AppPreferences {
|
|||||||
allow_remote_clipboard: true,
|
allow_remote_clipboard: true,
|
||||||
confirm_multiline_paste: true,
|
confirm_multiline_paste: true,
|
||||||
restore_sessions: true,
|
restore_sessions: true,
|
||||||
|
sync_folder: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,23 @@ pub const VERSION: &str = match option_env!("NEXUS_VERSION") {
|
|||||||
};
|
};
|
||||||
const MAX_IMPORT_JSON_BYTES: usize = 5 * 1024 * 1024;
|
const MAX_IMPORT_JSON_BYTES: usize = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Secret-store key of the profile-sync password.
|
||||||
|
const SYNC_PASSWORD: &str = "sync:password";
|
||||||
|
/// Setting holding deleted profile ids and when, for sync.
|
||||||
|
const SYNC_DELETED: &str = "sync_deleted";
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct SyncReport {
|
||||||
|
/// Profiles created or updated from the shared file.
|
||||||
|
pub pulled: usize,
|
||||||
|
/// Profiles deleted because another machine deleted them.
|
||||||
|
pub removed: usize,
|
||||||
|
/// Incoming profiles that failed validation.
|
||||||
|
pub skipped: usize,
|
||||||
|
/// Whether the shared file was rewritten.
|
||||||
|
pub pushed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Service {
|
pub struct Service {
|
||||||
pub config: AppConfig,
|
pub config: AppConfig,
|
||||||
pub store: Arc<Store>,
|
pub store: Arc<Store>,
|
||||||
@@ -72,6 +89,10 @@ impl Service {
|
|||||||
let p = self.store.get_profile(id).map_err(|e| format!("profile not found: {e}"))?;
|
let p = self.store.get_profile(id).map_err(|e| format!("profile not found: {e}"))?;
|
||||||
self.store.delete_profile(id).map_err(|e| format!("failed to delete profile: {e}"))?;
|
self.store.delete_profile(id).map_err(|e| format!("failed to delete profile: {e}"))?;
|
||||||
self.secrets.delete_profile(id);
|
self.secrets.delete_profile(id);
|
||||||
|
// So other machines syncing the same folder delete it too.
|
||||||
|
let mut gone = self.sync_tombstones();
|
||||||
|
gone.insert(id.to_string(), now());
|
||||||
|
let _ = self.store.set_setting(SYNC_DELETED, &serde_json::to_string(&gone).unwrap_or_default());
|
||||||
self.store.add_audit("profile.delete", id, "", &format!("Deleted profile {:?}", p.display_name));
|
self.store.add_audit("profile.delete", id, "", &format!("Deleted profile {:?}", p.display_name));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -88,6 +109,65 @@ impl Service {
|
|||||||
Ok(d)
|
Ok(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sync_tombstones(&self) -> std::collections::BTreeMap<String, Time> {
|
||||||
|
self.store.setting(SYNC_DELETED).and_then(|j| serde_json::from_str(&j).ok()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets (or, when empty, forgets) the profile-sync password.
|
||||||
|
pub fn set_sync_password(&self, password: &str) -> Result<()> {
|
||||||
|
if password.is_empty() {
|
||||||
|
self.secrets.delete(SYNC_PASSWORD);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.secrets.set_checked(SYNC_PASSWORD, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_sync_password(&self) -> bool {
|
||||||
|
self.secrets.get(SYNC_PASSWORD).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges profiles with the shared sync file; see [`crate::sync`].
|
||||||
|
pub fn sync_profiles(&self) -> Result<SyncReport> {
|
||||||
|
let folder = self.store.preferences().sync_folder;
|
||||||
|
if folder.trim().is_empty() {
|
||||||
|
return Err("profile sync isn't set up (choose a folder in Settings)".into());
|
||||||
|
}
|
||||||
|
let folder = PathBuf::from(folder.trim());
|
||||||
|
if !folder.is_dir() {
|
||||||
|
return Err(format!("sync folder {} isn't available", folder.display()).into());
|
||||||
|
}
|
||||||
|
let password = self.secrets.get(SYNC_PASSWORD).ok_or("no sync password is stored (set one in Settings)")?;
|
||||||
|
let remote = crate::sync::read(&folder, &password)?.unwrap_or_default();
|
||||||
|
let m = crate::sync::merge(&self.profiles(), &self.sync_tombstones(), &remote, now());
|
||||||
|
let mut report = SyncReport::default();
|
||||||
|
for mut p in m.upsert {
|
||||||
|
// The file is ours, but it crossed a shared folder: validate what comes in.
|
||||||
|
p.normalize();
|
||||||
|
if validation::validate_profile(&p).is_err() {
|
||||||
|
report.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let r = if self.store.get_profile(&p.id).is_ok() { self.store.update_profile(&p) } else { self.store.create_profile(&p) };
|
||||||
|
match r {
|
||||||
|
Ok(()) => report.pulled += 1,
|
||||||
|
Err(_) => report.skipped += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in &m.delete {
|
||||||
|
if self.store.delete_profile(id).is_ok() {
|
||||||
|
self.secrets.delete_profile(id);
|
||||||
|
report.removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.file != remote {
|
||||||
|
crate::sync::write(&folder, &password, &m.file)?;
|
||||||
|
report.pushed = true;
|
||||||
|
}
|
||||||
|
self.store.set_setting(SYNC_DELETED, &serde_json::to_string(&m.file.deleted)?)?;
|
||||||
|
self.store.add_audit("sync", "", "", &format!("Profile sync: {} in, {} removed, wrote file: {}", report.pulled, report.removed, report.pushed));
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn profiles(&self) -> Vec<Profile> {
|
pub fn profiles(&self) -> Vec<Profile> {
|
||||||
self.store.list_profiles().unwrap_or_default()
|
self.store.list_profiles().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! Profile sync through a folder the user picks (a cloud drive, a network share, a git checkout):
|
||||||
|
//! one encrypted file, `nexus-profiles.nexus`, in the export format (Argon2id + AES-256-GCM) under
|
||||||
|
//! a sync password. Each side merges profile by profile — the newer `updated_at` wins — and
|
||||||
|
//! deletions travel as dated tombstones, so a profile removed on one machine goes away on the
|
||||||
|
//! others. Credentials are never synced: they live in each machine's keychain or vault.
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
|
use crate::models::{Profile, Time};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub const FILE_NAME: &str = "nexus-profiles.nexus";
|
||||||
|
/// Tombstones older than this are forgotten (every machine has had time to see them).
|
||||||
|
const TOMBSTONE_DAYS: i64 = 180;
|
||||||
|
const MAX_FILE: u64 = 20 << 20;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SyncFile {
|
||||||
|
pub version: i64,
|
||||||
|
pub profiles: Vec<Profile>,
|
||||||
|
/// Deleted profile id → when.
|
||||||
|
#[serde(default)]
|
||||||
|
pub deleted: BTreeMap<String, Time>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, PartialEq)]
|
||||||
|
pub struct Merged {
|
||||||
|
/// Profiles to create or update locally.
|
||||||
|
pub upsert: Vec<Profile>,
|
||||||
|
/// Local profiles to delete.
|
||||||
|
pub delete: Vec<String>,
|
||||||
|
/// What the shared file should now hold.
|
||||||
|
pub file: SyncFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges local state with the shared file.
|
||||||
|
pub fn merge(local: &[Profile], local_deleted: &BTreeMap<String, Time>, remote: &SyncFile, now: Time) -> Merged {
|
||||||
|
let mut deleted = remote.deleted.clone();
|
||||||
|
for (id, t) in local_deleted {
|
||||||
|
let e = deleted.entry(id.clone()).or_insert(*t);
|
||||||
|
*e = (*e).max(*t);
|
||||||
|
}
|
||||||
|
deleted.retain(|_, t| (now - *t).num_days() < TOMBSTONE_DAYS);
|
||||||
|
|
||||||
|
let remote_by: HashMap<&str, &Profile> = remote.profiles.iter().map(|p| (p.id.as_str(), p)).collect();
|
||||||
|
let local_by: HashMap<&str, &Profile> = local.iter().map(|p| (p.id.as_str(), p)).collect();
|
||||||
|
let mut out = Merged::default();
|
||||||
|
let mut ids: Vec<&str> = local_by.keys().chain(remote_by.keys()).copied().collect();
|
||||||
|
ids.sort_unstable();
|
||||||
|
ids.dedup();
|
||||||
|
for id in ids {
|
||||||
|
let winner = match (local_by.get(id), remote_by.get(id)) {
|
||||||
|
(Some(l), Some(r)) => {
|
||||||
|
if r.updated_at > l.updated_at {
|
||||||
|
out.upsert.push((*r).clone());
|
||||||
|
*r
|
||||||
|
} else {
|
||||||
|
*l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(l), None) => *l,
|
||||||
|
(None, Some(r)) => *r,
|
||||||
|
(None, None) => continue,
|
||||||
|
};
|
||||||
|
// A deletion newer than the last edit wins; an edit after the deletion brings it back.
|
||||||
|
if deleted.get(id).is_some_and(|t| *t >= winner.updated_at) {
|
||||||
|
if local_by.contains_key(id) {
|
||||||
|
out.delete.push(id.to_string());
|
||||||
|
}
|
||||||
|
out.upsert.retain(|p| p.id != id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
deleted.remove(id);
|
||||||
|
if !local_by.contains_key(id) {
|
||||||
|
out.upsert.push(winner.clone());
|
||||||
|
}
|
||||||
|
out.file.profiles.push(winner.clone());
|
||||||
|
}
|
||||||
|
out.file.version = 1;
|
||||||
|
out.file.deleted = deleted;
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read(folder: &Path, password: &str) -> Result<Option<SyncFile>> {
|
||||||
|
let path = folder.join(FILE_NAME);
|
||||||
|
let Ok(meta) = std::fs::metadata(&path) else { return Ok(None) };
|
||||||
|
if meta.len() > MAX_FILE {
|
||||||
|
return Err("sync file is too large".into());
|
||||||
|
}
|
||||||
|
let plain = crate::exportimport::open(&std::fs::read(&path)?, password).map_err(|_| "the sync password doesn't open the sync file (or it is damaged)")?;
|
||||||
|
Ok(Some(serde_json::from_slice(&plain).map_err(|e| format!("sync file isn't readable: {e}"))?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write(folder: &Path, password: &str, file: &SyncFile) -> Result<()> {
|
||||||
|
crate::secrets::write_private(&folder.join(FILE_NAME), &crate::exportimport::seal(&serde_json::to_vec(file)?, password)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::Protocol;
|
||||||
|
|
||||||
|
fn p(id: &str, host: &str, t: i64) -> Profile {
|
||||||
|
let mut p = Profile::new(Protocol::Ssh);
|
||||||
|
p.id = id.into();
|
||||||
|
p.host = host.into();
|
||||||
|
p.updated_at = chrono::DateTime::from_timestamp(t, 0).unwrap().fixed_offset();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
fn at(t: i64) -> Time {
|
||||||
|
chrono::DateTime::from_timestamp(t, 0).unwrap().fixed_offset()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newest_wins_and_deletions_travel() {
|
||||||
|
let now = at(1_000_000);
|
||||||
|
let local = vec![p("a", "a-local-new", 200), p("b", "b-local-old", 100), p("c", "only-local", 100), p("d", "deleted-remotely", 100)];
|
||||||
|
let remote = SyncFile {
|
||||||
|
version: 1,
|
||||||
|
profiles: vec![p("a", "a-remote-old", 150), p("b", "b-remote-new", 300), p("e", "only-remote", 100), p("f", "deleted-locally", 100)],
|
||||||
|
deleted: [("d".to_string(), at(150))].into(),
|
||||||
|
};
|
||||||
|
let local_deleted = [("f".to_string(), at(120))].into();
|
||||||
|
let m = merge(&local, &local_deleted, &remote, now);
|
||||||
|
let hosts = |v: &[Profile]| v.iter().map(|p| p.host.clone()).collect::<Vec<_>>();
|
||||||
|
assert_eq!(hosts(&m.upsert), ["b-remote-new", "only-remote"]);
|
||||||
|
assert_eq!(m.delete, ["d"]);
|
||||||
|
assert_eq!(hosts(&m.file.profiles), ["a-local-new", "b-remote-new", "only-local", "only-remote"]);
|
||||||
|
assert_eq!(m.file.deleted.keys().collect::<Vec<_>>(), ["d", "f"]);
|
||||||
|
// An edit after a deletion brings the profile back (and clears the tombstone).
|
||||||
|
let remote = SyncFile { version: 1, profiles: vec![], deleted: [("x".to_string(), at(100))].into() };
|
||||||
|
let m = merge(&[p("x", "edited-later", 200)], &BTreeMap::new(), &remote, now);
|
||||||
|
assert!(m.delete.is_empty() && m.file.deleted.is_empty() && m.file.profiles.len() == 1);
|
||||||
|
// Old tombstones are forgotten.
|
||||||
|
let remote = SyncFile { version: 1, profiles: vec![], deleted: [("old".to_string(), at(0))].into() };
|
||||||
|
assert!(merge(&[], &BTreeMap::new(), &remote, at(TOMBSTONE_DAYS * 86_400 + 10)).file.deleted.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_round_trips_under_the_password() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("nexus-sync-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let f = SyncFile { version: 1, profiles: vec![p("a", "h", 1)], deleted: BTreeMap::new() };
|
||||||
|
write(&dir, "pw", &f).unwrap();
|
||||||
|
assert_eq!(read(&dir, "pw").unwrap().unwrap().profiles[0].host, "h");
|
||||||
|
assert!(read(&dir, "wrong").is_err());
|
||||||
|
assert!(read(&dir.join("missing"), "pw").unwrap().is_none());
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//! Profile sync between two "machines" (data directories) sharing one folder.
|
||||||
|
|
||||||
|
use nexus_core::config::AppConfig;
|
||||||
|
use nexus_core::models::{Profile, Protocol};
|
||||||
|
use nexus_core::service::Service;
|
||||||
|
|
||||||
|
fn machine(root: &std::path::Path, name: &str, share: &std::path::Path) -> Service {
|
||||||
|
let cfg = AppConfig::in_dir(root.join(name));
|
||||||
|
std::fs::create_dir_all(&cfg.vault_dir).unwrap();
|
||||||
|
let svc = Service::open(cfg).unwrap();
|
||||||
|
svc.secrets.vault.initialise("master").unwrap();
|
||||||
|
let mut prefs = svc.preferences();
|
||||||
|
prefs.sync_folder = share.to_string_lossy().into_owned();
|
||||||
|
svc.save_preferences(&prefs).unwrap();
|
||||||
|
svc.set_sync_password("shared-secret").unwrap();
|
||||||
|
svc
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profiles_follow_edits_and_deletes_across_machines() {
|
||||||
|
let root = std::env::temp_dir().join(format!("nexus-sync-e2e-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
let share = root.join("share");
|
||||||
|
std::fs::create_dir_all(&share).unwrap();
|
||||||
|
let (a, b) = (machine(&root, "a", &share), machine(&root, "b", &share));
|
||||||
|
|
||||||
|
let mut p = Profile::new(Protocol::Ssh);
|
||||||
|
p.display_name = "web".into();
|
||||||
|
p.host = "web.example".into();
|
||||||
|
p.username = "me".into();
|
||||||
|
let p = a.create_profile(p).unwrap();
|
||||||
|
assert!(a.sync_profiles().unwrap().pushed);
|
||||||
|
assert_eq!(b.sync_profiles().unwrap().pulled, 1);
|
||||||
|
assert_eq!(b.profiles()[0].host, "web.example");
|
||||||
|
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1100)); // updated_at has 1 s resolution
|
||||||
|
let mut edited = b.profiles()[0].clone();
|
||||||
|
edited.host = "web2.example".into();
|
||||||
|
b.update_profile(edited).unwrap();
|
||||||
|
b.sync_profiles().unwrap();
|
||||||
|
assert_eq!(a.sync_profiles().unwrap().pulled, 1);
|
||||||
|
assert_eq!(a.profiles()[0].host, "web2.example");
|
||||||
|
|
||||||
|
a.delete_profile(&p.id).unwrap();
|
||||||
|
a.sync_profiles().unwrap();
|
||||||
|
assert_eq!(b.sync_profiles().unwrap().removed, 1);
|
||||||
|
assert!(b.profiles().is_empty());
|
||||||
|
// Nothing left to do on either side.
|
||||||
|
assert!(!a.sync_profiles().unwrap().pushed && !b.sync_profiles().unwrap().pushed);
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user