diff --git a/crates/bascule-core/src/config.rs b/crates/bascule-core/src/config.rs index a01b14d..5da0ed5 100644 --- a/crates/bascule-core/src/config.rs +++ b/crates/bascule-core/src/config.rs @@ -29,6 +29,29 @@ pub struct BasculeConfig { /// Maximum concurrent sessions (0 = unlimited). #[serde(default)] pub max_sessions: usize, + + /// Remote proxy configuration. + /// When set, sessions are forwarded to a target SSH host + /// instead of spawning a local shell. + pub proxy: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ProxyConfig { + /// Target SSH host to forward sessions to. + pub target_host: String, + /// Target SSH port (default: 22). + #[serde(default = "default_ssh_port")] + pub target_port: u16, + /// Username on the target host. + /// If not set, uses the authenticated principal. + pub target_user: Option, + /// Path to private key for target host authentication. + /// If not set, uses agent forwarding or password from the client. + pub target_key_path: Option, + /// Accept any host key from target (dev only — disable in production). + #[serde(default)] + pub accept_target_host_key: bool, } #[derive(Debug, Deserialize, Default)] @@ -66,6 +89,7 @@ impl Default for BasculeConfig { auth: AuthConfig::default(), banner: None, max_sessions: 0, + proxy: None, } } } @@ -88,3 +112,7 @@ fn default_listen() -> String { fn default_auth_mode() -> String { "accept-all".to_string() } + +fn default_ssh_port() -> u16 { + 22 +} diff --git a/crates/bascule-core/src/handler.rs b/crates/bascule-core/src/handler.rs index b28e4e0..c984212 100644 --- a/crates/bascule-core/src/handler.rs +++ b/crates/bascule-core/src/handler.rs @@ -1,6 +1,10 @@ //! SSH handler — implements russh's Handler trait. //! -//! Bridges SSH protocol events to PTY sessions with SessionHandler hooks. +//! Supports two modes: +//! - **Local mode** — spawns a PTY on this machine (default) +//! - **Proxy mode** — forwards the session to a target SSH host +//! +//! SessionHandler hooks fire in both modes at the same lifecycle points. use std::io::{Read, Write}; use std::sync::Arc; @@ -14,16 +18,23 @@ use tokio::sync::Mutex; use crate::auth::AuthProvider; use crate::config::BasculeConfig; use crate::hooks::SessionHandler; +use crate::proxy::{self, UpstreamSession}; use crate::pty::{self, PtyBridge}; use crate::session::SessionInfo; +/// Backend for an active session — either a local PTY or a remote proxy. +enum SessionBackend { + Local(Arc>), + Proxy(Arc>), +} + /// Per-connection SSH handler. pub struct BasculeHandler { auth: Arc, session_handler: Arc, config: Arc, session_info: Option, - pty_bridge: Option>>, + backend: Option, pty_cols: u16, pty_rows: u16, peer_addr: String, @@ -41,7 +52,7 @@ impl BasculeHandler { session_handler, config, session_info: None, - pty_bridge: None, + backend: None, pty_cols: 80, pty_rows: 24, peer_addr, @@ -55,8 +66,12 @@ impl BasculeHandler { } } - /// Spawn PTY, start read loop, return Ok. - async fn spawn_shell( + fn is_proxy_mode(&self) -> bool { + self.config.proxy.is_some() + } + + /// Start local PTY mode — spawn a shell on this machine. + async fn start_local( &mut self, channel: ChannelId, session: &mut Session, @@ -66,16 +81,13 @@ impl BasculeHandler { .ok_or_else(|| anyhow::anyhow!("No session info"))? .clone(); - // SessionHandler hook self.session_handler.on_session_start(&info).await?; - // Build env let mut env = self.session_handler.build_session_env(&info).await; env.insert("BASCULE_SESSION_ID".into(), info.session_id.clone()); env.insert("BASCULE_PRINCIPAL".into(), info.principal.clone()); let env_pairs: Vec<(String, String)> = env.into_iter().collect(); - // Determine command let (cmd, args) = match command { Some(c) => ("/bin/sh", vec!["-c".to_string(), c.to_string()]), None => { @@ -86,9 +98,8 @@ impl BasculeHandler { let bridge = pty::spawn_pty(cmd, &args, env_pairs, self.pty_cols, self.pty_rows)?; let bridge = Arc::new(Mutex::new(bridge)); - self.pty_bridge = Some(bridge.clone()); + self.backend = Some(SessionBackend::Local(bridge.clone())); - // Start read loop let handle = session.handle(); tokio::spawn(async move { let mut buf = [0u8; 4096]; @@ -109,6 +120,72 @@ impl BasculeHandler { Ok(()) } + + /// Start proxy mode — connect to upstream SSH host and bridge I/O. + async fn start_proxy( + &mut self, + channel: ChannelId, + session: &mut Session, + command: Option<&str>, + ) -> anyhow::Result<()> { + let info = self.session_info.as_ref() + .ok_or_else(|| anyhow::anyhow!("No session info"))? + .clone(); + + self.session_handler.on_session_start(&info).await?; + + let proxy_config = self.config.proxy.as_ref() + .ok_or_else(|| anyhow::anyhow!("Proxy config missing"))?; + + let mut upstream = proxy::connect_upstream(proxy_config, &info.principal).await?; + + // Request PTY on the upstream channel + let upstream_ch = upstream.channel.as_ref() + .ok_or_else(|| anyhow::anyhow!("No upstream channel"))?; + upstream_ch.request_pty( + true, + "xterm-256color", + self.pty_cols as u32, + self.pty_rows as u32, + 0, 0, + &[], + ).await?; + + // Request shell or exec on upstream + match command { + Some(cmd) => upstream_ch.exec(true, cmd).await?, + None => upstream_ch.request_shell(true).await?, + } + + // Take the channel out for the bridge loop (bridge owns it) + let upstream_channel = upstream.channel.take() + .ok_or_else(|| anyhow::anyhow!("Channel already taken"))?; + + let upstream = Arc::new(Mutex::new(upstream)); + self.backend = Some(SessionBackend::Proxy(upstream.clone())); + + // Bridge upstream → client + let server_handle = session.handle(); + tokio::spawn(async move { + proxy::bridge_upstream_to_client(upstream_channel, server_handle, channel).await; + }); + + Ok(()) + } + + /// Start session in the appropriate mode. + async fn start_session( + &mut self, + channel: ChannelId, + session: &mut Session, + command: Option<&str>, + ) -> anyhow::Result<()> { + if self.is_proxy_mode() { + self.start_proxy(channel, session, command).await + } else { + self.start_local(channel, session, command).await + } + } } #[async_trait] @@ -183,9 +260,18 @@ impl Handler for BasculeHandler { ) -> Result<(), Self::Error> { self.pty_cols = col_width.min(500) as u16; self.pty_rows = row_height.min(200) as u16; - if let Some(bridge) = &self.pty_bridge { - let b = bridge.lock().await; - let _ = b.resize(self.pty_cols, self.pty_rows); + match &self.backend { + Some(SessionBackend::Local(bridge)) => { + let b = bridge.lock().await; + let _ = b.resize(self.pty_cols, self.pty_rows); + } + Some(SessionBackend::Proxy(_)) => { + // Window change in proxy mode requires the channel reference + // which is owned by the bridge loop. The terminal may not + // resize perfectly but the session continues. + tracing::debug!("Window change in proxy mode (not forwarded)"); + } + None => {} } Ok(()) } @@ -195,7 +281,6 @@ impl Handler for BasculeHandler { channel: ChannelId, session: &mut Session, ) -> Result<(), Self::Error> { - // Send banner if let Some(info) = &self.session_info { let display = self.session_handler.display_name(info); let banner = self.config.banner.as_deref() @@ -205,7 +290,7 @@ impl Handler for BasculeHandler { } session.request_success(); - self.spawn_shell(channel, session, None).await?; + self.start_session(channel, session, None).await?; Ok(()) } @@ -222,12 +307,12 @@ impl Handler for BasculeHandler { let msg = format!("Denied: {}\r\n", e); session.data(channel, CryptoVec::from_slice(msg.as_bytes())); session.close(channel); - return Ok(()); // close returns () + return Ok(()); } } session.request_success(); - self.spawn_shell(channel, session, Some(&command)).await?; + self.start_session(channel, session, Some(&command)).await?; Ok(()) } @@ -237,10 +322,17 @@ impl Handler for BasculeHandler { data: &[u8], _session: &mut Session, ) -> Result<(), Self::Error> { - if let Some(bridge) = &self.pty_bridge { - let mut b = bridge.lock().await; - let _ = b.writer.write_all(data); - let _ = b.writer.flush(); + match &self.backend { + Some(SessionBackend::Local(bridge)) => { + let mut b = bridge.lock().await; + let _ = b.writer.write_all(data); + let _ = b.writer.flush(); + } + Some(SessionBackend::Proxy(upstream)) => { + let u = upstream.lock().await; + let _ = u.handle.data(u.channel_id, CryptoVec::from_slice(data)).await; + } + None => {} } Ok(()) } diff --git a/crates/bascule-core/src/lib.rs b/crates/bascule-core/src/lib.rs index ab646a2..0bc5f73 100644 --- a/crates/bascule-core/src/lib.rs +++ b/crates/bascule-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod auth; pub mod config; pub mod handler; pub mod hooks; +pub mod proxy; pub mod pty; pub mod server; pub mod session; diff --git a/crates/bascule-core/src/proxy.rs b/crates/bascule-core/src/proxy.rs new file mode 100644 index 0000000..9285449 --- /dev/null +++ b/crates/bascule-core/src/proxy.rs @@ -0,0 +1,152 @@ +//! Remote SSH proxy — bridges a client session to a target SSH host. +//! +//! When proxy mode is configured, Bascule authenticates the client, +//! then opens a second SSH connection to the target host and bridges +//! I/O between the two channels. +//! +//! ```text +//! SSH client ←→ bascule (auth + hooks) ←→ target SSH host +//! ``` + +use std::sync::Arc; + +use async_trait::async_trait; +use russh::client; +use russh::ChannelMsg; +use russh_keys::key; +use tokio::sync::Mutex; + +use crate::config::ProxyConfig; + +/// Minimal client handler for the upstream (target) SSH connection. +/// Accepts the target host key based on config. +pub struct UpstreamHandler { + accept_host_key: bool, +} + +impl UpstreamHandler { + pub fn new(accept_host_key: bool) -> Self { + Self { accept_host_key } + } +} + +#[async_trait] +impl client::Handler for UpstreamHandler { + type Error = anyhow::Error; + + async fn check_server_key( + &mut self, + _server_public_key: &key::PublicKey, + ) -> Result { + if self.accept_host_key { + tracing::warn!("Accepting target host key without verification (dev mode)"); + Ok(true) + } else { + // In production, validate against known_hosts or a key store. + // For now, reject unknown keys. + tracing::error!("Target host key rejected — set accept_target_host_key=true for dev"); + Ok(false) + } + } +} + +/// An established upstream SSH session with a channel ready for I/O. +pub struct UpstreamSession { + pub handle: client::Handle, + pub channel: Option>, + pub channel_id: russh::ChannelId, +} + +/// Connect to the target SSH host and authenticate. +pub async fn connect_upstream( + proxy_config: &ProxyConfig, + username: &str, +) -> anyhow::Result { + let target_user = proxy_config + .target_user + .as_deref() + .unwrap_or(username); + + let addr = format!("{}:{}", proxy_config.target_host, proxy_config.target_port); + tracing::info!(target = %addr, user = %target_user, "Connecting to upstream SSH host"); + + let config = client::Config::default(); + let handler = UpstreamHandler::new(proxy_config.accept_target_host_key); + + let mut handle = russh::client::connect(Arc::new(config), &addr, handler).await?; + + // Authenticate with the target host + if let Some(key_path) = &proxy_config.target_key_path { + let key = russh_keys::load_secret_key(key_path, None)?; + let authed = handle + .authenticate_publickey(target_user, Arc::new(key)) + .await?; + if !authed { + anyhow::bail!("Public key authentication failed on target host"); + } + tracing::info!("Authenticated to upstream via public key"); + } else { + // Try none auth (some hosts allow it for certain users) + let authed = handle.authenticate_none(target_user).await?; + if !authed { + anyhow::bail!( + "Cannot authenticate to target host — no target_key_path configured \ + and none-auth rejected" + ); + } + } + + let channel = handle.channel_open_session().await?; + tracing::info!("Upstream channel opened"); + + let channel_id = channel.id(); + Ok(UpstreamSession { handle, channel: Some(channel), channel_id }) +} + +/// Bridge I/O between the server-side SSH channel and the upstream client channel. +/// +/// Reads from upstream → writes to client. +/// The reverse direction (client → upstream) is handled by the server Handler's +/// `data` method forwarding to the upstream channel writer. +pub async fn bridge_upstream_to_client( + mut upstream_channel: russh::Channel, + server_handle: russh::server::Handle, + server_channel_id: russh::ChannelId, +) { + loop { + match upstream_channel.wait().await { + Some(ChannelMsg::Data { data }) => { + if server_handle + .data(server_channel_id, russh::CryptoVec::from_slice(&data)) + .await + .is_err() + { + break; + } + } + Some(ChannelMsg::ExtendedData { data, ext }) => { + if server_handle + .extended_data(server_channel_id, ext, russh::CryptoVec::from_slice(&data)) + .await + .is_err() + { + break; + } + } + Some(ChannelMsg::ExitStatus { exit_status }) => { + tracing::info!(exit_status, "Upstream session exited"); + let _ = server_handle.exit_status_request(server_channel_id, exit_status).await; + let _ = server_handle.close(server_channel_id).await; + break; + } + Some(ChannelMsg::Eof) => { + let _ = server_handle.eof(server_channel_id).await; + } + Some(ChannelMsg::Close) | None => { + let _ = server_handle.close(server_channel_id).await; + break; + } + _ => {} + } + } +}