Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
69 changes: 28 additions & 41 deletions src/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
config_path: PathBuf,
fn ssh_config_path(config_dir: &Path, device: &str) -> PathBuf {
config_dir.join(format!("{device}_config"))
}

fn query_yes_no<R, W>(query: impl AsRef<str>, mut reader: R, mut writer: W) -> Result<bool>
Expand All @@ -49,6 +45,13 @@ where
}
}

pub struct Config {
backend: Url,
dir: PathBuf,
priv_key_path: Option<PathBuf>,
config_path: Option<PathBuf>,
}

impl Config {
pub fn new(
backend: impl AsRef<str>,
Expand Down Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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}"))?;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -405,19 +390,20 @@ 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"))?,
)?;

// 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)
Expand Down Expand Up @@ -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,
Expand All @@ -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(())
}
Expand Down
130 changes: 116 additions & 14 deletions tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"{
Expand All @@ -1025,58 +1023,58 @@ 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
.unwrap();

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(),
Expand All @@ -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]
Expand Down
Loading