diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e3a006..cd771fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,11 @@ Versions follow [Semantic Versioning](https://semver.org/). ## Unreleased ### Features +- **Hosts imported from `~/.ssh/config` can be edited in the desktop app.** Imported hosts were shown read-only, so changing a port or a username for one meant deleting it from your SSH config and adding it again by hand — while the terminal app had allowed the edit all along. Editing one now saves your own copy, which OmnySSH uses from then on; `~/.ssh/config` is still never written to. The form says so before you save, since later changes you make to that file stop reaching the host once it has been adopted. The bastion and key path the app parsed are carried over even though the form cannot show them, so an adopted `ProxyJump` host keeps connecting through its jump server. Deleting is still offered for your own hosts only — there is nothing of an import to remove — and deleting a copy you adopted brings the imported version back, which the confirmation now tells you. - **Watch a host by a port check instead of an SSH login.** Firewalls, switches and other appliances answer SSH but have no shell to read `top` or `free` from, so monitoring could only ever fail on them — and log in again every cycle to find that out. A host can now be set to **TCP port check**: OmnySSH opens a connection to the port and closes it, with no login and no commands. Its card shows reachable / unreachable in place of the metric tiles, rather than tiles for numbers nobody collected. Set it in the host form — `tcp` (the host's SSH port) or `tcp:PORT` in the terminal app, a dropdown in the desktop app. Existing hosts are untouched and stay on SSH monitoring. ICMP is not offered yet: unprivileged ping is unavailable on the Linux packaging most people use. ### Bug Fixes +- **Nerd Font glyphs render instead of empty boxes.** The desktop terminal asked for a list of system monospace fonts, none of which carry the glyphs that prompts like starship and powerlevel10k, or `eza --icons`, draw from — so those came out as boxes even for people who had a Nerd Font installed. Common Nerd Font families are now named as fallbacks, behind the regular monospace ones so ordinary text keeps the same typeface and spacing. Snippet output and SFTP file previews get the same treatment, since both show text straight from the server. You still need a Nerd Font installed; the app does not ship one. - **The desktop app reopens at the size and position you left it.** Every launch reset the window to 1100x720 wherever the system chose to put it, so a window sized to your screen — or moved to a second monitor — had to be set up again each time. Size and position now persist. Visibility deliberately does not: the window still starts hidden and appears once the interface has painted, so restoring geometry never brings back the blank frame at launch. - **macOS: the window buttons no longer sit across the edge of a collapsed sidebar.** The red/amber/green cluster is positioned by the system against the window, not by the app's layout, and it is wider than the collapsed sidebar was — so the sidebar's edge fell on the green button and left it over the content area while the other two stayed on the sidebar. The collapsed sidebar is now wide enough to hold the whole cluster on macOS. Windows and Linux draw their own title bar and keep the narrower one. - **Hosts split across `Include` files are imported.** A relative pattern — `Include conf.d/*.conf`, the form nearly every split-config guide prints — was looked for in whatever directory the app happened to be launched from, which for the desktop app started from Finder or the application menu is `/`. Nothing matched, so every host defined in `~/.ssh/conf.d` was missing and had to be added by hand. Those patterns now resolve against `~/.ssh`, the way `ssh` itself resolves them. Four things in the same code path were fixed alongside it: full glob patterns work (`?`, `[abc]`, a wildcard in a directory name, and more than one `*` in a file name), several pathnames on one `Include` line are all read instead of none, a quoted path keeps its spaces, and an `Include` written inside a `Host` block no longer swallows that host's remaining settings. An `Include` that matches nothing is written to the log rather than passing in silence. diff --git a/README.md b/README.md index 78cede3..32b97b0 100644 --- a/README.md +++ b/README.md @@ -52,16 +52,16 @@ You add a server once. After that it sits on the dashboard as a card with live C Cards for every host with CPU, RAM and disk bars, uptime, OS version, top processes, and a Docker badge showing how many containers are up. Bars turn yellow, then red, so a sick server is obvious from across the room. ### Real terminals -Full PTY sessions in tabs. Open several servers at once, split the view, keep them running while you work in the dashboard. +Full PTY sessions in tabs. Open as many servers as you need, switch between them from the sidebar, and keep them running while you work in the dashboard. ### Two panel SFTP -Local on the left, remote on the right. Drag files across, watch the progress bar, select many at once. Nobody remembers `scp -r` syntax anyway. +Local on the left, remote on the right. Tick the files you want and move them across, watch the progress bar, select many at once. Nobody remembers `scp -r` syntax anyway. ### Snippets -Save the commands you paste every week. Run one on a host with a keypress, or broadcast it to every server you have. Snippets take parameters, so `sudo systemctl restart {{service}}` asks you for the name. +Save the commands you paste every week. Pick a snippet, tick the hosts to send it to, and it runs on all of them at once. Snippets take parameters, so `sudo systemctl restart {{service}}` asks you for the name. ### Search everything -Hit ⌘K and start typing. Hosts, snippets, screens. It gets you there in three keystrokes. +Hit ⌘K and start typing. Every host you have, plus every session already open. Enter drops you into a terminal on the host you picked, or back into the session you left. ### Streamer mode Swaps every real IP on screen for a fake one. Record a demo or share your screen without leaking client infrastructure. @@ -78,7 +78,7 @@ Around 130 MB of RAM with several sessions open, on a 20 MB download. Termius on Password auth on a fresh VPS is the thing you always mean to fix and never do. OmnySSH does it in one click. -Pick a password based host, hit **Set up SSH key**, and the app generates an Ed25519 key, appends the public half to `authorized_keys`, and switches the host over to key auth. It then opens a fresh connection with the new key to prove the key works. Only after that does it offer to turn off password login. +Pick a host you added yourself that has no key configured, hit **Set up SSH key**, and the app generates an Ed25519 key, appends the public half to `authorized_keys`, and switches the host over to key auth. It then opens a fresh connection with the new key to prove the key works, and only after that does it turn password login off. There is no confirmation step in between: starting the flow means going through with it. Before touching `sshd_config` it saves a backup on the server. If any step fails, it restores the backup and leaves your access exactly as it was. Your private key never leaves your machine, and nothing gets sent anywhere except the server you chose. diff --git a/crates/omnyssh-gui/src/commands/hosts.rs b/crates/omnyssh-gui/src/commands/hosts.rs index 7a6ef40..e05d5f3 100644 --- a/crates/omnyssh-gui/src/commands/hosts.rs +++ b/crates/omnyssh-gui/src/commands/hosts.rs @@ -2,7 +2,7 @@ use tauri::{AppHandle, State}; use tauri_specta::Event; use omnyssh_core::config::{load_hosts, save_hosts}; -use omnyssh_core::ssh::client::Host; +use omnyssh_core::ssh::client::{Host, HostSource}; use crate::dto::{HostDto, HostInputDto}; use crate::error::CommandError; @@ -56,15 +56,25 @@ pub async fn reload_hosts(app: AppHandle, state: State<'_, GuiState>) -> Result< Ok(()) } -/// Add or edit a **manual** host and persist to `hosts.toml` (tech-gui.md §4.2, Stage -/// 4.1). Upserts by name; SSH-config hosts are read-only imports and are never written -/// (this operates on the manual `hosts.toml` alone). The frontend calls `reload_hosts` -/// afterwards to refresh the merged cache + restart the pollers. Secrets stay -/// backend-side (§3.4): the payload's password/identity never left the backend. +/// Add or edit a host and persist to `hosts.toml` (tech-gui.md §4.2, Stage 4.1). +/// Upserts by name. Editing an SSH-config import adopts it: the saved copy is a manual +/// entry that `merge_hosts` then prefers over the parsed one, so `~/.ssh/config` itself +/// is still never written. The frontend calls `reload_hosts` afterwards to refresh the +/// merged cache + restart the pollers. Secrets stay backend-side (§3.4): the payload's +/// password/identity never left the backend. #[tauri::command] #[specta::specta] -pub async fn save_host(input: HostInputDto) -> Result<(), CommandError> { - persist(move |hosts| upsert(hosts, input)).await +pub async fn save_host( + input: HostInputDto, + state: State<'_, GuiState>, +) -> Result<(), CommandError> { + // The parsed import is the only record of its bastion and key path, and neither + // crosses the boundary (§3.4), so read them off the cache before the write moves + // to a blocking task. + let imported = state + .host_by_name(&input.name) + .filter(|h| h.source == HostSource::SshConfig); + persist(move |hosts| upsert(hosts, input, imported)).await } /// Delete a manual host by name and persist (tech-gui.md §4.2, Stage 4.1). Only manual @@ -83,7 +93,7 @@ pub async fn delete_host(name: String) -> Result<(), CommandError> { /// SSH-config rename origin, and a monitoring mode the payload left out. Editing /// e.g. notes therefore never drops a stored secret or a recorded key setup. A /// provided secret still overwrites the old one. -fn upsert(hosts: &mut Vec, input: HostInputDto) { +fn upsert(hosts: &mut Vec, input: HostInputDto, imported: Option) { // An omitted monitoring mode means "unchanged", not "back to SSH" — losing it // would silently start logging in to a device chosen for reachability only. let monitoring_given = input.monitoring.is_some(); @@ -105,7 +115,23 @@ fn upsert(hosts: &mut Vec, input: HostInputDto) { host.original_ssh_host = existing.original_ssh_host.clone(); hosts[i] = host; } - None => hosts.push(host), + None => { + // A brand-new host, or the first save of an SSH-config import — only the + // import has anything to salvage. The form never saw its `ProxyJump` or + // identity file, and a copy without the bastion would dial the target + // address direct, which is exactly the hazard fixed in 1.1.1. + if let Some(imported) = imported { + host.proxy_jump = host.proxy_jump.or(imported.proxy_jump); + host.identity_file = host.identity_file.or(imported.identity_file); + // Which `~/.ssh/config` entry this copy stands in for. Inert while the + // names match — `merge_hosts` already drops the import on the name — but + // it is what keeps the import hidden once the copy is renamed in the TUI, + // and what another host's `ProxyJump` alias resolves through. The TUI + // records the same thing when it adopts (app/host.rs). + host.original_ssh_host = Some(host.name.clone()); + } + hosts.push(host); + } } } @@ -158,7 +184,7 @@ mod tests { #[test] fn upsert_appends_a_new_manual_host() { let mut hosts = vec![]; - upsert(&mut hosts, input("web")); + upsert(&mut hosts, input("web"), None); assert_eq!(hosts.len(), 1); assert_eq!(hosts[0].name, "web"); assert_eq!(hosts[0].source, HostSource::Manual); @@ -176,7 +202,7 @@ mod tests { let mut edit = input("web"); edit.hostname = "new.example.com".to_string(); edit.notes = Some("new".to_string()); - upsert(&mut hosts, edit); + upsert(&mut hosts, edit, None); assert_eq!(hosts.len(), 1, "edit is in-place, not an append"); assert_eq!(hosts[0].hostname, "new.example.com"); assert_eq!(hosts[0].notes.as_deref(), Some("new")); @@ -197,7 +223,7 @@ mod tests { source: HostSource::Manual, ..Host::default() }]; - upsert(&mut hosts, input("web")); + upsert(&mut hosts, input("web"), None); let h = &hosts[0]; assert_eq!(h.password.as_deref(), Some("keep-me")); assert_eq!(h.identity_file.as_deref(), Some("/keys/id")); @@ -207,6 +233,59 @@ mod tests { assert_eq!(h.original_ssh_host.as_deref(), Some("web-old")); } + #[test] + fn adopting_an_ssh_config_host_keeps_its_bastion_and_key() { + // Editing an import writes a manual copy. `HostDto` carries neither `proxyJump` + // nor the identity path (§3.4), so the form submits both blank — dropping them + // would leave the copy dialling the target address direct. + let imported = Host { + name: "internal".to_string(), + hostname: "10.0.0.9".to_string(), + proxy_jump: Some("public-proxy".to_string()), + identity_file: Some("/keys/id_ed25519".to_string()), + source: HostSource::SshConfig, + ..Host::default() + }; + let mut hosts = vec![]; + let mut edit = input("internal"); + edit.notes = Some("adopted".to_string()); + upsert(&mut hosts, edit, Some(imported)); + + assert_eq!(hosts.len(), 1); + let h = &hosts[0]; + assert_eq!( + h.source, + HostSource::Manual, + "the copy is what hosts.toml holds" + ); + assert_eq!(h.proxy_jump.as_deref(), Some("public-proxy")); + assert_eq!(h.identity_file.as_deref(), Some("/keys/id_ed25519")); + assert_eq!(h.notes.as_deref(), Some("adopted")); + assert_eq!( + h.original_ssh_host.as_deref(), + Some("internal"), + "the copy records which import it shadows, so a later rename still hides it" + ); + } + + #[test] + fn an_explicit_identity_wins_over_the_imported_one() { + let imported = Host { + name: "internal".to_string(), + identity_file: Some("/keys/from-ssh-config".to_string()), + source: HostSource::SshConfig, + ..Host::default() + }; + let mut hosts = vec![]; + let mut edit = input("internal"); + edit.identity_file = Some("/keys/typed-by-hand".to_string()); + upsert(&mut hosts, edit, Some(imported)); + assert_eq!( + hosts[0].identity_file.as_deref(), + Some("/keys/typed-by-hand") + ); + } + #[test] fn upsert_keeps_a_monitoring_mode_the_payload_left_out() { let mut hosts = vec![Host { @@ -217,7 +296,7 @@ mod tests { ..Host::default() }]; - upsert(&mut hosts, input("fw")); + upsert(&mut hosts, input("fw"), None); // Silently reverting to SSH would start logging in to a device the user // deliberately put on a reachability probe. @@ -237,7 +316,7 @@ mod tests { let mut back_to_ssh = input("fw"); back_to_ssh.monitoring = Some(MonitorModeDto::Ssh); - upsert(&mut hosts, back_to_ssh); + upsert(&mut hosts, back_to_ssh, None); assert_eq!(hosts[0].monitoring, MonitorMode::Ssh); } @@ -252,7 +331,7 @@ mod tests { }]; let mut edit = input("web"); edit.password = Some("rotated".to_string()); - upsert(&mut hosts, edit); + upsert(&mut hosts, edit, None); assert_eq!(hosts[0].password.as_deref(), Some("rotated")); } diff --git a/crates/omnyssh-gui/src/dto.rs b/crates/omnyssh-gui/src/dto.rs index 71bd7e5..35bb3cb 100644 --- a/crates/omnyssh-gui/src/dto.rs +++ b/crates/omnyssh-gui/src/dto.rs @@ -71,8 +71,9 @@ pub struct HostDto { pub monitor_port: Option, } -/// Inbound host form payload for `save_host` (tech-gui.md §4.1, Stage 4.1). Builds a -/// **manual** `Host` — SSH-config hosts are read-only imports and are never saved. +/// Inbound host form payload for `save_host` (tech-gui.md §4.1, Stage 4.1). Always +/// builds a **manual** `Host`: editing an SSH-config import saves a copy that shadows +/// it, and `~/.ssh/config` itself is never written. /// `password`/`identityFile` arrive here (the create/edit form owns them) but never /// travel back out: the outbound `HostDto` omits both (§3.4). Inbound only, so it /// derives `Deserialize` (not `Serialize`). @@ -582,8 +583,8 @@ mod tests { #[test] fn host_input_maps_to_a_manual_host() { - // The form only authors manual entries; SSH-config hosts are read-only imports - // (tech-gui.md §4.1, Stage 4.1) — so `source` is forced regardless of input. + // The form only ever authors manual entries — editing an import produces a + // manual copy (tech-gui.md §4.1) — so `source` is forced regardless of input. let host = Host::from(full_input()); assert_eq!(host.name, "web-prod-1"); assert_eq!(host.hostname, "10.0.0.1"); diff --git a/crates/omnyssh-gui/ui/e2e/hosts.spec.ts b/crates/omnyssh-gui/ui/e2e/hosts.spec.ts index 6b72b4d..c8712f9 100644 --- a/crates/omnyssh-gui/ui/e2e/hosts.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/hosts.spec.ts @@ -126,13 +126,28 @@ test('deletes a manual host after confirmation', async ({ page }) => { await expect(page.getByText('web-1', { exact: true })).toHaveCount(0); }); -test('SSH-config hosts are read-only imports', async ({ page }) => { +test('an SSH-config host is adopted by editing it', async ({ page }) => { await boot(page); - // The imported host is marked and offers no edit/delete affordances (§4.1). + // The import is marked, and there is nothing here to delete: it lives in + // ~/.ssh/config, which this app never writes. await expect(page.getByText('ssh config')).toBeVisible(); - await expect(page.getByRole('button', { name: 'Edit imported' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Delete imported' })).toHaveCount(0); + + await page.getByRole('button', { name: 'Edit imported' }).click(); + const editor = page.getByRole('dialog', { name: 'Edit host' }); + await expect(editor).toBeVisible(); + // The form states what saving does, since the SSH config file itself does not change. + await expect(editor.getByText(/never written/)).toBeVisible(); + + await editor.getByLabel('Hostname / IP').fill('adopted.example.com'); + await editor.getByRole('button', { name: 'Save' }).click(); + + // Saved as a manual copy: the import badge is gone and delete is now offered. + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(page.getByText('root@adopted.example.com:22')).toBeVisible(); + await expect(page.getByText('ssh config')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Delete imported' })).toHaveCount(1); }); test('rejects a new host whose name already exists', async ({ page }) => { diff --git a/crates/omnyssh-gui/ui/src/lib/bindings.ts b/crates/omnyssh-gui/ui/src/lib/bindings.ts index 6c3af67..f9315d1 100644 --- a/crates/omnyssh-gui/ui/src/lib/bindings.ts +++ b/crates/omnyssh-gui/ui/src/lib/bindings.ts @@ -30,11 +30,12 @@ async reloadHosts() : Promise> { } }, /** - * Add or edit a **manual** host and persist to `hosts.toml` (tech-gui.md §4.2, Stage - * 4.1). Upserts by name; SSH-config hosts are read-only imports and are never written - * (this operates on the manual `hosts.toml` alone). The frontend calls `reload_hosts` - * afterwards to refresh the merged cache + restart the pollers. Secrets stay - * backend-side (§3.4): the payload's password/identity never left the backend. + * Add or edit a host and persist to `hosts.toml` (tech-gui.md §4.2, Stage 4.1). + * Upserts by name. Editing an SSH-config import adopts it: the saved copy is a manual + * entry that `merge_hosts` then prefers over the parsed one, so `~/.ssh/config` itself + * is still never written. The frontend calls `reload_hosts` afterwards to refresh the + * merged cache + restart the pollers. Secrets stay backend-side (§3.4): the payload's + * password/identity never left the backend. */ async saveHost(input: HostInputDto) : Promise> { try { @@ -437,8 +438,9 @@ export type FilePreview = { sessionId: number; path: string; content: string } */ export type HostDto = { name: string; hostname: string; user: string; port: number; tags: string[]; notes?: string | null; source: HostSourceDto; hasKey: boolean; passwordAuthDisabled?: boolean | null; monitoring: MonitorModeDto; monitorPort?: number | null } /** - * Inbound host form payload for `save_host` (tech-gui.md §4.1, Stage 4.1). Builds a - * **manual** `Host` — SSH-config hosts are read-only imports and are never saved. + * Inbound host form payload for `save_host` (tech-gui.md §4.1, Stage 4.1). Always + * builds a **manual** `Host`: editing an SSH-config import saves a copy that shadows + * it, and `~/.ssh/config` itself is never written. * `password`/`identityFile` arrive here (the create/edit form owns them) but never * travel back out: the outbound `HostDto` omits both (§3.4). Inbound only, so it * derives `Deserialize` (not `Serialize`). diff --git a/crates/omnyssh-gui/ui/src/lib/screens/Dashboard.svelte b/crates/omnyssh-gui/ui/src/lib/screens/Dashboard.svelte index 9406fba..a8012cf 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/Dashboard.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/Dashboard.svelte @@ -3,8 +3,9 @@ // detected services. Colour is reserved for semantic state — the header dot and // the metric fills read from `statusToken`; everything else is ink-on-paper. The // per-card `sh`/`files` buttons are the host-first spawn path (§2). Host management - // (add/edit/delete of manual hosts, §4.1) lives here — there is no separate Hosts - // screen (§2) — with SSH-config hosts shown read-only. + // (add/edit/delete, §4.1) lives here — there is no separate Hosts screen (§2). + // Editing an SSH-config host adopts it into hosts.toml; the file itself is never + // written, so only Delete stays manual-only. import { get } from 'svelte/store'; import type { HostDto, HostInputDto } from '$lib/bindings'; import { Surface, Chip, StatusDot, Icon, Button, statusToken } from '$lib/theme'; @@ -204,7 +205,7 @@ {#if card.host.source === 'sshConfig'} ssh config @@ -247,6 +248,10 @@ {action.label} {/each} + {#if card.host.source === 'manual' && !card.host.hasKey} {/if} + + {#if card.host.source === 'manual'} - diff --git a/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte b/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte index 50a9fb2..5e1683f 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/HostEditor.svelte @@ -1,6 +1,6 @@