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/// A stretch of days in one request - what an agent sends after time offline.
191#[derive(Debug, Clone, Serialize)]
192pub struct BatchUpload {
193 pub days: Vec<DayUpload>,
194}
195
196/// What came of a batch.
197///
198/// The counts are read first because the status will not tell: a batch
199/// answers `200` even when days inside it were refused (ADR 0005). A client
200/// that checks only the status believes a rejected day arrived.
201#[derive(Debug, Clone, Deserialize)]
202pub struct BatchResult {
203 pub accepted: usize,
204
205 pub rejected: usize,
206
207 /// One entry per day sent, in the order they were sent.
208 #[serde(default)]
209 pub results: Vec<DayResult>,
210}
211
212/// One day's fate inside a batch.
213///
214/// Tagged by `status` to match what the server serializes. An unrecognized
215/// tag is a contract the two sides no longer share, and is surfaced rather
216/// than silently treated as either outcome.
217#[derive(Debug, Clone, Deserialize)]
218#[serde(tag = "status", rename_all = "lowercase")]
219pub enum DayResult {
220 Accepted {
221 #[serde(flatten)]
222 day: DayAccepted,
223 },
224 Rejected {
225 date: NaiveDate,
226 error: String,
227 },
228}
229
230/// Why an upload failed, and whether sending the same bytes again could ever
231/// work.
232///
233/// The distinction is the server's own (ADR 0005) and it is the whole reason
234/// this is an enum rather than a message: `4xx` means the payload will never
235/// be accepted as sent, so a queue must stop asking; `5xx` and `429` mean the
236/// server could not answer this time, so it must keep the day and try later.
237#[derive(Debug)]
238pub enum UploadError {
239 /// The server refused the payload itself. Retrying is pointless.
240 Rejected { status: StatusCode, message: String },
241
242 /// The server could not answer, or answered that it was unavailable.
243 /// The day is still worth sending.
244 Retryable { message: String },
245}
246
247impl UploadError {
248 /// Whether sending this day again could succeed.
249 pub fn is_retryable(&self) -> bool {
250 matches!(self, UploadError::Retryable { .. })
251 }
252}
253
254impl std::fmt::Display for UploadError {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 match self {
257 UploadError::Rejected { status, message } => write!(f, "the server refused the day ({}): {}", status, message),
258 UploadError::Retryable { message } => write!(f, "{}", message),
259 }
260 }
261}
262
263impl std::error::Error for UploadError {}
264
265/// A client bound to one kasl-server instance.
266#[derive(Debug, Clone)]
267pub struct KaslServer {
268 client: Client,
269
270 /// Base URL without a trailing slash, so paths append cleanly.
271 base_url: String,
272}
273
274impl KaslServer {
275 /// Builds a client for the configured server.
276 ///
277 /// A configured CA certificate is added to the trust store rather than
278 /// replacing it: a company CA for the server and public CAs for
279 /// everything else is the normal self-hosted arrangement.
280 pub fn new(config: &KaslServerConfig) -> Result<Self> {
281 let mut builder = Client::builder().timeout(REQUEST_TIMEOUT);
282
283 if let Some(path) = &config.ca_certificate {
284 let pem = fs::read(path).with_context(|| format!("cannot read the CA certificate at '{}'", path))?;
285
286 // `Certificate::from_pem` defers parsing to the TLS backend and
287 // accepts anything here - an empty file, a DER file saved with a
288 // .pem name, a text file. The failure then surfaces at the first
289 // request as an opaque TLS error, pointing at the network rather
290 // than at the file. Checked here, where the path is still in hand.
291 if !looks_like_pem_certificate(&pem) {
292 bail!(
293 "'{}' does not contain a PEM-encoded certificate (expected a -----BEGIN CERTIFICATE----- block)",
294 path
295 );
296 }
297
298 let certificate = reqwest::Certificate::from_pem(&pem).with_context(|| format!("'{}' is not a PEM-encoded certificate", path))?;
299 builder = builder.add_root_certificate(certificate);
300 }
301
302 Ok(Self {
303 client: builder.build().context("cannot build the HTTP client for kasl-server")?,
304 base_url: normalize_url(&config.url),
305 })
306 }
307
308 /// Asks the server whether it is serviceable, and which version it runs.
309 ///
310 /// Unauthenticated: this is the call that tells a misspelled URL from a
311 /// bad token, so it must not need the token to answer.
312 pub async fn health(&self) -> Result<Health> {
313 let url = format!("{}/health", self.base_url);
314 let response = self
315 .client
316 .get(&url)
317 .send()
318 .await
319 .with_context(|| format!("cannot reach kasl-server at {}", self.base_url))?;
320
321 let status = response.status();
322 if !status.is_success() {
323 bail!("{} answered {} instead of a health report", self.base_url, status);
324 }
325
326 // A URL that points at something else entirely - a proxy, a parked
327 // domain - answers 200 with a page. Insisting on the documented shape
328 // keeps that from reading as a healthy server.
329 response
330 .json::<Health>()
331 .await
332 .with_context(|| format!("{} answered, but not like a kasl-server", self.base_url))
333 }
334
335 /// Resolves the agent token to the person it reports for.
336 ///
337 /// Doubles as the token check: the server refuses an unknown, revoked, or
338 /// deactivated token with `401`, which is reported as such rather than as
339 /// a transport failure.
340 pub async fn identify(&self, token: &str) -> Result<AgentIdentity> {
341 let url = format!("{}/api/v1/agent/whoami", self.base_url);
342 let response = self
343 .client
344 .get(&url)
345 .bearer_auth(token)
346 .send()
347 .await
348 .with_context(|| format!("cannot reach kasl-server at {}", self.base_url))?;
349
350 match response.status() {
351 StatusCode::OK => response.json::<AgentIdentity>().await.context("cannot read the server's answer"),
352 StatusCode::UNAUTHORIZED => bail!("the server rejected this token - it may be mistyped, revoked, or issued for a deactivated account"),
353 status => bail!("the server answered {} when asked whose token this is", status),
354 }
355 }
356
357 /// Sends one day to `POST /api/v1/days`.
358 ///
359 /// The last upload wins on the server, so re-sending a day corrects it
360 /// and sending the same day twice changes nothing (ADR 0004). That makes
361 /// a retry safe by construction, which is what the failure split here is
362 /// for: [`UploadError`] separates a payload the server will never take
363 /// from a server that could not answer this time.
364 pub async fn upload_day(&self, token: &str, day: &DayUpload) -> Result<DayAccepted, UploadError> {
365 let url = format!("{}/api/v1/days", self.base_url);
366 let response = match self.client.post(&url).bearer_auth(token).json(day).send().await {
367 Ok(response) => response,
368 // Nothing was answered: DNS, TLS, a refused connection, a timeout.
369 // The day is untouched on the server and worth sending again.
370 Err(error) => {
371 return Err(UploadError::Retryable {
372 message: format!("cannot reach kasl-server at {}: {}", self.base_url, error),
373 });
374 }
375 };
376
377 let status = response.status();
378 if status.is_success() {
379 // A 2xx whose body is not a day report means the address answers
380 // for something other than this endpoint. Retrying a URL that is
381 // wrong would never come good, so it is a rejection.
382 return response.json::<DayAccepted>().await.map_err(|error| UploadError::Rejected {
383 status,
384 message: format!("the server accepted the day but answered unreadably: {}", error),
385 });
386 }
387
388 // Read the body before classifying: the server explains a refusal
389 // there ("tasks[0]: name is empty"), and a status alone would leave
390 // the user with nothing to fix.
391 let message = response.text().await.unwrap_or_default();
392 Err(classify_failure(status, describe_failure(status, &message)))
393 }
394
395 /// Sends several days to `POST /api/v1/days/batch`.
396 ///
397 /// The reason a backlog goes this way rather than as a loop of single
398 /// uploads is not the round trips - it is that the agent learns about the
399 /// run as a whole. Each day is written in its own transaction there, so
400 /// one day the server will never accept does not hold back the rest
401 /// (ADR 0005).
402 ///
403 /// The error here covers the *request*: a batch that never arrived, or a
404 /// server that refused the whole shape of it. The fate of the days inside
405 /// a batch that did arrive is in [`BatchResult`], and has to be read per
406 /// day - a `200` here means the request was processed, not that every day
407 /// in it was stored.
408 pub async fn upload_batch(&self, token: &str, days: &[DayUpload]) -> Result<BatchResult, UploadError> {
409 let url = format!("{}/api/v1/days/batch", self.base_url);
410 let batch = BatchUpload { days: days.to_vec() };
411
412 let response = match self.client.post(&url).bearer_auth(token).json(&batch).send().await {
413 Ok(response) => response,
414 Err(error) => {
415 return Err(UploadError::Retryable {
416 message: format!("cannot reach kasl-server at {}: {}", self.base_url, error),
417 });
418 }
419 };
420
421 let status = response.status();
422 if status.is_success() {
423 return response.json::<BatchResult>().await.map_err(|error| UploadError::Rejected {
424 status,
425 message: format!("the server accepted the batch but answered unreadably: {}", error),
426 });
427 }
428
429 // A batch too large for the server to take is refused whole with 413.
430 // It is a rejection of this request, not of the days: the caller
431 // splits the backlog and the same days go again in smaller groups.
432 let message = response.text().await.unwrap_or_default();
433 Err(classify_failure(status, describe_failure(status, &message)))
434 }
435
436 /// The base URL this client talks to, as stored.
437 pub fn base_url(&self) -> &str {
438 &self.base_url
439 }
440}
441
442/// Sorts a failed status into "never going to work" and "try later".
443///
444/// The server's own rule, not a guess (ADR 0005): `4xx` will not be accepted
445/// as sent, `5xx` and `429` are worth repeating. `429` sits inside the `4xx`
446/// range and is the single exception to it.
447///
448/// Shared by both upload paths so the single day and the batch can never
449/// drift into disagreeing about which failures are worth a retry.
450fn classify_failure(status: StatusCode, message: String) -> UploadError {
451 if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
452 UploadError::Retryable { message }
453 } else {
454 UploadError::Rejected { status, message }
455 }
456}
457
458/// Trims a user-typed URL into the form the client stores.
459///
460/// Only the trailing slash is removed. Guessing a scheme is deliberately not
461/// done here: `http://` and `https://` differ by whether the token crosses
462/// the network in the clear, which is not a default worth inventing on the
463/// user's behalf.
464pub fn normalize_url(url: &str) -> String {
465 url.trim().trim_end_matches('/').to_string()
466}
467
468/// Extracts the sentence a person can act on from a failed response.
469///
470/// The server answers errors as JSON (`{"error": "..."}`), and showing that
471/// wrapper verbatim buries the sentence that matters. A body in any other
472/// shape is passed through as-is rather than dropped: an error from a proxy
473/// in front of the server is still the most informative thing available.
474///
475/// The status is deliberately *not* added here. It is already carried by the
476/// error and printed once when the failure is displayed, and some of the
477/// server's own messages open with it too - stamping it on again produced
478/// "the server refused the day (401 Unauthorized): 401 Unauthorized: the
479/// token is not recognized", which reads as three different problems.
480fn describe_failure(status: StatusCode, body: &str) -> String {
481 let body = body.trim();
482 if body.is_empty() {
483 return format!("the server gave no explanation ({})", status);
484 }
485
486 serde_json::from_str::<serde_json::Value>(body)
487 .ok()
488 .and_then(|value| value.get("error").and_then(|error| error.as_str()).map(str::to_string))
489 .unwrap_or_else(|| body.chars().take(300).collect())
490}
491
492/// Whether a file's bytes carry a PEM certificate block.
493///
494/// A deliberately shallow check. It catches the mistakes people actually make
495/// (the wrong file, an empty file, a DER export named `.pem`) and leaves
496/// judging the certificate itself to the TLS backend, which is the only thing
497/// qualified to do so.
498fn looks_like_pem_certificate(pem: &[u8]) -> bool {
499 // Text search over bytes rather than a UTF-8 conversion: a PEM file is
500 // ASCII, but a binary file that is not valid UTF-8 should fail this check
501 // rather than fail to be examined.
502 pem.windows(BEGIN_CERTIFICATE.len()).any(|window| window == BEGIN_CERTIFICATE)
503}
504
505/// The header opening a PEM certificate block.
506const BEGIN_CERTIFICATE: &[u8] = b"-----BEGIN CERTIFICATE-----";
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn normalize_url_drops_a_trailing_slash() {
514 assert_eq!(normalize_url("https://kasl.example.com/"), "https://kasl.example.com");
515 assert_eq!(normalize_url("https://kasl.example.com"), "https://kasl.example.com");
516 }
517
518 #[test]
519 fn normalize_url_trims_surrounding_whitespace() {
520 // Pasting a URL from a chat message routinely brings a space along.
521 assert_eq!(normalize_url(" https://kasl.example.com/ "), "https://kasl.example.com");
522 }
523
524 #[test]
525 fn normalize_url_keeps_a_path_prefix() {
526 // A server behind a reverse proxy can live under a sub-path, and
527 // dropping it would send every request to the proxy's root.
528 assert_eq!(normalize_url("https://intranet.example.com/kasl/"), "https://intranet.example.com/kasl");
529 }
530
531 #[test]
532 fn a_client_is_built_without_a_certificate() {
533 let config = KaslServerConfig {
534 url: "https://kasl.example.com/".to_string(),
535 ca_certificate: None,
536 };
537
538 let server = KaslServer::new(&config).unwrap();
539 assert_eq!(server.base_url(), "https://kasl.example.com");
540 }
541
542 #[test]
543 fn a_missing_certificate_file_is_reported_by_path() {
544 let config = KaslServerConfig {
545 url: "https://kasl.example.com".to_string(),
546 ca_certificate: Some("/nonexistent/company-ca.pem".to_string()),
547 };
548
549 let error = KaslServer::new(&config).unwrap_err().to_string();
550 assert!(error.contains("company-ca.pem"), "the error should name the file: {}", error);
551 }
552
553 /// Writes `bytes` to a uniquely named file and hands back its path.
554 fn certificate_file(name: &str, bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf) {
555 let dir = std::env::temp_dir().join(format!("kasl-ca-test-{}-{}", std::process::id(), name));
556 fs::create_dir_all(&dir).unwrap();
557 let path = dir.join(format!("{name}.pem"));
558 fs::write(&path, bytes).unwrap();
559 (dir, path)
560 }
561
562 /// Every shape of "not a certificate" a person actually hands over.
563 ///
564 /// `reqwest::Certificate::from_pem` accepts all of these without
565 /// complaint - it defers parsing to the TLS backend - so each one used to
566 /// connect happily and fail later as an opaque TLS error.
567 #[test]
568 fn a_certificate_that_is_not_pem_is_refused() {
569 for (name, bytes) in [
570 ("plain-text", &b"this is not a certificate"[..]),
571 ("empty", &b""[..]),
572 ("wrong-pem-block", &b"-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n"[..]),
573 // A DER export saved with a .pem name: binary, and not valid UTF-8.
574 ("der-as-pem", &[0x30u8, 0x82, 0x01, 0x0a, 0xff, 0xfe][..]),
575 ] {
576 let (dir, path) = certificate_file(name, bytes);
577
578 let config = KaslServerConfig {
579 url: "https://kasl.example.com".to_string(),
580 ca_certificate: Some(path.to_string_lossy().into_owned()),
581 };
582
583 let error = match KaslServer::new(&config) {
584 Ok(_) => panic!("'{name}' should not have been accepted as a certificate"),
585 Err(error) => error.to_string(),
586 };
587 assert!(error.contains("PEM"), "the error for '{}' should say the file is not PEM: {}", name, error);
588 assert!(error.contains(name), "the error for '{}' should name the file: {}", name, error);
589
590 let _ = fs::remove_dir_all(&dir);
591 }
592 }
593
594 #[test]
595 fn a_real_certificate_block_is_accepted() {
596 // The counterpart to the test above: the check must not refuse the
597 // file it exists to let through. Body content is left to the TLS
598 // backend - what is asserted here is that a PEM block gets that far.
599 let (dir, path) = certificate_file("company-ca", b"-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKZ\n-----END CERTIFICATE-----\n");
600
601 let config = KaslServerConfig {
602 url: "https://kasl.example.com".to_string(),
603 ca_certificate: Some(path.to_string_lossy().into_owned()),
604 };
605
606 // Accepted by our check; whether the bytes decode is the backend's
607 // call, and either answer here means the shallow check let it through.
608 let _ = KaslServer::new(&config);
609
610 let _ = fs::remove_dir_all(&dir);
611 }
612}