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