Skip to content

Commit

Permalink
feat(ssh): enable ssh tunnel from containerized environments
Browse files Browse the repository at this point in the history
When running the omnect-cli from within a containerized environment,
authentication will not work out of the box: when binding to localhost, the
client will bind to localhost from inside of the container, it is not possible
to connect from the container externally.

This adds a check for the "CONTAINERIZED" environment variable. If this
environment variable is set, the auth redirect server will instead bind to
"0.0.0.0", the idea being, that docker can do the mapping to local host, then.

Furthermore, we add the "CONTAINER_HOST" variable which allows the omnect-cli to
determine the host system so that an according ssh config can be generated.
  • Loading branch information
empwilli committed Apr 8, 2024
1 parent 8e6814c commit cfbe327
Show file tree
Hide file tree
Showing 7 changed files with 193 additions and 41 deletions.
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.21.1"
version = "0.22.0"

[dependencies]
actix-web = "4.4"
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,6 @@ COPY --from=builder /copy/usr/lib/ /usr/lib/
COPY --from=builder /copy/lib/ /lib/
COPY --from=builder /copy/status.d /var/lib/dpkg/status.d

ENTRYPOINT [ "/usr/bin/omnect-cli" ]
ENV CONTAINERIZED="true"

ENTRYPOINT [ "/usr/bin/omnect-cli" ]
37 changes: 32 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ docker run --rm -it \
omnect/omnect-cli:latest file copy-to-image --files /source/my-source-file,boot:/my-dest-file -i /source/my-image.wic
```

**Note1**: `omnect-cli ssh set-connection`command is not supported by docker image.
**Note2**: `-b` option to create bmap file is not supported by docker image.
**Note1**: `-b` option to create bmap file is not supported by docker image.
**Note2**: The ssh tunnel option requires some additional settings. See [here](Usage-with-docker) for more details.

# Build from sources

Expand Down Expand Up @@ -175,7 +175,7 @@ omnect-cli ssh set-connection --help

Open an ssh tunnel to the device `prod_device` in the `prod` environment as follows:
```sh
~ omnect-cli ssh set-connection prod_device
omnect-cli ssh set-connection prod_device

Successfully established ssh tunnel!
Certificate dir: /run/user/1000/omnect-cli
Expand All @@ -186,7 +186,7 @@ ssh -F /run/user/1000/omnect-cli/ssh_config prod_device
Now follow the command output to establish a connection to the device as such:

```sh
~ ssh -F /run/user/1000/omnect-cli/ssh_config prod_device
ssh -F /run/user/1000/omnect-cli/ssh_config prod_device

[omnect@prod_device ~]$
```
Expand All @@ -208,12 +208,39 @@ redirect = 'http://localhost:4000'

You then have to pass this configuration with the `--env` flag:
```sh
~ omnect-cli ssh set-connection dev_device --env dev_env.toml
omnect-cli ssh set-connection dev_device --env dev_env.toml

Successfully established ssh tunnel!
...
```

#### Usage with docker

To use the ssh tunnel feature within a docker image, some additional steps are
necessary:

1. bind mount the directory to where the ssh keys and configurations should be generated to
2. set `CONTAINERIZED` environment variable as "true". The provided docker image
already has this variable set accordingly. **Note**: if running on a Windows
host, you additionally have to set the `CONTAINER_HOST` variable to
`windows`.
3. map the container's port on localhost 4000 to the hosts port 4000

With our `prod_device` from above, the call would then look, for example, as follows:

```sh
docker run --rm \
-u 0:0 \
-v "~/.ssh":/root/.local/share/omnect-cli \
-p 127.0.0.1:4000:4000 \
omnect/omnect-cli:latest \
ssh set-connection
```

If you want to use a custom backend configuration, you additionally have to
bind mount the config file, as well, i.e., `-v host/path/to/config.toml:/config.toml`,
and then tell omnect-cli to use this path.

# Troubleshooting

If anything goes wrong, setting RUST_LOG=debug enables output of debug information.
Expand Down
6 changes: 5 additions & 1 deletion src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,11 @@ pub async fn authorize<A>(auth_provider: A) -> Result<oauth2::AccessToken>
where
A: Into<AuthInfo>,
{
let auth_info: AuthInfo = auth_provider.into();
let mut auth_info: AuthInfo = auth_provider.into();

if let Ok("true") | Ok("1") = std::env::var("CONTAINERIZED").as_deref() {
auth_info.bind_addr = "0.0.0.0:4000".to_string();
}

// If there is a refresh token from previous runs, try to create our access
// token from that. Note, that we don't store access tokens themselves as
Expand Down
117 changes: 89 additions & 28 deletions src/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ 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 = "ssh_config";
static SSH_CONFIG_NAME: &str = "config";

pub struct Config {
backend: Url,
Expand Down Expand Up @@ -46,7 +46,15 @@ impl Config {
};

let dir = match dir {
Some(dir) => dir,
Some(dir) => {
if let Ok("true") | Ok("1") = std::env::var("CONTAINERIZED").as_deref() {
anyhow::bail!(
"Custom config paths are not supported in containerized environments."
);
}

dir
}
None => ProjectDirs::from("de", "conplement AG", "omnect-cli")
.ok_or_else(|| anyhow::anyhow!("Application dirs not accessible"))?
.data_dir()
Expand Down Expand Up @@ -192,16 +200,61 @@ fn create_ssh_config(
) -> Result<()> {
let config_file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.create_new(true)
.open(config_path.to_str().unwrap())
.map_err(|err| anyhow::anyhow!("Failed to open ssh config file: {err}"))?;
.map_err(|err| match err.kind() {
std::io::ErrorKind::AlreadyExists => {
anyhow::anyhow!(
r#"ssh config file already exists and would be overwritten.
Please remove config file first."#
)
}
_ => anyhow::anyhow!("Failed to create ssh config file: {err}"),
})?;

let mut writer = BufWriter::new(config_file);

writeln!(
&mut writer,
r#"Host bastion
if let Ok("windows") = std::env::var("CONTAINER_HOST").as_deref() {
writeln!(
&mut writer,
r#"Host bastion
User {}
Hostname {}
Port {}
IdentityFile ~/.ssh/{}
CertificateFile ~/.ssh/{}
ProxyCommand none
Host {}
User {}
IdentityFile ~/.ssh/{}
CertificateFile ~/.ssh/{}
ProxyCommand ssh bastion"#,
bastion_details.username,
bastion_details.hostname,
bastion_details.port,
bastion_details
.priv_key
.file_name()
.unwrap()
.to_str()
.unwrap(), // safe
bastion_details.cert.file_name().unwrap().to_str().unwrap(), // safe
device_details.hostname,
device_details.username,
device_details
.priv_key
.file_name()
.unwrap()
.to_str()
.unwrap(), // safe
device_details.cert.file_name().unwrap().to_str().unwrap(), // safe
)
.map_err(|err| anyhow::anyhow!("Failed to write ssh config file: {err}"))?;
} else {
writeln!(
&mut writer,
r#"Host bastion
User {}
Hostname {}
Port {}
Expand All @@ -214,32 +267,40 @@ Host {}
IdentityFile {}
CertificateFile {}
ProxyCommand ssh -F {} bastion"#,
bastion_details.username,
bastion_details.hostname,
bastion_details.port,
bastion_details.priv_key.to_str().unwrap(), // safe
bastion_details.cert.to_str().unwrap(), // safe
device_details.hostname,
device_details.username,
device_details.priv_key.to_str().unwrap(), // safe
device_details.cert.to_str().unwrap(), // safe
config_path.to_str().unwrap(), // safe
)
.map_err(|err| anyhow::anyhow!("Failed to write ssh config file: {err}"))?;
bastion_details.username,
bastion_details.hostname,
bastion_details.port,
bastion_details.priv_key.to_str().unwrap(), // safe
bastion_details.cert.to_str().unwrap(), // safe
device_details.hostname,
device_details.username,
device_details.priv_key.to_str().unwrap(), // safe
device_details.cert.to_str().unwrap(), // safe
config_path.to_str().unwrap(), // safe
)
.map_err(|err| anyhow::anyhow!("Failed to write ssh config file: {err}"))?;
}

Ok(())
}

fn print_ssh_tunnel_info(cert_dir: &Path, config_path: &Path, destination: &str) {
println!("Successfully established ssh tunnel!");
println!("Certificate dir: {}", cert_dir.to_str().unwrap());
println!("Configuration path: {}", config_path.to_str().unwrap());
println!(
"Use the configuration in \"{}\" to use the tunnel, e.g.:\nssh -F {} {}",
config_path.to_str().unwrap(), // safe
config_path.to_str().unwrap(), // safe
destination
);
if let Ok("windows") = std::env::var("CONTAINER_HOST").as_deref() {
println!(
"You can ssh to now ssh to your device via its device name, e.g.:\nssh {}",
destination
);
} else {
println!("Certificate dir: {}", cert_dir.to_str().unwrap());
println!("Configuration path: {}", config_path.to_str().unwrap());
println!(
"Use the configuration in \"{}\" to use the tunnel, e.g.:\nssh -F {} {}",
config_path.to_str().unwrap(), // safe
config_path.to_str().unwrap(), // safe
destination
);
}
}

pub async fn ssh_create_tunnel(
Expand Down
66 changes: 62 additions & 4 deletions tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,7 @@ fn check_image_decompression() {

#[tokio::test]
async fn check_ssh_tunnel_setup() {
let tr = Testrunner::new(function_name!().split("::").last().unwrap());
let tr = Testrunner::new("check_ssh_tunnel_setup");

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

Expand Down Expand Up @@ -866,7 +866,7 @@ async fn check_ssh_tunnel_setup() {

assert!(tr
.pathbuf()
.join("ssh_config")
.join("config")
.try_exists()
.is_ok_and(|exists| exists));
assert!(tr
Expand All @@ -890,7 +890,7 @@ async fn check_ssh_tunnel_setup() {
.try_exists()
.is_ok_and(|exists| exists));

let ssh_config = std::fs::read_to_string(tr.pathbuf().join("ssh_config")).unwrap();
let ssh_config = std::fs::read_to_string(tr.pathbuf().join("config")).unwrap();
let expected_config = format!(
r#"Host bastion
User bastion_user
Expand All @@ -904,7 +904,7 @@ Host test_device
User test_user
IdentityFile {}/id_ed25519
CertificateFile {}/device-cert.pub
ProxyCommand ssh -F {}/ssh_config bastion
ProxyCommand ssh -F {}/config bastion
"#,
tr.pathbuf().to_string_lossy(),
tr.pathbuf().to_string_lossy(),
Expand All @@ -915,3 +915,61 @@ Host test_device

assert_eq!(ssh_config, expected_config);
}

#[tokio::test]
async fn check_existing_ssh_config_not_overwritten() {
let tr = Testrunner::new("check_existing_ssh_config_not_overwritten");

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);
});

let mut config_path = tr.pathbuf();
config_path.push("config");

let config_content_before = "some_test_data";
std::fs::write(&config_path, config_content_before).unwrap();

let mut config = ssh::Config::new(
"test-backend",
Some(tr.pathbuf()),
None,
Some(config_path.clone()),
)
.unwrap();

config.set_backend(url::Url::parse(&server.base_url()).unwrap());

let result =
ssh::ssh_create_tunnel("test_device", "test_user", config, mock_access_token).await;

assert!(matches!(result, Result::Err(_)));

assert_eq!(
result.err().unwrap().to_string(),
r#"ssh config file already exists and would be overwritten.
Please remove config file first."#
);

let config_content_after = std::fs::read_to_string(&config_path).unwrap();

assert_eq!(config_content_before, &config_content_after);
}

0 comments on commit cfbe327

Please sign in to comment.