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
3 changes: 2 additions & 1 deletion crates/omnyssh-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ keywords = ["ssh", "devops"]
categories = ["network-programming"]

[dependencies]
tokio = { workspace = true }
# `net`: the local listeners of port forwarding.
tokio = { workspace = true, features = ["net"] }

# SSH
russh = "0.46"
Expand Down
114 changes: 113 additions & 1 deletion crates/omnyssh-core/src/config/ssh_config.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
//! Parser for `~/.ssh/config`.
//!
//! Supported directives: `Host`, `HostName`, `User`, `Port`,
//! `IdentityFile`, `ProxyJump`, `Include`.
//! `IdentityFile`, `ProxyJump`, `LocalForward`, `Include`. `Match` blocks are
//! skipped.
//!
//! The original file is **never modified**.

use std::collections::HashSet;
use std::path::{Path, PathBuf};

use crate::ssh::client::{Host, HostSource};
use crate::ssh::tunnel::LocalForward;

/// Parses the text of an SSH config file and returns all non-wildcard hosts.
///
Expand Down Expand Up @@ -120,6 +122,26 @@ fn parse_content(
h.proxy_jump = Some(value.to_string());
}
}
"localforward" if !in_wildcard => {
if let Some(ref mut h) = current {
match parse_local_forward(value) {
Ok(forward) => h.local_forwards.push(forward),
Err(e) => {
tracing::warn!(host = %h.name, error = %e, "LocalForward skipped")
}
}
}
}
// 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.
"match" => {
if let Some(h) = current.take() {
hosts.push(h);
}
hosts.append(&mut deferred);
in_wildcard = true;
}
// An Include may sit inside a Host block; the enclosing host keeps
// collecting directives after it.
"include" => {
Expand Down Expand Up @@ -179,6 +201,17 @@ fn parse_content(
hosts
}

/// Parses a `LocalForward` value: `[bind_address:]port host:hostport`, the two
/// arguments `ssh_config(5)` takes, joined into the `ssh -L` notation.
fn parse_local_forward(value: &str) -> Result<LocalForward, String> {
match value.split_whitespace().collect::<Vec<_>>().as_slice() {
[listen, target] => format!("{listen}:{target}").parse(),
_ => Err(format!(
"expected '[bind_address:]port host:hostport', got '{value}'"
)),
}
}

/// Removes everything from the first `#` onwards (inline comments).
fn strip_comment(line: &str) -> &str {
match line.find('#') {
Expand Down Expand Up @@ -443,6 +476,85 @@ host server1
assert_eq!(hosts[0].source, crate::ssh::client::HostSource::SshConfig);
}

#[test]
fn test_local_forwards() {
let cfg = "\
Host nas
HostName 10.0.0.5
LocalForward 9443 127.0.0.1:9443
LocalForward localhost:5432 db.internal:5432
LocalForward [::1]:8080 [fe80::1]:80
";
let hosts = parse_ssh_config(cfg);
let specs: Vec<String> = hosts[0]
.local_forwards
.iter()
.map(ToString::to_string)
.collect();
assert_eq!(
specs,
[
"9443:127.0.0.1:9443",
"localhost:5432:db.internal:5432",
"[::1]:8080:[fe80::1]:80",
]
);
}

#[test]
fn test_unusable_local_forward_skipped() {
// A Unix-socket forward, a missing target and a bad port are dropped
// one by one; the host and its good forward survive.
let cfg = "\
Host nas
LocalForward /tmp/local.sock /run/remote.sock
LocalForward 9443
LocalForward 99999 localhost:80
LocalForward 3000 localhost:3000
";
let hosts = parse_ssh_config(cfg);
assert_eq!(hosts.len(), 1);
assert_eq!(hosts[0].local_forwards.len(), 1);
assert_eq!(
hosts[0].local_forwards[0].to_string(),
"3000:localhost:3000"
);
}

#[test]
fn test_match_block_not_attached_to_previous_host() {
let cfg = "\
Host web
HostName 10.0.0.1

Match host db*
User postgres
LocalForward 0.0.0.0:3306 db:3306

Host api
HostName 10.0.0.2
";
let hosts = parse_ssh_config(cfg);
assert_eq!(hosts.len(), 2);
assert!(hosts[0].local_forwards.is_empty());
assert_ne!(hosts[0].user, "postgres");
assert_eq!(hosts[1].name, "api");
assert_eq!(hosts[1].hostname, "10.0.0.2");
}

#[test]
fn test_wildcard_local_forward_ignored() {
let cfg = "\
Host *
LocalForward 8080 localhost:80

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

#[test]
fn test_equals_separator() {
// Some configs use '=' instead of space.
Expand Down
7 changes: 7 additions & 0 deletions crates/omnyssh-core/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use crate::config::snippets::Snippet;
use crate::ssh::client::{ConnectionStatus, Host};
use crate::ssh::key_setup::KeySetupStep;
use crate::ssh::sftp::FileEntry;
use crate::ssh::tunnel::TunnelStatus;

/// Placeholder type aliases for future stages.
/// `HostId` is the host's `name` field — stable, human-readable key.
Expand Down Expand Up @@ -131,6 +132,12 @@ pub enum CoreEvent {
/// Discovery failed for a host with an error message.
DiscoveryFailed(HostId, String),

// -----------------------------------------------------------------------
// Port forwarding
// -----------------------------------------------------------------------
/// A host's tunnel changed state.
TunnelStatusChanged(HostId, TunnelStatus),

// -----------------------------------------------------------------------
// Auto SSH Key Setup events
// -----------------------------------------------------------------------
Expand Down
45 changes: 44 additions & 1 deletion crates/omnyssh-core/src/ssh/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
//! Connections delegated to the system SSH binary.
//! Also provides russh-based client for live metrics.

use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};

use crate::ssh::tunnel::LocalForward;

/// Indicates where a host entry originated.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
Expand Down Expand Up @@ -82,6 +84,16 @@ pub struct Host {
/// Port for the reachability probe. Falls back to `port` when unset.
#[serde(skip_serializing_if = "Option::is_none")]
pub monitor_port: Option<u16>,
/// Local port forwards (`ssh -L`) carried by this host's tunnel.
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
deserialize_with = "readable_forwards"
)]
pub local_forwards: Vec<LocalForward>,
/// Start the tunnel when OmnySSH starts.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub tunnel_autostart: bool,

// -----------------------------------------------------------------------
// Auto SSH Key Setup metadata
Expand All @@ -94,6 +106,19 @@ pub struct Host {
pub password_auth_disabled: Option<bool>,
}

/// Reads the forwards one by one, dropping an unreadable rule with a warning:
/// failing it would fail the whole file, and every manual host with it.
fn readable_forwards<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<LocalForward>, D::Error> {
Ok(Vec::<String>::deserialize(d)?
.into_iter()
.filter_map(|spec| {
spec.parse()
.map_err(|e| tracing::warn!(error = %e, "port forward skipped"))
.ok()
})
.collect())
}

fn default_user() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
Expand All @@ -120,6 +145,8 @@ impl Default for Host {
original_ssh_host: None,
monitoring: MonitorMode::default(),
monitor_port: None,
local_forwards: Vec::new(),
tunnel_autostart: false,
key_setup_date: None,
password_auth_disabled: None,
}
Expand Down Expand Up @@ -173,6 +200,22 @@ mod tests {
}
}

/// A hand-edited typo costs only the rule it is in, not the file.
#[test]
fn an_unreadable_forward_is_skipped_not_fatal() {
let host: Host = toml::from_str(
"name = \"nas\"\nhostname = \"10.0.0.5\"\n\
local_forwards = [\"9443:localhost:9443\", \"94430:localhost\"]\n",
)
.expect("a bad rule must not fail the host");
let specs: Vec<String> = host
.local_forwards
.iter()
.map(ToString::to_string)
.collect();
assert_eq!(specs, ["9443:localhost:9443"]);
}

#[test]
fn a_reachability_host_persists_its_mode_and_probe_port() {
let host = Host {
Expand Down
5 changes: 3 additions & 2 deletions crates/omnyssh-core/src/ssh/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/// SSH client, session management, SFTP and metrics collection.
///
/// A native russh client powers metrics collection, SFTP, and the
/// multi-session terminal emulator, plus Smart Server Context with service
/// discovery and Auto SSH Key Setup for secure authentication.
/// multi-session terminal emulator and local port forwarding, plus Smart Server
/// Context with service discovery and Auto SSH Key Setup for secure authentication.
pub mod client;
pub mod discovery;
pub mod jump;
Expand All @@ -14,3 +14,4 @@ pub mod pty;
pub mod services;
pub mod session;
pub mod sftp;
pub mod tunnel;
Loading
Loading