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}