1use std::sync::Mutex;
16use std::time::{Duration, Instant};
17
18use serde::Deserialize;
19
20pub const DISCOVERY_PATH: &str = "/.well-known/agentmux-cloud.json";
22
23const CACHE_TTL: Duration = Duration::from_secs(300);
25const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
26
27#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
29pub struct CloudSettings {
30 pub api: String,
32 pub ws: String,
34 #[serde(default)]
36 pub console: String,
37 pub cognito: CognitoSettings,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct CognitoSettings {
43 pub domain: String,
45 pub client_id: String,
47 #[serde(default)]
48 pub region: String,
49 #[serde(default)]
50 pub user_pool_id: String,
51}
52
53pub fn parse(body: &str, rest_base: &str) -> Option<CloudSettings> {
58 let value: serde_json::Value = serde_json::from_str(body).ok()?;
59 if value.get("version").and_then(|v| v.as_u64()) != Some(1) {
60 return None;
61 }
62 let settings: CloudSettings = serde_json::from_value(value).ok()?;
63 let local = rest_base.starts_with("http://");
64 let secure = |url: &str, scheme: &str, dev: &str| url.starts_with(scheme) || (local && url.starts_with(dev));
65 let ok = secure(&settings.api, "https://", "http://")
66 && secure(&settings.ws, "wss://", "ws://")
67 && secure(&settings.cognito.domain, "https://", "http://")
68 && !settings.cognito.client_id.is_empty();
69 ok.then_some(settings)
70}
71
72struct Cache {
73 fetched_at: Instant,
74 last_good: Option<CloudSettings>,
76}
77
78static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
79
80pub async fn cloud_settings(http: &reqwest::Client) -> Option<CloudSettings> {
86 if let Some(cache) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).as_ref() {
87 if cache.fetched_at.elapsed() < CACHE_TTL {
88 return cache.last_good.clone();
89 }
90 }
91 let base = super::relay::rest_base_url();
92 let fetched = fetch(http, &base).await;
93 let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
94 let last_good = keep_last_good(guard.as_ref().and_then(|c| c.last_good.as_ref()), fetched);
95 *guard = Some(Cache { fetched_at: Instant::now(), last_good: last_good.clone() });
96 last_good
97}
98
99fn keep_last_good(previous: Option<&CloudSettings>, fetched: Option<CloudSettings>) -> Option<CloudSettings> {
101 fetched.or_else(|| previous.cloned())
102}
103
104async fn fetch(http: &reqwest::Client, base: &str) -> Option<CloudSettings> {
105 let url = format!("{}{}", base.trim_end_matches('/'), DISCOVERY_PATH);
106 let resp = match http.get(&url).timeout(FETCH_TIMEOUT).send().await {
107 Ok(r) => r,
108 Err(e) => {
109 tracing::debug!(url = %url, error = %e, "cloud discovery: fetch failed; using defaults");
110 return None;
111 }
112 };
113 if !resp.status().is_success() {
114 tracing::debug!(url = %url, status = %resp.status(), "cloud discovery: no document; using defaults");
115 return None;
116 }
117 let body = resp.text().await.ok()?;
118 let parsed = parse(&body, base);
119 if parsed.is_none() {
120 tracing::warn!(url = %url, "cloud discovery: document rejected (version, fields or URL schemes); using defaults");
121 }
122 parsed
123}
124
125pub async fn ws_url(http: &reqwest::Client) -> String {
128 if let Some(url) = std::env::var("AGENTMUX_MUXBUS_WS_URL")
131 .ok()
132 .filter(|v| !v.is_empty())
133 {
134 return url;
135 }
136 cloud_settings(http)
137 .await
138 .map(|s| s.ws)
139 .unwrap_or_else(|| super::cloud_subscriber::MUXBUS_WS_URL.to_string())
140}
141
142pub fn resolve_login(
146 discovered: Option<&CloudSettings>,
147 supplied_domain: &str,
148 supplied_client_id: &str,
149) -> Result<(String, String), String> {
150 if let Some(s) = discovered {
151 return Ok((s.cognito.domain.clone(), s.cognito.client_id.clone()));
152 }
153 if !supplied_domain.is_empty() && !supplied_client_id.is_empty() {
154 return Ok((supplied_domain.to_string(), supplied_client_id.to_string()));
155 }
156 Err("AgentMux Cloud sign-in isn't configured: the cloud publishes no settings and this build has none".to_string())
157}
158
159pub fn client_id_superseded(discovered: Option<&CloudSettings>, stored_client_id: &str) -> bool {
164 match discovered {
165 Some(s) => !stored_client_id.is_empty() && s.cognito.client_id != stored_client_id,
166 None => false,
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 const DOC: &str = r#"{
175 "version": 1,
176 "api": "https://muxbus.example.test",
177 "ws": "wss://muxbus-ws.example.test",
178 "console": "https://cloud.example.test",
179 "cognito": { "domain": "https://auth.example.test", "clientId": "abc123", "region": "us-east-1", "userPoolId": "us-east-1_X" },
180 "someFutureField": true
181 }"#;
182
183 #[test]
184 fn a_version_1_document_parses_and_ignores_unknown_fields() {
185 let s = parse(DOC, "https://muxbus.example.test").unwrap();
186 assert_eq!(s.ws, "wss://muxbus-ws.example.test");
187 assert_eq!(s.cognito.client_id, "abc123");
188 assert_eq!(s.cognito.user_pool_id, "us-east-1_X");
189 }
190
191 #[test]
192 fn other_versions_and_incomplete_documents_are_ignored() {
193 let base = "https://muxbus.example.test";
194 assert!(parse(&DOC.replace("\"version\": 1", "\"version\": 2"), base).is_none());
195 assert!(parse(&DOC.replace("\"clientId\": \"abc123\"", "\"clientId\": \"\""), base).is_none());
196 assert!(parse(r#"{"version":1,"api":"https://a","ws":"wss://b"}"#, base).is_none());
197 assert!(parse("not json", base).is_none());
198 }
199
200 #[test]
201 fn insecure_urls_are_refused_except_from_a_local_dev_relay() {
202 let plain = DOC.replace("wss://muxbus-ws", "ws://muxbus-ws");
203 assert!(parse(&plain, "https://muxbus.example.test").is_none());
204 assert!(parse(&plain, "http://localhost:3100").is_some());
205 let plain_auth = DOC.replace("https://auth", "http://auth");
206 assert!(parse(&plain_auth, "https://muxbus.example.test").is_none());
207 }
208
209 #[test]
210 fn login_uses_the_published_pair_else_the_builds_else_errors() {
211 let s = parse(DOC, "https://muxbus.example.test").unwrap();
212 assert_eq!(
213 resolve_login(Some(&s), "https://compiled", "compiled-id").unwrap(),
214 ("https://auth.example.test".to_string(), "abc123".to_string())
215 );
216 assert_eq!(
217 resolve_login(None, "https://compiled", "compiled-id").unwrap(),
218 ("https://compiled".to_string(), "compiled-id".to_string())
219 );
220 assert!(resolve_login(None, "https://compiled", "").is_err());
221 assert!(resolve_login(None, "", "").is_err());
222 }
223
224 #[test]
225 fn a_sign_in_from_another_client_is_superseded_only_when_the_cloud_says_so() {
226 let s = parse(DOC, "https://muxbus.example.test").unwrap();
227 assert!(client_id_superseded(Some(&s), "old-client"));
228 assert!(!client_id_superseded(Some(&s), "abc123"));
229 assert!(!client_id_superseded(None, "old-client"), "no document: can't tell");
230 assert!(!client_id_superseded(Some(&s), ""), "no recorded client id: can't tell");
231 }
232
233 async fn fake_relay(status: &'static str, body: String) -> String {
235 use tokio::io::{AsyncReadExt, AsyncWriteExt};
236 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
237 let addr = listener.local_addr().unwrap();
238 tokio::spawn(async move {
239 while let Ok((mut stream, _)) = listener.accept().await {
240 let body = body.clone();
241 tokio::spawn(async move {
242 let mut buf = [0u8; 2048];
243 let _ = stream.read(&mut buf).await;
244 let resp = format!(
245 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
246 body.len()
247 );
248 let _ = stream.write_all(resp.as_bytes()).await;
249 });
250 }
251 });
252 format!("http://{addr}")
253 }
254
255 #[tokio::test]
256 async fn a_relay_serving_the_document_is_discovered() {
257 let base = fake_relay("200 OK", DOC.to_string()).await;
258 let s = fetch(&reqwest::Client::new(), &base).await.expect("document served");
259 assert_eq!(s.cognito.client_id, "abc123");
260 }
261
262 #[tokio::test]
263 async fn an_unreachable_relay_or_one_without_the_document_is_not_discovered() {
264 let http = reqwest::Client::new();
265 assert!(fetch(&http, "http://127.0.0.1:1").await.is_none());
267 let base = fake_relay("404 Not Found", "{}".to_string()).await;
268 assert!(fetch(&http, &base).await.is_none());
269 let base = fake_relay("200 OK", DOC.replace("\"version\": 1", "\"version\": 2")).await;
270 assert!(fetch(&http, &base).await.is_none());
271 }
272
273 #[test]
274 fn a_failed_fetch_keeps_the_last_good_document() {
275 let old = parse(DOC, "https://muxbus.example.test").unwrap();
276 let new = parse(&DOC.replace("abc123", "def456"), "https://muxbus.example.test").unwrap();
277 assert_eq!(keep_last_good(Some(&old), None), Some(old.clone()));
278 assert_eq!(keep_last_good(Some(&old), Some(new.clone())), Some(new));
279 assert_eq!(keep_last_good(None, None), None);
280 }
281}