mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
227 lines
8.0 KiB
Rust
227 lines
8.0 KiB
Rust
//! SSH tunnels against an in-process russh server that forwards `direct-tcpip`
|
|
//! channels to a local echo server.
|
|
|
|
use ide_core::error::ErrorKind;
|
|
use ide_db::tunnel::{OpenError, Tunnel, TunnelSpec};
|
|
use ide_db::{DbSsh, DbSshAuth};
|
|
use russh::Channel;
|
|
use russh::keys::{PrivateKey, PublicKey, decode_secret_key};
|
|
use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::{TcpListener, TcpStream};
|
|
|
|
fn fixture(name: &str) -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ssh").join(name)
|
|
}
|
|
|
|
fn key(name: &str, passphrase: Option<&str>) -> PrivateKey {
|
|
decode_secret_key(&std::fs::read_to_string(fixture(name)).unwrap(), passphrase).unwrap()
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct Sshd {
|
|
client_key: PublicKey,
|
|
}
|
|
|
|
impl server::Server for Sshd {
|
|
type Handler = Sshd;
|
|
fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> Sshd {
|
|
self.clone()
|
|
}
|
|
}
|
|
|
|
impl server::Handler for Sshd {
|
|
type Error = russh::Error;
|
|
|
|
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
|
|
Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() })
|
|
}
|
|
|
|
async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result<Auth, Self::Error> {
|
|
Ok(if user == "dev" && key.key_data() == self.client_key.key_data() { Auth::Accept } else { Auth::reject() })
|
|
}
|
|
|
|
async fn channel_open_direct_tcpip(
|
|
&mut self,
|
|
channel: Channel<Msg>,
|
|
host: &str,
|
|
port: u32,
|
|
_originator_address: &str,
|
|
_originator_port: u32,
|
|
reply: ChannelOpenHandle,
|
|
_session: &mut Session,
|
|
) -> Result<(), Self::Error> {
|
|
let Ok(mut upstream) = TcpStream::connect((host, port as u16)).await else {
|
|
return Ok(());
|
|
};
|
|
reply.accept().await;
|
|
tokio::spawn(async move {
|
|
let mut stream = channel.into_stream();
|
|
let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await;
|
|
});
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Echo server standing in for the database.
|
|
async fn echo() -> u16 {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
tokio::spawn(async move {
|
|
while let Ok((mut s, _)) = listener.accept().await {
|
|
tokio::spawn(async move {
|
|
let (mut r, mut w) = s.split();
|
|
let _ = tokio::io::copy(&mut r, &mut w).await;
|
|
});
|
|
}
|
|
});
|
|
port
|
|
}
|
|
|
|
async fn sshd() -> u16 {
|
|
let config = Arc::new(server::Config {
|
|
keys: vec![key("host", None)],
|
|
auth_rejection_time: std::time::Duration::from_millis(10),
|
|
auth_rejection_time_initial: Some(std::time::Duration::ZERO),
|
|
..Default::default()
|
|
});
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
let mut server = Sshd { client_key: key("client", None).public_key().clone() };
|
|
tokio::spawn(async move {
|
|
let _ = server.run_on_socket(config, &listener).await;
|
|
});
|
|
port
|
|
}
|
|
|
|
struct Env {
|
|
_tmp: tempfile::TempDir,
|
|
known_hosts: PathBuf,
|
|
ssh_port: u16,
|
|
db_port: u16,
|
|
}
|
|
|
|
async fn env() -> Env {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let known_hosts = tmp.path().join("known_hosts");
|
|
Env { known_hosts, _tmp: tmp, ssh_port: sshd().await, db_port: echo().await }
|
|
}
|
|
|
|
impl Env {
|
|
fn ssh(&self, auth: DbSshAuth) -> DbSsh {
|
|
DbSsh {
|
|
enabled: true,
|
|
host: "127.0.0.1".into(),
|
|
port: Some(self.ssh_port),
|
|
user: "dev".into(),
|
|
auth,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
async fn open(&self, ssh: &DbSsh, secret: Option<&str>) -> Result<Tunnel, OpenError> {
|
|
Tunnel::open(TunnelSpec {
|
|
ssh,
|
|
secret,
|
|
target_host: "127.0.0.1",
|
|
target_port: self.db_port,
|
|
known_hosts: &self.known_hosts,
|
|
ssh_config: None,
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Trusts the server's key on the first connection, as the UI does.
|
|
async fn open_trusted(&self, ssh: &DbSsh, secret: Option<&str>) -> Result<Tunnel, OpenError> {
|
|
match self.open(ssh, secret).await {
|
|
Err(OpenError::UnknownHostKey(k)) => {
|
|
k.trust(&self.known_hosts).unwrap();
|
|
self.open(ssh, secret).await
|
|
}
|
|
other => other,
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn round_trip(t: &Tunnel) {
|
|
let mut s = TcpStream::connect(("127.0.0.1", t.port())).await.unwrap();
|
|
s.write_all(b"SELECT 1").await.unwrap();
|
|
let mut buf = [0u8; 8];
|
|
s.read_exact(&mut buf).await.unwrap();
|
|
assert_eq!(&buf, b"SELECT 1");
|
|
}
|
|
|
|
fn denied(r: Result<Tunnel, OpenError>) -> String {
|
|
match r {
|
|
Err(OpenError::Other(e)) if e.kind == ErrorKind::PermissionDenied => e.message,
|
|
Err(OpenError::Other(e)) => panic!("expected permission denied, got {e:?}"),
|
|
Err(OpenError::UnknownHostKey(k)) => panic!("unexpected unknown host key {}", k.fingerprint()),
|
|
Ok(_) => panic!("expected permission denied, connected"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_host_key_must_be_trusted_and_changed_key_is_refused() {
|
|
let env = env().await;
|
|
let ssh = env.ssh(DbSshAuth::Password);
|
|
let Err(OpenError::UnknownHostKey(k)) = env.open(&ssh, Some("secret")).await else {
|
|
panic!("expected an unknown host key");
|
|
};
|
|
assert!(k.fingerprint().starts_with("ssh-ed25519 SHA256:"), "{}", k.fingerprint());
|
|
k.trust(&env.known_hosts).unwrap();
|
|
round_trip(&env.open(&ssh, Some("secret")).await.ok().unwrap()).await;
|
|
|
|
// Another key recorded for the same host: refused, never offered for trust.
|
|
let other = key("client", None).public_key().to_openssh().unwrap();
|
|
std::fs::write(&env.known_hosts, format!("[127.0.0.1]:{} {other}\n", env.ssh_port)).unwrap();
|
|
let msg = denied(env.open(&ssh, Some("secret")).await);
|
|
assert!(msg.contains("has changed"), "{msg}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn password_authentication() {
|
|
let env = env().await;
|
|
let ssh = env.ssh(DbSshAuth::Password);
|
|
let tunnel = env.open_trusted(&ssh, Some("secret")).await.ok().unwrap();
|
|
round_trip(&tunnel).await;
|
|
round_trip(&tunnel).await;
|
|
assert!(!tunnel.is_closed());
|
|
|
|
assert!(denied(env.open(&ssh, None).await).starts_with("SSH password required"));
|
|
assert!(denied(env.open(&ssh, Some("wrong")).await).starts_with("SSH authentication failed"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn key_pair_authentication_with_and_without_passphrase() {
|
|
let env = env().await;
|
|
let ssh = DbSsh { key_file: fixture("client").to_string_lossy().into(), ..env.ssh(DbSshAuth::KeyPair) };
|
|
round_trip(&env.open_trusted(&ssh, None).await.ok().unwrap()).await;
|
|
|
|
// An encrypted key the server does not accept: passphrase first, then the key is refused.
|
|
let locked = DbSsh { key_file: fixture("locked").to_string_lossy().into(), ..ssh.clone() };
|
|
assert!(denied(env.open(&locked, None).await).starts_with("SSH key passphrase required"));
|
|
assert!(denied(env.open(&locked, Some("nope")).await).contains("wrong passphrase"));
|
|
assert!(denied(env.open(&locked, Some("pw")).await).starts_with("SSH authentication failed"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unreachable_database_fails_fast_with_the_reason() {
|
|
let env = env().await;
|
|
let ssh = env.ssh(DbSshAuth::Password);
|
|
drop(env.open_trusted(&ssh, Some("secret")).await.ok().unwrap());
|
|
let closed = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port();
|
|
let r = Tunnel::open(TunnelSpec {
|
|
ssh: &ssh,
|
|
secret: Some("secret"),
|
|
target_host: "127.0.0.1",
|
|
target_port: closed,
|
|
known_hosts: &env.known_hosts,
|
|
ssh_config: None,
|
|
})
|
|
.await;
|
|
let Err(OpenError::Other(e)) = r else { panic!("expected an error") };
|
|
assert!(e.message.contains(&format!("cannot reach the database at 127.0.0.1:{closed}")), "{}", e.message);
|
|
}
|