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