diff --git a/Cargo.lock b/Cargo.lock index 968407c..3c69c27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2258,7 +2258,7 @@ dependencies = [ [[package]] name = "omnect-cli" -version = "0.26.3" +version = "0.27.0" dependencies = [ "actix-web", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 844d882..dcb8123 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT OR Apache-2.0" name = "omnect-cli" readme = "README.md" repository = "https://github.com/omnect/omnect-cli" -version = "0.26.3" +version = "0.27.0" [dependencies] actix-web = "4.11" diff --git a/src/ssh.rs b/src/ssh.rs index f6f12f9..e6b309a 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -16,13 +16,9 @@ static SSH_KEY_FORMAT: &str = "ed25519"; static BASTION_CERT_NAME: &str = "bastion-cert.pub"; static DEVICE_CERT_NAME: &str = "device-cert.pub"; -static SSH_CONFIG_NAME: &str = "config"; -pub struct Config { - backend: Url, - dir: PathBuf, - priv_key_path: Option, - config_path: PathBuf, +fn ssh_config_path(config_dir: &Path, device: &str) -> PathBuf { + config_dir.join(format!("{device}_config")) } fn query_yes_no(query: impl AsRef, mut reader: R, mut writer: W) -> Result @@ -49,6 +45,13 @@ where } } +pub struct Config { + backend: Url, + dir: PathBuf, + priv_key_path: Option, + config_path: Option, +} + impl Config { pub fn new( backend: impl AsRef, @@ -130,12 +133,14 @@ impl Config { backend, dir: dir.clone(), priv_key_path, - config_path: config_path.unwrap_or_else(|| dir.join(SSH_CONFIG_NAME)), + config_path, }) } - pub fn set_backend(&mut self, backend: Url) { - self.backend = backend; + pub fn config_path(&self, device: &str) -> PathBuf { + self.config_path + .clone() + .unwrap_or_else(|| ssh_config_path(&self.dir, device)) } } @@ -235,12 +240,13 @@ async fn request_ssh_tunnel( } fn store_certs( + device: &str, cert_dir: &Path, bastion_cert: String, device_cert: String, ) -> Result<(PathBuf, PathBuf)> { - let mut bastion_cert_path = cert_dir.join(BASTION_CERT_NAME); - let mut device_cert_path = cert_dir.join(DEVICE_CERT_NAME); + let mut bastion_cert_path = cert_dir.join(format!("{device}_{BASTION_CERT_NAME}")); + let mut device_cert_path = cert_dir.join(format!("{device}_{DEVICE_CERT_NAME}")); fs::write(&mut bastion_cert_path, bastion_cert) .map_err(|err| anyhow::anyhow!("Failed to store bastion certificate: {err}"))?; @@ -280,31 +286,10 @@ fn create_ssh_config( .create(true) .truncate(true) .open(config_path.to_str().unwrap()) - .map_err(|err| match err.kind() { - std::io::ErrorKind::AlreadyExists => { - eprintln!( - r#"ssh config file "{}" already exists and would be overwritten. -Please remove config file first."#, - config_path.to_string_lossy(), - ); - - anyhow::anyhow!( - r#"config file "{}" already exists and would be overwritten."#, - config_path.to_string_lossy(), - ) - } - _ => { - eprintln!( - r#"Failed to create ssh config file "{}": {err}"#, - config_path.to_string_lossy() - ); - - anyhow::anyhow!( - r#"Failed to create ssh config file "{}": {err}"#, - config_path.to_string_lossy() - ) - } - })?; + .context(format!( + r#"Failed to create ssh config file "{}""#, + config_path.to_string_lossy(), + ))?; let mut writer = BufWriter::new(config_file); @@ -405,11 +390,12 @@ pub async fn ssh_create_tunnel( config: Config, access_token: oauth2::AccessToken, ) -> Result<()> { + let device_config_path = config.config_path(device); + // setup place to store the certificates and configuration fs::create_dir_all(&config.dir)?; fs::create_dir_all( - config - .config_path + device_config_path .parent() .ok_or_else(|| anyhow::anyhow!("Invalid config path"))?, )?; @@ -417,7 +403,7 @@ pub async fn ssh_create_tunnel( // create ssh key pair, if necessary let (priv_key_path, pub_key_path) = match &config.priv_key_path { None => { - let priv_key_path = config.dir.join(format!("id_{}", SSH_KEY_FORMAT)); + let priv_key_path = config.dir.join(format!("{device}_id_{SSH_KEY_FORMAT}")); let pub_key_path = priv_key_path.with_extension("pub"); create_ssh_key_pair(&priv_key_path, &pub_key_path) @@ -451,6 +437,7 @@ pub async fn ssh_create_tunnel( .await?; let (bastion_cert, device_cert) = store_certs( + device, &config.dir, ssh_tunnel_info.bastion_cert, ssh_tunnel_info.device_cert, @@ -470,9 +457,9 @@ pub async fn ssh_create_tunnel( cert: device_cert, }; - create_ssh_config(&config.config_path, bastion_details, device_details)?; + create_ssh_config(&device_config_path, bastion_details, device_details)?; - print_ssh_tunnel_info(&config.dir, &config.config_path, device); + print_ssh_tunnel_info(&config.dir, &device_config_path, device); Ok(()) } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 4b9c492..b6fd758 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1003,8 +1003,6 @@ async fn check_ssh_tunnel_setup() { let mock_access_token = oauth2::AccessToken::new("test_token_mock".to_string()); - let mut config = ssh::Config::new("test-backend", Some(tr.pathbuf()), None, None).unwrap(); - let server = MockServer::start(); let request_reply = r#"{ @@ -1025,7 +1023,7 @@ async fn check_ssh_tunnel_setup() { .body(request_reply); }); - config.set_backend(url::Url::parse(&server.base_url()).unwrap()); + let config = ssh::Config::new(server.base_url(), Some(tr.pathbuf()), None, None).unwrap(); ssh::ssh_create_tunnel("test_device", "test_user", config, mock_access_token) .await @@ -1033,50 +1031,50 @@ async fn check_ssh_tunnel_setup() { assert!( tr.pathbuf() - .join("config") + .join("test_device_config") .try_exists() .is_ok_and(|exists| exists) ); assert!( tr.pathbuf() - .join("id_ed25519") + .join("test_device_id_ed25519") .try_exists() .is_ok_and(|exists| exists) ); assert!( tr.pathbuf() - .join("id_ed25519.pub") + .join("test_device_id_ed25519.pub") .try_exists() .is_ok_and(|exists| exists) ); assert!( tr.pathbuf() - .join("bastion-cert.pub") + .join("test_device_bastion-cert.pub") .try_exists() .is_ok_and(|exists| exists) ); assert!( tr.pathbuf() - .join("device-cert.pub") + .join("test_device_device-cert.pub") .try_exists() .is_ok_and(|exists| exists) ); - let ssh_config = std::fs::read_to_string(tr.pathbuf().join("config")).unwrap(); + let ssh_config = std::fs::read_to_string(tr.pathbuf().join("test_device_config")).unwrap(); let expected_config = format!( r#"Host bastion User bastion_user Hostname 132.23.0.1 Port 22 - IdentityFile {}/id_ed25519 - CertificateFile {}/bastion-cert.pub + IdentityFile {}/test_device_id_ed25519 + CertificateFile {}/test_device_bastion-cert.pub ProxyCommand none Host test_device User test_user - IdentityFile {}/id_ed25519 - CertificateFile {}/device-cert.pub - ProxyCommand ssh -F {}/config bastion + IdentityFile {}/test_device_id_ed25519 + CertificateFile {}/test_device_device-cert.pub + ProxyCommand ssh -F {}/test_device_config bastion "#, tr.pathbuf().to_string_lossy(), tr.pathbuf().to_string_lossy(), @@ -1088,6 +1086,110 @@ Host test_device assert_eq!(ssh_config, expected_config); } +#[tokio::test] +async fn check_multi_ssh_tunnel_setup() { + let tr = Testrunner::new("check_multi_ssh_tunnel_setup"); + + let mock_access_token = oauth2::AccessToken::new("test_token_mock".to_string()); + + let server = MockServer::start(); + + let request_reply = r#"{ + "clientBastionCert": "-----BEGIN CERTIFICATE-----\nMIIFrjCCA5agAwIBAgIBATANBgkqhkiG...", + "clientDeviceCert": "-----BEGIN CERTIFICATE-----\nMIIFrjCCA5agAwIBAgIBATANBgkqhkiG...", + "host": "132.23.0.1", + "port": 22, + "bastionUser": "bastion_user" +} +"#; + + let _ = server.mock(|when, then| { + when.method(POST) + .path("/api/devices/prepareSSHConnection") + .header("authorization", "Bearer test_token_mock"); + then.status(200) + .header("content-type", "application/json") + .body(request_reply); + }); + + ssh::ssh_create_tunnel( + "test_device_a", + "test_user", + ssh::Config::new(server.base_url(), Some(tr.pathbuf()), None, None).unwrap(), + mock_access_token.clone(), + ) + .await + .unwrap(); + + ssh::ssh_create_tunnel( + "test_device_b", + "test_user", + ssh::Config::new(server.base_url(), Some(tr.pathbuf()), None, None).unwrap(), + mock_access_token, + ) + .await + .unwrap(); + + for device in ["test_device_a", "test_device_b"] { + assert!( + tr.pathbuf() + .join(format!("{device}_config")) + .try_exists() + .is_ok_and(|exists| exists) + ); + assert!( + tr.pathbuf() + .join(format!("{device}_id_ed25519")) + .try_exists() + .is_ok_and(|exists| exists) + ); + assert!( + tr.pathbuf() + .join(format!("{device}_id_ed25519.pub")) + .try_exists() + .is_ok_and(|exists| exists) + ); + assert!( + tr.pathbuf() + .join(format!("{device}_bastion-cert.pub")) + .try_exists() + .is_ok_and(|exists| exists) + ); + assert!( + tr.pathbuf() + .join(format!("{device}_device-cert.pub")) + .try_exists() + .is_ok_and(|exists| exists) + ); + + let ssh_config = + std::fs::read_to_string(tr.pathbuf().join(format!("{device}_config"))).unwrap(); + let expected_config = format!( + r#"Host bastion + User bastion_user + Hostname 132.23.0.1 + Port 22 + IdentityFile {}/{device}_id_ed25519 + CertificateFile {}/{device}_bastion-cert.pub + ProxyCommand none + +Host {device} + User test_user + IdentityFile {}/{device}_id_ed25519 + CertificateFile {}/{device}_device-cert.pub + ProxyCommand ssh -F {}/{device}_config bastion +"#, + tr.pathbuf().to_string_lossy(), + tr.pathbuf().to_string_lossy(), + tr.pathbuf().to_string_lossy(), + tr.pathbuf().to_string_lossy(), + tr.pathbuf().to_string_lossy() + ); + + assert_eq!(ssh_config, expected_config); + } +} + // currently disabled as we have no way to test this in our pipeline were we // don't have docker installed #[ignore]