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 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}