Skip to main content

lean_ctx/core/providers/
connection.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! A real connection check for one built-in source: the same configuration a
4//! provider builds, one authenticated read-only request, and who the source
5//! says the Engine is. Used by `lean-ctx provider check <id>`.
6
7use std::time::{Duration, Instant};
8
9use super::config::GitLabConfig;
10use super::github::GitHubConfig;
11use super::jira::JiraConfig;
12
13#[derive(Debug, Clone, serde::Serialize)]
14pub struct ConnectionCheck {
15    pub provider: String,
16    pub connected: bool,
17    /// The account the source reports for these credentials.
18    pub account: Option<String>,
19    /// What the source will be read for (repository, project, database).
20    pub scope: Option<String>,
21    pub error: Option<String>,
22    /// Why the check failed, machine-readable (`None` when connected).
23    pub reason: Option<FailureReason>,
24    pub latency_ms: u64,
25}
26
27/// Why a provider check or call failed. Stable snake_case strings; readers
28/// show an unknown value as-is.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
30#[serde(rename_all = "snake_case")]
31pub enum FailureReason {
32    /// A required setting or credential is missing.
33    NotConfigured,
34    /// The source refused the credentials (HTTP 401/403, a database login).
35    Auth,
36    /// The source could not be reached (DNS, connection refused, TLS).
37    Unreachable,
38    Timeout,
39    RateLimited,
40    NotFound,
41    /// The source answered with a server error (HTTP 5xx).
42    Unavailable,
43    /// This source cannot be checked or called here.
44    Unsupported,
45    /// The request itself was malformed (bad arguments or input).
46    InvalidInput,
47    Error,
48}
49
50impl FailureReason {
51    /// The reason of an HTTP status.
52    pub fn of_status(status: u16) -> Self {
53        match status {
54            401 | 403 => Self::Auth,
55            404 => Self::NotFound,
56            408 => Self::Timeout,
57            429 => Self::RateLimited,
58            500..=599 => Self::Unavailable,
59            _ => Self::Error,
60        }
61    }
62
63    /// The reason a client's error text names, for sources whose clients
64    /// only report text (Jira, `psql`).
65    pub fn of_message(message: &str) -> Self {
66        let m = message.to_ascii_lowercase();
67        let has = |needles: &[&str]| needles.iter().any(|n| m.contains(n));
68        if has(&[
69            "not set",
70            "not configured",
71            "missing",
72            "no such file",
73            "not found in path",
74        ]) {
75            Self::NotConfigured
76        } else if has(&[
77            "http 401",
78            "http 403",
79            "unauthorized",
80            "forbidden",
81            "authentication failed",
82            "refused the token",
83        ]) {
84            Self::Auth
85        } else if has(&["timeout", "timed out", "no answer within"]) {
86            Self::Timeout
87        } else if has(&["http 429", "rate limit"]) {
88            Self::RateLimited
89        } else if has(&["http 404"]) {
90            Self::NotFound
91        } else if has(&["http 5"]) {
92            Self::Unavailable
93        } else if has(&[
94            "could not connect",
95            "connection refused",
96            "dns",
97            "resolve",
98            "unreachable",
99            "tls",
100            "connect",
101        ]) {
102            Self::Unreachable
103        } else {
104            Self::Error
105        }
106    }
107}
108
109pub const CHECKABLE: &[&str] = &["github", "gitlab", "jira", "postgres"];
110
111/// Upper bound for one check, below the Cockpit's 40 s command deadline.
112pub const CHECK_TIMEOUT: Duration = Duration::from_secs(30);
113
114/// Checks `provider` within `timeout`. Every secret the check could see
115/// (tokens, the database address and its password) is masked in `account`,
116/// `scope` and `error`, whatever the source or its client echoed back.
117pub fn check(provider: &str, timeout: Duration) -> ConnectionCheck {
118    let started = Instant::now();
119    let (sender, receiver) = std::sync::mpsc::channel();
120    let id = provider.to_string();
121    let spawned = std::thread::Builder::new()
122        .name("provider-check".into())
123        .spawn(move || {
124            let _ = sender.send(check_now(&id));
125        });
126    let result = match spawned {
127        Ok(_) => receiver.recv_timeout(timeout).unwrap_or_else(|_| {
128            failed(
129                provider,
130                FailureReason::Timeout,
131                format!("no answer within {} s", timeout.as_secs()),
132                started,
133            )
134        }),
135        Err(e) => failed(
136            provider,
137            FailureReason::Error,
138            format!("cannot start the check: {e}"),
139            started,
140        ),
141    };
142    redact(result, &secret_values())
143}
144
145fn failed(
146    provider: &str,
147    reason: FailureReason,
148    error: String,
149    started: Instant,
150) -> ConnectionCheck {
151    ConnectionCheck {
152        provider: provider.to_string(),
153        connected: false,
154        account: None,
155        scope: None,
156        error: Some(error),
157        reason: Some(reason),
158        latency_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
159    }
160}
161
162/// The secret values a check resolves, longest first so a token that
163/// contains another secret is masked whole.
164fn secret_values() -> Vec<String> {
165    let mut values = Vec::new();
166    for name in super::credentials::SECRET_NAMES {
167        if let Ok(value) = super::credentials::var(name) {
168            let value = value.trim().to_string();
169            if let Some(password) = url_password(&value) {
170                values.push(password);
171            }
172            values.push(value);
173        }
174    }
175    // Very short values would mask ordinary words; real secrets are longer.
176    values.retain(|value| value.len() >= 4);
177    values.sort_by_key(|value| std::cmp::Reverse(value.len()));
178    values.dedup();
179    values
180}
181
182/// The password of `scheme://user:password@host/…`, if any.
183fn url_password(url: &str) -> Option<String> {
184    let (_, rest) = url.split_once("://")?;
185    let authority = rest.split('/').next()?;
186    let (userinfo, _) = authority.rsplit_once('@')?;
187    let (_, password) = userinfo.split_once(':')?;
188    (!password.is_empty()).then(|| password.to_string())
189}
190
191fn mask(text: &str, secrets: &[String]) -> String {
192    secrets.iter().fold(text.to_string(), |text, secret| {
193        text.replace(secret.as_str(), "[redacted]")
194    })
195}
196
197fn redact(mut result: ConnectionCheck, secrets: &[String]) -> ConnectionCheck {
198    result.account = result.account.map(|value| mask(&value, secrets));
199    result.scope = result.scope.map(|value| mask(&value, secrets));
200    // Errors may quote a response body: also apply the generic secret rules.
201    result.error = result
202        .error
203        .map(|value| crate::core::redaction::redact_text(&mask(&value, secrets)));
204    result
205}
206
207fn check_now(provider: &str) -> ConnectionCheck {
208    let started = Instant::now();
209    let outcome = match provider {
210        "github" => github(),
211        "gitlab" => gitlab(),
212        "jira" => jira(),
213        "postgres" => postgres(),
214        other => Err((
215            FailureReason::Unsupported,
216            format!(
217                "{other} cannot be checked here; use one of {}",
218                CHECKABLE.join(", ")
219            ),
220        )),
221    };
222    let latency_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
223    match outcome {
224        Ok((account, scope)) => ConnectionCheck {
225            provider: provider.to_string(),
226            connected: true,
227            account: Some(account),
228            scope,
229            error: None,
230            reason: None,
231            latency_ms,
232        },
233        Err((reason, error)) => ConnectionCheck {
234            provider: provider.to_string(),
235            connected: false,
236            account: None,
237            scope: None,
238            error: Some(error),
239            reason: Some(reason),
240            latency_ms,
241        },
242    }
243}
244
245/// A failed check: its reason and its (unredacted) text.
246type Failure = (FailureReason, String);
247
248fn not_configured(error: String) -> Failure {
249    (FailureReason::NotConfigured, error)
250}
251
252fn by_message(error: String) -> Failure {
253    (FailureReason::of_message(&error), error)
254}
255
256/// The body of an HTTP answer, or the failure its outcome names.
257fn http_body(
258    outcome: super::hardened_http::HttpOutcome,
259    context: impl FnOnce(String) -> String,
260) -> Result<String, Failure> {
261    use super::hardened_http::HttpOutcome as O;
262    let reason = match &outcome {
263        O::Success { .. } => None,
264        O::Timeout { .. } => Some(FailureReason::Timeout),
265        O::NetworkError { .. } => Some(FailureReason::Unreachable),
266        O::HttpError { status, .. } => Some(FailureReason::of_status(*status)),
267    };
268    outcome
269        .into_body()
270        .map_err(|e| (reason.unwrap_or(FailureReason::Error), context(e)))
271}
272
273fn field(body: &str, keys: &[&str]) -> Option<String> {
274    let value: serde_json::Value = serde_json::from_str(body).ok()?;
275    keys.iter()
276        .find_map(|key| value.get(*key).and_then(serde_json::Value::as_str))
277        .map(str::to_string)
278}
279
280fn github() -> Result<(String, Option<String>), Failure> {
281    let config = GitHubConfig::from_env().map_err(not_configured)?;
282    let token = format!("Bearer {}", config.token);
283    let body = http_body(
284        super::hardened_http::provider_get_with_headers(
285            "github",
286            &format!("{}/user", config.api_base),
287            &[
288                ("Authorization", &token),
289                ("Accept", "application/vnd.github+json"),
290                ("X-GitHub-Api-Version", "2022-11-28"),
291            ],
292        ),
293        |e| format!("GitHub refused the token: {e}"),
294    )?;
295    let login = field(&body, &["login"]).unwrap_or_else(|| "signed in".to_string());
296    Ok((login, config.repo_slug()))
297}
298
299fn gitlab() -> Result<(String, Option<String>), Failure> {
300    let config = GitLabConfig::from_env().map_err(not_configured)?;
301    let body = http_body(
302        super::hardened_http::provider_get_with_headers(
303            "gitlab",
304            &config.api_url("/user"),
305            &[("PRIVATE-TOKEN", &config.token)],
306        ),
307        |e| format!("GitLab at {} refused the token: {e}", config.host),
308    )?;
309    let user = field(&body, &["username", "name"]).unwrap_or_else(|| "signed in".to_string());
310    Ok((user, config.project_path.clone()))
311}
312
313fn jira() -> Result<(String, Option<String>), Failure> {
314    let config = JiraConfig::from_env().map_err(not_configured)?;
315    let user = config.whoami().map_err(by_message)?;
316    Ok((user, config.project.clone()))
317}
318
319/// The check's `psql` call; the connection travels only in `PG*` variables.
320fn postgres_command() -> Result<std::process::Command, Failure> {
321    if !super::credentials::is_set("DATABASE_URL") && !super::credentials::is_set("PGDATABASE") {
322        return Err(not_configured("DATABASE_URL not set".to_string()));
323    }
324    let mut command = super::postgres::psql_command().map_err(not_configured)?;
325    command
326        .args([
327            "-X",
328            "-w",
329            "-t",
330            "-A",
331            "-c",
332            "SELECT current_user || '|' || current_database();",
333        ])
334        .env("PGCLIENTENCODING", "UTF8")
335        .stdin(std::process::Stdio::null());
336    Ok(command)
337}
338
339fn postgres() -> Result<(String, Option<String>), Failure> {
340    let mut command = postgres_command()?;
341    let out = super::postgres::run_psql(&mut command, std::time::Duration::from_secs(10))
342        .map_err(by_message)?;
343    let line = out.lines().next().unwrap_or("").trim();
344    let (user, database) = line.split_once('|').unwrap_or((line, ""));
345    Ok((
346        user.to_string(),
347        (!database.is_empty()).then(|| database.to_string()),
348    ))
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn unknown_sources_are_refused_without_a_request() {
357        let result = check("weather", CHECK_TIMEOUT);
358        assert!(!result.connected);
359        assert_eq!(result.reason, Some(FailureReason::Unsupported));
360        assert!(result.error.unwrap().contains("github"));
361    }
362
363    #[test]
364    fn failures_carry_a_machine_readable_reason() {
365        assert_eq!(FailureReason::of_status(401), FailureReason::Auth);
366        assert_eq!(FailureReason::of_status(429), FailureReason::RateLimited);
367        assert_eq!(FailureReason::of_status(503), FailureReason::Unavailable);
368        for (text, reason) in [
369            ("JIRA_URL not set", FailureReason::NotConfigured),
370            ("HTTP 401: Unauthorized", FailureReason::Auth),
371            (
372                "psql: FATAL: password authentication failed",
373                FailureReason::Auth,
374            ),
375            (
376                "psql: could not connect to server: Connection refused",
377                FailureReason::Unreachable,
378            ),
379            ("request timed out", FailureReason::Timeout),
380            ("something odd", FailureReason::Error),
381        ] {
382            assert_eq!(FailureReason::of_message(text), reason, "{text}");
383        }
384        let line = serde_json::to_string(&FailureReason::NotConfigured).unwrap();
385        assert_eq!(line, "\"not_configured\"");
386    }
387
388    #[test]
389    fn database_password_is_masked_in_every_reported_field() {
390        use super::super::credentials;
391        let _lock = crate::core::data_dir::test_env_lock();
392        let url = concat!("postgres://reader:", "Pw-Secret9", "@db.example/app");
393        credentials::set_candidates("postgres", &[("DATABASE_URL".into(), url.into())]).unwrap();
394        let secrets = secret_values();
395        credentials::clear_candidates();
396        let result = redact(
397            ConnectionCheck {
398                provider: "postgres".into(),
399                connected: false,
400                account: Some("reader:Pw-Secret9".into()),
401                scope: Some("app".into()),
402                error: Some(format!("cannot reach {url}: password Pw-Secret9 refused")),
403                reason: Some(FailureReason::Auth),
404                latency_ms: 1,
405            },
406            &secrets,
407        );
408        let line = serde_json::to_string(&result).unwrap();
409        assert!(!line.contains("Pw-Secret9"), "{line}");
410        assert_eq!(result.scope.as_deref(), Some("app"));
411    }
412
413    #[test]
414    fn psql_argv_never_carries_the_database_address() {
415        use super::super::credentials;
416        let _lock = crate::core::data_dir::test_env_lock();
417        let url = concat!(
418            "postgresql://reader:",
419            "Pw-Secret9%40x",
420            "@db.example:6543/app?sslmode=require"
421        );
422        credentials::set_candidates("postgres", &[("DATABASE_URL".into(), url.into())]).unwrap();
423        let command = postgres_command();
424        credentials::clear_candidates();
425        let command = command.unwrap();
426        for arg in command.get_args() {
427            let arg = arg.to_string_lossy();
428            assert!(
429                !arg.contains("Pw-Secret9") && !arg.contains("db.example"),
430                "{arg}"
431            );
432        }
433        let env: std::collections::BTreeMap<String, String> = command
434            .get_envs()
435            .filter_map(|(k, v)| {
436                Some((
437                    k.to_string_lossy().into_owned(),
438                    v?.to_string_lossy().into_owned(),
439                ))
440            })
441            .collect();
442        for (key, value) in [
443            ("PGPASSWORD", "Pw-Secret9@x"),
444            ("PGUSER", "reader"),
445            ("PGHOST", "db.example"),
446            ("PGPORT", "6543"),
447            ("PGDATABASE", "app"),
448            ("PGSSLMODE", "require"),
449        ] {
450            assert_eq!(env.get(key).map(String::as_str), Some(value), "{key}");
451        }
452    }
453
454    #[test]
455    fn account_fields_are_read_from_the_response() {
456        assert_eq!(
457            field(r#"{"login":"octo","name":"Octo"}"#, &["login"]).as_deref(),
458            Some("octo")
459        );
460        assert_eq!(
461            field(r#"{"name":"Ada"}"#, &["username", "name"]).as_deref(),
462            Some("Ada")
463        );
464        assert_eq!(field("not json", &["login"]), None);
465    }
466}