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 chrono::{DateTime, FixedOffset, NaiveDate};
28use reqwest::{Client, StatusCode};
29use serde::{Deserialize, Serialize};
30use std::fs;
31use std::time::Duration;
32
33/// Keyring credential holding the agent token.
34///
35/// Named like the other secrets so it shows up beside them in the platform's
36/// credential UI; the leading dot and `_secret` suffix are what
37/// [`Secret::new`](crate::libs::secret::Secret::new) trims into the account
38/// name.
39pub const AGENT_TOKEN_SECRET: &str = ".kasl_server_secret";
40
41/// Prompt shown when the agent token is missing from the keyring.
42pub const AGENT_TOKEN_PROMPT: &str = "Enter the agent token issued by your kasl-server administrator";
43
44/// How long to wait on a request before giving up.
45///
46/// Short on purpose: every call here is a foreground command the user is
47/// waiting on, and a self-hosted server that has not answered in this long is
48/// down rather than slow.
49const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
50
51/// What `GET /health` reports.
52#[derive(Debug, Clone, Deserialize)]
53pub struct Health {
54 /// `ok` when the server considers itself serviceable.
55 pub status: String,
56
57 /// The server's own version - the product version, which the web UI and
58 /// the API share.
59 pub version: String,
60
61 /// Whether the server reached its database on this request.
62 pub database: String,
63}
64
65/// The identity behind a token, as `GET /api/v1/agent/whoami` reports it.
66///
67/// Used to confirm a token belongs to whom the user expects: connecting with
68/// a colleague's token would otherwise succeed silently and file this
69/// machine's days under their name.
70#[derive(Debug, Clone, Deserialize)]
71pub struct AgentIdentity {
72 /// Display name of the employee the token reports for.
73 pub user_name: String,
74
75 /// The label the administrator gave this agent, typically the machine.
76 pub agent_name: String,
77
78 /// The API version the server serves this path under.
79 pub api_version: String,
80
81 /// The server's own version.
82 pub server_version: String,
83}
84
85/// One day as this agent recorded it, in the shape the server accepts.
86///
87/// Field names and types mirror the ingest contract (ADR 0004 in
88/// kasl-server) rather than kasl's own model, so a change on either side
89/// shows up as a compile error here rather than as a `400` in the field.
90///
91/// Every instant carries a UTC offset. kasl stores bare wall-clock text,
92/// which is unambiguous on one laptop and meaningless across a team; the
93/// offset is attached when the day is assembled, and a day whose offset
94/// cannot be determined is not sent (ADR 0003).
95#[derive(Debug, Clone, Serialize)]
96pub struct DayUpload {
97 /// The employee's own calendar date, sent rather than derived: near
98 /// midnight the date of `started_at` and the date the work belongs to
99 /// disagree, and the agent is the side that knows which is meant.
100 pub date: NaiveDate,
101
102 /// When the day started.
103 pub started_at: DateTime<FixedOffset>,
104
105 /// When it ended; absent while the day is still open.
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub ended_at: Option<DateTime<FixedOffset>>,
108
109 pub pauses: Vec<PauseUpload>,
110
111 pub tasks: Vec<TaskUpload>,
112
113 /// Declares `tasks` to be everything this agent holds for the date, so a
114 /// task the employee deleted here is deleted there too (ADR 0005).
115 ///
116 /// kasl always sends the whole date, so this is always true. It is a
117 /// field rather than a constant because the server defaults it to false
118 /// for agents that predate it, and saying it explicitly is what
119 /// distinguishes "I have nothing more" from "I did not mention".
120 pub tasks_are_complete: bool,
121}
122
123/// One break, as the server takes it.
124#[derive(Debug, Clone, Serialize)]
125pub struct PauseUpload {
126 pub started_at: DateTime<FixedOffset>,
127
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub ended_at: Option<DateTime<FixedOffset>>,
130
131 /// Seconds. Sent explicitly because kasl merges neighbouring pauses
132 /// before reporting them, so this is not always `ended_at - started_at`.
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub duration_seconds: Option<i32>,
135
136 /// A break the employee entered by hand - kasl's `protected` flag.
137 pub manual: bool,
138
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub reason: Option<String>,
141}
142
143/// One task, keyed by the ids this agent knows it by.
144#[derive(Debug, Clone, Serialize)]
145pub struct TaskUpload {
146 /// This agent's row id: the key a re-upload matches on, so a corrected
147 /// task updates the stored row instead of piling up beside it.
148 pub agent_task_id: i32,
149
150 /// This agent's `task_id`, tying the same work across several days.
151 #[serde(skip_serializing_if = "Option::is_none")]
152 pub agent_group_id: Option<i32>,
153
154 pub recorded_at: DateTime<FixedOffset>,
155
156 pub name: String,
157
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub comment: Option<String>,
160
161 /// Percent complete, 0..=100.
162 pub completeness: i16,
163}
164
165/// What the server reports about a day it stored.
166#[derive(Debug, Clone, Deserialize)]
167pub struct DayAccepted {
168 /// The server's own id for the day, which this agent does not otherwise
169 /// know: worth printing so a day can be looked up on the other side.
170 pub workday_id: String,
171
172 pub date: NaiveDate,
173
174 pub pauses: usize,
175
176 pub tasks: usize,
177
178 /// Tasks the server dropped because this upload declared its set
179 /// authoritative. Non-zero means deletions here reached the server.
180 #[serde(default)]
181 pub deleted_tasks: u64,
182
183 /// The installation's privacy level, always reported - an agent should be
184 /// able to tell a server that keeps everything from one whose policy it
185 /// has not read (ADR 0011).
186 #[serde(default)]
187 pub privacy_level: Option<String>,
188}
189
190/// Why an upload failed, and whether sending the same bytes again could ever
191/// work.
192///
193/// The distinction is the server's own (ADR 0005) and it is the whole reason
194/// this is an enum rather than a message: `4xx` means the payload will never
195/// be accepted as sent, so a queue must stop asking; `5xx` and `429` mean the
196/// server could not answer this time, so it must keep the day and try later.
197#[derive(Debug)]
198pub enum UploadError {
199 /// The server refused the payload itself. Retrying is pointless.
200 Rejected { status: StatusCode, message: String },
201
202 /// The server could not answer, or answered that it was unavailable.
203 /// The day is still worth sending.
204 Retryable { message: String },
205}
206
207impl UploadError {
208 /// Whether sending this day again could succeed.
209 pub fn is_retryable(&self) -> bool {
210 matches!(self, UploadError::Retryable { .. })
211 }
212}
213
214impl std::fmt::Display for UploadError {
215 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 match self {
217 UploadError::Rejected { status, message } => write!(f, "the server refused the day ({}): {}", status, message),
218 UploadError::Retryable { message } => write!(f, "{}", message),
219 }
220 }
221}
222
223impl std::error::Error for UploadError {}
224
225/// A client bound to one kasl-server instance.
226#[derive(Debug, Clone)]
227pub struct KaslServer {
228 client: Client,
229
230 /// Base URL without a trailing slash, so paths append cleanly.
231 base_url: String,
232}
233
234impl KaslServer {
235 /// Builds a client for the configured server.
236 ///
237 /// A configured CA certificate is added to the trust store rather than
238 /// replacing it: a company CA for the server and public CAs for
239 /// everything else is the normal self-hosted arrangement.
240 pub fn new(config: &KaslServerConfig) -> Result<Self> {
241 let mut builder = Client::builder().timeout(REQUEST_TIMEOUT);
242
243 if let Some(path) = &config.ca_certificate {
244 let pem = fs::read(path).with_context(|| format!("cannot read the CA certificate at '{}'", path))?;
245
246 // `Certificate::from_pem` defers parsing to the TLS backend and
247 // accepts anything here - an empty file, a DER file saved with a
248 // .pem name, a text file. The failure then surfaces at the first
249 // request as an opaque TLS error, pointing at the network rather
250 // than at the file. Checked here, where the path is still in hand.
251 if !looks_like_pem_certificate(&pem) {
252 bail!(
253 "'{}' does not contain a PEM-encoded certificate (expected a -----BEGIN CERTIFICATE----- block)",
254 path
255 );
256 }
257
258 let certificate = reqwest::Certificate::from_pem(&pem).with_context(|| format!("'{}' is not a PEM-encoded certificate", path))?;
259 builder = builder.add_root_certificate(certificate);
260 }
261
262 Ok(Self {
263 client: builder.build().context("cannot build the HTTP client for kasl-server")?,
264 base_url: normalize_url(&config.url),
265 })
266 }
267
268 /// Asks the server whether it is serviceable, and which version it runs.
269 ///
270 /// Unauthenticated: this is the call that tells a misspelled URL from a
271 /// bad token, so it must not need the token to answer.
272 pub async fn health(&self) -> Result<Health> {
273 let url = format!("{}/health", self.base_url);
274 let response = self
275 .client
276 .get(&url)
277 .send()
278 .await
279 .with_context(|| format!("cannot reach kasl-server at {}", self.base_url))?;
280
281 let status = response.status();
282 if !status.is_success() {
283 bail!("{} answered {} instead of a health report", self.base_url, status);
284 }
285
286 // A URL that points at something else entirely - a proxy, a parked
287 // domain - answers 200 with a page. Insisting on the documented shape
288 // keeps that from reading as a healthy server.
289 response
290 .json::<Health>()
291 .await
292 .with_context(|| format!("{} answered, but not like a kasl-server", self.base_url))
293 }
294
295 /// Resolves the agent token to the person it reports for.
296 ///
297 /// Doubles as the token check: the server refuses an unknown, revoked, or
298 /// deactivated token with `401`, which is reported as such rather than as
299 /// a transport failure.
300 pub async fn identify(&self, token: &str) -> Result<AgentIdentity> {
301 let url = format!("{}/api/v1/agent/whoami", self.base_url);
302 let response = self
303 .client
304 .get(&url)
305 .bearer_auth(token)
306 .send()
307 .await
308 .with_context(|| format!("cannot reach kasl-server at {}", self.base_url))?;
309
310 match response.status() {
311 StatusCode::OK => response.json::<AgentIdentity>().await.context("cannot read the server's answer"),
312 StatusCode::UNAUTHORIZED => bail!("the server rejected this token - it may be mistyped, revoked, or issued for a deactivated account"),
313 status => bail!("the server answered {} when asked whose token this is", status),
314 }
315 }
316
317 /// Sends one day to `POST /api/v1/days`.
318 ///
319 /// The last upload wins on the server, so re-sending a day corrects it
320 /// and sending the same day twice changes nothing (ADR 0004). That makes
321 /// a retry safe by construction, which is what the failure split here is
322 /// for: [`UploadError`] separates a payload the server will never take
323 /// from a server that could not answer this time.
324 pub async fn upload_day(&self, token: &str, day: &DayUpload) -> Result<DayAccepted, UploadError> {
325 let url = format!("{}/api/v1/days", self.base_url);
326 let response = match self.client.post(&url).bearer_auth(token).json(day).send().await {
327 Ok(response) => response,
328 // Nothing was answered: DNS, TLS, a refused connection, a timeout.
329 // The day is untouched on the server and worth sending again.
330 Err(error) => {
331 return Err(UploadError::Retryable {
332 message: format!("cannot reach kasl-server at {}: {}", self.base_url, error),
333 });
334 }
335 };
336
337 let status = response.status();
338 if status.is_success() {
339 // A 2xx whose body is not a day report means the address answers
340 // for something other than this endpoint. Retrying a URL that is
341 // wrong would never come good, so it is a rejection.
342 return response.json::<DayAccepted>().await.map_err(|error| UploadError::Rejected {
343 status,
344 message: format!("the server accepted the day but answered unreadably: {}", error),
345 });
346 }
347
348 // Read the body before classifying: the server explains a refusal
349 // there ("tasks[0]: name is empty"), and a status alone would leave
350 // the user with nothing to fix.
351 let message = response.text().await.unwrap_or_default();
352 let message = describe_failure(status, &message);
353
354 // The server's own rule, not a guess: 4xx will not be accepted as
355 // sent; 5xx and 429 are worth repeating. 429 sits inside the 4xx
356 // range and is the one exception to it.
357 if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
358 Err(UploadError::Retryable { message })
359 } else {
360 Err(UploadError::Rejected { status, message })
361 }
362 }
363
364 /// The base URL this client talks to, as stored.
365 pub fn base_url(&self) -> &str {
366 &self.base_url
367 }
368}
369
370/// Trims a user-typed URL into the form the client stores.
371///
372/// Only the trailing slash is removed. Guessing a scheme is deliberately not
373/// done here: `http://` and `https://` differ by whether the token crosses
374/// the network in the clear, which is not a default worth inventing on the
375/// user's behalf.
376pub fn normalize_url(url: &str) -> String {
377 url.trim().trim_end_matches('/').to_string()
378}
379
380/// Extracts the sentence a person can act on from a failed response.
381///
382/// The server answers errors as JSON (`{"error": "..."}`), and showing that
383/// wrapper verbatim buries the sentence that matters. A body in any other
384/// shape is passed through as-is rather than dropped: an error from a proxy
385/// in front of the server is still the most informative thing available.
386///
387/// The status is deliberately *not* added here. It is already carried by the
388/// error and printed once when the failure is displayed, and some of the
389/// server's own messages open with it too - stamping it on again produced
390/// "the server refused the day (401 Unauthorized): 401 Unauthorized: the
391/// token is not recognized", which reads as three different problems.
392fn describe_failure(status: StatusCode, body: &str) -> String {
393 let body = body.trim();
394 if body.is_empty() {
395 return format!("the server gave no explanation ({})", status);
396 }
397
398 serde_json::from_str::<serde_json::Value>(body)
399 .ok()
400 .and_then(|value| value.get("error").and_then(|error| error.as_str()).map(str::to_string))
401 .unwrap_or_else(|| body.chars().take(300).collect())
402}
403
404/// Whether a file's bytes carry a PEM certificate block.
405///
406/// A deliberately shallow check. It catches the mistakes people actually make
407/// (the wrong file, an empty file, a DER export named `.pem`) and leaves
408/// judging the certificate itself to the TLS backend, which is the only thing
409/// qualified to do so.
410fn looks_like_pem_certificate(pem: &[u8]) -> bool {
411 // Text search over bytes rather than a UTF-8 conversion: a PEM file is
412 // ASCII, but a binary file that is not valid UTF-8 should fail this check
413 // rather than fail to be examined.
414 pem.windows(BEGIN_CERTIFICATE.len()).any(|window| window == BEGIN_CERTIFICATE)
415}
416
417/// The header opening a PEM certificate block.
418const BEGIN_CERTIFICATE: &[u8] = b"-----BEGIN CERTIFICATE-----";
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn normalize_url_drops_a_trailing_slash() {
426 assert_eq!(normalize_url("https://kasl.example.com/"), "https://kasl.example.com");
427 assert_eq!(normalize_url("https://kasl.example.com"), "https://kasl.example.com");
428 }
429
430 #[test]
431 fn normalize_url_trims_surrounding_whitespace() {
432 // Pasting a URL from a chat message routinely brings a space along.
433 assert_eq!(normalize_url(" https://kasl.example.com/ "), "https://kasl.example.com");
434 }
435
436 #[test]
437 fn normalize_url_keeps_a_path_prefix() {
438 // A server behind a reverse proxy can live under a sub-path, and
439 // dropping it would send every request to the proxy's root.
440 assert_eq!(normalize_url("https://intranet.example.com/kasl/"), "https://intranet.example.com/kasl");
441 }
442
443 #[test]
444 fn a_client_is_built_without_a_certificate() {
445 let config = KaslServerConfig {
446 url: "https://kasl.example.com/".to_string(),
447 ca_certificate: None,
448 };
449
450 let server = KaslServer::new(&config).unwrap();
451 assert_eq!(server.base_url(), "https://kasl.example.com");
452 }
453
454 #[test]
455 fn a_missing_certificate_file_is_reported_by_path() {
456 let config = KaslServerConfig {
457 url: "https://kasl.example.com".to_string(),
458 ca_certificate: Some("/nonexistent/company-ca.pem".to_string()),
459 };
460
461 let error = KaslServer::new(&config).unwrap_err().to_string();
462 assert!(error.contains("company-ca.pem"), "the error should name the file: {}", error);
463 }
464
465 /// Writes `bytes` to a uniquely named file and hands back its path.
466 fn certificate_file(name: &str, bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf) {
467 let dir = std::env::temp_dir().join(format!("kasl-ca-test-{}-{}", std::process::id(), name));
468 fs::create_dir_all(&dir).unwrap();
469 let path = dir.join(format!("{name}.pem"));
470 fs::write(&path, bytes).unwrap();
471 (dir, path)
472 }
473
474 /// Every shape of "not a certificate" a person actually hands over.
475 ///
476 /// `reqwest::Certificate::from_pem` accepts all of these without
477 /// complaint - it defers parsing to the TLS backend - so each one used to
478 /// connect happily and fail later as an opaque TLS error.
479 #[test]
480 fn a_certificate_that_is_not_pem_is_refused() {
481 for (name, bytes) in [
482 ("plain-text", &b"this is not a certificate"[..]),
483 ("empty", &b""[..]),
484 ("wrong-pem-block", &b"-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n"[..]),
485 // A DER export saved with a .pem name: binary, and not valid UTF-8.
486 ("der-as-pem", &[0x30u8, 0x82, 0x01, 0x0a, 0xff, 0xfe][..]),
487 ] {
488 let (dir, path) = certificate_file(name, bytes);
489
490 let config = KaslServerConfig {
491 url: "https://kasl.example.com".to_string(),
492 ca_certificate: Some(path.to_string_lossy().into_owned()),
493 };
494
495 let error = match KaslServer::new(&config) {
496 Ok(_) => panic!("'{name}' should not have been accepted as a certificate"),
497 Err(error) => error.to_string(),
498 };
499 assert!(error.contains("PEM"), "the error for '{}' should say the file is not PEM: {}", name, error);
500 assert!(error.contains(name), "the error for '{}' should name the file: {}", name, error);
501
502 let _ = fs::remove_dir_all(&dir);
503 }
504 }
505
506 #[test]
507 fn a_real_certificate_block_is_accepted() {
508 // The counterpart to the test above: the check must not refuse the
509 // file it exists to let through. Body content is left to the TLS
510 // backend - what is asserted here is that a PEM block gets that far.
511 let (dir, path) = certificate_file("company-ca", b"-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKZ\n-----END CERTIFICATE-----\n");
512
513 let config = KaslServerConfig {
514 url: "https://kasl.example.com".to_string(),
515 ca_certificate: Some(path.to_string_lossy().into_owned()),
516 };
517
518 // Accepted by our check; whether the bytes decode is the backend's
519 // call, and either answer here means the shallow check let it through.
520 let _ = KaslServer::new(&config);
521
522 let _ = fs::remove_dir_all(&dir);
523 }
524}