Skip to content

Commit cfb143e

Browse files
committed
feat(ssh): add support for multiple ssh connections
Until now the omnect-cli would overwrite ssh configs, keys, and certificates. This had the consequence, that one could only ever use a single configuration at a time (if they wouldn't explicitly specify the file paths to use). We now prefix these file paths with the device names in order to lift this limitation.
1 parent 3250029 commit cfb143e

File tree

4 files changed

+149
-56
lines changed

4 files changed

+149
-56
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ license = "MIT OR Apache-2.0"
77
name = "omnect-cli"
88
readme = "README.md"
99
repository = "https://github.com/omnect/omnect-cli"
10-
version = "0.26.3"
10+
version = "0.27.0"
1111

1212
[dependencies]
1313
actix-web = "4.11"

src/ssh.rs

Lines changed: 31 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,9 @@ static SSH_KEY_FORMAT: &str = "ed25519";
1616

1717
static BASTION_CERT_NAME: &str = "bastion-cert.pub";
1818
static DEVICE_CERT_NAME: &str = "device-cert.pub";
19-
static SSH_CONFIG_NAME: &str = "config";
2019

21-
pub struct Config {
22-
backend: Url,
23-
dir: PathBuf,
24-
priv_key_path: Option<PathBuf>,
25-
config_path: PathBuf,
20+
fn ssh_config_path(config_dir: &Path, device: &str) -> PathBuf {
21+
config_dir.join(format!("{device}_config"))
2622
}
2723

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

48+
pub struct Config {
49+
backend: Url,
50+
dir: PathBuf,
51+
priv_key_path: Option<PathBuf>,
52+
config_path: Option<PathBuf>,
53+
}
54+
5255
impl Config {
5356
pub fn new(
5457
backend: impl AsRef<str>,
@@ -130,12 +133,15 @@ impl Config {
130133
backend,
131134
dir: dir.clone(),
132135
priv_key_path,
133-
config_path: config_path.unwrap_or_else(|| dir.join(SSH_CONFIG_NAME)),
136+
config_path,
134137
})
135138
}
136139

137-
pub fn set_backend(&mut self, backend: Url) {
138-
self.backend = backend;
140+
pub fn config_path(&self, device: &str) -> PathBuf {
141+
match &self.config_path {
142+
Some(config_path) => config_path.clone(),
143+
None => ssh_config_path(&self.dir, device),
144+
}
139145
}
140146
}
141147

@@ -235,12 +241,13 @@ async fn request_ssh_tunnel(
235241
}
236242

237243
fn store_certs(
244+
device: &str,
238245
cert_dir: &Path,
239246
bastion_cert: String,
240247
device_cert: String,
241248
) -> Result<(PathBuf, PathBuf)> {
242-
let mut bastion_cert_path = cert_dir.join(BASTION_CERT_NAME);
243-
let mut device_cert_path = cert_dir.join(DEVICE_CERT_NAME);
249+
let mut bastion_cert_path = cert_dir.join(format!("{device}_{BASTION_CERT_NAME}"));
250+
let mut device_cert_path = cert_dir.join(format!("{device}_{DEVICE_CERT_NAME}"));
244251

245252
fs::write(&mut bastion_cert_path, bastion_cert)
246253
.map_err(|err| anyhow::anyhow!("Failed to store bastion certificate: {err}"))?;
@@ -280,30 +287,12 @@ fn create_ssh_config(
280287
.create(true)
281288
.truncate(true)
282289
.open(config_path.to_str().unwrap())
283-
.map_err(|err| match err.kind() {
284-
std::io::ErrorKind::AlreadyExists => {
285-
eprintln!(
286-
r#"ssh config file "{}" already exists and would be overwritten.
287-
Please remove config file first."#,
288-
config_path.to_string_lossy(),
289-
);
290-
291-
anyhow::anyhow!(
292-
r#"config file "{}" already exists and would be overwritten."#,
293-
config_path.to_string_lossy(),
294-
)
295-
}
296-
_ => {
297-
eprintln!(
298-
r#"Failed to create ssh config file "{}": {err}"#,
299-
config_path.to_string_lossy()
300-
);
301-
302-
anyhow::anyhow!(
303-
r#"Failed to create ssh config file "{}": {err}"#,
304-
config_path.to_string_lossy()
305-
)
306-
}
290+
.map_err(|err| {
291+
anyhow::anyhow!(
292+
r#"Failed to create ssh config file "{}": {}"#,
293+
config_path.to_string_lossy(),
294+
err.kind()
295+
)
307296
})?;
308297

309298
let mut writer = BufWriter::new(config_file);
@@ -405,19 +394,20 @@ pub async fn ssh_create_tunnel(
405394
config: Config,
406395
access_token: oauth2::AccessToken,
407396
) -> Result<()> {
397+
let device_config_path = config.config_path(device);
398+
408399
// setup place to store the certificates and configuration
409400
fs::create_dir_all(&config.dir)?;
410401
fs::create_dir_all(
411-
config
412-
.config_path
402+
device_config_path
413403
.parent()
414404
.ok_or_else(|| anyhow::anyhow!("Invalid config path"))?,
415405
)?;
416406

417407
// create ssh key pair, if necessary
418408
let (priv_key_path, pub_key_path) = match &config.priv_key_path {
419409
None => {
420-
let priv_key_path = config.dir.join(format!("id_{}", SSH_KEY_FORMAT));
410+
let priv_key_path = config.dir.join(format!("{}_id_{}", device, SSH_KEY_FORMAT));
421411
let pub_key_path = priv_key_path.with_extension("pub");
422412

423413
create_ssh_key_pair(&priv_key_path, &pub_key_path)
@@ -451,6 +441,7 @@ pub async fn ssh_create_tunnel(
451441
.await?;
452442

453443
let (bastion_cert, device_cert) = store_certs(
444+
device,
454445
&config.dir,
455446
ssh_tunnel_info.bastion_cert,
456447
ssh_tunnel_info.device_cert,
@@ -470,9 +461,9 @@ pub async fn ssh_create_tunnel(
470461
cert: device_cert,
471462
};
472463

473-
create_ssh_config(&config.config_path, bastion_details, device_details)?;
464+
create_ssh_config(&device_config_path, bastion_details, device_details)?;
474465

475-
print_ssh_tunnel_info(&config.dir, &config.config_path, device);
466+
print_ssh_tunnel_info(&config.dir, &device_config_path, device);
476467

477468
Ok(())
478469
}

tests/integration_tests.rs

Lines changed: 116 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,8 +1003,6 @@ async fn check_ssh_tunnel_setup() {
10031003

10041004
let mock_access_token = oauth2::AccessToken::new("test_token_mock".to_string());
10051005

1006-
let mut config = ssh::Config::new("test-backend", Some(tr.pathbuf()), None, None).unwrap();
1007-
10081006
let server = MockServer::start();
10091007

10101008
let request_reply = r#"{
@@ -1025,58 +1023,58 @@ async fn check_ssh_tunnel_setup() {
10251023
.body(request_reply);
10261024
});
10271025

1028-
config.set_backend(url::Url::parse(&server.base_url()).unwrap());
1026+
let config = ssh::Config::new(server.base_url(), Some(tr.pathbuf()), None, None).unwrap();
10291027

10301028
ssh::ssh_create_tunnel("test_device", "test_user", config, mock_access_token)
10311029
.await
10321030
.unwrap();
10331031

10341032
assert!(
10351033
tr.pathbuf()
1036-
.join("config")
1034+
.join("test_device_config")
10371035
.try_exists()
10381036
.is_ok_and(|exists| exists)
10391037
);
10401038
assert!(
10411039
tr.pathbuf()
1042-
.join("id_ed25519")
1040+
.join("test_device_id_ed25519")
10431041
.try_exists()
10441042
.is_ok_and(|exists| exists)
10451043
);
10461044
assert!(
10471045
tr.pathbuf()
1048-
.join("id_ed25519.pub")
1046+
.join("test_device_id_ed25519.pub")
10491047
.try_exists()
10501048
.is_ok_and(|exists| exists)
10511049
);
10521050
assert!(
10531051
tr.pathbuf()
1054-
.join("bastion-cert.pub")
1052+
.join("test_device_bastion-cert.pub")
10551053
.try_exists()
10561054
.is_ok_and(|exists| exists)
10571055
);
10581056
assert!(
10591057
tr.pathbuf()
1060-
.join("device-cert.pub")
1058+
.join("test_device_device-cert.pub")
10611059
.try_exists()
10621060
.is_ok_and(|exists| exists)
10631061
);
10641062

1065-
let ssh_config = std::fs::read_to_string(tr.pathbuf().join("config")).unwrap();
1063+
let ssh_config = std::fs::read_to_string(tr.pathbuf().join("test_device_config")).unwrap();
10661064
let expected_config = format!(
10671065
r#"Host bastion
10681066
User bastion_user
10691067
Hostname 132.23.0.1
10701068
Port 22
1071-
IdentityFile {}/id_ed25519
1072-
CertificateFile {}/bastion-cert.pub
1069+
IdentityFile {}/test_device_id_ed25519
1070+
CertificateFile {}/test_device_bastion-cert.pub
10731071
ProxyCommand none
10741072
10751073
Host test_device
10761074
User test_user
1077-
IdentityFile {}/id_ed25519
1078-
CertificateFile {}/device-cert.pub
1079-
ProxyCommand ssh -F {}/config bastion
1075+
IdentityFile {}/test_device_id_ed25519
1076+
CertificateFile {}/test_device_device-cert.pub
1077+
ProxyCommand ssh -F {}/test_device_config bastion
10801078
"#,
10811079
tr.pathbuf().to_string_lossy(),
10821080
tr.pathbuf().to_string_lossy(),
@@ -1088,6 +1086,110 @@ Host test_device
10881086
assert_eq!(ssh_config, expected_config);
10891087
}
10901088

1089+
#[tokio::test]
1090+
async fn check_multi_ssh_tunnel_setup() {
1091+
let tr = Testrunner::new("check_multi_ssh_tunnel_setup");
1092+
1093+
let mock_access_token = oauth2::AccessToken::new("test_token_mock".to_string());
1094+
1095+
let server = MockServer::start();
1096+
1097+
let request_reply = r#"{
1098+
"clientBastionCert": "-----BEGIN CERTIFICATE-----\nMIIFrjCCA5agAwIBAgIBATANBgkqhkiG...",
1099+
"clientDeviceCert": "-----BEGIN CERTIFICATE-----\nMIIFrjCCA5agAwIBAgIBATANBgkqhkiG...",
1100+
"host": "132.23.0.1",
1101+
"port": 22,
1102+
"bastionUser": "bastion_user"
1103+
}
1104+
"#;
1105+
1106+
let _ = server.mock(|when, then| {
1107+
when.method(POST)
1108+
.path("/api/devices/prepareSSHConnection")
1109+
.header("authorization", "Bearer test_token_mock");
1110+
then.status(200)
1111+
.header("content-type", "application/json")
1112+
.body(request_reply);
1113+
});
1114+
1115+
ssh::ssh_create_tunnel(
1116+
"test_device_a",
1117+
"test_user",
1118+
ssh::Config::new(server.base_url(), Some(tr.pathbuf()), None, None).unwrap(),
1119+
mock_access_token.clone(),
1120+
)
1121+
.await
1122+
.unwrap();
1123+
1124+
ssh::ssh_create_tunnel(
1125+
"test_device_b",
1126+
"test_user",
1127+
ssh::Config::new(server.base_url(), Some(tr.pathbuf()), None, None).unwrap(),
1128+
mock_access_token,
1129+
)
1130+
.await
1131+
.unwrap();
1132+
1133+
for device in ["test_device_a", "test_device_b"] {
1134+
assert!(
1135+
tr.pathbuf()
1136+
.join(format!("{device}_config"))
1137+
.try_exists()
1138+
.is_ok_and(|exists| exists)
1139+
);
1140+
assert!(
1141+
tr.pathbuf()
1142+
.join(format!("{device}_id_ed25519"))
1143+
.try_exists()
1144+
.is_ok_and(|exists| exists)
1145+
);
1146+
assert!(
1147+
tr.pathbuf()
1148+
.join(format!("{device}_id_ed25519.pub"))
1149+
.try_exists()
1150+
.is_ok_and(|exists| exists)
1151+
);
1152+
assert!(
1153+
tr.pathbuf()
1154+
.join(format!("{device}_bastion-cert.pub"))
1155+
.try_exists()
1156+
.is_ok_and(|exists| exists)
1157+
);
1158+
assert!(
1159+
tr.pathbuf()
1160+
.join(format!("{device}_device-cert.pub"))
1161+
.try_exists()
1162+
.is_ok_and(|exists| exists)
1163+
);
1164+
1165+
let ssh_config =
1166+
std::fs::read_to_string(tr.pathbuf().join(format!("{device}_config"))).unwrap();
1167+
let expected_config = format!(
1168+
r#"Host bastion
1169+
User bastion_user
1170+
Hostname 132.23.0.1
1171+
Port 22
1172+
IdentityFile {}/{device}_id_ed25519
1173+
CertificateFile {}/{device}_bastion-cert.pub
1174+
ProxyCommand none
1175+
1176+
Host {device}
1177+
User test_user
1178+
IdentityFile {}/{device}_id_ed25519
1179+
CertificateFile {}/{device}_device-cert.pub
1180+
ProxyCommand ssh -F {}/{device}_config bastion
1181+
"#,
1182+
tr.pathbuf().to_string_lossy(),
1183+
tr.pathbuf().to_string_lossy(),
1184+
tr.pathbuf().to_string_lossy(),
1185+
tr.pathbuf().to_string_lossy(),
1186+
tr.pathbuf().to_string_lossy()
1187+
);
1188+
1189+
assert_eq!(ssh_config, expected_config);
1190+
}
1191+
}
1192+
10911193
// currently disabled as we have no way to test this in our pipeline were we
10921194
// don't have docker installed
10931195
#[ignore]

0 commit comments

Comments
 (0)