//! "Is a Codeman server answering at this address, and does it want a login?" //! //! One `GET /api/status`, five seconds at most, run off the UI thread. //! The answer is classified into something the connect page can explain. It is //! asked once per user action, never in a loop: Codeman counts a request //! without valid credentials against the login rate limit (10 per IP). use serde::Serialize; use std::time::Duration; use url::Url; const TIMEOUT: Duration = Duration::from_secs(5); const MAX_BODY: u64 = 8 * 1024 * 1024; /// The path the probe asks for, relative to the server's base URL. const STATUS_PATH: &str = "api/status"; /// rustls' text for `Error::InvalidCertificate`, which is what a handshake /// with a certificate the OS does not trust fails with. ureq hands it over as /// an `io::Error` of kind `InvalidData`, so the text is all there is to match /// without a direct rustls dependency. const CERT_REJECTED_PREFIX: &str = "invalid peer certificate"; /// std's text for a failed `getaddrinfo`. Its kind is `Uncategorized`, which /// cannot be matched on stable Rust, so the prefix is the only handle. The /// prefix is the same on macOS and Linux; only the resolver's reason differs. const LOOKUP_FAILED_PREFIX: &str = "failed to lookup address information"; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum ProbeOutcome { Ok { version: String, }, /// 401 and we sent no credentials. NeedsLogin, /// 401 although we sent credentials. BadCredentials, /// 429: too many failed logins from this address. A single-user Codeman /// still accepts the right password, so the page needs to know whether /// this probe carried one. `retry_after_secs` is the server's /// `Retry-After`, when it gave one in seconds. #[serde(rename_all = "camelCase")] RateLimited { sent_credentials: bool, #[serde(skip_serializing_if = "Option::is_none")] retry_after_secs: Option, }, /// 403 `PASSWORD_CHANGE_REQUIRED`: the credentials are valid, but a /// multi-user Codeman wants this account's password changed first. Page /// loads are exempt, so the web UI can show its change-password dialog. PasswordChangeRequired, /// 403 from Codeman's Host guard (a name it does not know, e.g. a custom domain). HostNotAllowed, /// 3xx. `location` is the target resolved against the probed URL. /// `suggested` is a server address to use instead, set only when the /// redirect stays on the same host, only moves to https or another port, /// and still points at the status API. Never followed: a second request /// would carry the credentials to an address the user did not type. Redirect { location: String, suggested: Option, }, /// Something answered, but not Codeman's status API. NotCodeman { status: u16, }, Refused, /// No route to the address: the machine is off or on another network, /// or macOS's Local Network privacy setting blocked the app. Unreachable, /// An http:// probe whose connection was closed without an answer. A /// Codeman started with `--https` does exactly that to plain HTTP. MaybeHttps, Timeout, TlsUntrusted { detail: String, }, Unresolvable, Failed { detail: String, }, } /// Classify an HTTP answer to `/api/status`. `request` is the URL that /// was asked, `location` and `retry_after` are those response headers. pub fn classify_response( request: &Url, status: u16, location: Option<&str>, retry_after: Option<&str>, sent_credentials: bool, body: &str, ) -> ProbeOutcome { match status { 200 => { let parsed: Option = serde_json::from_str(body).ok(); let version = parsed.as_ref().and_then(|v| { (v.get("success")? == &serde_json::Value::Bool(true)) .then(|| v.get("data")?.get("version")?.as_str().map(str::to_string)) .flatten() }); match version { Some(version) => ProbeOutcome::Ok { version }, None => ProbeOutcome::NotCodeman { status }, } } 300..=399 => { // A 3xx without a usable Location (304, a garbled header) is not a // redirect anyone can act on. match location.and_then(|l| request.join(l.trim()).ok()) { Some(target) => ProbeOutcome::Redirect { suggested: suggested_server(request, &target), location: target.into(), }, None => ProbeOutcome::NotCodeman { status }, } } 401 if sent_credentials => ProbeOutcome::BadCredentials, 401 => ProbeOutcome::NeedsLogin, 403 if body.contains("host not allowed") => ProbeOutcome::HostNotAllowed, // Codeman only says this after the Basic credentials verified (the // probe sends no cookie), so without credentials it is not Codeman's // answer to us, and loading the page without them would not help. 403 if sent_credentials && error_code(body).as_deref() == Some("PASSWORD_CHANGE_REQUIRED") => { ProbeOutcome::PasswordChangeRequired } 429 => ProbeOutcome::RateLimited { sent_credentials, retry_after_secs: retry_after.and_then(|v| v.trim().parse::().ok()), }, other => ProbeOutcome::NotCodeman { status: other }, } } /// `errorCode` of a Codeman `ApiResponse` error body. fn error_code(body: &str) -> Option { let parsed: serde_json::Value = serde_json::from_str(body).ok()?; Some(parsed.get("errorCode")?.as_str()?.to_string()) } /// The server base URL a redirect points to, when it is the same server /// moved to https or another port (a reverse proxy upgrading plain HTTP, the /// usual case). Anything else, such as another host (a login gateway the /// window could not pass anyway) or a downgrade to http, gets no suggestion. fn suggested_server(request: &Url, target: &Url) -> Option { let upgrade = request.scheme() == "http" && target.scheme() == "https"; let port_change = request.scheme() == target.scheme() && request.port_or_known_default() != target.port_or_known_default(); if !(upgrade || port_change) || target.host() != request.host() { return None; } if !target.username().is_empty() || target.password().is_some() { return None; } // The redirect must keep pointing at the status API (a proxy may add a // base path in front of it); the base is what comes before it. if !target.path().ends_with(request.path()) || !request.path().ends_with(STATUS_PATH) { return None; } let base_path = &target.path()[..target.path().len() - STATUS_PATH.len()]; let mut base = target.clone(); base.set_path(base_path); base.set_query(None); base.set_fragment(None); Some(base.into()) } /// Transport failures, reduced to what the connect page explains differently. #[derive(Debug, Clone, PartialEq, Eq)] pub enum TransportError { Refused, Unreachable, ClosedWithoutAnswer, Timeout, Tls(String), Dns, Other(String), } pub fn classify_transport(err: TransportError) -> ProbeOutcome { match err { TransportError::Refused => ProbeOutcome::Refused, TransportError::Unreachable => ProbeOutcome::Unreachable, TransportError::ClosedWithoutAnswer => ProbeOutcome::MaybeHttps, TransportError::Timeout => ProbeOutcome::Timeout, TransportError::Tls(detail) => ProbeOutcome::TlsUntrusted { detail }, TransportError::Dns => ProbeOutcome::Unresolvable, TransportError::Other(detail) => ProbeOutcome::Failed { detail }, } } /// `plain_http`: the probe went to an http:// URL. Only there does a closed /// connection hint at an https-only server; over https the same close is a /// failed handshake with nothing more to say about it. fn transport_error(err: &ureq::Error, plain_http: bool) -> TransportError { use std::io::ErrorKind; match err { ureq::Error::Timeout(_) => TransportError::Timeout, ureq::Error::HostNotFound => TransportError::Dns, ureq::Error::ConnectionFailed => TransportError::Refused, // ureq passes both a rejected certificate (rustls wraps it as // InvalidData during the handshake) and a failed name lookup (std's // own error) through as plain io errors, so they are told apart here // before the kinds below swallow them. ureq::Error::Io(e) if e.kind() == ErrorKind::InvalidData && e.to_string().starts_with(CERT_REJECTED_PREFIX) => { TransportError::Tls(e.to_string()) } ureq::Error::Io(e) if e.raw_os_error().is_none() && e.to_string().starts_with(LOOKUP_FAILED_PREFIX) => { TransportError::Dns } ureq::Error::Io(e) => match e.kind() { ErrorKind::ConnectionRefused | ErrorKind::ConnectionReset | ErrorKind::AddrNotAvailable => { TransportError::Refused } ErrorKind::HostUnreachable | ErrorKind::NetworkUnreachable => TransportError::Unreachable, ErrorKind::UnexpectedEof if plain_http => TransportError::ClosedWithoutAnswer, ErrorKind::TimedOut | ErrorKind::WouldBlock => TransportError::Timeout, _ => TransportError::Other(e.to_string()), }, // ureq's own rustls errors come from setting up the TLS client (the // handshake's arrive as Io above); only a certificate rejection is // "untrusted", anything else is reported as it is. ureq::Error::Rustls(e) if e.to_string().starts_with(CERT_REJECTED_PREFIX) => TransportError::Tls(e.to_string()), other => TransportError::Other(other.to_string()), } } /// Ask the server. Blocking: call it from a worker thread. pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome { let status_url = match server.join(STATUS_PATH) { Ok(u) => u, Err(e) => return ProbeOutcome::Failed { detail: e.to_string() }, }; let tls = ureq::tls::TlsConfig::builder() .root_certs(ureq::tls::RootCerts::PlatformVerifier) .build(); let agent: ureq::Agent = ureq::Agent::config_builder() .timeout_global(Some(TIMEOUT)) .http_status_as_error(false) // Never follow: a 3xx comes back as an answer and is explained. .max_redirects(0) .tls_config(tls) .user_agent(concat!("CodemanDesktop/", env!("CARGO_PKG_VERSION"))) .build() .into(); let mut req = agent.get(status_url.as_str()).header("Accept", "application/json"); if let Some(auth) = authorization { req = req.header("Authorization", auth); } match req.call() { Ok(mut resp) => { let status = resp.status().as_u16(); let header = |name: &str| { resp.headers() .get(name) .and_then(|v| v.to_str().ok()) .map(str::to_string) }; let location = header("location"); let retry_after = header("retry-after"); let body = resp .body_mut() .with_config() .limit(MAX_BODY) .read_to_string() .unwrap_or_default(); classify_response( &status_url, status, location.as_deref(), retry_after.as_deref(), authorization.is_some(), &body, ) } Err(e) => classify_transport(transport_error(&e, status_url.scheme() == "http")), } } #[cfg(test)] mod tests { use super::*; use std::io; fn status_url() -> Url { Url::parse("http://studio.lan:3000/api/status").unwrap() } /// `classify_response` for the cases that need no headers. fn classify(status: u16, sent_credentials: bool, body: &str) -> ProbeOutcome { classify_response(&status_url(), status, None, None, sent_credentials, body) } fn redirect(request: &str, location: &str) -> ProbeOutcome { classify_response(&Url::parse(request).unwrap(), 301, Some(location), None, false, "") } fn io_outcome(kind: io::ErrorKind, message: &str, plain_http: bool) -> ProbeOutcome { let err = ureq::Error::Io(io::Error::new(kind, message.to_string())); classify_transport(transport_error(&err, plain_http)) } #[test] fn ok_needs_success_and_version() { assert_eq!( classify( 200, false, r#"{"success":true,"data":{"version":"1.47.0","sessions":[]}}"# ), ProbeOutcome::Ok { version: "1.47.0".into() } ); assert_eq!( classify(200, false, "hello"), ProbeOutcome::NotCodeman { status: 200 } ); assert_eq!( classify(200, false, r#"{"success":false,"data":{"version":"1"}}"#), ProbeOutcome::NotCodeman { status: 200 } ); assert_eq!( classify(200, false, r#"{"success":true}"#), ProbeOutcome::NotCodeman { status: 200 } ); } #[test] fn auth_and_guards() { assert_eq!(classify(401, false, "Unauthorized"), ProbeOutcome::NeedsLogin); assert_eq!(classify(401, true, "Unauthorized"), ProbeOutcome::BadCredentials); assert_eq!( classify(403, false, "Forbidden: host not allowed"), ProbeOutcome::HostNotAllowed ); // The Host guard runs before authentication, so it wins even with credentials. assert_eq!( classify(403, true, "Forbidden: host not allowed"), ProbeOutcome::HostNotAllowed ); assert_eq!(classify(403, false, "nope"), ProbeOutcome::NotCodeman { status: 403 }); assert_eq!(classify(502, false, ""), ProbeOutcome::NotCodeman { status: 502 }); } /// The exact body auth.ts sends: `createErrorResponse(PASSWORD_CHANGE_REQUIRED)`. #[test] fn password_change_required_only_after_sent_credentials() { let body = r#"{"success":false,"error":"You must change your password before continuing","errorCode":"PASSWORD_CHANGE_REQUIRED"}"#; assert_eq!(classify(403, true, body), ProbeOutcome::PasswordChangeRequired); assert_eq!(classify(403, false, body), ProbeOutcome::NotCodeman { status: 403 }); // Another Codeman error code, or the text without the JSON field, is not it. assert_eq!( classify(403, true, r#"{"success":false,"errorCode":"FORBIDDEN"}"#), ProbeOutcome::NotCodeman { status: 403 } ); assert_eq!( classify(403, true, "PASSWORD_CHANGE_REQUIRED"), ProbeOutcome::NotCodeman { status: 403 } ); } #[test] fn rate_limited_says_whether_credentials_went_along() { let body = "Too Many Requests, try again later"; assert_eq!( classify(429, false, body), ProbeOutcome::RateLimited { sent_credentials: false, retry_after_secs: None } ); assert_eq!( classify(429, true, body), ProbeOutcome::RateLimited { sent_credentials: true, retry_after_secs: None } ); // auth.ts sends Retry-After in whole seconds; an HTTP date is not parsed. assert_eq!( classify_response(&status_url(), 429, None, Some(" 742 "), true, body), ProbeOutcome::RateLimited { sent_credentials: true, retry_after_secs: Some(742) } ); assert_eq!( classify_response( &status_url(), 429, None, Some("Wed, 21 Oct 2026 07:28:00 GMT"), false, body ), ProbeOutcome::RateLimited { sent_credentials: false, retry_after_secs: None } ); } #[test] fn redirect_to_https_on_the_same_host_is_suggested() { assert_eq!( redirect("http://studio.lan/api/status", "https://studio.lan/api/status"), ProbeOutcome::Redirect { location: "https://studio.lan/api/status".into(), suggested: Some("https://studio.lan/".into()) } ); // A base path the proxy adds stays part of the suggestion; a query does not. assert_eq!( redirect( "http://studio.lan:3000/api/status", "https://studio.lan/codeman/api/status?from=http" ), ProbeOutcome::Redirect { location: "https://studio.lan/codeman/api/status?from=http".into(), suggested: Some("https://studio.lan/codeman/".into()) } ); // A relative Location resolves against the probed URL; another port is fine. assert_eq!( redirect( "http://studio.lan:3000/base/api/status", "//studio.lan:3001/base/api/status" ), ProbeOutcome::Redirect { location: "http://studio.lan:3001/base/api/status".into(), suggested: Some("http://studio.lan:3001/base/".into()) } ); } #[test] fn other_redirects_are_reported_without_a_suggestion() { // Another host (a login gateway, say). assert_eq!( redirect("http://studio.lan/api/status", "https://sso.example.com/login?next=x"), ProbeOutcome::Redirect { location: "https://sso.example.com/login?next=x".into(), suggested: None } ); // Same host, but no longer the status API. assert_eq!( redirect("http://studio.lan/api/status", "/login"), ProbeOutcome::Redirect { location: "http://studio.lan/login".into(), suggested: None } ); // A downgrade to plain http. assert_eq!( redirect("https://studio.lan/api/status", "http://studio.lan/api/status"), ProbeOutcome::Redirect { location: "http://studio.lan/api/status".into(), suggested: None } ); // Same scheme and port, only the path moved. assert_eq!( redirect("http://studio.lan/api/status", "/x/api/status"), ProbeOutcome::Redirect { location: "http://studio.lan/x/api/status".into(), suggested: None } ); // Credentials in the target are never suggested. assert_eq!( redirect("http://studio.lan/api/status", "https://u:p@studio.lan/api/status"), ProbeOutcome::Redirect { location: "https://u:p@studio.lan/api/status".into(), suggested: None } ); // No Location (304), or one that does not parse: nothing to act on. assert_eq!( classify_response(&status_url(), 304, None, None, false, ""), ProbeOutcome::NotCodeman { status: 304 } ); assert_eq!( classify_response(&status_url(), 302, Some("http://[::1"), None, false, ""), ProbeOutcome::NotCodeman { status: 302 } ); } #[test] fn transport_classes() { assert_eq!(classify_transport(TransportError::Refused), ProbeOutcome::Refused); assert_eq!(classify_transport(TransportError::Timeout), ProbeOutcome::Timeout); assert_eq!(classify_transport(TransportError::Dns), ProbeOutcome::Unresolvable); assert_eq!( classify_transport(TransportError::Tls("bad cert".into())), ProbeOutcome::TlsUntrusted { detail: "bad cert".into() } ); } /// The io errors exactly as ureq hands them over (`Error::Io`). #[test] fn io_errors_are_told_apart() { use io::ErrorKind; // rustls' certificate rejection from the handshake. let cert = "invalid peer certificate: UnknownIssuer"; assert_eq!( io_outcome(ErrorKind::InvalidData, cert, false), ProbeOutcome::TlsUntrusted { detail: cert.into() } ); // Any other InvalidData (a port that does not speak TLS, say) is not a certificate problem. assert_eq!( io_outcome( ErrorKind::InvalidData, "received corrupt message of type InvalidContentType", false ), ProbeOutcome::Failed { detail: "received corrupt message of type InvalidContentType".into() } ); // std's getaddrinfo failure (its real kind, Uncategorized, cannot be built on stable). assert_eq!( io_outcome( ErrorKind::Other, "failed to lookup address information: nodename nor servname provided, or not known", true ), ProbeOutcome::Unresolvable ); // The same text from the OS (an errno) is not a lookup failure. let os = ureq::Error::Io(io::Error::from_raw_os_error(1)); assert!(!matches!( classify_transport(transport_error(&os, true)), ProbeOutcome::Unresolvable )); // ureq's "Peer disconnected": over http a hint at https, over https just a failure. assert_eq!( io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", true), ProbeOutcome::MaybeHttps ); assert_eq!( io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", false), ProbeOutcome::Failed { detail: "Peer disconnected".into() } ); // No route: Local Network privacy, or a machine that is off. assert_eq!( io_outcome(ErrorKind::HostUnreachable, "No route to host", true), ProbeOutcome::Unreachable ); assert_eq!( io_outcome(ErrorKind::NetworkUnreachable, "Network is unreachable", false), ProbeOutcome::Unreachable ); assert_eq!( io_outcome(ErrorKind::ConnectionRefused, "Connection refused", true), ProbeOutcome::Refused ); assert_eq!( classify_transport(transport_error(&ureq::Error::HostNotFound, true)), ProbeOutcome::Unresolvable ); // A ureq TLS setup message is not an untrusted certificate. assert_eq!( classify_transport(transport_error( &ureq::Error::Tls("Rustls invalid dns name error"), false )), ProbeOutcome::Failed { detail: "Rustls invalid dns name error".into() } ); } #[test] fn outcome_serializes_for_the_page() { let json = |o: ProbeOutcome| serde_json::to_value(o).unwrap(); assert_eq!( json(ProbeOutcome::NotCodeman { status: 404 }), serde_json::json!({"kind":"notCodeman","status":404}) ); assert_eq!(json(ProbeOutcome::NeedsLogin), serde_json::json!({"kind":"needsLogin"})); assert_eq!( json(ProbeOutcome::RateLimited { sent_credentials: true, retry_after_secs: None }), serde_json::json!({"kind":"rateLimited","sentCredentials":true}) ); assert_eq!( json(ProbeOutcome::RateLimited { sent_credentials: false, retry_after_secs: Some(60) }), serde_json::json!({"kind":"rateLimited","sentCredentials":false,"retryAfterSecs":60}) ); assert_eq!( json(ProbeOutcome::PasswordChangeRequired), serde_json::json!({"kind":"passwordChangeRequired"}) ); assert_eq!( json(ProbeOutcome::Redirect { location: "https://studio.lan/api/status".into(), suggested: Some("https://studio.lan/".into()) }), serde_json::json!({"kind":"redirect","location":"https://studio.lan/api/status","suggested":"https://studio.lan/"}) ); assert_eq!( json(ProbeOutcome::Redirect { location: "https://sso.example.com/".into(), suggested: None }), serde_json::json!({"kind":"redirect","location":"https://sso.example.com/","suggested":null}) ); assert_eq!( json(ProbeOutcome::Unreachable), serde_json::json!({"kind":"unreachable"}) ); assert_eq!(json(ProbeOutcome::MaybeHttps), serde_json::json!({"kind":"maybeHttps"})); assert_eq!( json(ProbeOutcome::TlsUntrusted { detail: "x".into() }), serde_json::json!({"kind":"tlsUntrusted","detail":"x"}) ); assert_eq!( json(ProbeOutcome::Unresolvable), serde_json::json!({"kind":"unresolvable"}) ); } /// Against real sockets: a closed port is `Refused`, a listener that /// speaks HTTP but not Codeman is `NotCodeman`, a fake Codeman is `Ok`, /// a 401 is `NeedsLogin`, a redirect is reported without being followed, /// and a listener that hangs up without answering is `MaybeHttps`. #[test] fn probe_against_local_sockets() { use std::io::{Read, Write}; use std::net::TcpListener; let closed = { let l = TcpListener::bind("127.0.0.1:0").unwrap(); l.local_addr().unwrap().port() }; let url = Url::parse(&format!("http://127.0.0.1:{closed}/")).unwrap(); assert_eq!(probe(&url, None), ProbeOutcome::Refused); /// Accept one connection, read the request and send `response` /// (nothing, then close, for an empty one). fn serve_raw(response: String) -> u16 { let l = TcpListener::bind("127.0.0.1:0").unwrap(); let port = l.local_addr().unwrap().port(); std::thread::spawn(move || { if let Ok((mut s, _)) = l.accept() { // Read the whole request head (a GET has no body) so that // closing sends a FIN, not a reset for unread bytes. let mut head = Vec::new(); let mut buf = [0u8; 4096]; while !head.windows(4).any(|w| w == b"\r\n\r\n") { match s.read(&mut buf) { Ok(0) | Err(_) => break, Ok(n) => head.extend_from_slice(&buf[..n]), } } let _ = s.write_all(response.as_bytes()); } }); port } fn serve_once(status_line: &str, extra_headers: &str, body: &str) -> u16 { serve_raw(format!( "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{extra_headers}Connection: close\r\n\r\n{body}", body.len() )) } let p = serve_once("200 OK", "", r#"{"success":true,"data":{"version":"9.9.9"}}"#); let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap(); assert_eq!( probe(&url, None), ProbeOutcome::Ok { version: "9.9.9".into() } ); let p = serve_once("404 Not Found", "", "nope"); let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap(); assert_eq!(probe(&url, None), ProbeOutcome::NotCodeman { status: 404 }); let p = serve_once("401 Unauthorized", "", "Unauthorized"); let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap(); assert_eq!(probe(&url, None), ProbeOutcome::NeedsLogin); let p = serve_once("429 Too Many Requests", "Retry-After: 30\r\n", "Too Many Requests"); let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap(); assert_eq!( probe(&url, Some("Basic YWRtaW46eA==")), ProbeOutcome::RateLimited { sent_credentials: true, retry_after_secs: Some(30) } ); // Nothing listens on the redirect target: had it been followed, the // answer would be Refused instead. let p = serve_once( "301 Moved Permanently", &format!("Location: https://127.0.0.1:{closed}/api/status\r\n"), "", ); let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap(); assert_eq!( probe(&url, None), ProbeOutcome::Redirect { location: format!("https://127.0.0.1:{closed}/api/status"), suggested: Some(format!("https://127.0.0.1:{closed}/")) } ); let p = serve_raw(String::new()); let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap(); assert_eq!(probe(&url, None), ProbeOutcome::MaybeHttps); } }