#![allow(clippy::unwrap_used)]
use super::*;
use crate::ssh::client::{ConnectionConfig, ExecutionOutput, SshClientTrait, TransferResult};
use async_trait::async_trait;
use std::path::Path;
use std::sync::Arc;
use tokio::net::TcpListener;
struct Stub {
allocated: u16,
}
impl Stub {
fn new() -> Self {
Self { allocated: 0 }
}
fn with_allocated(port: u16) -> Self {
Self { allocated: port }
}
}
#[async_trait]
impl SshClientTrait for Stub {
async fn connect(_cfg: ConnectionConfig) -> Result<Box<Self>, crate::errors::SshCliError> {
Ok(Box::new(Stub::new()))
}
async fn run_command(
&mut self,
_cmd: &str,
_max: usize,
_stdin: Option<Vec<u8>>,
) -> Result<ExecutionOutput, crate::errors::SshCliError> {
unreachable!()
}
async fn upload(
&self,
_l: &Path,
_r: &Path,
) -> Result<TransferResult, crate::errors::SshCliError> {
unreachable!()
}
async fn download(
&self,
_r: &Path,
_l: &Path,
) -> Result<TransferResult, crate::errors::SshCliError> {
unreachable!()
}
async fn open_tunnel_channel(
&self,
_h: &str,
_p: u16,
_o: &str,
_po: u16,
) -> Result<Box<dyn crate::ssh::client::TunnelChannel>, crate::errors::SshCliError> {
Err(crate::errors::SshCliError::channel_msg("stub"))
}
async fn request_remote_forward(
&self,
_address: &str,
_port: u16,
) -> Result<u16, crate::errors::SshCliError> {
Ok(self.allocated)
}
async fn cancel_remote_forward(
&self,
_address: &str,
_port: u16,
) -> Result<(), crate::errors::SshCliError> {
Ok(())
}
async fn disconnect(&self) -> Result<(), crate::errors::SshCliError> {
Ok(())
}
}
fn request(timeout_ms: u64, mode: TunnelMode, bind_addr: &str) -> TunnelRequest {
TunnelRequest {
vps_name: "no-such-host".to_string(),
local_port: 0,
mode,
config_override: None,
auth: TunnelAuth::default(),
timeout_ms,
replace_host_key: false,
json: true,
bind_addr: bind_addr.to_string(),
accept_network_exposure: false,
}
}
fn local_mode() -> TunnelMode {
TunnelMode::Local {
remote_host: "localhost".to_string(),
remote_port: 5432,
}
}
#[tokio::test]
async fn timeout_zero_is_rejected_before_touching_the_registry() {
let err = run_tunnel(request(0, local_mode(), "127.0.0.1"))
.await
.expect_err("timeout_ms == 0 must be rejected");
let ssh = err
.downcast_ref::<SshCliError>()
.expect("must surface a typed SshCliError");
assert!(
matches!(ssh, SshCliError::InvalidArgument(_)),
"expected InvalidArgument, got {ssh:?}"
);
assert_eq!(ssh.exit_code(), crate::errors::exit_codes::EX_USAGE);
assert!(!matches!(ssh, SshCliError::VpsNotFound(_)));
}
#[tokio::test]
async fn relative_remote_socket_is_rejected_before_the_registry() {
let err = run_tunnel(request(
1_000,
TunnelMode::StreamLocal {
socket_path: "run/docker.sock".to_string(),
},
"127.0.0.1",
))
.await
.expect_err("relative remote socket must be rejected");
let ssh = err.downcast_ref::<SshCliError>().unwrap();
assert_eq!(ssh.exit_code(), crate::errors::exit_codes::EX_USAGE);
assert!(!matches!(ssh, SshCliError::VpsNotFound(_)));
}
#[tokio::test]
async fn reverse_bind_outside_remote_loopback_requires_acceptance() {
let err = run_tunnel(request(
1_000,
TunnelMode::Reverse {
remote_bind: "0.0.0.0".to_string(),
remote_port: 8080,
},
"127.0.0.1",
))
.await
.expect_err("0.0.0.0 on the server must not bind without acknowledgement");
let ssh = err.downcast_ref::<SshCliError>().unwrap();
assert_eq!(ssh.exit_code(), crate::errors::exit_codes::EX_USAGE);
assert!(!matches!(ssh, SshCliError::VpsNotFound(_)));
}
#[test]
fn non_loopback_bind_requires_explicit_acceptance() {
let err = guard_network_exposure("0.0.0.0", false)
.expect_err("0.0.0.0 must not bind without acknowledgement");
assert!(matches!(err, SshCliError::InvalidArgument(_)));
assert_eq!(err.exit_code(), crate::errors::exit_codes::EX_USAGE);
}
#[test]
fn loopback_bind_needs_no_acceptance() {
assert!(guard_network_exposure("127.0.0.1", false).is_ok());
assert!(guard_network_exposure("::1", false).is_ok());
}
#[test]
fn non_loopback_bind_allowed_once_accepted() {
assert!(guard_network_exposure("0.0.0.0", true).is_ok());
}
#[test]
fn malformed_bind_is_rejected() {
let err = guard_network_exposure("127.0.0..1", true)
.expect_err("malformed address must not reach bind()");
assert!(matches!(err, SshCliError::InvalidArgument(_)));
}
#[test]
fn remote_loopback_names_need_no_acceptance() {
assert!(guard_remote_exposure("127.0.0.1", false).is_ok());
assert!(guard_remote_exposure("localhost", false).is_ok());
assert!(guard_remote_exposure("::1", false).is_ok());
}
#[test]
fn empty_remote_bind_means_all_interfaces_and_needs_acceptance() {
let err = guard_remote_exposure("", false).expect_err("empty bind is all-interfaces");
assert!(matches!(err, SshCliError::InvalidArgument(_)));
assert!(guard_remote_exposure("", true).is_ok());
}
#[test]
fn mode_labels_are_stable_wire_values() {
assert_eq!(local_mode().label(), "local");
assert_eq!(TunnelMode::Socks5.label(), "socks5");
assert_eq!(
TunnelMode::StreamLocal {
socket_path: "/tmp/x.sock".into()
}
.label(),
"streamlocal"
);
assert_eq!(
TunnelMode::Reverse {
remote_bind: "127.0.0.1".into(),
remote_port: 0
}
.label(),
"reverse"
);
}
#[test]
fn socks5_listening_event_reports_no_single_destination() {
assert_eq!(ForwardKind::Socks5.event_host(), "*");
assert_eq!(ForwardKind::Socks5.event_port(), 0);
}
#[tokio::test]
async fn tunnel_ephemeral_bind_reports_real_port() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("ephemeral bind");
let port = listener.local_addr().expect("local_addr").port();
assert_ne!(port, 0, "OS must assign port > 0 after bind :0");
assert!(
(1..=65535).contains(&port),
"effective port out of 1..=65535: {port}"
);
}
#[tokio::test]
async fn ephemeral_bind_reports_os_assigned_port_not_the_requested_zero() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("ephemeral bind");
let effective = listener
.local_addr()
.map(|a| a.port())
.expect("local_addr after bind");
assert_ne!(
effective, 0,
"agents connect to the port from the event; 0 would be unusable"
);
}
#[tokio::test]
#[serial_test::serial]
async fn tunnel_with_client_ends_on_cancel() {
crate::signals::reset_flags_for_tests();
let stub: Box<dyn SshClientTrait> = Box::new(Stub::new());
let bound = Arc::new(AtomicBool::new(false));
let handle = tokio::spawn({
let bound = Arc::clone(&bound);
async move {
run_tunnel_with_client(
crate::tunnel::ServeContext {
vps_name: "stub-vps".to_string(),
local_port: 0,
timeout_ms: 60_000,
json: false,
bind_addr: "127.0.0.1".to_string(),
bound_flag: Some(bound),
stats: None,
},
"localhost",
5432,
stub,
)
.await
}
});
let mut attempts = 0;
while !bound.load(Ordering::Acquire) && attempts < 100 {
tokio::time::sleep(Duration::from_millis(10)).await;
attempts += 1;
}
assert!(
bound.load(Ordering::Acquire),
"run_tunnel_with_client must publish the bound flag after listening"
);
assert!(
!handle.is_finished(),
"tunnel must keep accepting until signal or deadline"
);
handle.abort();
let _ = handle.await;
}
#[tokio::test]
#[serial_test::serial]
async fn reverse_publishes_the_port_the_server_allocated() {
crate::signals::reset_flags_for_tests();
let stats = Arc::new(TunnelStats::default());
let bound = Arc::new(AtomicBool::new(false));
let client: Box<dyn SshClientTrait> = Box::new(Stub::with_allocated(41_337));
super::reverse::serve(
super::reverse::ReverseServe {
vps_name: "stub-vps".to_string(),
remote_bind: "127.0.0.1".to_string(),
remote_port: 0,
local_host: "127.0.0.1".to_string(),
local_port: 5432,
timeout_ms: 60_000,
json: false,
},
client,
Some(Arc::clone(&bound)),
Some(Arc::clone(&stats)),
)
.await
.expect("reverse serve must end cleanly when the channel source closes");
assert!(
bound.load(Ordering::Acquire),
"an established remote listener must publish the bound flag"
);
assert_eq!(
stats.effective_port.load(Ordering::Acquire),
41_337,
"the allocated port, not the requested 0, must be reported"
);
assert_eq!(
stats.close_reason(),
crate::json_wire::TunnelCloseReason::AcceptError
);
}
#[tokio::test]
async fn unsupported_modes_refuse_on_a_client_without_them() {
struct Bare;
#[async_trait]
impl SshClientTrait for Bare {
async fn connect(_cfg: ConnectionConfig) -> Result<Box<Self>, crate::errors::SshCliError> {
Ok(Box::new(Bare))
}
async fn run_command(
&mut self,
_c: &str,
_m: usize,
_s: Option<Vec<u8>>,
) -> Result<ExecutionOutput, crate::errors::SshCliError> {
unreachable!()
}
async fn upload(
&self,
_l: &Path,
_r: &Path,
) -> Result<TransferResult, crate::errors::SshCliError> {
unreachable!()
}
async fn download(
&self,
_r: &Path,
_l: &Path,
) -> Result<TransferResult, crate::errors::SshCliError> {
unreachable!()
}
async fn open_tunnel_channel(
&self,
_h: &str,
_p: u16,
_o: &str,
_po: u16,
) -> Result<Box<dyn crate::ssh::client::TunnelChannel>, crate::errors::SshCliError>
{
unreachable!()
}
async fn disconnect(&self) -> Result<(), crate::errors::SshCliError> {
Ok(())
}
}
let bare = Bare;
assert!(bare.open_streamlocal_channel("/tmp/x.sock").await.is_err());
assert!(bare.request_remote_forward("127.0.0.1", 0).await.is_err());
assert!(bare.cancel_remote_forward("127.0.0.1", 0).await.is_err());
assert!(bare.accept_forwarded_channel().await.is_none());
}
fn record(timeout_ms: u64) -> crate::vps::model::VpsRecord {
crate::vps::model::VpsRecord::test_new(
"resolver-host",
"203.0.113.7",
2222,
"operator",
secrecy::SecretString::from("registry-password".to_string()),
None,
None,
Some(timeout_ms),
None,
None,
None,
None,
false,
)
}
#[test]
fn agent_auth_overrides_reach_the_connection_config() {
let auth = TunnelAuth {
use_agent: true,
agent_socket: Some("/run/user/1000/keyring/ssh".to_string()),
..TunnelAuth::default()
};
let cfg = resolve_tunnel_connection(record(9_000), auth, None, false);
assert!(cfg.use_agent, "--use-agent must survive to the connection");
assert_eq!(
cfg.agent_socket.as_deref(),
Some(std::path::Path::new("/run/user/1000/keyring/ssh")),
"--agent-socket must survive to the connection"
);
}
#[test]
fn key_override_replaces_the_registry_credential() {
let auth = TunnelAuth {
key: Some("/tmp/override_ed25519".to_string()),
..TunnelAuth::default()
};
let cfg = resolve_tunnel_connection(record(9_000), auth, None, false);
assert_eq!(
cfg.key_path.as_ref().map(|k| k.as_path()),
Some(std::path::Path::new("/tmp/override_ed25519")),
"--key must win over the stored credential, matching exec and scp"
);
}
#[test]
fn registry_timeout_is_carried_but_never_reinterpreted_as_the_deadline() {
let cfg = resolve_tunnel_connection(record(1_234), TunnelAuth::default(), None, false);
assert_eq!(
cfg.timeout_ms.get(),
1_234,
"the connection budget still comes from the record"
);
}
#[test]
fn replace_host_key_reaches_the_connection_config() {
let cfg = resolve_tunnel_connection(record(9_000), TunnelAuth::default(), None, true);
assert!(cfg.replace_host_key);
let cfg = resolve_tunnel_connection(record(9_000), TunnelAuth::default(), None, false);
assert!(!cfg.replace_host_key);
}
#[test]
fn only_an_accept_error_makes_the_closed_event_not_ok() {
use crate::json_wire::TunnelCloseReason;
for reason in [TunnelCloseReason::Deadline, TunnelCloseReason::Signal] {
let v = crate::output::build_tunnel_closed(crate::output::TunnelClosedInput {
vps: "h",
reason,
bind: "127.0.0.1",
local_port: 8080,
forwards_served: 0,
capacity_waits: 0,
duration_ms: 10,
mode: "local",
});
assert!(v.ok, "{reason:?} is a clean lifetime");
}
let v = crate::output::build_tunnel_closed(crate::output::TunnelClosedInput {
vps: "h",
reason: TunnelCloseReason::AcceptError,
bind: "127.0.0.1",
local_port: 8080,
forwards_served: 0,
capacity_waits: 0,
duration_ms: 10,
mode: "local",
});
assert!(
!v.ok,
"the process still exits 0 for having bound, so `ok` is the only field that \
distinguishes a loop that died seconds into a five-minute deadline"
);
}
#[test]
fn closed_event_reports_the_counters_it_was_given() {
let v = crate::output::build_tunnel_closed(crate::output::TunnelClosedInput {
vps: "prod-db",
reason: crate::json_wire::TunnelCloseReason::Deadline,
bind: "0.0.0.0",
local_port: 15_432,
forwards_served: 7,
capacity_waits: 3,
duration_ms: 60_000,
mode: "socks5",
});
assert_eq!(v.event, "tunnel_closed");
assert_eq!(v.vps, "prod-db");
assert_eq!(v.bind, "0.0.0.0");
assert_eq!(v.local_port, 15_432);
assert_eq!(
v.forwards_served, 7,
"G-TUN-R11: did anything ever connect?"
);
assert_eq!(v.capacity_waits, 3, "G-TUN-R12: was the tunnel throttled?");
assert_eq!(v.duration_ms, 60_000);
assert_eq!(v.mode, "socks5");
let json = serde_json::to_string(&v).expect("serialize");
assert!(json.contains("\"reason\":\"deadline\""), "got: {json}");
assert!(json.contains("\"forwards_served\":7"), "got: {json}");
assert!(json.contains("\"capacity_waits\":3"), "got: {json}");
}
#[test]
fn listening_event_reports_the_effective_bind_address() {
let loopback = crate::output::build_tunnel_listening(
"h",
8080,
"db.internal",
5432,
30_000,
"127.0.0.1",
"local",
);
assert_eq!(loopback.bind, "127.0.0.1");
assert_eq!(loopback.event, "tunnel_listening");
let exposed = crate::output::build_tunnel_listening(
"h",
8080,
"db.internal",
5432,
30_000,
"0.0.0.0",
"local",
);
assert_eq!(
exposed.bind, "0.0.0.0",
"an agent must be able to tell these two apart from the event alone"
);
assert_ne!(
loopback.bind, exposed.bind,
"collapsing the two is exactly what G-TUN-R06 was written to prevent"
);
}
#[tokio::test]
#[serial_test::serial]
async fn accepting_a_connection_increments_forwards_served() {
crate::signals::reset_flags_for_tests();
let bound = Arc::new(AtomicBool::new(false));
let stats = Arc::new(TunnelStats::default());
let bound_probe = Arc::clone(&bound);
let stats_probe = Arc::clone(&stats);
let loop_handle = tokio::spawn(run_tunnel_with_client_stats(
crate::tunnel::ServeContext {
vps_name: "counted".to_string(),
local_port: 0,
timeout_ms: 60_000,
json: false,
bind_addr: "127.0.0.1".to_string(),
bound_flag: Some(Arc::clone(&bound)),
stats: Some(Arc::clone(&stats)),
},
"localhost",
5432,
Box::new(Stub::new()),
));
let mut port = 0;
for _ in 0..200 {
if bound_probe.load(Ordering::Acquire) {
port = u16::try_from(stats_probe.effective_port.load(Ordering::Acquire)).unwrap_or(0);
if port != 0 {
break;
}
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert!(port != 0, "listener never published an effective port");
let conn = tokio::net::TcpStream::connect(("127.0.0.1", port)).await;
assert!(conn.is_ok(), "loopback dial to the tunnel listener failed");
drop(conn);
for _ in 0..200 {
if stats_probe.forwards_served.load(Ordering::Relaxed) > 0 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert!(
stats_probe.forwards_served.load(Ordering::Relaxed) >= 1,
"a connection was accepted but the counter the shutdown event publishes stayed at zero"
);
loop_handle.abort();
let _ = loop_handle.await;
}