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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ Versions follow [Semantic Versioning](https://semver.org/).
### Features
- **OmnySSH asks for the login password when no key gets in.** A host without a working key and without a saved password — typically one imported from `~/.ssh/config` — failed with "SSH authentication failed" and offered no way in short of saving its password to disk. A terminal now asks the way `ssh` does, right in the tab (`user@host's password:`), and a file session asks in a dialog. Three tries, then the login fails; Ctrl+C or Cancel ends it. The password is kept in memory until you quit, never written to disk, and only once the server has accepted it; the dashboard, and any tunnel on that host, pick it up and connect on their own — they never ask themselves. The first time OmnySSH meets a server, the prompt shows the host key it just recorded, so you can check it before typing. A saved password is still tried first, and a key whose passphrase is needed still comes before any password when the host names it as its identity file.

- **Copy from the desktop terminal with Ctrl+Shift+C.** On Windows and Linux there was no key that copied: Ctrl+C sent the shell an interrupt, as it must, and the only way to copy was the right-click menu. Ctrl+Shift+C now copies the selection, as in GNOME Terminal and Windows Terminal, and Ctrl+Shift+V keeps pasting. Ctrl+C and Ctrl+V still reach the shell as ^C and ^V, which vim and readline rely on. On macOS Cmd+C and Cmd+V already worked and are unchanged.
- **Forward your SSH agent to a host, like `ssh -A`.** A host can now lend its terminals your local SSH agent, so `sudo` through `pam_ssh_agent_auth`, `git` over SSH and hopping on to another server all work with the keys on your machine, and `SSH_AUTH_SOCK` is set on the remote side. It is off for every host until you turn it on — "Forward SSH agent" in the host form of both apps — or the host has `ForwardAgent yes` in `~/.ssh/config`. Only terminals get the agent, only on the host itself and not its `ProxyJump` bastions, and only while an agent is running here; the dashboard, file sessions and tunnels never do. Anyone with root on that server can use your keys while a terminal is open, so keep it to servers you trust. A server that opens an agent channel it was not offered is refused, as `ssh` does. A `ForwardAgent yes` under `Host *` or `Match`, or one pointing at another agent's socket, is not read, and `IdentityAgent` is not followed; a `ForwardAgent no` anywhere above a host keeps its agent home. Not available on Windows yet, where OmnySSH does not use the agent for logins either.
- **Minimize or close the desktop app to the system tray.** Closing the window quit the app, and with it every open terminal, file transfer and tunnel. Two settings under Window now keep it running in the tray instead — one for minimizing, one for closing — and the tray icon brings the window back or quits. Both are off until you turn them on. On macOS the icon lives in the menu bar and minimizing stays with the Dock. Launching OmnySSH again while it runs now brings the running window forward rather than starting a second copy. On Linux the tray needs `libayatana-appindicator3` — the `.deb` and `.rpm` now depend on it, and the AppImage carries its own copy — and a panel that shows tray icons, which stock GNOME does not without the AppIndicator extension; where either is missing, the settings say so and the window keeps closing as before. Minimizing to the tray needs X11: Wayland never tells an app its window was minimized.

### Bug Fixes
- **Ctrl keys work in the desktop terminal on a non-Latin keyboard layout (Linux).** With a Russian, Ukrainian, Greek or other non-Latin layout active, Ctrl+C, Ctrl+D, Ctrl+Z and the other Ctrl chords sent nothing to the shell, and Ctrl+Shift+V did not paste, because the Linux webview reports no key code for those letters. The physical key now decides, as in GNOME Terminal. Windows, macOS and the terminal app were not affected.
- **Devices that only take the password by keyboard-interactive log in.** UniFi consoles such as the Dream Machine Pro, and other servers with `PasswordAuthentication no`, accept a password only through keyboard-interactive — which is what `ssh` and PuTTY fall back to without telling you. OmnySSH sent the saved password by the password method alone, so these hosts showed as offline with "SSH authentication failed". It now offers the password the other way too, and remembers which one a server takes, so a wrong password costs one failed login, not two. A server that asks for a one-time code instead of a password is told so rather than sent the password.
- **A silent or refusing SSH agent no longer leaves every host stuck on "connecting".** Every login asks the agent first, and nothing bounded that: an agent that accepts connections but never answers — as the launchd agent does on some macOS Tahoe setups — or one that turns a signature down (a declined 1Password or Secretive approval, a key added with `ssh-add -c`) held the login forever, password hosts included. The agent now gets five seconds to list its keys and a minute to sign, a refused signature moves on to the next method, and a signature you turned down is not asked for again by background reconnects.
- **The dashboard card says why a host is down.** A failed host showed a grey "offline" with the reason nowhere on screen. The card now shows it — "SSH connection failed: Connection refused", an authentication failure, a timeout — hidden in streamer mode like the tunnel's reason. The terminal app keeps the whole cause in the host's detail view, and a terminal that fails to open no longer replaces its reason with "SSH session closed.".
Expand Down
21 changes: 21 additions & 0 deletions Cargo.lock

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

149 changes: 144 additions & 5 deletions crates/omnyssh-core/src/config/ssh_config.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
//! Parser for `~/.ssh/config`.
//!
//! Supported directives: `Host`, `HostName`, `User`, `Port`,
//! `IdentityFile`, `ProxyJump`, `LocalForward`, `Include`. `Match` blocks are
//! skipped.
//! `IdentityFile`, `ProxyJump`, `LocalForward`, `ForwardAgent`, `Include`.
//! Wildcard `Host` and `Match` blocks are skipped, except that a `ForwardAgent no`
//! there, or in the global section, keeps the agent from every host after it.
//!
//! The original file is **never modified**.

Expand All @@ -20,7 +21,13 @@ use crate::ssh::tunnel::LocalForward;
/// configuration.
pub fn parse_ssh_config(content: &str) -> Vec<Host> {
let mut visited: HashSet<PathBuf> = HashSet::new();
parse_content(content, default_include_base().as_deref(), 0, &mut visited)
parse_content(
content,
default_include_base().as_deref(),
0,
&mut visited,
&mut false,
)
}

/// Loads and parses an SSH config file from disk.
Expand All @@ -35,7 +42,13 @@ pub fn load_from_file(path: &Path) -> anyhow::Result<Vec<Host>> {
.filter(|p| !p.as_os_str().is_empty())
.map_or_else(default_include_base, |p| Some(p.to_path_buf()));
let mut visited: HashSet<PathBuf> = HashSet::new();
Ok(parse_content(&content, base.as_deref(), 0, &mut visited))
Ok(parse_content(
&content,
base.as_deref(),
0,
&mut visited,
&mut false,
))
}

/// `~/.ssh` — where `ssh_config(5)` resolves a relative `Include` in a user
Expand All @@ -48,11 +61,16 @@ fn default_include_base() -> Option<PathBuf> {
// Internal helpers
// ---------------------------------------------------------------------------

/// `agent_barred` is set by a `ForwardAgent no` in a place this parser does not
/// read hosts from — the global section, a wildcard `Host`, a `Match` — which ssh(1)
/// may take first for any host after it. Shared with the files an `Include` pulls in,
/// which ssh(1) reads in place.
fn parse_content(
content: &str,
base: Option<&Path>,
depth: usize,
visited: &mut HashSet<PathBuf>,
agent_barred: &mut bool,
) -> Vec<Host> {
if depth > 3 {
return Vec::new();
Expand All @@ -65,6 +83,9 @@ fn parse_content(
let mut deferred: Vec<Host> = Vec::new();
// True when we are inside a wildcard `Host *` block (skip directives).
let mut in_wildcard = false;
// ForwardAgent is first-wins, as in ssh(1): a later `yes` in the same block
// must not turn on what an earlier `no` kept off.
let mut agent_seen = false;

for raw_line in content.lines() {
let line = strip_comment(raw_line).trim().to_string();
Expand All @@ -83,6 +104,7 @@ fn parse_content(
hosts.push(h);
}
hosts.append(&mut deferred);
agent_seen = false;
in_wildcard = value.contains('*') || value.contains('?');
if !in_wildcard {
let h = Host {
Expand Down Expand Up @@ -132,6 +154,26 @@ fn parse_content(
}
}
}
// Lending the agent is never read from outside a host's own block, but a
// `no` there still counts: which host it covers is not worked out here,
// so it covers every host after it. A socket path or `$VAR` names another
// agent, and lending the default one instead is not what was asked.
"forwardagent" => {
let answer = value.to_ascii_lowercase();
let off = matches!(answer.as_str(), "no" | "false");
match current {
Some(ref mut h) if !agent_seen => {
agent_seen = true;
match answer.as_str() {
"yes" | "true" => h.forward_agent = !*agent_barred,
"no" | "false" => h.forward_agent = false,
_ => tracing::warn!(host = %h.name, value, "ForwardAgent skipped"),
}
}
None if off => *agent_barred = true,
_ => {}
}
}
// A Match block's directives apply by condition, not to the host
// above it; skip them like a wildcard block — a `LocalForward` there
// must not open a port for a host that never asked for it.
Expand Down Expand Up @@ -173,7 +215,13 @@ fn parse_content(
continue; // already visited — break cycle
}
match std::fs::read_to_string(&path) {
Ok(sub) => sink.extend(parse_content(&sub, base, depth + 1, visited)),
Ok(sub) => sink.extend(parse_content(
&sub,
base,
depth + 1,
visited,
agent_barred,
)),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "Include file unreadable")
}
Expand Down Expand Up @@ -555,6 +603,97 @@ Host web
assert!(hosts[0].local_forwards.is_empty());
}

#[test]
fn test_forward_agent() {
let cfg = "\
Host bastion
ForwardAgent yes
Host lab
ForwardAgent True
Host web
ForwardAgent no
Host plain
HostName 10.0.0.1
";
let hosts = parse_ssh_config(cfg);
let forwarding: Vec<bool> = hosts.iter().map(|h| h.forward_agent).collect();
assert_eq!(forwarding, [true, true, false, false]);
}

#[test]
fn test_forward_agent_first_value_wins() {
let cfg = "\
Host web
ForwardAgent no
ForwardAgent yes
Host db
ForwardAgent yes
";
let hosts = parse_ssh_config(cfg);
assert!(
!hosts[0].forward_agent,
"a later yes overrode an earlier no"
);
assert!(hosts[1].forward_agent, "the next block starts afresh");
}

#[test]
fn test_forward_agent_to_another_socket_skipped() {
// A path or `$VAR` names a different agent; lending the default one
// instead would hand out keys the config never meant to.
let cfg = "\
Host a
ForwardAgent /run/user/1000/other.sock
Host b
ForwardAgent $OTHER_SOCK
";
let hosts = parse_ssh_config(cfg);
assert!(hosts.iter().all(|h| !h.forward_agent));
}

#[test]
fn test_wildcard_forward_agent_ignored() {
let cfg = "\
Host *
ForwardAgent yes

Host web
HostName 10.0.0.1
";
let hosts = parse_ssh_config(cfg);
assert!(!hosts[0].forward_agent);
}

#[test]
fn test_an_earlier_general_no_keeps_the_agent_home() {
// ssh(1) takes the first value that applies, so a `no` in the global
// section, a wildcard block or a Match block ahead of a host wins over
// the host's own `yes`.
for general in [
"ForwardAgent no\n",
"Host *\n ForwardAgent no\n",
"Host *.internal\n ForwardAgent no\n",
"Match all\n ForwardAgent no\n",
] {
let cfg = format!("{general}Host web\n ForwardAgent yes\n");
let hosts = parse_ssh_config(&cfg);
assert!(!hosts[0].forward_agent, "{general:?} did not win");
}
}

#[test]
fn test_a_later_general_no_leaves_an_earlier_yes() {
let cfg = "\
Host web
ForwardAgent yes

Host *
ForwardAgent no
";
let hosts = parse_ssh_config(cfg);
assert!(hosts[0].forward_agent);
}

#[test]
fn test_equals_separator() {
// Some configs use '=' instead of space.
Expand Down
5 changes: 5 additions & 0 deletions crates/omnyssh-core/src/ssh/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ pub struct Host {
/// Start the tunnel when OmnySSH starts.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub tunnel_autostart: bool,
/// Lend the local SSH agent to this host's terminals (`ssh -A`). Anyone with
/// root on the host can use it while a terminal is open, so it is opt-in.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub forward_agent: bool,

// -----------------------------------------------------------------------
// Auto SSH Key Setup metadata
Expand Down Expand Up @@ -147,6 +151,7 @@ impl Default for Host {
monitor_port: None,
local_forwards: Vec::new(),
tunnel_autostart: false,
forward_agent: false,
key_setup_date: None,
password_auth_disabled: None,
}
Expand Down
21 changes: 18 additions & 3 deletions crates/omnyssh-core/src/ssh/pty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ use crate::event::CoreEvent;
use crate::ssh::client::Host;
use crate::ssh::identity;
use crate::ssh::password::{AskPassword, NoAnswer, Prompt};
use crate::ssh::session::{connect_and_auth, passphrase_required, Passwords, SshConnection};
use crate::ssh::session::{
connect_for_shell, forwards_agent, passphrase_required, Passwords, SshConnection,
};

/// Stable numeric identifier for a PTY session (mirrors [`crate::event::SessionId`]).
pub type SessionId = u64;
Expand Down Expand Up @@ -143,6 +145,7 @@ async fn forward_locale(channel: &russh::Channel<russh::client::Msg>) {
/// Opens a channel and requests a remote PTY + shell (the `ssh -t` equivalent).
async fn open_shell(
handle: &SshConnection,
lends_agent: bool,
cols: u16,
rows: u16,
) -> Result<russh::Channel<russh::client::Msg>> {
Expand All @@ -152,6 +155,13 @@ async fn open_shell(
.context("open terminal channel")?;
// Sent before the shell starts so it inherits the locale.
forward_locale(&channel).await;
// Likewise `SSH_AUTH_SOCK`.
if lends_agent {
channel
.agent_forward(false)
.await
.context("request agent forwarding")?;
}
// IUTF8 tells the server's line discipline that input is UTF-8, so multibyte
// (e.g. Cyrillic) editing works in canonical mode. Unknown modes are ignored.
channel
Expand Down Expand Up @@ -198,7 +208,10 @@ async fn session_task(
asked: false,
closed: false,
};
let connected = connect_and_auth(&host, Passwords::Ask(&mut prompt)).await;
// Asked once: the connection that takes agent channels and the request that
// invites them must agree, even if the agent comes or goes during the login.
let lends_agent = forwards_agent(&host);
let connected = connect_for_shell(&host, Passwords::Ask(&mut prompt), lends_agent).await;
// Keys typed past a password prompt must not reach the new shell (a
// password entered twice would be echoed there). Without a prompt they are
// the user's first command, and stay queued.
Expand All @@ -212,7 +225,9 @@ async fn session_task(
return;
}
let result = match connected {
Ok(handle) => open_shell(&handle, cols, rows).await.map(|ch| (handle, ch)),
Ok(handle) => open_shell(&handle, lends_agent, cols, rows)
.await
.map(|ch| (handle, ch)),
Err(e) => Err(e),
};
let (_handle, mut channel) = match result {
Expand Down
Loading
Loading