wasmer_cli/commands/
ssh.rs

1//! Edge SSH command.
2
3use anyhow::Context;
4use wasmer_backend_api::{WasmerClient, types::DeployApp};
5
6use super::{AsyncCliCommand, app::AppIdentArgOpts};
7use crate::{config::WasmerEnv, edge_config::LoadedEdgeConfig};
8
9/// Start a remote SSH session.
10#[derive(clap::Parser, Debug)]
11pub struct CmdSsh {
12    #[clap(flatten)]
13    pub app: AppIdentArgOpts,
14
15    #[clap(flatten)]
16    env: WasmerEnv,
17
18    /// SSH port to use.
19    #[clap(long, default_value = "22")]
20    pub ssh_port: u16,
21
22    /// Local port mapping to the package that's running, this allows
23    /// for instance a HTTP server to be tested remotely while giving
24    /// instant logs over stderr channelled via SSH.
25    #[clap(long)]
26    pub map_port: Vec<u16>,
27
28    /// The Edge SSH server host to connect to.
29    ///
30    /// You can usually ignore this flag, it mainly exists for debugging purposes.
31    #[clap(long)]
32    pub host: Option<String>,
33
34    /// Prints the SSH command rather than executing it.
35    #[clap(short, long)]
36    pub print: bool,
37
38    // All extra arguments.
39    #[clap(trailing_var_arg = true)]
40    pub extra: Vec<String>,
41}
42
43#[async_trait::async_trait]
44impl AsyncCliCommand for CmdSsh {
45    type Output = ();
46
47    async fn run_async(self) -> Result<(), anyhow::Error> {
48        let mut config = crate::edge_config::load_config(None)?;
49        let client = self.env.client()?;
50
51        let token = acquire_ssh_token(&client, &config, &self.app).await?;
52        let app_id = token.app.as_ref().map(|a| a.id.inner().to_owned());
53
54        let host = if let Some(host) = self.host {
55            host
56        } else if let Some(app) = &token.app {
57            // For apps, use the app domain to ensure proper routing.
58            let url = app.url.clone();
59            if url.starts_with("http://") || url.starts_with("https://") {
60                let url = url.parse::<url::Url>().context("Could not parse app url")?;
61                url.host_str()
62                    .context("Could not determine host from app url")?
63                    .to_string()
64            } else {
65                url
66            }
67        } else {
68            // No custom host specified, use an appropriate one based on the
69            // environment.
70            self.env
71                .app_domain()
72                .context("Could not determine SSH host based on the backend url")?
73        };
74        let port = self.ssh_port;
75
76        if token.is_new {
77            eprintln!("Acquired new SSH token");
78            config.config.add_ssh_token(app_id, token.token.clone());
79            if let Err(err) = config.save() {
80                eprintln!("WARNING: failed to save config: {err}");
81            }
82        }
83
84        if let Some(app) = &token.app {
85            eprintln!("Connecting to app '{}' at {}", app.name, host);
86        }
87
88        let mut cmd = std::process::Command::new("ssh");
89        let mut cmd = cmd
90            .args([
91                // No controlpath becaue we don't want to re-use connections
92                "-o",
93                "ControlPath=none",
94                // Disable host key checking, because we use a DNS-load-balanced
95                // host.
96                "-o",
97                "StrictHostKeyChecking=no",
98                // Don't persist known hosts - don't want to clutter users
99                // regular ssh data.
100                "-o",
101                "UserKnownHostsFile=/dev/null",
102                "-o",
103                "IdentitiesOnly=yes",
104                // Don't print ssh related output.
105                "-q",
106            ])
107            .args(["-p", format!("{port}").as_str()]);
108        for map_port in self.map_port {
109            cmd = cmd.args(["-L", format!("{map_port}:localhost:{map_port}").as_str()]);
110        }
111
112        cmd = cmd
113            .arg(format!("{}@{}", token.token, host))
114            .args(&self.extra);
115
116        if self.print {
117            print!("ssh");
118            for arg in cmd.get_args() {
119                print!(" {}", arg.to_string_lossy().as_ref());
120            }
121            println!();
122            return Ok(());
123        }
124
125        let exit = cmd.spawn()?.wait()?;
126        if exit.success() {
127            Ok(())
128        } else {
129            Err(anyhow::anyhow!("ssh failed with status {exit}"))
130        }
131    }
132}
133
134type RawToken = String;
135
136struct AcquiredToken {
137    token: RawToken,
138    app: Option<DeployApp>,
139    is_new: bool,
140}
141
142async fn acquire_ssh_token(
143    client: &WasmerClient,
144    config: &LoadedEdgeConfig,
145    app_opts: &AppIdentArgOpts,
146) -> Result<AcquiredToken, anyhow::Error> {
147    let app_opts = app_opts.clone().to_opts();
148
149    let app = if let Some((_, app)) = app_opts.load_app_opt(client).await? {
150        Some(app)
151    } else {
152        None
153    };
154
155    // Check cached.
156    let app_id = app.as_ref().map(|a| a.id.inner());
157    if let Some(token) = config.config.get_valid_ssh_token(app_id) {
158        return Ok(AcquiredToken {
159            token: token.to_string(),
160            app,
161            is_new: false,
162        });
163    }
164
165    // Create new.
166    let token = create_ssh_token(client, app_id.map(|x| x.to_owned())).await?;
167
168    Ok(AcquiredToken {
169        token,
170        app,
171        is_new: true,
172    })
173}
174
175/// Create a new token for SSH access through the backend API.
176async fn create_ssh_token(
177    client: &WasmerClient,
178    app: Option<String>,
179) -> Result<RawToken, anyhow::Error> {
180    wasmer_backend_api::query::generate_ssh_token(client, app)
181        .await
182        .context("Could not create token for ssh access")
183}