diff --git a/ServerDeck/src-tauri/Cargo.lock b/ServerDeck/src-tauri/Cargo.lock index e6bc253..c834db7 100644 --- a/ServerDeck/src-tauri/Cargo.lock +++ b/ServerDeck/src-tauri/Cargo.lock @@ -359,6 +359,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -382,7 +392,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -395,7 +405,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -1785,6 +1795,18 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "zeroize", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -3019,7 +3041,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -3037,7 +3059,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -3046,7 +3068,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -3150,6 +3172,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3157,7 +3192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3412,9 +3447,10 @@ dependencies = [ [[package]] name = "serverdeck" -version = "0.0.30" +version = "0.0.35" dependencies = [ "dirs", + "keyring", "portable-pty", "rusqlite", "serde", @@ -3707,7 +3743,7 @@ checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ "bitflags 2.11.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch2", diff --git a/ServerDeck/src-tauri/Cargo.toml b/ServerDeck/src-tauri/Cargo.toml index d05ed99..5faf956 100644 --- a/ServerDeck/src-tauri/Cargo.toml +++ b/ServerDeck/src-tauri/Cargo.toml @@ -16,6 +16,7 @@ portable-pty = "0.8" tauri-plugin-updater = "2.10.0" tauri-plugin-process = "2.3.1" rusqlite = { version = "0.31", features = ["bundled"] } +keyring = { version = "3", features = ["apple-native"] } [features] default = ["custom-protocol"] diff --git a/ServerDeck/src-tauri/src/main.rs b/ServerDeck/src-tauri/src/main.rs index 8bd7512..bdb945e 100644 --- a/ServerDeck/src-tauri/src/main.rs +++ b/ServerDeck/src-tauri/src/main.rs @@ -34,6 +34,81 @@ struct HostRecord { auth_type: String, password: Option, private_key_path: Option, + // author: BrianXiong + // time: 2026/07/21/00:00:00 + // True when a password for this host is stored in the system keychain. + // Passwords themselves are never sent to the UI anymore. + #[serde(default)] + has_password: bool, +} + +// author: BrianXiong +// time: 2026/07/21/00:00:00 +// Passwords live in the macOS Keychain. The hosts table only stores this +// marker so we can tell "password saved in keychain" apart from "no password" +// without touching the keychain on every host listing. +const KEYCHAIN_SERVICE: &str = "ServerDeck"; +const KEYCHAIN_PASSWORD_MARKER: &str = "__serverdeck_keychain__"; + +fn keychain_entry(host_id: &str) -> Result { + keyring::Entry::new(KEYCHAIN_SERVICE, host_id).map_err(|error| error.to_string()) +} + +fn keychain_set_password(host_id: &str, password: &str) -> Result<(), String> { + keychain_entry(host_id)? + .set_password(password) + .map_err(|error| error.to_string()) +} + +fn keychain_get_password(host_id: &str) -> Option { + keychain_entry(host_id) + .ok()? + .get_password() + .ok() + .filter(|value| !value.is_empty()) +} + +fn keychain_delete_password(host_id: &str) { + if let Ok(entry) = keychain_entry(host_id) { + let _ = entry.delete_credential(); + } +} + +// Resolve the password to use for a connection: an explicit password on the +// record wins (e.g. a value the user just typed but has not saved), otherwise +// fall back to the keychain entry for this host. +fn resolve_host_password(host: &HostRecord) -> String { + if host.auth_type != "password" { + return String::new(); + } + + if let Some(password) = &host.password { + if !password.is_empty() && password != KEYCHAIN_PASSWORD_MARKER { + return password.clone(); + } + } + + if let Some(password) = keychain_get_password(&host.id) { + return password; + } + + // Last resort: legacy plaintext still in the DB because a keychain write + // failed. Keeps existing setups working until migration succeeds. + if let Ok(conn) = open_db() { + if let Ok(stored) = conn.query_row( + "SELECT password FROM hosts WHERE id = ?1", + params![host.id], + |row| row.get::<_, Option>(0), + ) { + if let Some(value) = stored { + if !value.is_empty() && value != KEYCHAIN_PASSWORD_MARKER { + return value; + } + } + } + } + + String::new() } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -264,9 +339,42 @@ pub(crate) fn open_db() -> Result { let conn = Connection::open(path).map_err(|error| error.to_string())?; init_db(&conn)?; migrate_legacy_hosts_if_needed(&conn)?; + migrate_plaintext_passwords_to_keychain(&conn)?; Ok(conn) } +// author: BrianXiong +// time: 2026/07/21/00:00:00 +// One-time (idempotent) migration: move any plaintext password still sitting +// in the hosts table into the macOS Keychain and replace it with the marker. +// If the keychain write fails the plaintext row is left untouched so the app +// keeps working; the migration retries on the next database open. +fn migrate_plaintext_passwords_to_keychain(conn: &Connection) -> Result<(), String> { + let mut stmt = conn + .prepare("SELECT id, password FROM hosts WHERE password IS NOT NULL AND password != '' AND password != ?1") + .map_err(|error| error.to_string())?; + + let rows = stmt + .query_map(params![KEYCHAIN_PASSWORD_MARKER], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + + for (id, password) in rows { + if keychain_set_password(&id, &password).is_ok() { + conn.execute( + "UPDATE hosts SET password = ?1 WHERE id = ?2", + params![KEYCHAIN_PASSWORD_MARKER, id], + ) + .map_err(|error| error.to_string())?; + } + } + + Ok(()) +} + // author: BrianXiong // time: 2026/04/06/11:42:03 fn init_db(conn: &Connection) -> Result<(), String> { @@ -543,6 +651,13 @@ fn load_hosts_from_db(conn: &Connection) -> Result, String> { let rows = stmt .query_map([], |row| { + let stored_password: Option = row.get(7)?; + // author: BrianXiong + // time: 2026/07/21/00:00:00 + // Never hand passwords (or the keychain marker) to the UI — only + // a boolean that says whether one is stored. + let has_password = matches!(&stored_password, Some(value) if !value.is_empty()); + Ok(HostRecord { id: row.get(0)?, label: row.get(1)?, @@ -551,8 +666,9 @@ fn load_hosts_from_db(conn: &Connection) -> Result, String> { port: row.get(4)?, username: row.get(5)?, auth_type: row.get(6)?, - password: row.get(7)?, + password: None, private_key_path: row.get(8)?, + has_password, }) }) .map_err(|error| error.to_string())?; @@ -1063,9 +1179,10 @@ fn append_ssh_options(command: &mut Command, host: &HostRecord, ssh_options: &Ss fn build_ssh_command(host: &HostRecord, ssh_options: &SshConnectionOptions) -> Command { let destination = format!("{}@{}", host.username, host.address); - let mut command = if host.auth_type == "password" && host.password.clone().unwrap_or_default() != "" { + let password = resolve_host_password(host); + let mut command = if host.auth_type == "password" && !password.is_empty() { let mut sshpass = Command::new(resolve_binary("sshpass").unwrap_or_else(|_| PathBuf::from("sshpass"))); - sshpass.arg("-p").arg(host.password.clone().unwrap_or_default()); + sshpass.arg("-p").arg(&password); sshpass.arg(resolve_binary("ssh").unwrap_or_else(|_| PathBuf::from("ssh"))); sshpass } else { @@ -1116,9 +1233,10 @@ fn parse_process_observations(output: &str, key: &str) -> Vec Command { - let mut command = if host.auth_type == "password" && host.password.clone().unwrap_or_default() != "" { + let password = resolve_host_password(host); + let mut command = if host.auth_type == "password" && !password.is_empty() { let mut sshpass = Command::new(resolve_binary("sshpass").unwrap_or_else(|_| PathBuf::from("sshpass"))); - sshpass.arg("-p").arg(host.password.clone().unwrap_or_default()); + sshpass.arg("-p").arg(&password); sshpass.arg(resolve_binary("sftp").unwrap_or_else(|_| PathBuf::from("sftp"))); sshpass } else { @@ -1772,13 +1890,43 @@ fn list_hosts() -> Result, String> { } #[tauri::command] +// author: BrianXiong +// time: 2026/07/21/00:00:00 +// Passwords are written to the macOS Keychain; the DB only stores a marker. +// An empty password on save means "keep the stored one" so editing a host +// never requires re-typing its password. fn save_host(mut host: HostRecord) -> Result { if host.id.trim().is_empty() { host.id = now_millis(); } let conn = open_db()?; + + if host.auth_type == "password" { + let provided = host.password.clone().unwrap_or_default(); + if !provided.is_empty() && provided != KEYCHAIN_PASSWORD_MARKER { + keychain_set_password(&host.id, &provided)?; + host.password = Some(KEYCHAIN_PASSWORD_MARKER.to_string()); + } else { + // Keep whatever is currently stored for this host (marker or legacy value). + let existing: Option = conn + .query_row( + "SELECT password FROM hosts WHERE id = ?1", + params![host.id], + |row| row.get::<_, Option>(0), + ) + .unwrap_or(None); + host.password = existing; + } + } else { + keychain_delete_password(&host.id); + host.password = None; + } + save_hosts_to_db(&conn, &[host.clone()])?; + + host.has_password = matches!(&host.password, Some(value) if !value.is_empty()); + host.password = None; Ok(host) } @@ -1787,9 +1935,41 @@ fn delete_host(id: String) -> Result { let conn = open_db()?; conn.execute("DELETE FROM hosts WHERE id = ?1", params![id]) .map_err(|error| error.to_string())?; + keychain_delete_password(&id); Ok(true) } +// author: BrianXiong +// time: 2026/07/21/00:00:00 +// Duplicate a host including its keychain-stored password, so copies of +// password-auth hosts stay connectable without re-typing the password. +#[tauri::command] +fn duplicate_host(id: String, label: String) -> Result { + let conn = open_db()?; + let hosts = load_hosts_from_db(&conn)?; + let source = hosts + .into_iter() + .find(|host| host.id == id) + .ok_or_else(|| "Host not found".to_string())?; + + let mut duplicated = source.clone(); + duplicated.id = now_millis(); + duplicated.label = label; + + if duplicated.auth_type == "password" { + if let Some(password) = keychain_get_password(&id) { + keychain_set_password(&duplicated.id, &password)?; + duplicated.password = Some(KEYCHAIN_PASSWORD_MARKER.to_string()); + } + } + + save_hosts_to_db(&conn, &[duplicated.clone()])?; + + duplicated.has_password = matches!(&duplicated.password, Some(value) if !value.is_empty()); + duplicated.password = None; + Ok(duplicated) +} + #[tauri::command] fn test_connection(host: HostRecord, ssh_options: SshConnectionOptions) -> Result { let output = build_ssh_command(&host, &ssh_options) @@ -2391,7 +2571,8 @@ fn start_terminal_session( eprintln!("[DEBUG] Auth type: {}", host.auth_type); let destination = format!("{}@{}", host.username, host.address); - let mut cmd = if host.auth_type == "password" && host.password.clone().unwrap_or_default() != "" { + let password = resolve_host_password(&host); + let mut cmd = if host.auth_type == "password" && !password.is_empty() { eprintln!("[DEBUG] Using sshpass for password auth"); let mut c = CommandBuilder::new( resolve_binary("sshpass") @@ -2402,7 +2583,7 @@ fn start_terminal_session( })?, ); c.arg("-p"); - c.arg(host.password.clone().unwrap_or_default()); + c.arg(&password); c.arg( resolve_binary("ssh") .map(|path| path.to_string_lossy().into_owned()) @@ -2882,6 +3063,7 @@ fn main() { list_hosts, save_host, delete_host, + duplicate_host, test_connection, observe_server, list_local_directory, diff --git a/ServerDeck/src/App.tsx b/ServerDeck/src/App.tsx index b465f7c..bc9f80f 100644 --- a/ServerDeck/src/App.tsx +++ b/ServerDeck/src/App.tsx @@ -51,6 +51,7 @@ import { deleteLocalEntry, deleteHost, deleteRemoteEntry, + duplicateHost, detectAiProviderImports, downloadFromRemote, fetchAiProviderModels, @@ -3457,11 +3458,10 @@ export default function App() { setStatusTone("neutral"); try { - const duplicated = await saveHost({ - ...targetHost, - id: crypto.randomUUID(), - label: `${getDisplayHostTitle(targetHost)} ${messages.hostCopySuffix}` - }); + const duplicated = await duplicateHost( + targetHost.id, + `${getDisplayHostTitle(targetHost)} ${messages.hostCopySuffix}` + ); setContextMenu(null); setStatus(messages.duplicatedHost(getDisplayHostTitle(targetHost))); setStatusTone("success"); @@ -3523,7 +3523,7 @@ export default function App() { if (!targetHost.address.trim()) { throw new Error(messages.selectOrFillHostFirst); } - if (targetHost.authType === "password" && !(targetHost.password || "").trim()) { + if (targetHost.authType === "password" && !(targetHost.password || "").trim() && !targetHost.hasPassword) { throw new Error(messages.passwordRequired); } if (targetHost.authType === "key" && !(targetHost.privateKeyPath || "").trim()) { @@ -4474,6 +4474,7 @@ export default function App() { setDraft({ ...draft, password: event.target.value })} /> diff --git a/ServerDeck/src/lib/api.ts b/ServerDeck/src/lib/api.ts index 4393f46..761cd40 100644 --- a/ServerDeck/src/lib/api.ts +++ b/ServerDeck/src/lib/api.ts @@ -10,6 +10,11 @@ export type SavedHost = { authType: "password" | "key"; password?: string; privateKeyPath?: string; + // author: BrianXiong + // time: 2026/07/21/00:00:00 + // True when a password is stored in the system keychain for this host. + // The password itself is never returned to the UI. + hasPassword?: boolean; }; export type FileEntry = { @@ -227,6 +232,24 @@ export async function saveHost(host: SavedHost) { return next[existingIndex >= 0 ? existingIndex : next.length - 1]; } +// author: BrianXiong +// time: 2026/07/21/00:00:00 +export async function duplicateHost(id: string, label: string) { + if (hasTauri()) { + return tauriInvoke("duplicate_host", { id, label }); + } + + const items = loadBrowserHosts(); + const source = items.find((item) => item.id === id); + if (!source) { + throw new Error("Host not found"); + } + + const duplicated = { ...source, id: crypto.randomUUID(), label }; + saveBrowserHosts([...items, duplicated]); + return duplicated; +} + export async function deleteHost(id: string) { if (hasTauri()) { return tauriInvoke("delete_host", { id }); diff --git a/ServerDeck/src/lib/i18n.ts b/ServerDeck/src/lib/i18n.ts index 12e9b6c..5f3f99a 100644 --- a/ServerDeck/src/lib/i18n.ts +++ b/ServerDeck/src/lib/i18n.ts @@ -166,6 +166,7 @@ type Messages = { authPassword: string; authKey: string; password: string; + passwordStoredPlaceholder: string; privateKeyPath: string; working: string; testSsh: string; @@ -513,6 +514,7 @@ export const messagesByLanguage: Record = { authPassword: "Password", authKey: "Private Key", password: "Password", + passwordStoredPlaceholder: "Saved in Keychain — leave blank to keep", privateKeyPath: "Private Key Path", working: "Working...", testSsh: "Test SSH", @@ -858,6 +860,7 @@ export const messagesByLanguage: Record = { authPassword: "密码", authKey: "私钥", password: "密码", + passwordStoredPlaceholder: "已存入钥匙串,留空表示不修改", privateKeyPath: "私钥路径", working: "处理中...", testSsh: "测试 SSH", @@ -1203,6 +1206,7 @@ export const messagesByLanguage: Record = { authPassword: "パスワード", authKey: "秘密鍵", password: "パスワード", + passwordStoredPlaceholder: "キーチェーンに保存済み。変更しない場合は空欄", privateKeyPath: "秘密鍵パス", working: "処理中...", testSsh: "SSH をテスト",