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