mobius_cli/
gateway_accounts.rs1use 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)]
25pub struct GatewayAccounts {
27 path: PathBuf,
28 record: TokenStoreRecord,
29}
30
31impl GatewayAccounts {
32 pub fn load() -> Result<Self> {
37 Self::load_from(token_path()?)
38 }
39
40 pub fn endpoints(&self) -> impl ExactSizeIterator<Item = &str> {
42 self.record.tokens.keys().map(String::as_str)
43 }
44
45 pub fn selected(&self) -> Option<&str> {
47 self.record.selected_endpoint.as_deref()
48 }
49
50 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 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 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 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 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 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
165pub 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
178pub 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
189pub 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
207pub 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
229pub 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
236pub 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}