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}