Skip to main content

mobius_cli/
gateway_accounts.rs

1use std::collections::BTreeMap;
2use std::env;
3use std::io::Write as _;
4#[cfg(unix)]
5use std::os::unix::fs::PermissionsExt as _;
6use std::path::{Path, PathBuf};
7
8use mobius_gateway::auth::MAX_CLIENT_CREDENTIAL_BYTES as MAX_TOKEN_BYTES;
9use mobius_gateway::client::{Endpoint, token_from_env};
10use mobius_gateway::config::{ConfigStore, GatewayConfig};
11use mobius_gateway::{Error, Result};
12use serde::{Deserialize, Serialize};
13
14const MAX_STORE_BYTES: usize = 64 * 1024;
15const MAX_ACCOUNTS: usize = 64;
16
17#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
18#[serde(deny_unknown_fields)]
19struct TokenStoreRecord {
20    selected_endpoint: Option<String>,
21    tokens: BTreeMap<String, String>,
22}
23
24#[derive(Clone, Debug)]
25/// Data for gateway accounts.
26pub struct GatewayAccounts {
27    path: PathBuf,
28    record: TokenStoreRecord,
29}
30
31impl GatewayAccounts {
32    /// Loads the saved gateway accounts.
33    /// # Errors
34    ///
35    /// Returns an error if the resource cannot be read, decoded, or validated.
36    pub fn load() -> Result<Self> {
37        Self::load_from(token_path()?)
38    }
39
40    /// Returns the saved gateway endpoints.
41    pub fn endpoints(&self) -> impl ExactSizeIterator<Item = &str> {
42        self.record.tokens.keys().map(String::as_str)
43    }
44
45    /// Returns the selected gateway endpoint.
46    pub fn selected(&self) -> Option<&str> {
47        self.record.selected_endpoint.as_deref()
48    }
49
50    /// Returns the token saved for an endpoint.
51    pub fn token(&self, endpoint: &Endpoint) -> Option<&str> {
52        self.record
53            .tokens
54            .get(&endpoint.to_string())
55            .map(String::as_str)
56    }
57
58    /// Selects a saved gateway endpoint.
59    /// # Errors
60    ///
61    /// Returns an error if validation or an operation required by this function fails.
62    pub fn select(&mut self, endpoint: &str) -> Result<()> {
63        if !self.record.tokens.contains_key(endpoint) {
64            return Err(Error::Config(format!(
65                "gateway endpoint `{endpoint}` is not saved"
66            )));
67        }
68        self.record.selected_endpoint = Some(endpoint.into());
69        Ok(())
70    }
71
72    /// Adds or replaces a gateway account.
73    /// # Errors
74    ///
75    /// Returns an error if validation or an operation required by this function fails.
76    pub fn add(&mut self, endpoint: &Endpoint, token: String) -> Result<()> {
77        validate_token(&token)?;
78        let endpoint = endpoint.to_string();
79        if !self.record.tokens.contains_key(&endpoint) && self.record.tokens.len() >= MAX_ACCOUNTS {
80            return Err(Error::Config(
81                "gateway token file has too many endpoints".into(),
82            ));
83        }
84        self.record.tokens.insert(endpoint.clone(), token);
85        self.record.selected_endpoint = Some(endpoint);
86        Ok(())
87    }
88
89    /// Forgets a saved gateway endpoint.
90    pub fn forget(&mut self, endpoint: &str) {
91        self.record.tokens.remove(endpoint);
92        if self.selected() == Some(endpoint) {
93            self.record.selected_endpoint = None;
94        }
95    }
96
97    /// Prepares secure token storage.
98    /// # Errors
99    ///
100    /// Returns an error if validation or an operation required by this function fails.
101    pub fn prepare(&self) -> Result<()> {
102        let parent = parent(&self.path)?;
103        std::fs::create_dir_all(parent)?;
104        let file = tempfile::NamedTempFile::new_in(parent)?;
105        secure(&file)?;
106        Ok(())
107    }
108
109    /// Saves the gateway accounts atomically.
110    /// # Errors
111    ///
112    /// Returns an error if the value cannot be encoded or persisted.
113    pub fn save(&self) -> Result<()> {
114        validate_record(&self.record)?;
115        let contents = serde_json::to_vec(&self.record)?;
116        if contents.len() > MAX_STORE_BYTES {
117            return Err(Error::Config("gateway token file is too large".into()));
118        }
119        let parent = parent(&self.path)?;
120        std::fs::create_dir_all(parent)?;
121        let parent_file = std::fs::File::open(parent)?;
122        let mut file = tempfile::NamedTempFile::new_in(parent)?;
123        secure(&file)?;
124        file.write_all(&contents)?;
125        file.as_file().sync_all()?;
126        file.persist(&self.path).map_err(|error| error.error)?;
127        parent_file.sync_all()?;
128        Ok(())
129    }
130
131    fn load_from(path: PathBuf) -> Result<Self> {
132        let metadata = match std::fs::metadata(&path) {
133            Ok(metadata) => metadata,
134            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
135                return Ok(Self {
136                    path,
137                    record: TokenStoreRecord::default(),
138                });
139            }
140            Err(error) => return Err(error.into()),
141        };
142        if !metadata.is_file() {
143            return Err(Error::Config("gateway token path is not a file".into()));
144        }
145        #[cfg(unix)]
146        if metadata.permissions().mode() & 0o077 != 0 {
147            return Err(Error::Config(
148                "gateway token file must be readable only by its owner".into(),
149            ));
150        }
151        if metadata.len() > MAX_STORE_BYTES as u64 {
152            return Err(Error::Config("gateway token file is too large".into()));
153        }
154        let record = serde_json::from_slice(&std::fs::read(&path)?).map_err(|_| {
155            Error::Config(format!(
156                "gateway token file has an unsupported format; delete {} and pair again",
157                path.display()
158            ))
159        })?;
160        validate_record(&record)?;
161        Ok(Self { path, record })
162    }
163}
164
165/// Returns the configured endpoint.
166/// # Errors
167///
168/// Returns an error if validation or an operation required by this function fails.
169pub fn configured_endpoint() -> Result<Endpoint> {
170    if environment_override_message().is_some() {
171        return Endpoint::from_env();
172    }
173    GatewayAccounts::load()?
174        .selected()
175        .map_or_else(Endpoint::from_env, str::parse)
176}
177
178/// Returns the configured token.
179/// # Errors
180///
181/// Returns an error if validation or an operation required by this function fails.
182pub fn configured_token(endpoint: &Endpoint) -> Result<Option<String>> {
183    if env::var_os("MOBIUS_GATEWAY_TOKEN").is_some() {
184        return token_from_env().map(Some);
185    }
186    Ok(GatewayAccounts::load()?.token(endpoint).map(str::to_owned))
187}
188
189/// Returns the dashboard gateway endpoint.
190/// # Errors
191///
192/// Returns an error if validation or an operation required by this function fails.
193pub fn dashboard_gateway_endpoint(state_dir: &Path) -> Result<Endpoint> {
194    let (_, config) = ConfigStore::open(state_dir.to_path_buf())?;
195    if config.tls.is_some() {
196        if env::var_os("MOBIUS_GATEWAY_ENDPOINT").is_none() {
197            return Err(Error::Config(
198                "TLS dashboards require MOBIUS_GATEWAY_ENDPOINT with the certificate hostname"
199                    .into(),
200            ));
201        }
202        return Endpoint::from_env();
203    }
204    endpoint_from_config(&config)
205}
206
207/// Validates local gateway config.
208/// # Errors
209///
210/// Returns an error if the supplied value is invalid.
211pub fn validate_local_gateway_config(
212    state_dir: &Path,
213    endpoint: &Endpoint,
214    has_saved_token: bool,
215) -> Result<()> {
216    let (_, config) = ConfigStore::open(state_dir.to_path_buf())?;
217    let configured_endpoint = endpoint_from_config(&config)?;
218    if endpoint != &configured_endpoint {
219        return Err(Error::Config(format!(
220            "saved endpoint {endpoint} is not the local gateway configured at {configured_endpoint}; start it separately or select the configured endpoint"
221        )));
222    }
223    if !has_saved_token && config.cloudflare.is_none() {
224        return Err(missing_local_token(endpoint));
225    }
226    Ok(())
227}
228
229/// Returns the missing local token.
230pub fn missing_local_token(endpoint: &Endpoint) -> Error {
231    Error::Config(format!(
232        "local gateway state exists but mobius-cli is not paired; stop the gateway, run `mobius-gateway connect` in another terminal, then run `mobius pair {endpoint} <one-time-code>`"
233    ))
234}
235
236/// Returns the environment override message.
237pub fn environment_override_message() -> Option<&'static str> {
238    match (
239        env::var_os("MOBIUS_GATEWAY_ENDPOINT").is_some(),
240        env::var_os("MOBIUS_GATEWAY_TOKEN").is_some(),
241    ) {
242        (true, true) => Some(
243            "Gateway selection is controlled by MOBIUS_GATEWAY_ENDPOINT and MOBIUS_GATEWAY_TOKEN. Unset them to manage saved gateways.",
244        ),
245        (true, false) => Some(
246            "Gateway selection is controlled by MOBIUS_GATEWAY_ENDPOINT. Unset it to manage saved gateways.",
247        ),
248        (false, true) => Some(
249            "Gateway selection is controlled by MOBIUS_GATEWAY_TOKEN. Unset it to manage saved gateways.",
250        ),
251        (false, false) => None,
252    }
253}
254
255fn validate_record(record: &TokenStoreRecord) -> Result<()> {
256    if record.tokens.len() > MAX_ACCOUNTS {
257        return Err(Error::Config(
258            "gateway token file has too many endpoints".into(),
259        ));
260    }
261    for (endpoint, token) in &record.tokens {
262        let parsed = endpoint.parse::<Endpoint>()?;
263        if parsed.to_string() != *endpoint {
264            return Err(Error::Config(
265                "saved gateway endpoint is not canonical".into(),
266            ));
267        }
268        validate_token(token)?;
269    }
270    if record
271        .selected_endpoint
272        .as_ref()
273        .is_some_and(|endpoint| !record.tokens.contains_key(endpoint))
274    {
275        return Err(Error::Config(
276            "selected gateway endpoint is not saved".into(),
277        ));
278    }
279    Ok(())
280}
281
282fn endpoint_from_config(config: &GatewayConfig) -> Result<Endpoint> {
283    format!(
284        "{}://{}",
285        if config.tls.is_some() { "tls" } else { "tcp" },
286        config.listen
287    )
288    .parse()
289}
290
291fn validate_token(token: &str) -> Result<()> {
292    if token.is_empty() || token.len() > MAX_TOKEN_BYTES || token.trim() != token {
293        return Err(Error::Config("saved gateway token is invalid".into()));
294    }
295    Ok(())
296}
297
298fn token_path() -> Result<PathBuf> {
299    if let Some(path) = env::var_os("MOBIUS_GATEWAY_TOKEN_FILE") {
300        return Ok(path.into());
301    }
302    env::var_os("HOME")
303        .or_else(|| env::var_os("USERPROFILE"))
304        .map(PathBuf::from)
305        .map(|path| path.join(".mobius").join("gateway-tokens.json"))
306        .ok_or_else(|| {
307            Error::Config("cannot determine token path; set MOBIUS_GATEWAY_TOKEN_FILE".into())
308        })
309}
310
311fn parent(path: &Path) -> Result<&Path> {
312    path.parent()
313        .ok_or_else(|| Error::Config("token path has no parent".into()))
314}
315
316fn secure(file: &tempfile::NamedTempFile) -> Result<()> {
317    #[cfg(unix)]
318    file.as_file()
319        .set_permissions(std::fs::Permissions::from_mode(0o600))?;
320    Ok(())
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    fn accounts(path: &Path) -> GatewayAccounts {
328        GatewayAccounts::load_from(path.to_path_buf()).expect("load accounts")
329    }
330
331    fn endpoint(value: &str) -> Endpoint {
332        value.parse().expect("valid endpoint")
333    }
334
335    #[test]
336    fn selecting_a_saved_account_updates_the_selected_endpoint() {
337        let directory = tempfile::tempdir().expect("token directory");
338        let mut accounts = accounts(&directory.path().join("tokens.json"));
339        accounts
340            .add(&endpoint("tcp://127.0.0.1:8741"), "local-token".into())
341            .expect("local account");
342        accounts
343            .add(
344                &endpoint("tls://gateway.example:443"),
345                "remote-token".into(),
346            )
347            .expect("remote account");
348
349        accounts
350            .select("tcp://127.0.0.1:8741")
351            .expect("select account");
352
353        assert_eq!(accounts.selected(), Some("tcp://127.0.0.1:8741"));
354    }
355
356    #[test]
357    fn forgetting_the_selected_account_clears_selection() {
358        let directory = tempfile::tempdir().expect("token directory");
359        let mut accounts = accounts(&directory.path().join("tokens.json"));
360        accounts
361            .add(&endpoint("tcp://127.0.0.1:8741"), "local-token".into())
362            .expect("local account");
363
364        accounts.forget("tcp://127.0.0.1:8741");
365
366        assert_eq!(accounts.selected(), None);
367    }
368
369    #[test]
370    fn account_record_round_trips_selection_and_tokens() {
371        let directory = tempfile::tempdir().expect("token directory");
372        let path = directory.path().join("tokens.json");
373        let mut accounts = accounts(&path);
374        let endpoint = endpoint("tls://gateway.example:443");
375        accounts
376            .add(&endpoint, "remote-token".into())
377            .expect("remote account");
378        accounts.save().expect("save accounts");
379
380        let loaded = GatewayAccounts::load_from(path).expect("reload accounts");
381
382        assert_eq!(
383            (loaded.selected(), loaded.token(&endpoint)),
384            (Some("tls://gateway.example:443"), Some("remote-token"))
385        );
386    }
387
388    #[test]
389    fn old_token_maps_fail_with_repair_guidance() {
390        let directory = tempfile::tempdir().expect("token directory");
391        let path = directory.path().join("tokens.json");
392        std::fs::write(&path, r#"{"tcp://127.0.0.1:8741":"token"}"#).expect("legacy token map");
393        #[cfg(unix)]
394        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
395            .expect("private permissions");
396
397        let error = GatewayAccounts::load_from(path).expect_err("old format must fail");
398
399        assert!(error.to_string().contains("delete"));
400        assert!(error.to_string().contains("pair again"));
401    }
402
403    #[test]
404    fn local_gateway_config_rejects_a_different_saved_endpoint() {
405        let directory = tempfile::tempdir().expect("gateway state parent");
406        let state = directory.path().join("gateway");
407        mobius_gateway::command::initialize_quick_cloudflare(state.clone())
408            .expect("initialize gateway");
409        let endpoint = endpoint("tcp://127.0.0.1:9999");
410
411        let error = validate_local_gateway_config(&state, &endpoint, true)
412            .expect_err("mismatched endpoint must fail");
413
414        assert!(error.to_string().contains("127.0.0.1:8741"));
415    }
416}