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::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}