Skip to main content

kasl/commands/
server.rs

1//! `kasl server`: connect this machine to a kasl-server, and see where it
2//! stands.
3//!
4//! The server is the team's, not this machine's: an administrator issues an
5//! agent token and hands it over, and connecting is pasting it here (ADR 0004
6//! in kasl-server - there is no open enrolment to abuse). What this command
7//! owns is everything around that: checking the server is really a
8//! kasl-server before storing anything, putting the token in the OS keyring
9//! rather than in a readable config file, and saying whose token it is out
10//! loud, while a person is watching.
11//!
12//! That last check is the one worth spelling out. A token is an opaque string;
13//! one pasted from the wrong chat window works perfectly and files this
14//! machine's days under a colleague's name. The server is asked who it thinks
15//! is connecting, and the answer is printed.
16
17use crate::api::kasl_server::{AGENT_TOKEN_PROMPT, AGENT_TOKEN_SECRET, KaslServer, UploadError, normalize_url};
18use crate::db::server_outbox::ServerOutbox;
19use crate::db::workdays::Workdays;
20use crate::libs::config::{Config, KaslServerConfig};
21use crate::libs::day_delivery::{Delivered, deliver, record_single};
22use crate::libs::day_upload::build_day_upload;
23use crate::libs::messages::Message;
24use crate::libs::secret::Secret;
25use crate::{msg_error_anyhow, msg_info, msg_print, msg_success, msg_warning};
26use anyhow::{Context, Result};
27use chrono::{Duration, Local, NaiveDate};
28use clap::{Args, Subcommand};
29use dialoguer::{Input, Password, theme::ColorfulTheme};
30use reqwest::StatusCode;
31
32/// Command-line arguments for the server command.
33#[derive(Debug, Args)]
34pub struct ServerArgs {
35    #[command(subcommand)]
36    command: ServerCommand,
37}
38
39/// Available server operations.
40#[derive(Debug, Subcommand)]
41enum ServerCommand {
42    /// Connect this machine to a kasl-server
43    #[command(about = "Connect this machine to a kasl-server")]
44    Connect(ConnectArgs),
45
46    /// Show the current connection
47    #[command(about = "Show the current connection to a kasl-server")]
48    Status,
49
50    /// Send a day to the server
51    #[command(about = "Send a day's work to the connected kasl-server")]
52    Push(PushArgs),
53
54    /// Send everything that is still owed
55    #[command(about = "Send every day still waiting to reach the server")]
56    Flush,
57
58    /// Show what is still waiting to be sent
59    #[command(about = "Show the days still waiting to reach the server")]
60    Queue,
61
62    /// Show what this server keeps about you
63    #[command(about = "Show what the connected kasl-server stores about you")]
64    Manifest,
65
66    /// Queue a stretch of past days
67    #[command(about = "Queue every recorded day in a date range and send them")]
68    Backfill(BackfillArgs),
69
70    /// Forget the connection and the stored token
71    #[command(about = "Forget the connection and the stored agent token")]
72    Disconnect,
73}
74
75/// Arguments accepted by `kasl server backfill`.
76#[derive(Debug, Args)]
77pub struct BackfillArgs {
78    /// First date of the range, YYYY-MM-DD; without it, the whole history
79    #[arg(long, value_name = "YYYY-MM-DD")]
80    from: Option<NaiveDate>,
81
82    /// Last date of the range, YYYY-MM-DD; defaults to today
83    #[arg(long, value_name = "YYYY-MM-DD")]
84    to: Option<NaiveDate>,
85}
86
87/// Arguments accepted by `kasl server push`.
88#[derive(Debug, Args)]
89pub struct PushArgs {
90    /// Send yesterday instead of today
91    #[arg(long, short, help = "Send the last day instead of today")]
92    last: bool,
93
94    /// Send a specific date, YYYY-MM-DD
95    #[arg(long, value_name = "YYYY-MM-DD", conflicts_with = "last")]
96    date: Option<NaiveDate>,
97}
98
99/// Arguments accepted by `kasl server connect`.
100#[derive(Debug, Args)]
101pub struct ConnectArgs {
102    /// Server URL, e.g. https://kasl.example.com; prompted for when omitted
103    #[arg(long, value_name = "URL")]
104    url: Option<String>,
105
106    /// PEM file with the CA that signed the server's certificate
107    #[arg(long, value_name = "PATH")]
108    ca_certificate: Option<String>,
109}
110
111/// Routes a server subcommand.
112pub async fn cmd(args: ServerArgs) -> Result<()> {
113    match args.command {
114        ServerCommand::Connect(args) => connect(args).await,
115        ServerCommand::Status => status().await,
116        ServerCommand::Push(args) => push(args).await,
117        ServerCommand::Flush => flush().await,
118        ServerCommand::Queue => queue(),
119        ServerCommand::Manifest => manifest().await,
120        ServerCommand::Backfill(args) => backfill(args).await,
121        ServerCommand::Disconnect => disconnect(),
122    }
123}
124
125/// Walks through connecting: URL, token, and two checks against the server.
126///
127/// Nothing is written until both checks pass. A half-written connection - a
128/// URL saved with a token the server never accepted - would leave the agent
129/// looking configured while every upload failed.
130async fn connect(args: ConnectArgs) -> Result<()> {
131    // The token is always typed at a prompt - never taken from an argument,
132    // where it would land in shell history - so this command cannot finish
133    // without a terminal whatever else it was given. Checked before anything
134    // else so a run that cannot succeed fails immediately, rather than after
135    // reaching the network and reporting a server it is about to walk away
136    // from.
137    crate::libs::prompt::ensure_interactive("`kasl server connect` needs a terminal to ask for the agent token")?;
138
139    let mut config = Config::read().unwrap_or_default();
140
141    let url = match args.url {
142        Some(url) => normalize_url(&url),
143        None => {
144            let entered: String = Input::with_theme(&ColorfulTheme::default())
145                .with_prompt(Message::PromptKaslServerUrl.to_string())
146                .with_initial_text(config.kasl_server.as_ref().map(|s| s.url.clone()).unwrap_or_default())
147                .interact_text()?;
148            normalize_url(&entered)
149        }
150    };
151
152    // A URL without a scheme reaches nothing and the failure reads like the
153    // server is down, so it is caught here where the cause is still visible.
154    if !url.starts_with("http://") && !url.starts_with("https://") {
155        return Err(msg_error_anyhow!(Message::KaslServerUrlNeedsScheme(url)));
156    }
157
158    let candidate = KaslServerConfig {
159        url: url.clone(),
160        // A certificate named now wins; otherwise an existing one is kept, so
161        // reconnecting to the same server does not silently drop it.
162        ca_certificate: args
163            .ca_certificate
164            .or_else(|| config.kasl_server.as_ref().and_then(|s| s.ca_certificate.clone())),
165    };
166
167    let client = KaslServer::new(&candidate)?;
168
169    // First check: is this a kasl-server at all? Asked before the token, so a
170    // mistyped URL is not reported as a rejected token.
171    let health = client.health().await?;
172    msg_info!(Message::KaslServerReached {
173        url: url.clone(),
174        version: health.version.clone(),
175    });
176    if health.database != "ok" {
177        // Serviceable enough to answer, not enough to accept a day. Worth
178        // saying now rather than at the first upload.
179        msg_warning!(Message::KaslServerDatabaseUnhealthy(health.database.clone()));
180    }
181
182    let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
183    let token: String = Password::with_theme(&ColorfulTheme::default())
184        .with_prompt(Message::PromptKaslServerToken.to_string())
185        .interact()?;
186    let token = token.trim().to_string();
187    if token.is_empty() {
188        return Err(msg_error_anyhow!(Message::KaslServerTokenEmpty));
189    }
190
191    // Second check: the server accepts this token, and says whose it is.
192    let identity = client.identify(&token).await?;
193
194    // Both checks passed - only now is anything persisted.
195    secret
196        .store(&token)
197        .context("the token was accepted but could not be stored in the OS keyring")?;
198    config.kasl_server = Some(candidate);
199    config.save()?;
200
201    msg_success!(Message::KaslServerConnected {
202        user_name: identity.user_name,
203        agent_name: identity.agent_name,
204    });
205    Ok(())
206}
207
208/// Reports the stored connection, and whether it still works.
209///
210/// Reaches the server rather than reading the config back: a connection that
211/// was valid when it was made and is not any more - a revoked token, a server
212/// that moved - is exactly what someone runs this to find out.
213async fn status() -> Result<()> {
214    let config = Config::read().unwrap_or_default();
215    let Some(server_config) = config.kasl_server else {
216        msg_print!(Message::KaslServerNotConnected);
217        return Ok(());
218    };
219
220    msg_info!(Message::KaslServerConfigured(server_config.url.clone()));
221
222    let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
223    let Some(token) = secret.try_get_cached() else {
224        // The config says connected and the keyring disagrees: reconnecting is
225        // the fix, and saying so beats a 401 at the next upload.
226        msg_warning!(Message::KaslServerTokenMissing);
227        return Ok(());
228    };
229
230    let client = KaslServer::new(&server_config)?;
231
232    match client.health().await {
233        Ok(health) => msg_info!(Message::KaslServerReached {
234            url: server_config.url.clone(),
235            version: health.version,
236        }),
237        Err(error) => {
238            msg_warning!(Message::KaslServerUnreachable(error.to_string()));
239            return Ok(());
240        }
241    }
242
243    match client.identify(&token).await {
244        Ok(identity) => {
245            msg_success!(Message::KaslServerConnected {
246                user_name: identity.user_name,
247                agent_name: identity.agent_name,
248            });
249            // The two versions that decide whether this agent and that server
250            // understand each other, printed where someone diagnosing a
251            // refused upload is already looking. `whoami` has reported both
252            // since kasl-server 0.14.1, which is also the floor `connect`
253            // enforces, so an answer here means the pair is compatible - the
254            // only way to reach this line at all is to have connected.
255            msg_info!(Message::KaslServerCompatibility {
256                server_version: identity.server_version,
257                api_version: identity.api_version,
258            });
259        }
260        Err(error) => msg_warning!(Message::KaslServerTokenRejected(error.to_string())),
261    }
262
263    Ok(())
264}
265
266/// Prints what the connected server stores about this person.
267///
268/// Read from the server every time rather than described from here. The
269/// manifest is generated on the server out of the level it enforces at ingest
270/// (ADR 0011 in kasl-server), so it describes the installation this machine
271/// actually reports to; a copy kept in kasl would describe the server kasl
272/// was built against, and would be most wrong exactly when it mattered - on
273/// an installation that had narrowed what it keeps.
274///
275/// Showing is all this command does. The level belongs to the installation
276/// and an administrator sets it; there is no personal opt-out (ADR 0011), and
277/// a flag here that appeared to narrow it would be a promise kasl cannot
278/// keep.
279async fn manifest() -> Result<()> {
280    let (client, token) = connected_client()?;
281
282    let manifest = client.privacy(&token).await?;
283
284    msg_info!(Message::KaslServerPrivacyHeading(manifest.level));
285    msg_print!(Message::KaslServerPrivacySummary(manifest.summary));
286
287    msg_print!(Message::KaslServerPrivacyStoredHeading);
288    for stored in manifest.stored {
289        msg_print!(Message::KaslServerPrivacyStored {
290            what: stored.what,
291            detail: stored.detail,
292        });
293    }
294
295    msg_print!(Message::KaslServerPrivacyNeverHeading);
296    for line in manifest.never_collected {
297        msg_print!(Message::KaslServerPrivacyBullet(line));
298    }
299
300    msg_print!(Message::KaslServerPrivacyVisibleHeading);
301    for line in manifest.visible_to {
302        msg_print!(Message::KaslServerPrivacyBullet(line));
303    }
304
305    msg_print!(Message::KaslServerPrivacyRetention(manifest.retention));
306    msg_print!(Message::KaslServerPrivacyOnChange(manifest.on_change));
307
308    // Only when the server says. An absent timestamp means this server does
309    // not record when the level was set, which is not the same as a level
310    // that was never changed - printing "never" for it would invent a fact.
311    if let Some(updated_at) = manifest.updated_at {
312        msg_print!(Message::KaslServerPrivacyUpdatedAt(
313            updated_at.with_timezone(&Local).format("%Y-%m-%d %H:%M").to_string()
314        ));
315    }
316
317    msg_print!(Message::KaslServerPrivacySetByAdmin);
318
319    Ok(())
320}
321
322/// Sends one day's work to the connected server.
323///
324/// The whole day goes every time - workday bounds, pauses, tasks - because
325/// the server stores a day as a unit and the last upload wins (ADR 0004 in
326/// kasl-server). Sending the same day twice therefore changes nothing, and a
327/// day corrected here corrects itself there on the next push.
328///
329/// The day is assembled before the token is fetched: a day that cannot be
330/// built - a timestamp with no valid offset, a task without an id - is a local
331/// problem, and reporting it without first touching the keyring or the network
332/// keeps the cause visible.
333///
334/// A day that cannot be delivered is queued rather than lost, and a day that
335/// is delivered takes the rest of the backlog with it - a laptop coming back
336/// from a week offline pays the whole debt on the first push, without the user
337/// having to know a queue exists.
338async fn push(args: PushArgs) -> Result<()> {
339    let date = match args.date {
340        Some(date) => date,
341        None if args.last => (Local::now() - Duration::days(1)).date_naive(),
342        None => Local::now().date_naive(),
343    };
344
345    let config = Config::read().unwrap_or_default();
346    let Some(server_config) = config.kasl_server else {
347        return Err(msg_error_anyhow!(Message::KaslServerNotConnected));
348    };
349
350    let Some(day) = build_day_upload(date)? else {
351        msg_print!(Message::KaslServerNoDayToPush(date.to_string()));
352        return Ok(());
353    };
354
355    let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
356    let Some(token) = secret.try_get_cached() else {
357        return Err(msg_error_anyhow!(Message::KaslServerTokenMissing));
358    };
359
360    let client = KaslServer::new(&server_config)?;
361
362    match client.upload_day(&token, &day).await {
363        Ok(accepted) => {
364            msg_success!(Message::KaslServerDayPushed {
365                date: accepted.date.to_string(),
366                pauses: accepted.pauses,
367                tasks: accepted.tasks,
368            });
369            // Worth saying out loud rather than hiding in a debug log: this is
370            // the visible consequence of declaring the task set authoritative,
371            // and the only sign that a deletion here reached the server.
372            if accepted.deleted_tasks > 0 {
373                msg_info!(Message::KaslServerTasksDeleted(accepted.deleted_tasks));
374            }
375
376            // A day that arrives cancels its own debt. Without this a date
377            // queued by an earlier failure would be sent again by the next
378            // flush, forever.
379            ServerOutbox::new()?.remove(date)?;
380
381            // Today went, so the backlog is worth a try on the same
382            // connection: a machine that comes back online typically owes
383            // several days, and making the user run a second command to
384            // discover that would be a queue that hides itself.
385            flush_with(&client, &token).await?;
386            Ok(())
387        }
388        // Three failures, three different things to do about them: a
389        // credential to renew, a payload to fix, or a server to wait for.
390        // Telling someone whose token was revoked to fix the day and push
391        // again would send them looking at data that is not the problem.
392        // All three are errors - the day did not arrive in any of them.
393        Err(error) => {
394            // Queued before it is reported, and only if a retry could ever
395            // work: a day the server will never accept as sent would
396            // otherwise sit in the queue retrying until someone noticed.
397            let outcome = record_single(&mut ServerOutbox::new()?, date, &error)?;
398            if matches!(outcome, Delivered::Deferred { .. }) {
399                msg_info!(Message::KaslServerDayQueued(date.to_string()));
400            }
401
402            match error {
403                error @ UploadError::Rejected {
404                    status: StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN,
405                    ..
406                } => Err(msg_error_anyhow!(Message::KaslServerPushTokenRejected(error.to_string()))),
407                error @ UploadError::Rejected { .. } => Err(msg_error_anyhow!(Message::KaslServerPushRejected(error.to_string()))),
408                error => Err(msg_error_anyhow!(Message::KaslServerPushRetryable(error.to_string()))),
409            }
410        }
411    }
412}
413
414/// Sends everything the outbox still owes.
415async fn flush() -> Result<()> {
416    // What is owed is checked before the connection is, because an empty queue
417    // is nothing to do whatever the connection looks like. The other order
418    // makes an hourly `kasl server flush` on an unconnected machine fail every
419    // hour over work that does not exist - and a cron job that cries wolf is
420    // one nobody reads by the time it matters.
421    if ServerOutbox::new()?.count()? == 0 {
422        msg_print!(Message::KaslServerQueueEmpty);
423        return Ok(());
424    }
425
426    let (client, token) = connected_client()?;
427    flush_with(&client, &token).await
428}
429
430/// Drains the outbox against an already-built client.
431///
432/// Shared with `push` so a successful upload carries the backlog with it, on
433/// the connection that was just proven to work.
434async fn flush_with(client: &KaslServer, token: &str) -> Result<()> {
435    let mut outbox = ServerOutbox::new()?;
436    let dates: Vec<NaiveDate> = outbox.pending()?.into_iter().map(|owed| owed.date).collect();
437    if dates.is_empty() {
438        return Ok(());
439    }
440
441    msg_info!(Message::KaslServerQueueSending(dates.len()));
442
443    let outcomes = deliver(client, token, &mut outbox, &dates).await?;
444
445    let (mut accepted, mut refused, mut deferred) = (0, 0, 0);
446    for outcome in &outcomes {
447        match outcome {
448            Delivered::Accepted {
449                date,
450                pauses,
451                tasks,
452                deleted_tasks,
453            } => {
454                accepted += 1;
455                msg_success!(Message::KaslServerDayPushed {
456                    date: date.to_string(),
457                    pauses: *pauses,
458                    tasks: *tasks,
459                });
460                if *deleted_tasks > 0 {
461                    msg_info!(Message::KaslServerTasksDeleted(*deleted_tasks));
462                }
463            }
464            // Named rather than counted: a day dropped because the server
465            // will never take it is data that is not going to arrive, and
466            // burying that in a total would be the queue losing a day
467            // quietly.
468            Delivered::Refused { date, reason } => {
469                refused += 1;
470                msg_warning!(Message::KaslServerDayRefused {
471                    date: date.to_string(),
472                    reason: reason.clone(),
473                });
474            }
475            Delivered::Deferred { date, reason } => {
476                deferred += 1;
477                msg_warning!(Message::KaslServerDayDeferred {
478                    date: date.to_string(),
479                    reason: reason.clone(),
480                });
481            }
482        }
483    }
484
485    msg_print!(Message::KaslServerFlushSummary { accepted, refused, deferred });
486    Ok(())
487}
488
489/// Lists what is still owed, without touching the network.
490///
491/// Deliberately offline: this is the command someone runs to find out whether
492/// their work is safe, and it has to answer on a train.
493fn queue() -> Result<()> {
494    let outbox = ServerOutbox::new()?;
495    let owed = outbox.pending()?;
496
497    if owed.is_empty() {
498        msg_print!(Message::KaslServerQueueEmpty);
499        return Ok(());
500    }
501
502    msg_info!(Message::KaslServerQueueOwed(owed.len() as i64));
503    for day in &owed {
504        msg_print!(Message::KaslServerQueueEntry {
505            date: day.date.to_string(),
506            attempts: day.attempts,
507            last_error: day.last_error.clone(),
508        });
509    }
510
511    Ok(())
512}
513
514/// Queues every recorded day in a range and sends them.
515///
516/// The range is read out of the database rather than walked across the
517/// calendar: only dates that actually have a workday are queued, so a month
518/// containing weekends and leave does not fill the outbox with days that were
519/// never worked and can never be sent.
520///
521/// Without `--from` the range opens at the first day this machine ever
522/// recorded. That is the honest default for what the command is for - a
523/// machine that tracked locally before the team had a server owes everything,
524/// and asking the employee to first find out when they started using kasl in
525/// order to say so is asking the database's own question back at them.
526async fn backfill(args: BackfillArgs) -> Result<()> {
527    let to = args.to.unwrap_or_else(|| Local::now().date_naive());
528    if let Some(from) = args.from
529        && from > to
530    {
531        return Err(msg_error_anyhow!(Message::KaslServerBackfillOrderReversed));
532    }
533
534    let (client, token) = connected_client()?;
535
536    let dates = Workdays::new()?.recorded_dates(args.from, Some(to))?;
537
538    if dates.is_empty() {
539        match args.from {
540            Some(from) => msg_print!(Message::KaslServerBackfillNoDays {
541                from: from.to_string(),
542                to: to.to_string(),
543            }),
544            // A different sentence on purpose. "No workdays between the
545            // beginning and today" would read as a range that happened to be
546            // empty; what it actually means is that this machine has never
547            // recorded a day, and the fix is to start one rather than to pick
548            // other dates.
549            None => msg_print!(Message::KaslServerBackfillNothingRecorded),
550        }
551        return Ok(());
552    }
553
554    match args.from {
555        Some(from) => msg_info!(Message::KaslServerBackfillRange {
556            from: from.to_string(),
557            to: to.to_string(),
558            days: dates.len(),
559        }),
560        // The first recorded date is named rather than left as "the
561        // beginning": it is the one fact that tells the employee how much of
562        // their history is about to reach the server, which is the thing
563        // worth knowing before it does.
564        None => msg_info!(Message::KaslServerBackfillWholeHistory {
565            from: dates[0].to_string(),
566            to: to.to_string(),
567            days: dates.len(),
568        }),
569    }
570
571    // Queued before they are sent, so an interrupted backfill is not lost: a
572    // run cut off halfway leaves the rest owed rather than forgotten.
573    let mut outbox = ServerOutbox::new()?;
574    for date in &dates {
575        outbox.enqueue(*date, "queued by backfill")?;
576    }
577
578    flush_with(&client, &token).await
579}
580
581/// The client and token for the configured server, or a message saying why
582/// there is none.
583///
584/// Both failures are the same shape - nothing can be sent - and both have a
585/// single fix, `kasl server connect`.
586fn connected_client() -> Result<(KaslServer, String)> {
587    let config = Config::read().unwrap_or_default();
588    let Some(server_config) = config.kasl_server else {
589        return Err(msg_error_anyhow!(Message::KaslServerNotConnected));
590    };
591
592    let Some(token) = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT).try_get_cached() else {
593        return Err(msg_error_anyhow!(Message::KaslServerTokenMissing));
594    };
595
596    Ok((KaslServer::new(&server_config)?, token))
597}
598
599/// Forgets the connection: the token first, then the config.
600///
601/// In that order deliberately. If removing the config succeeded and the token
602/// removal then failed, a working credential would be left behind with nothing
603/// pointing at it - the one outcome this command exists to prevent.
604fn disconnect() -> Result<()> {
605    let mut config = Config::read().unwrap_or_default();
606
607    // An unreachable keyring must not stop the address being forgotten. On a
608    // headless machine - a container, a build agent, a server with no session
609    // keyring - there is no store to hold a token and nothing to remove, and
610    // refusing to disconnect there leaves the config pointing at a server for
611    // good. The failure is still reported, because on a machine that does have
612    // a keyring it means a credential survived.
613    if let Err(error) = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT).delete() {
614        msg_warning!(Message::KaslServerTokenNotRemoved(error.to_string()));
615    }
616
617    if config.kasl_server.take().is_some() {
618        config.save()?;
619        msg_success!(Message::KaslServerDisconnected);
620    } else {
621        // The token is gone either way, which is what was asked for.
622        msg_print!(Message::KaslServerNotConnected);
623    }
624
625    Ok(())
626}