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::libs::config::{Config, KaslServerConfig};
19use crate::libs::day_upload::build_day_upload;
20use crate::libs::messages::Message;
21use crate::libs::secret::Secret;
22use crate::{msg_error_anyhow, msg_info, msg_print, msg_success, msg_warning};
23use anyhow::{Context, Result};
24use chrono::{Duration, Local, NaiveDate};
25use clap::{Args, Subcommand};
26use dialoguer::{Input, Password, theme::ColorfulTheme};
27use reqwest::StatusCode;
28
29/// Command-line arguments for the server command.
30#[derive(Debug, Args)]
31pub struct ServerArgs {
32    #[command(subcommand)]
33    command: ServerCommand,
34}
35
36/// Available server operations.
37#[derive(Debug, Subcommand)]
38enum ServerCommand {
39    /// Connect this machine to a kasl-server
40    #[command(about = "Connect this machine to a kasl-server")]
41    Connect(ConnectArgs),
42
43    /// Show the current connection
44    #[command(about = "Show the current connection to a kasl-server")]
45    Status,
46
47    /// Send a day to the server
48    #[command(about = "Send a day's work to the connected kasl-server")]
49    Push(PushArgs),
50
51    /// Forget the connection and the stored token
52    #[command(about = "Forget the connection and the stored agent token")]
53    Disconnect,
54}
55
56/// Arguments accepted by `kasl server push`.
57#[derive(Debug, Args)]
58pub struct PushArgs {
59    /// Send yesterday instead of today
60    #[arg(long, short, help = "Send the last day instead of today")]
61    last: bool,
62
63    /// Send a specific date, YYYY-MM-DD
64    #[arg(long, value_name = "YYYY-MM-DD", conflicts_with = "last")]
65    date: Option<NaiveDate>,
66}
67
68/// Arguments accepted by `kasl server connect`.
69#[derive(Debug, Args)]
70pub struct ConnectArgs {
71    /// Server URL, e.g. https://kasl.example.com; prompted for when omitted
72    #[arg(long, value_name = "URL")]
73    url: Option<String>,
74
75    /// PEM file with the CA that signed the server's certificate
76    #[arg(long, value_name = "PATH")]
77    ca_certificate: Option<String>,
78}
79
80/// Routes a server subcommand.
81pub async fn cmd(args: ServerArgs) -> Result<()> {
82    match args.command {
83        ServerCommand::Connect(args) => connect(args).await,
84        ServerCommand::Status => status().await,
85        ServerCommand::Push(args) => push(args).await,
86        ServerCommand::Disconnect => disconnect(),
87    }
88}
89
90/// Walks through connecting: URL, token, and two checks against the server.
91///
92/// Nothing is written until both checks pass. A half-written connection - a
93/// URL saved with a token the server never accepted - would leave the agent
94/// looking configured while every upload failed.
95async fn connect(args: ConnectArgs) -> Result<()> {
96    // The token is always typed at a prompt - never taken from an argument,
97    // where it would land in shell history - so this command cannot finish
98    // without a terminal whatever else it was given. Checked before anything
99    // else so a run that cannot succeed fails immediately, rather than after
100    // reaching the network and reporting a server it is about to walk away
101    // from.
102    crate::libs::prompt::ensure_interactive("`kasl server connect` needs a terminal to ask for the agent token")?;
103
104    let mut config = Config::read().unwrap_or_default();
105
106    let url = match args.url {
107        Some(url) => normalize_url(&url),
108        None => {
109            let entered: String = Input::with_theme(&ColorfulTheme::default())
110                .with_prompt(Message::PromptKaslServerUrl.to_string())
111                .with_initial_text(config.kasl_server.as_ref().map(|s| s.url.clone()).unwrap_or_default())
112                .interact_text()?;
113            normalize_url(&entered)
114        }
115    };
116
117    // A URL without a scheme reaches nothing and the failure reads like the
118    // server is down, so it is caught here where the cause is still visible.
119    if !url.starts_with("http://") && !url.starts_with("https://") {
120        return Err(msg_error_anyhow!(Message::KaslServerUrlNeedsScheme(url)));
121    }
122
123    let candidate = KaslServerConfig {
124        url: url.clone(),
125        // A certificate named now wins; otherwise an existing one is kept, so
126        // reconnecting to the same server does not silently drop it.
127        ca_certificate: args
128            .ca_certificate
129            .or_else(|| config.kasl_server.as_ref().and_then(|s| s.ca_certificate.clone())),
130    };
131
132    let client = KaslServer::new(&candidate)?;
133
134    // First check: is this a kasl-server at all? Asked before the token, so a
135    // mistyped URL is not reported as a rejected token.
136    let health = client.health().await?;
137    msg_info!(Message::KaslServerReached {
138        url: url.clone(),
139        version: health.version.clone(),
140    });
141    if health.database != "ok" {
142        // Serviceable enough to answer, not enough to accept a day. Worth
143        // saying now rather than at the first upload.
144        msg_warning!(Message::KaslServerDatabaseUnhealthy(health.database.clone()));
145    }
146
147    let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
148    let token: String = Password::with_theme(&ColorfulTheme::default())
149        .with_prompt(Message::PromptKaslServerToken.to_string())
150        .interact()?;
151    let token = token.trim().to_string();
152    if token.is_empty() {
153        return Err(msg_error_anyhow!(Message::KaslServerTokenEmpty));
154    }
155
156    // Second check: the server accepts this token, and says whose it is.
157    let identity = client.identify(&token).await?;
158
159    // Both checks passed - only now is anything persisted.
160    secret
161        .store(&token)
162        .context("the token was accepted but could not be stored in the OS keyring")?;
163    config.kasl_server = Some(candidate);
164    config.save()?;
165
166    msg_success!(Message::KaslServerConnected {
167        user_name: identity.user_name,
168        agent_name: identity.agent_name,
169    });
170    Ok(())
171}
172
173/// Reports the stored connection, and whether it still works.
174///
175/// Reaches the server rather than reading the config back: a connection that
176/// was valid when it was made and is not any more - a revoked token, a server
177/// that moved - is exactly what someone runs this to find out.
178async fn status() -> Result<()> {
179    let config = Config::read().unwrap_or_default();
180    let Some(server_config) = config.kasl_server else {
181        msg_print!(Message::KaslServerNotConnected);
182        return Ok(());
183    };
184
185    msg_info!(Message::KaslServerConfigured(server_config.url.clone()));
186
187    let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
188    let Some(token) = secret.try_get_cached() else {
189        // The config says connected and the keyring disagrees: reconnecting is
190        // the fix, and saying so beats a 401 at the next upload.
191        msg_warning!(Message::KaslServerTokenMissing);
192        return Ok(());
193    };
194
195    let client = KaslServer::new(&server_config)?;
196
197    match client.health().await {
198        Ok(health) => msg_info!(Message::KaslServerReached {
199            url: server_config.url.clone(),
200            version: health.version,
201        }),
202        Err(error) => {
203            msg_warning!(Message::KaslServerUnreachable(error.to_string()));
204            return Ok(());
205        }
206    }
207
208    match client.identify(&token).await {
209        Ok(identity) => msg_success!(Message::KaslServerConnected {
210            user_name: identity.user_name,
211            agent_name: identity.agent_name,
212        }),
213        Err(error) => msg_warning!(Message::KaslServerTokenRejected(error.to_string())),
214    }
215
216    Ok(())
217}
218
219/// Sends one day's work to the connected server.
220///
221/// The whole day goes every time - workday bounds, pauses, tasks - because
222/// the server stores a day as a unit and the last upload wins (ADR 0004 in
223/// kasl-server). Sending the same day twice therefore changes nothing, and a
224/// day corrected here corrects itself there on the next push.
225///
226/// The day is assembled before the token is fetched: a day that cannot be
227/// built - a timestamp with no valid offset, a task without an id - is a local
228/// problem, and reporting it without first touching the keyring or the network
229/// keeps the cause visible.
230async fn push(args: PushArgs) -> Result<()> {
231    let date = match args.date {
232        Some(date) => date,
233        None if args.last => (Local::now() - Duration::days(1)).date_naive(),
234        None => Local::now().date_naive(),
235    };
236
237    let config = Config::read().unwrap_or_default();
238    let Some(server_config) = config.kasl_server else {
239        return Err(msg_error_anyhow!(Message::KaslServerNotConnected));
240    };
241
242    let Some(day) = build_day_upload(date)? else {
243        msg_print!(Message::KaslServerNoDayToPush(date.to_string()));
244        return Ok(());
245    };
246
247    let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
248    let Some(token) = secret.try_get_cached() else {
249        return Err(msg_error_anyhow!(Message::KaslServerTokenMissing));
250    };
251
252    let client = KaslServer::new(&server_config)?;
253
254    match client.upload_day(&token, &day).await {
255        Ok(accepted) => {
256            msg_success!(Message::KaslServerDayPushed {
257                date: accepted.date.to_string(),
258                pauses: accepted.pauses,
259                tasks: accepted.tasks,
260            });
261            // Worth saying out loud rather than hiding in a debug log: this is
262            // the visible consequence of declaring the task set authoritative,
263            // and the only sign that a deletion here reached the server.
264            if accepted.deleted_tasks > 0 {
265                msg_info!(Message::KaslServerTasksDeleted(accepted.deleted_tasks));
266            }
267            Ok(())
268        }
269        // Three failures, three different things to do about them: a
270        // credential to renew, a payload to fix, or a server to wait for.
271        // Telling someone whose token was revoked to fix the day and push
272        // again would send them looking at data that is not the problem.
273        // All three are errors - the day did not arrive in any of them.
274        Err(
275            error @ UploadError::Rejected {
276                status: StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN,
277                ..
278            },
279        ) => Err(msg_error_anyhow!(Message::KaslServerPushTokenRejected(error.to_string()))),
280        Err(error @ UploadError::Rejected { .. }) => Err(msg_error_anyhow!(Message::KaslServerPushRejected(error.to_string()))),
281        Err(error) => Err(msg_error_anyhow!(Message::KaslServerPushRetryable(error.to_string()))),
282    }
283}
284
285/// Forgets the connection: the token first, then the config.
286///
287/// In that order deliberately. If removing the config succeeded and the token
288/// removal then failed, a working credential would be left behind with nothing
289/// pointing at it - the one outcome this command exists to prevent.
290fn disconnect() -> Result<()> {
291    let mut config = Config::read().unwrap_or_default();
292
293    // An unreachable keyring must not stop the address being forgotten. On a
294    // headless machine - a container, a build agent, a server with no session
295    // keyring - there is no store to hold a token and nothing to remove, and
296    // refusing to disconnect there leaves the config pointing at a server for
297    // good. The failure is still reported, because on a machine that does have
298    // a keyring it means a credential survived.
299    if let Err(error) = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT).delete() {
300        msg_warning!(Message::KaslServerTokenNotRemoved(error.to_string()));
301    }
302
303    if config.kasl_server.take().is_some() {
304        config.save()?;
305        msg_success!(Message::KaslServerDisconnected);
306    } else {
307        // The token is gone either way, which is what was asked for.
308        msg_print!(Message::KaslServerNotConnected);
309    }
310
311    Ok(())
312}