Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Versions follow [Semantic Versioning](https://semver.org/).

### Bug Fixes
- **Security: a server that offers a host key of another type than the one saved for it is refused.** OmnySSH compared the offered key only with saved keys of the same type. A host saved with its Ed25519 key that suddenly offered only an RSA or ECDSA key was therefore taken for a new host: the key was saved and the login went ahead, password included, and the dashboard did this in the background without anyone connecting. Someone intercepting the connection could get past the saved key this way. Like `ssh`, OmnySSH now refuses any key that matches none of those saved for the host, whatever its type, and says which type is saved. A server that really dropped the type of key it was saved with needs its old key removed once, as `ssh` also asks.
- **Security: the terminal app no longer lets server-supplied names control your terminal.** A file or process name with escape sequences went to the terminal as is, so anyone who can create a file in a folder you browse could retitle the terminal, recolour text or clear the screen. Control characters in anything the app draws now show as �, tabs as spaces, and a file keeps its exact name for transfers.
- **A changed host key names its type and, on Windows, whose list held the old one.** OmnySSH 1.1.3 and earlier kept host keys in `%USERPROFILE%\ssh\known_hosts`, a list that PuTTY and OpenSSH never read. When a server was reinstalled, or a new one took over its address, OmnySSH refused it while PuTTY and PowerShell's `ssh` connected, and nothing said why they disagree. A refusal from that file now says it is the list OmnySSH kept up to 1.1.3. Every refusal also names the type of the key the server offered, ED25519, ECDSA or RSA, and the command that prints that key's fingerprint on the server: a server has one key of each type, and comparing the wrong one shows a mismatch that is not there.
- **Servers from the RHEL/CentOS 6 era connect.** OpenSSH before 5.7 failed with "no common key exchange method". Its only key exchange without SHA-1 is diffie-hellman-group-exchange-sha256, which OmnySSH did not offer. Its only host keys are ssh-rsa and ssh-dss, and it predates the rsa-sha2 signatures, so an RSA key could not log in either. OmnySSH now offers diffie-hellman-group-exchange-sha256 and the ssh-rsa host key after every other method, so a server that supports anything newer negotiates exactly as before. An RSA key signs with SHA-1 only for a server that accepts nothing else: one that lists only ssh-rsa as the signatures it takes, or whose version, OpenSSH before 7.2 or Dropbear before 2020.79, predates that list, as PuTTY and `ssh` before 8.8 sign for it. SHA-1 key exchange, CBC ciphers and DSA keys are still left out.
- **FreeBSD hosts show CPU, RAM, load average and top processes.** A FreeBSD server's card showed only disk and uptime: its `top` rejects the options OmnySSH passed, it has neither `free` nor `/proc`, and its `ps` read the list of columns as a single column. CPU is now measured from the kernel's tick counters over one second, so each update of a FreeBSD host takes a second longer. RAM is read from the kernel's page counts, with free and inactive memory, the buffer cache and the ZFS ARC above its minimum counted as available, as htop counts them, so a server on ZFS does not look nearly full. The kernel's idle process, which `ps` charges with all idle time, is left out of the top processes. Where `/proc/loadavg` is missing, the load average now comes from `uptime`, which also fills it in for macOS hosts in the terminal app's host details. As on any system, an account whose login shell is csh or tcsh still shows no metrics.
Expand All @@ -22,6 +23,9 @@ Versions follow [Semantic Versioning](https://semver.org/).
- **macOS: a host on your local network that fails with "No route to host" says macOS may be blocking it.** macOS keeps an app away from devices on the local network until you allow it, and a blocked connection fails at once with "No route to host (os error 65)", while the router usually stays reachable because it is also your DNS server, so it looked like a fault on that one machine. The error now adds that macOS may be blocking the app and where to allow it: System Settings, Privacy & Security, Local Network, then reopen the app. The desktop app also now carries the explanation macOS shows when it asks for that permission. The terminal app needs the permission for the terminal it runs in; Apple's Terminal has it already.
- **A terminal the server closes right after login stays open and shows why.** A Synology NAS gives a shell only to administrators: any other account logs in, gets DSM's "Permission denied, please try again." and the session ends. `ssh` shows that message, but OmnySSH closed the tab the moment the session ended, so the terminal seemed to vanish right after the password while file sessions to the same NAS worked. In both apps, a terminal tab that showed anything now stays open when the remote side ends its session, including after `exit` or a dropped connection, with "[Connection closed. Press Enter to close this tab.]" under the last output. Enter or Esc closes it, and it takes no other input. A terminal that fails before showing anything still closes, with the reason in the status bar. The desktop app could also drop the last lines a session printed just before it ended; they now always arrive.
- **Settings keeps its bottom margin when scrolled to the end.** The last section sat flush against the bottom of the window.
- **A snippet with the same tag twice no longer empties the Snippets screen.** Tags typed as `db, db` in a snippet form, or written so into `snippets.toml`, left the desktop app's Snippets screen blank, so the snippet could not even be opened to fix it. The Run dialog broke the same way when two hosts share a name, as two `Host` blocks with one name in `~/.ssh/config` give. It now lists that name once, for the host a run goes to. The host picker and the command palette listed such a name twice, and either row opened the first host; they now list it once too.
- **Sizes in the terminal app's file manager stay in their column next to wide file names.** A name with Chinese, Japanese or Korean characters or emoji, which take two cells each, pushed its size past the panel's edge, so a 14-byte file read `1`. Names are now padded and cut by the cells they take.
- **Closing the host picker with Esc gives the keyboard back to the terminal.** In the desktop app, clicking SFTP or Terminal in the sidebar over an open terminal and then pressing Esc left the keyboard on the sidebar, so typing reached nothing until you clicked in the terminal. The command palette's sidebar button had the same problem, and so did clicking the terminal's own tab. A terminal that finishes connecting while the palette is open, or comes into view just as a passphrase prompt opens, no longer takes the keyboard from it.

---

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/omnyssh-core/src/ssh/password.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ pub(crate) enum NoAnswer {

/// Asks the user for a login password while a connection authenticates.
#[async_trait]
// async_trait < 0.1.92 marks the future it boxes `#[must_use]` a second time;
// clippy 1.99 flags that, older clippy doesn't know the lint.
#[allow(unknown_lints, clippy::double_must_use)]
pub(crate) trait AskPassword: Send {
async fn ask(&mut self, prompt: Prompt<'_>) -> Result<String, NoAnswer>;
}
Expand Down
27 changes: 24 additions & 3 deletions crates/omnyssh-gui/ui/e2e/palette.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const HOSTS = [
}
];

async function bootWithHosts(page: Page): Promise<void> {
async function bootWithHosts(page: Page, hosts = HOSTS): Promise<void> {
await page.addInitScript((hosts) => {
let cbid = 0;
(window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = {
Expand All @@ -42,10 +42,10 @@ async function bootWithHosts(page: Page): Promise<void> {
return id;
}
};
}, HOSTS);
}, hosts);
await page.goto('/');
// The status-bar total confirms `list_hosts` resolved into the store.
await expect(page.getByText('2 hosts')).toBeVisible();
await expect(page.getByText(`${hosts.length} hosts`)).toBeVisible();
}

test('⌘K navigator jumps to a host by opening a session', async ({ page }) => {
Expand Down Expand Up @@ -91,3 +91,24 @@ test('a spawner opens the host-picker and spawns a session for the chosen host',
await expect(row).toBeVisible();
await expect(row).toHaveAttribute('aria-current', 'true');
});

test('lists a shared host name once, as the host a session opens', async ({ page }) => {
const twin = { ...HOSTS[0], hostname: 'web-1b.example.com', user: 'admin' };
await bootWithHosts(page, [...HOSTS, twin]);

await page.getByRole('button', { name: 'Terminal' }).click();
const picker = page.getByRole('dialog', { name: 'Pick a host' });
const web = picker.getByRole('button', { name: /web-1/ });
await expect(web).toHaveCount(1);
await expect(web).toContainText('deploy@web-1.example.com');
await picker.getByRole('textbox').fill('web-1b');
await expect(picker.getByText('No matching hosts.')).toBeVisible();
await page.keyboard.press('Escape');

await page.keyboard.press('Control+k');
const listed = page
.getByRole('dialog', { name: 'Command palette' })
.getByRole('button', { name: /web-1/ });
await expect(listed).toHaveCount(1);
await expect(listed).toContainText('deploy@web-1.example.com');
});
22 changes: 22 additions & 0 deletions crates/omnyssh-gui/ui/e2e/passphrase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,28 @@ test('closing the dialog hands the keyboard back to the terminal it covered', as
await expect(terminalInput).toBeFocused();
});

test('a prompt that opens just as a terminal comes back keeps the keyboard', async ({ page }) => {
await boot(page, { lockedAtLaunch: false, terminal: 'ok' });

await page.getByTitle('sh on web-1').click();
await expect(page.locator('.xterm-helper-textarea')).toBeFocused();
await page.getByRole('button', { name: 'Dashboard', exact: true }).click();

// Showing the tab queues the terminal's focus for the next frame; the prompt opens
// before that frame, in the same task.
await page.evaluate(async (key) => {
document.querySelector<HTMLButtonElement>('button[aria-label="web-1 · terminal"]')?.click();
for (let i = 0; i < 5; i++) await Promise.resolve();
(window as unknown as { __fire: (e: string, p: unknown) => void }).__fire(
'key-passphrase-required',
{ hostName: 'web-2', keyPath: key }
);
}, KEY);
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));

await expect(page.getByRole('dialog', { name: 'Unlock SSH key' }).getByLabel('Passphrase')).toBeFocused();
});

test('cancelling while an unlock is running leaves the next key alone', async ({ page }) => {
await boot(page, { lockedAtLaunch: true, unlockDelay: 500 });

Expand Down
27 changes: 24 additions & 3 deletions crates/omnyssh-gui/ui/e2e/snippets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const SNIPPETS = [
{ name: 'restart-web', command: 'systemctl restart {{service}}', scope: 'global', params: ['service'] }
];

async function boot(page: Page, snippetsFixture = SNIPPETS): Promise<void> {
async function boot(page: Page, snippetsFixture = SNIPPETS, hostsFixture = HOSTS): Promise<void> {
await page.addInitScript(
({ hosts, snippets }) => {
let cbid = 0;
Expand Down Expand Up @@ -74,11 +74,11 @@ async function boot(page: Page, snippetsFixture = SNIPPETS): Promise<void> {
}
};
},
{ hosts: HOSTS, snippets: snippetsFixture }
{ hosts: hostsFixture, snippets: snippetsFixture }
);
await page.goto('/');
// The status-bar total confirms the app booted and `list_hosts` resolved.
await expect(page.getByText('2 hosts')).toBeVisible();
await expect(page.getByText(`${hostsFixture.length} hosts`)).toBeVisible();
await page.getByRole('button', { name: 'Snippets', exact: true }).click();
// The Snippets screen loads its list on mount.
await expect(page.getByRole('heading', { name: 'Snippets' })).toBeVisible();
Expand Down Expand Up @@ -178,3 +178,24 @@ test('renders duplicate-named snippets without crashing (core never dedups names
await expect(page.getByText('git pull')).toBeVisible();
await expect(page.getByText('systemctl restart app')).toBeVisible();
});

test('renders a snippet with a repeated tag (nothing dedups tags)', async ({ page }) => {
await boot(page, [{ name: 'backup', command: 'pg_dump app', scope: 'global', tags: ['db', 'db'] }]);

await expect(page.getByText('pg_dump app')).toBeVisible();
await expect(page.getByText('db', { exact: true })).toHaveCount(2);
});

test('lists a shared host name once in the runner, as the host a run uses', async ({ page }) => {
const twin = { ...HOSTS[0], hostname: 'web-1b.example.com', user: 'admin' };
await boot(page, SNIPPETS, [...HOSTS, twin]);

await page.getByRole('button', { name: 'Run uptime' }).click();
const runner = page.getByRole('dialog', { name: 'Run snippet' });
const web = runner.getByRole('checkbox', { name: 'web-1' });
await expect(web).toHaveCount(1);
await expect(web).toContainText('deploy@web-1.example.com');
await web.click();
await expect(web).toHaveAttribute('aria-checked', 'true');
await expect(runner.getByText('Run on 1 of 2')).toBeVisible();
});
51 changes: 47 additions & 4 deletions crates/omnyssh-gui/ui/e2e/terminal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,14 @@ async function boot(page: Page): Promise<void> {
const chId = (args.onOutput as { id: number }).id;
const sid = ++nextSession;
sessionChannel[sid] = chId;
// A shell prompt proves the streamed output renders + flips status to connected.
if (!win.__silent) setTimeout(() => sendToChannel(chId, 'omnyssh-ready> '), 0);
return Promise.resolve(sid);
const open = (): number => {
// A shell prompt proves the streamed output renders + flips status to connected.
if (!win.__silent) setTimeout(() => sendToChannel(chId, 'omnyssh-ready> '), 0);
return sid;
};
// A test can hold the open, as a slow connect does, until it calls __release.
if (win.__holdOpen) return new Promise((resolve) => (win.__release = () => resolve(open())));
return Promise.resolve(open());
}
case 'terminal_write': {
const { sessionId, data } = args as { sessionId: number; data: number[] };
Expand Down Expand Up @@ -130,7 +135,7 @@ test('host-first: spawn a terminal from a card, run a command, see output, then
await expect(page.locator('.xterm-rows')).toContainText('RESULT-OK');

// Closing the tab tears the terminal down.
await page.getByRole('button', { name: 'Close web-1 · terminal' }).click();
await page.getByRole('button', { name: 'Close web-1' }).click();
await expect(page.locator('.xterm')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'web-1 · terminal', exact: true })).toHaveCount(0);
});
Expand All @@ -148,6 +153,44 @@ test('action-first: the Terminal spawner opens the host picker, then a live term
await expect(page.locator('.xterm-rows')).toContainText('omnyssh-ready');
});

test('dismissing the host picker hands the keyboard back to the terminal', async ({ page }) => {
await boot(page);
await page.getByTitle('sh on web-1').click();
await expect(page.locator('.xterm-rows')).toContainText('omnyssh-ready');
await expect(page.locator('.xterm-helper-textarea')).toBeFocused();

await page.getByRole('button', { name: 'SFTP', exact: true }).click();
await expect(page.getByRole('dialog', { name: 'Pick a host' })).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(page.locator('.xterm-helper-textarea')).toBeFocused();

// Clicking the tab that is already active takes the keyboard to it as well.
await page.getByRole('button', { name: 'web-1 · terminal', exact: true }).click();
await expect(page.locator('.xterm-helper-textarea')).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.locator('.xterm-rows')).toContainText('RESULT-OK');
});

test('a terminal that opens under the palette leaves the keyboard to it', async ({ page }) => {
await boot(page);
await page.evaluate(() => ((window as unknown as Record<string, unknown>).__holdOpen = true));
await page.getByTitle('sh on web-1').click();
await page.getByTitle('Command palette (⌘K)').click();
const query = page.getByRole('dialog', { name: 'Command palette' }).getByRole('textbox');
await expect(query).toBeFocused();

await page.evaluate(() => (window as unknown as { __release: () => void }).__release());
await expect(page.locator('.xterm-rows')).toContainText('omnyssh-ready');
// Two frames, so the terminal's own focus pass has run.
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));

await page.keyboard.type('db');
await expect(query).toBeFocused();
await expect(query).toHaveValue('db');
expect(await page.evaluate(() => (window as unknown as { __writes?: unknown }).__writes)).toBeUndefined();
});

test('toggling the theme re-themes a live terminal (§5.1)', async ({ page }) => {
await boot(page);
await page.getByTitle('sh on web-1').click();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,7 @@
{#if items.length === 0}
<li class="px-3 py-6 text-center text-sm text-muted">{emptyMessage}</li>
{:else}
<!-- Keyed by position: rows are stateless, and host names are not guaranteed
unique, so a name key could throw each_key_duplicate. -->
<!-- Keyed by position: rows are stateless. -->
{#each items as item, i (i)}
{#if $palette.mode === 'navigate' && i === firstSession}
<li class={sectionHead}>Sessions</li>
Expand Down
10 changes: 7 additions & 3 deletions crates/omnyssh-gui/ui/src/lib/screens/SnippetRunner.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import type { SnippetDto } from '$lib/bindings';
import { Button, StatusDot, Icon } from '$lib/theme';
import Modal from '$lib/components/Modal.svelte';
import { hosts } from '$lib/stores/hosts';
import { hosts, firstOfEachName } from '$lib/stores/hosts';
import { statuses } from '$lib/stores/statuses';
import { streamerMode, displayHostname } from '$lib/stores/streamer';
import { hostStatusDot } from '$lib/stores/palette';
Expand Down Expand Up @@ -39,6 +39,10 @@
)
);
// One row per name, the first: the backend runs a name on its first host, so a
// second row would only toggle along with it.
const choices = $derived(firstOfEachName($hosts));
/** Focus the first param input on open for a keyboard-first run. */
function autofocus(node: HTMLInputElement, active: boolean): void {
if (active) node.focus();
Expand Down Expand Up @@ -84,13 +88,13 @@

<div class="space-y-2">
<h3 class="text-[11px] font-medium uppercase tracking-[0.18em] text-faint">
Run on {selected.size} of {$hosts.length}
Run on {selected.size} of {choices.length}
</h3>
{#if $hosts.length === 0}
<p class="text-sm text-muted">No hosts configured.</p>
{:else}
<ul class="space-y-1">
{#each $hosts as host (host.name)}
{#each choices as host (host.name)}
{@const checked = selected.has(host.name)}
<li>
<button
Expand Down
6 changes: 3 additions & 3 deletions crates/omnyssh-gui/ui/src/lib/screens/Snippets.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@
{/if}
</div>
{:else}
<!-- Keyed by position: the core never dedups snippet names, so a name key could
throw each_key_duplicate and blank the whole screen. -->
<!-- Keyed by position: the core never dedups snippet names or tags, so a value
key could throw each_key_duplicate and blank the whole screen. -->
<ul class="min-h-0 flex-1 space-y-2 overflow-y-auto">
{#each filtered as snippet, i (i)}
<li>
Expand All @@ -131,7 +131,7 @@
<div class="flex flex-wrap items-center gap-2">
<span class="truncate font-medium" title={snippet.name}>{snippet.name}</span>
<Chip>{snippet.scope === 'host' && snippet.host ? `host · ${snippet.host}` : 'global'}</Chip>
{#each snippet.tags ?? [] as tag (tag)}
{#each snippet.tags ?? [] as tag, t (t)}
<Chip variant="outline">{tag}</Chip>
{/each}
</div>
Expand Down
Loading
Loading