Skip to main content

kasl/api/
kasl_server.rs

1//! kasl-server client: the team server this agent reports to.
2//!
3//! Unlike the other clients here, this one has no session to manage. The
4//! server authenticates an agent by a long-lived bearer token that an
5//! administrator issues once (ADR 0004 in kasl-server), so there is nothing
6//! to log into and nothing to cache - the token goes in the OS keyring and
7//! every request carries it.
8//!
9//! ```rust,no_run
10//! # use kasl::api::kasl_server::KaslServer;
11//! # use kasl::libs::config::KaslServerConfig;
12//! # async fn f() -> anyhow::Result<()> {
13//! let config = KaslServerConfig {
14//!     url: "https://kasl.example.com".to_string(),
15//!     ca_certificate: None,
16//! };
17//!
18//! let server = KaslServer::new(&config)?;
19//! let health = server.health().await?;
20//! println!("kasl-server {}", health.version);
21//! # Ok(())
22//! # }
23//! ```
24
25use crate::libs::config::KaslServerConfig;
26use anyhow::{Context, Result, bail};
27use reqwest::{Client, StatusCode};
28use serde::Deserialize;
29use std::fs;
30use std::time::Duration;
31
32/// Keyring credential holding the agent token.
33///
34/// Named like the other secrets so it shows up beside them in the platform's
35/// credential UI; the leading dot and `_secret` suffix are what
36/// [`Secret::new`](crate::libs::secret::Secret::new) trims into the account
37/// name.
38pub const AGENT_TOKEN_SECRET: &str = ".kasl_server_secret";
39
40/// Prompt shown when the agent token is missing from the keyring.
41pub const AGENT_TOKEN_PROMPT: &str = "Enter the agent token issued by your kasl-server administrator";
42
43/// How long to wait on a request before giving up.
44///
45/// Short on purpose: every call here is a foreground command the user is
46/// waiting on, and a self-hosted server that has not answered in this long is
47/// down rather than slow.
48const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
49
50/// What `GET /health` reports.
51#[derive(Debug, Clone, Deserialize)]
52pub struct Health {
53    /// `ok` when the server considers itself serviceable.
54    pub status: String,
55
56    /// The server's own version - the product version, which the web UI and
57    /// the API share.
58    pub version: String,
59
60    /// Whether the server reached its database on this request.
61    pub database: String,
62}
63
64/// The identity behind a token, as `GET /api/v1/agent/whoami` reports it.
65///
66/// Used to confirm a token belongs to whom the user expects: connecting with
67/// a colleague's token would otherwise succeed silently and file this
68/// machine's days under their name.
69#[derive(Debug, Clone, Deserialize)]
70pub struct AgentIdentity {
71    /// Display name of the employee the token reports for.
72    pub user_name: String,
73
74    /// The label the administrator gave this agent, typically the machine.
75    pub agent_name: String,
76
77    /// The API version the server serves this path under.
78    pub api_version: String,
79
80    /// The server's own version.
81    pub server_version: String,
82}
83
84/// A client bound to one kasl-server instance.
85#[derive(Debug, Clone)]
86pub struct KaslServer {
87    client: Client,
88
89    /// Base URL without a trailing slash, so paths append cleanly.
90    base_url: String,
91}
92
93impl KaslServer {
94    /// Builds a client for the configured server.
95    ///
96    /// A configured CA certificate is added to the trust store rather than
97    /// replacing it: a company CA for the server and public CAs for
98    /// everything else is the normal self-hosted arrangement.
99    pub fn new(config: &KaslServerConfig) -> Result<Self> {
100        let mut builder = Client::builder().timeout(REQUEST_TIMEOUT);
101
102        if let Some(path) = &config.ca_certificate {
103            let pem = fs::read(path).with_context(|| format!("cannot read the CA certificate at '{}'", path))?;
104
105            // `Certificate::from_pem` defers parsing to the TLS backend and
106            // accepts anything here - an empty file, a DER file saved with a
107            // .pem name, a text file. The failure then surfaces at the first
108            // request as an opaque TLS error, pointing at the network rather
109            // than at the file. Checked here, where the path is still in hand.
110            if !looks_like_pem_certificate(&pem) {
111                bail!(
112                    "'{}' does not contain a PEM-encoded certificate (expected a -----BEGIN CERTIFICATE----- block)",
113                    path
114                );
115            }
116
117            let certificate = reqwest::Certificate::from_pem(&pem).with_context(|| format!("'{}' is not a PEM-encoded certificate", path))?;
118            builder = builder.add_root_certificate(certificate);
119        }
120
121        Ok(Self {
122            client: builder.build().context("cannot build the HTTP client for kasl-server")?,
123            base_url: normalize_url(&config.url),
124        })
125    }
126
127    /// Asks the server whether it is serviceable, and which version it runs.
128    ///
129    /// Unauthenticated: this is the call that tells a misspelled URL from a
130    /// bad token, so it must not need the token to answer.
131    pub async fn health(&self) -> Result<Health> {
132        let url = format!("{}/health", self.base_url);
133        let response = self
134            .client
135            .get(&url)
136            .send()
137            .await
138            .with_context(|| format!("cannot reach kasl-server at {}", self.base_url))?;
139
140        let status = response.status();
141        if !status.is_success() {
142            bail!("{} answered {} instead of a health report", self.base_url, status);
143        }
144
145        // A URL that points at something else entirely - a proxy, a parked
146        // domain - answers 200 with a page. Insisting on the documented shape
147        // keeps that from reading as a healthy server.
148        response
149            .json::<Health>()
150            .await
151            .with_context(|| format!("{} answered, but not like a kasl-server", self.base_url))
152    }
153
154    /// Resolves the agent token to the person it reports for.
155    ///
156    /// Doubles as the token check: the server refuses an unknown, revoked, or
157    /// deactivated token with `401`, which is reported as such rather than as
158    /// a transport failure.
159    pub async fn identify(&self, token: &str) -> Result<AgentIdentity> {
160        let url = format!("{}/api/v1/agent/whoami", self.base_url);
161        let response = self
162            .client
163            .get(&url)
164            .bearer_auth(token)
165            .send()
166            .await
167            .with_context(|| format!("cannot reach kasl-server at {}", self.base_url))?;
168
169        match response.status() {
170            StatusCode::OK => response.json::<AgentIdentity>().await.context("cannot read the server's answer"),
171            StatusCode::UNAUTHORIZED => bail!("the server rejected this token - it may be mistyped, revoked, or issued for a deactivated account"),
172            status => bail!("the server answered {} when asked whose token this is", status),
173        }
174    }
175
176    /// The base URL this client talks to, as stored.
177    pub fn base_url(&self) -> &str {
178        &self.base_url
179    }
180}
181
182/// Trims a user-typed URL into the form the client stores.
183///
184/// Only the trailing slash is removed. Guessing a scheme is deliberately not
185/// done here: `http://` and `https://` differ by whether the token crosses
186/// the network in the clear, which is not a default worth inventing on the
187/// user's behalf.
188pub fn normalize_url(url: &str) -> String {
189    url.trim().trim_end_matches('/').to_string()
190}
191
192/// Whether a file's bytes carry a PEM certificate block.
193///
194/// A deliberately shallow check. It catches the mistakes people actually make
195/// (the wrong file, an empty file, a DER export named `.pem`) and leaves
196/// judging the certificate itself to the TLS backend, which is the only thing
197/// qualified to do so.
198fn looks_like_pem_certificate(pem: &[u8]) -> bool {
199    // Text search over bytes rather than a UTF-8 conversion: a PEM file is
200    // ASCII, but a binary file that is not valid UTF-8 should fail this check
201    // rather than fail to be examined.
202    pem.windows(BEGIN_CERTIFICATE.len()).any(|window| window == BEGIN_CERTIFICATE)
203}
204
205/// The header opening a PEM certificate block.
206const BEGIN_CERTIFICATE: &[u8] = b"-----BEGIN CERTIFICATE-----";
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn normalize_url_drops_a_trailing_slash() {
214        assert_eq!(normalize_url("https://kasl.example.com/"), "https://kasl.example.com");
215        assert_eq!(normalize_url("https://kasl.example.com"), "https://kasl.example.com");
216    }
217
218    #[test]
219    fn normalize_url_trims_surrounding_whitespace() {
220        // Pasting a URL from a chat message routinely brings a space along.
221        assert_eq!(normalize_url("  https://kasl.example.com/  "), "https://kasl.example.com");
222    }
223
224    #[test]
225    fn normalize_url_keeps_a_path_prefix() {
226        // A server behind a reverse proxy can live under a sub-path, and
227        // dropping it would send every request to the proxy's root.
228        assert_eq!(normalize_url("https://intranet.example.com/kasl/"), "https://intranet.example.com/kasl");
229    }
230
231    #[test]
232    fn a_client_is_built_without_a_certificate() {
233        let config = KaslServerConfig {
234            url: "https://kasl.example.com/".to_string(),
235            ca_certificate: None,
236        };
237
238        let server = KaslServer::new(&config).unwrap();
239        assert_eq!(server.base_url(), "https://kasl.example.com");
240    }
241
242    #[test]
243    fn a_missing_certificate_file_is_reported_by_path() {
244        let config = KaslServerConfig {
245            url: "https://kasl.example.com".to_string(),
246            ca_certificate: Some("/nonexistent/company-ca.pem".to_string()),
247        };
248
249        let error = KaslServer::new(&config).unwrap_err().to_string();
250        assert!(error.contains("company-ca.pem"), "the error should name the file: {}", error);
251    }
252
253    /// Writes `bytes` to a uniquely named file and hands back its path.
254    fn certificate_file(name: &str, bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf) {
255        let dir = std::env::temp_dir().join(format!("kasl-ca-test-{}-{}", std::process::id(), name));
256        fs::create_dir_all(&dir).unwrap();
257        let path = dir.join(format!("{name}.pem"));
258        fs::write(&path, bytes).unwrap();
259        (dir, path)
260    }
261
262    /// Every shape of "not a certificate" a person actually hands over.
263    ///
264    /// `reqwest::Certificate::from_pem` accepts all of these without
265    /// complaint - it defers parsing to the TLS backend - so each one used to
266    /// connect happily and fail later as an opaque TLS error.
267    #[test]
268    fn a_certificate_that_is_not_pem_is_refused() {
269        for (name, bytes) in [
270            ("plain-text", &b"this is not a certificate"[..]),
271            ("empty", &b""[..]),
272            ("wrong-pem-block", &b"-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n"[..]),
273            // A DER export saved with a .pem name: binary, and not valid UTF-8.
274            ("der-as-pem", &[0x30u8, 0x82, 0x01, 0x0a, 0xff, 0xfe][..]),
275        ] {
276            let (dir, path) = certificate_file(name, bytes);
277
278            let config = KaslServerConfig {
279                url: "https://kasl.example.com".to_string(),
280                ca_certificate: Some(path.to_string_lossy().into_owned()),
281            };
282
283            let error = match KaslServer::new(&config) {
284                Ok(_) => panic!("'{name}' should not have been accepted as a certificate"),
285                Err(error) => error.to_string(),
286            };
287            assert!(error.contains("PEM"), "the error for '{}' should say the file is not PEM: {}", name, error);
288            assert!(error.contains(name), "the error for '{}' should name the file: {}", name, error);
289
290            let _ = fs::remove_dir_all(&dir);
291        }
292    }
293
294    #[test]
295    fn a_real_certificate_block_is_accepted() {
296        // The counterpart to the test above: the check must not refuse the
297        // file it exists to let through. Body content is left to the TLS
298        // backend - what is asserted here is that a PEM block gets that far.
299        let (dir, path) = certificate_file("company-ca", b"-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKZ\n-----END CERTIFICATE-----\n");
300
301        let config = KaslServerConfig {
302            url: "https://kasl.example.com".to_string(),
303            ca_certificate: Some(path.to_string_lossy().into_owned()),
304        };
305
306        // Accepted by our check; whether the bytes decode is the backend's
307        // call, and either answer here means the shallow check let it through.
308        let _ = KaslServer::new(&config);
309
310        let _ = fs::remove_dir_all(&dir);
311    }
312}