#![forbid(unsafe_code)]
mod local;
mod reverse;
mod socks;
mod stats;
mod streamlocal;
pub use local::ForwardKind;
pub use stats::TunnelStats;
pub use streamlocal::validate_remote_socket;
use crate::errors::SshCliError;
use crate::output;
use crate::ssh::client::{SshClient, SshClientTrait};
use crate::vps::find_by_name;
use anyhow::Result;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone)]
pub enum TunnelMode {
Local {
remote_host: String,
remote_port: u16,
},
Socks5,
StreamLocal {
socket_path: String,
},
Reverse {
remote_bind: String,
remote_port: u16,
},
}
impl TunnelMode {
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::Local { .. } => "local",
Self::Socks5 => "socks5",
Self::StreamLocal { .. } => "streamlocal",
Self::Reverse { .. } => "reverse",
}
}
}
#[derive(Default)]
pub struct TunnelAuth {
pub password: Option<secrecy::SecretString>,
pub key: Option<String>,
pub key_passphrase: Option<secrecy::SecretString>,
pub use_agent: bool,
pub agent_socket: Option<String>,
}
pub struct TunnelRequest {
pub vps_name: String,
pub local_port: u16,
pub mode: TunnelMode,
pub config_override: Option<PathBuf>,
pub auth: TunnelAuth,
pub timeout_ms: u64,
pub replace_host_key: bool,
pub json: bool,
pub bind_addr: String,
pub accept_network_exposure: bool,
}
pub async fn run_tunnel(request: TunnelRequest) -> Result<()> {
let TunnelRequest {
vps_name,
local_port,
mode,
config_override,
auth,
timeout_ms,
replace_host_key,
json,
bind_addr,
accept_network_exposure,
} = request;
if timeout_ms == 0 {
return Err(SshCliError::InvalidArgument(
"tunnel requires --timeout-ms > 0 (bounded one-shot)".to_string(),
)
.into());
}
match &mode {
TunnelMode::Reverse { remote_bind, .. } => {
guard_remote_exposure(remote_bind, accept_network_exposure)?;
}
_ => guard_network_exposure(&bind_addr, accept_network_exposure)?,
}
if let TunnelMode::StreamLocal { socket_path } = &mode {
validate_remote_socket(socket_path)?;
}
let vps = find_by_name(config_override.as_deref(), &vps_name)?
.ok_or_else(|| SshCliError::VpsNotFound(vps_name.clone()))?;
let path = crate::vps::resolve_config_path(config_override.as_deref())?;
let cfg = resolve_tunnel_connection(vps, auth, Some(&path), replace_host_key);
tracing::info!(
vps = %vps_name,
local_port,
mode = mode.label(),
timeout_ms,
"starting SSH tunnel with deadline"
);
if !json {
output::print_human_banner(&crate::i18n::t(crate::i18n::Message::TunnelPressCtrlC));
}
let bound = Arc::new(AtomicBool::new(false));
let bound_flag = Arc::clone(&bound);
let stats = Arc::new(TunnelStats::default());
let stats_loop = Arc::clone(&stats);
let started = std::time::Instant::now();
let mode_label = mode.label();
let bind_for_event = match &mode {
TunnelMode::Reverse { remote_bind, .. } => remote_bind.clone(),
_ => bind_addr.clone(),
};
let result = tokio::time::timeout(Duration::from_millis(timeout_ms), async {
let client: Box<dyn SshClientTrait> = <SshClient as SshClientTrait>::connect(cfg).await?;
serve_mode(
ServeContext {
vps_name: vps_name.clone(),
local_port,
timeout_ms,
json,
bind_addr,
bound_flag: Some(bound_flag),
stats: Some(stats_loop),
},
mode,
client,
)
.await
})
.await;
let emit_closed = |reason| {
if json && bound.load(Ordering::Acquire) {
let event = output::build_tunnel_closed(output::TunnelClosedInput {
vps: &vps_name,
reason,
bind: &bind_for_event,
local_port: u16::try_from(stats.effective_port.load(Ordering::Acquire))
.unwrap_or(local_port),
forwards_served: stats.forwards_served.load(Ordering::Relaxed),
capacity_waits: stats.capacity_waits.load(Ordering::Relaxed),
duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
mode: mode_label,
});
if let Err(e) = output::print_tunnel_closed_json(&event) {
tracing::warn!(err = %e, "failed to emit tunnel_closed event");
}
}
};
match result {
Ok(inner) => {
emit_closed(stats.close_reason());
inner
}
Err(_) if bound.load(Ordering::Acquire) => {
tracing::info!(timeout_ms, "tunnel ended by one-shot deadline (success)");
emit_closed(crate::json_wire::TunnelCloseReason::Deadline);
Ok(())
}
Err(_) => {
tracing::warn!(timeout_ms, "tunnel timeout before local bind");
Err(SshCliError::SshTimeout(timeout_ms).into())
}
}
}
#[must_use]
pub fn resolve_tunnel_connection(
mut vps: crate::vps::model::VpsRecord,
auth: TunnelAuth,
config_toml: Option<&std::path::Path>,
replace_host_key: bool,
) -> crate::ssh::client::ConnectionConfig {
crate::vps::apply_overrides(
&mut vps,
crate::vps::AuthOverrides {
password: auth.password,
key_path: auth.key,
key_passphrase: auth.key_passphrase,
use_agent: auth.use_agent,
agent_socket: auth.agent_socket,
..Default::default()
},
);
crate::vps::build_connection_config(&vps, config_toml, replace_host_key)
}
pub struct ServeContext {
pub vps_name: String,
pub local_port: u16,
pub timeout_ms: u64,
pub json: bool,
pub bind_addr: String,
pub bound_flag: Option<Arc<AtomicBool>>,
pub stats: Option<Arc<TunnelStats>>,
}
async fn serve_mode(
ctx: ServeContext,
mode: TunnelMode,
client: Box<dyn SshClientTrait>,
) -> Result<()> {
let ServeContext {
vps_name,
local_port,
timeout_ms,
json,
bind_addr,
bound_flag,
stats,
} = ctx;
let (vps_name, bind_addr) = (vps_name.as_str(), bind_addr.as_str());
match mode {
TunnelMode::Reverse {
remote_bind,
remote_port,
} => {
reverse::serve(
reverse::ReverseServe {
vps_name: vps_name.to_string(),
remote_bind,
remote_port,
local_host: crate::constants::DEFAULT_TUNNEL_BIND_ADDR.to_string(),
local_port,
timeout_ms,
json,
},
client,
bound_flag,
stats,
)
.await
}
other => {
let kind = match other {
TunnelMode::Local {
remote_host,
remote_port,
} => ForwardKind::Tcp {
host: remote_host,
port: remote_port,
},
TunnelMode::Socks5 => ForwardKind::Socks5,
TunnelMode::StreamLocal { socket_path } => ForwardKind::StreamLocal { socket_path },
TunnelMode::Reverse { .. } => unreachable!("handled by the arm above"),
};
local::serve(
local::LocalServe {
vps_name: vps_name.to_string(),
local_port,
bind_addr: bind_addr.to_string(),
timeout_ms,
json,
kind,
},
client,
bound_flag,
stats,
)
.await
}
}
}
pub fn guard_network_exposure(bind_addr: &str, accepted: bool) -> Result<(), SshCliError> {
let parsed: std::net::IpAddr = bind_addr.parse().map_err(|_| {
SshCliError::InvalidArgument(format!("invalid --bind address `{bind_addr}`"))
})?;
if parsed.is_loopback() || accepted {
if !parsed.is_loopback() {
tracing::warn!(
bind = %bind_addr,
"tunnel bound outside loopback: the forwarded remote service is reachable from the local network"
);
}
return Ok(());
}
Err(SshCliError::InvalidArgument(format!(
"--bind {bind_addr} exposes the forwarded service to the network; \
pass --i-accept-network-exposure to proceed"
)))
}
pub fn guard_remote_exposure(remote_bind: &str, accepted: bool) -> Result<(), SshCliError> {
let loopback = matches!(remote_bind, "127.0.0.1" | "::1" | "localhost");
if loopback || accepted {
if !loopback {
tracing::warn!(
bind = %remote_bind,
"reverse tunnel bound outside remote loopback: the local service is reachable from the remote network"
);
}
return Ok(());
}
Err(SshCliError::InvalidArgument(format!(
"--reverse binding `{remote_bind}` on the server exposes your local service to \
the remote network; pass --i-accept-network-exposure to proceed"
)))
}
pub(crate) async fn pump<L>(
mut local: L,
mut channel: Box<dyn crate::ssh::client::TunnelChannel>,
peer: &str,
peer_port: u16,
) -> Result<()>
where
L: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
match tokio::io::copy_bidirectional(&mut local, &mut *channel).await {
Ok((to_remote, to_local)) => {
tracing::debug!(
bytes_to_remote = to_remote,
bytes_to_local = to_local,
"tunnel forward completed"
);
Ok(())
}
Err(e) => {
tracing::warn!(err = %e, %peer, peer_port, "tunnel forward copy failed");
Err(SshCliError::Io(e).into())
}
}
}
pub(crate) async fn drain_forwards(forwards: &mut tokio::task::JoinSet<()>) {
if crate::signals::is_force_exit() {
tracing::info!("force-exit: aborting tunnel forwards");
forwards.abort_all();
}
let drain = tokio::time::timeout(
Duration::from_secs(crate::constants::TUNNEL_FORWARD_DRAIN_TIMEOUT_SECS),
async { while forwards.join_next().await.is_some() {} },
)
.await;
if drain.is_err() {
tracing::warn!("tunnel forward drain timed out; aborting remainder");
forwards.abort_all();
while forwards.join_next().await.is_some() {}
}
}
pub async fn run_tunnel_with_client(
mut ctx: ServeContext,
remote_host: &str,
remote_port: u16,
client: Box<dyn SshClientTrait>,
) -> Result<()> {
ctx.stats = None;
run_tunnel_with_client_stats(ctx, remote_host, remote_port, client).await
}
pub async fn run_tunnel_with_client_stats(
ctx: ServeContext,
remote_host: &str,
remote_port: u16,
client: Box<dyn SshClientTrait>,
) -> Result<()> {
local::serve(
local::LocalServe {
vps_name: ctx.vps_name,
local_port: ctx.local_port,
bind_addr: ctx.bind_addr,
timeout_ms: ctx.timeout_ms,
json: ctx.json,
kind: ForwardKind::Tcp {
host: remote_host.to_string(),
port: remote_port,
},
},
client,
ctx.bound_flag,
ctx.stats,
)
.await
}
#[cfg(test)]
mod tests;