wasmer_cli/
edge_config.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4    time::Duration,
5};
6
7use anyhow::{Context, bail};
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10
11#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
12pub struct EdgeConfig {
13    pub version: u32,
14    /// Token used for ssh access.
15    pub ssh_token: Option<String>,
16
17    /// Map from unique app ID (da_...) to SSH token.
18    #[serde(default)]
19    pub ssh_app_tokens: HashMap<String, String>,
20
21    /// Token used for network access.
22    pub network_token: Option<String>,
23}
24
25impl EdgeConfig {
26    pub const VERSION: u32 = 1;
27
28    pub fn from_slice(data: &[u8]) -> Result<Self, anyhow::Error> {
29        let data_str = std::str::from_utf8(data)?;
30        let value: toml::Value = toml::from_str(data_str).context("failed to parse config TOML")?;
31
32        let version = value
33            .get("version")
34            .and_then(|v| v.as_integer())
35            .context("invalid client config: no 'version' key found")?;
36
37        if version != Self::VERSION as i64 {
38            bail!("Invalid client config: unknown config version '{version}'");
39        }
40
41        let config = toml::from_str(data_str)?;
42        Ok(config)
43    }
44
45    /// Get a valid SSH token.
46    ///
47    /// Will filter out the stored token if it has expired.
48    pub fn get_valid_ssh_token(&self, app_id: Option<&str>) -> Option<&str> {
49        #[allow(clippy::manual_filter)]
50        if let Some(app_id) = app_id {
51            let token = self.ssh_app_tokens.get(app_id)?;
52            if jwt_token_valid(token) {
53                Some(token)
54            } else {
55                None
56            }
57        } else if let Some(token) = &self.ssh_token {
58            if jwt_token_valid(token) {
59                Some(token)
60            } else {
61                None
62            }
63        } else {
64            None
65        }
66    }
67
68    pub fn add_ssh_token(&mut self, app_id: Option<String>, token: String) {
69        if let Some(app_id) = app_id {
70            self.ssh_app_tokens.insert(app_id, token);
71        } else {
72            self.ssh_token = Some(token);
73        }
74    }
75}
76
77impl Default for EdgeConfig {
78    fn default() -> Self {
79        Self {
80            ssh_token: None,
81            ssh_app_tokens: HashMap::new(),
82            network_token: None,
83            version: 1,
84        }
85    }
86}
87
88const CONFIG_FILE_NAME: &str = "deploy_client.toml";
89const CONFIG_PATH_ENV_VAR: &str = "DEPLOY_CLIENT_CONFIG_PATH";
90
91pub struct LoadedEdgeConfig {
92    pub config: EdgeConfig,
93    pub path: PathBuf,
94}
95
96impl LoadedEdgeConfig {
97    #[allow(dead_code)]
98    pub fn set_network_token(&mut self, token: String) -> Result<(), anyhow::Error> {
99        self.config.network_token = Some(token);
100        self.save()?;
101        Ok(())
102    }
103
104    pub fn save(&self) -> Result<(), anyhow::Error> {
105        let data = toml::to_string(&self.config)?;
106        std::fs::write(&self.path, data)
107            .with_context(|| format!("failed to write config to '{}'", self.path.display()))?;
108        Ok(())
109    }
110}
111
112pub fn default_config_path() -> Result<PathBuf, anyhow::Error> {
113    if let Some(var) = std::env::var_os(CONFIG_PATH_ENV_VAR) {
114        Ok(var.into())
115    } else {
116        // TODO: use dirs crate to determine the correct path.
117        // (this also depends on general wasmer config moving there.)
118
119        #[allow(deprecated)]
120        let home = std::env::home_dir().context("failed to get home directory")?;
121        let path = home.join(".wasmer").join(CONFIG_FILE_NAME);
122        Ok(path)
123    }
124}
125
126pub fn load_config(custom_path: Option<PathBuf>) -> Result<LoadedEdgeConfig, anyhow::Error> {
127    let default_path = default_config_path()?;
128
129    let path = if let Some(p) = custom_path {
130        Some(p)
131    } else if default_path.is_file() {
132        Some(default_path.clone())
133    } else {
134        None
135    };
136
137    if let Some(path) = path {
138        if path.is_file() {
139            match try_load_config(&path) {
140                Ok(config) => {
141                    return Ok(LoadedEdgeConfig { config, path });
142                }
143                Err(err) => {
144                    eprintln!(
145                        "WARNING: failed to load config file at '{}': {}",
146                        path.display(),
147                        err
148                    );
149                }
150            }
151        }
152    }
153
154    Ok(LoadedEdgeConfig {
155        config: EdgeConfig::default(),
156        path: default_path,
157    })
158}
159
160fn try_load_config(path: &Path) -> Result<EdgeConfig, anyhow::Error> {
161    let data = std::fs::read(path)
162        .with_context(|| format!("failed to read config file at '{}'", path.display()))?;
163    let config = EdgeConfig::from_slice(&data)
164        .with_context(|| format!("failed to parse config file at '{}'", path.display()))?;
165    Ok(config)
166}
167
168fn jwt_token_valid(jwt_token: &str) -> bool {
169    if let Ok(expiration) = jwt_token_expiration_time(jwt_token) {
170        expiration > (OffsetDateTime::now_utc() + Duration::from_secs(30))
171    } else {
172        false
173    }
174}
175
176fn jwt_token_expiration_time(jwt_token: &str) -> Result<OffsetDateTime, anyhow::Error> {
177    use base64::Engine;
178
179    // JWT tokens have three parts separated by dots: Header.Payload.Signature
180    let parts: Vec<&str> = jwt_token.split('.').collect();
181
182    if parts.len() != 3 {
183        bail!("Invalid JWT token format: expected 3 parts.");
184    }
185
186    let payload_base64 = parts[1];
187
188    // Decode the Base64 URL-encoded payload
189    let decoded_payload_bytes = base64::prelude::BASE64_URL_SAFE_NO_PAD
190        .decode(payload_base64)
191        .with_context(|| "Failed to base64 decode payload")?;
192
193    let decoded_payload_str = String::from_utf8(decoded_payload_bytes)
194        .with_context(|| "Failed to convert decoded bytes to UTF-8")?;
195
196    // Parse the JSON payload
197    let payload_json: serde_json::Value = serde_json::from_str(&decoded_payload_str)
198        .with_context(|| "Failed to parse JSON payload")?;
199
200    // Extract the 'exp' (expiration time) claim
201    // It should be a Unix timestamp (seconds since epoch)
202    let exp_timestamp = payload_json
203        .get("exp")
204        .context("no 'exp' field in token payload")?
205        .as_i64()
206        .context("exp field is not an integer")?;
207
208    // Convert the Unix timestamp to an OffsetDateTime
209    let expiration_time = OffsetDateTime::from_unix_timestamp(exp_timestamp)
210        .context("Failed to convert Unix timestamp to OffsetDateTime")?;
211
212    Ok(expiration_time)
213}