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, normalize_url};
18use crate::libs::config::{Config, KaslServerConfig};
19use crate::libs::messages::Message;
20use crate::libs::secret::Secret;
21use crate::{msg_error_anyhow, msg_info, msg_print, msg_success, msg_warning};
22use anyhow::{Context, Result};
23use clap::{Args, Subcommand};
24use dialoguer::{Input, Password, theme::ColorfulTheme};
25
26/// Command-line arguments for the server command.
27#[derive(Debug, Args)]
28pub struct ServerArgs {
29    #[command(subcommand)]
30    command: ServerCommand,
31}
32
33/// Available server operations.
34#[derive(Debug, Subcommand)]
35enum ServerCommand {
36    /// Connect this machine to a kasl-server
37    #[command(about = "Connect this machine to a kasl-server")]
38    Connect(ConnectArgs),
39
40    /// Show the current connection
41    #[command(about = "Show the current connection to a kasl-server")]
42    Status,
43
44    /// Forget the connection and the stored token
45    #[command(about = "Forget the connection and the stored agent token")]
46    Disconnect,
47}
48
49/// Arguments accepted by `kasl server connect`.
50#[derive(Debug, Args)]
51pub struct ConnectArgs {
52    /// Server URL, e.g. https://kasl.example.com; prompted for when omitted
53    #[arg(long, value_name = "URL")]
54    url: Option<String>,
55
56    /// PEM file with the CA that signed the server's certificate
57    #[arg(long, value_name = "PATH")]
58    ca_certificate: Option<String>,
59}
60
61/// Routes a server subcommand.
62pub async fn cmd(args: ServerArgs) -> Result<()> {
63    match args.command {
64        ServerCommand::Connect(args) => connect(args).await,
65        ServerCommand::Status => status().await,
66        ServerCommand::Disconnect => disconnect(),
67    }
68}
69
70/// Walks through connecting: URL, token, and two checks against the server.
71///
72/// Nothing is written until both checks pass. A half-written connection - a
73/// URL saved with a token the server never accepted - would leave the agent
74/// looking configured while every upload failed.
75async fn connect(args: ConnectArgs) -> Result<()> {
76    // The token is always typed at a prompt - never taken from an argument,
77    // where it would land in shell history - so this command cannot finish
78    // without a terminal whatever else it was given. Checked before anything
79    // else so a run that cannot succeed fails immediately, rather than after
80    // reaching the network and reporting a server it is about to walk away
81    // from.
82    crate::libs::prompt::ensure_interactive("`kasl server connect` needs a terminal to ask for the agent token")?;
83
84    let mut config = Config::read().unwrap_or_default();
85
86    let url = match args.url {
87        Some(url) => normalize_url(&url),
88        None => {
89            let entered: String = Input::with_theme(&ColorfulTheme::default())
90                .with_prompt(Message::PromptKaslServerUrl.to_string())
91                .with_initial_text(config.kasl_server.as_ref().map(|s| s.url.clone()).unwrap_or_default())
92                .interact_text()?;
93            normalize_url(&entered)
94        }
95    };
96
97    // A URL without a scheme reaches nothing and the failure reads like the
98    // server is down, so it is caught here where the cause is still visible.
99    if !url.starts_with("http://") && !url.starts_with("https://") {
100        return Err(msg_error_anyhow!(Message::KaslServerUrlNeedsScheme(url)));
101    }
102
103    let candidate = KaslServerConfig {
104        url: url.clone(),
105        // A certificate named now wins; otherwise an existing one is kept, so
106        // reconnecting to the same server does not silently drop it.
107        ca_certificate: args
108            .ca_certificate
109            .or_else(|| config.kasl_server.as_ref().and_then(|s| s.ca_certificate.clone())),
110    };
111
112    let client = KaslServer::new(&candidate)?;
113
114    // First check: is this a kasl-server at all? Asked before the token, so a
115    // mistyped URL is not reported as a rejected token.
116    let health = client.health().await?;
117    msg_info!(Message::KaslServerReached {
118        url: url.clone(),
119        version: health.version.clone(),
120    });
121    if health.database != "ok" {
122        // Serviceable enough to answer, not enough to accept a day. Worth
123        // saying now rather than at the first upload.
124        msg_warning!(Message::KaslServerDatabaseUnhealthy(health.database.clone()));
125    }
126
127    let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
128    let token: String = Password::with_theme(&ColorfulTheme::default())
129        .with_prompt(Message::PromptKaslServerToken.to_string())
130        .interact()?;
131    let token = token.trim().to_string();
132    if token.is_empty() {
133        return Err(msg_error_anyhow!(Message::KaslServerTokenEmpty));
134    }
135
136    // Second check: the server accepts this token, and says whose it is.
137    let identity = client.identify(&token).await?;
138
139    // Both checks passed - only now is anything persisted.
140    secret
141        .store(&token)
142        .context("the token was accepted but could not be stored in the OS keyring")?;
143    config.kasl_server = Some(candidate);
144    config.save()?;
145
146    msg_success!(Message::KaslServerConnected {
147        user_name: identity.user_name,
148        agent_name: identity.agent_name,
149    });
150    Ok(())
151}
152
153/// Reports the stored connection, and whether it still works.
154///
155/// Reaches the server rather than reading the config back: a connection that
156/// was valid when it was made and is not any more - a revoked token, a server
157/// that moved - is exactly what someone runs this to find out.
158async fn status() -> Result<()> {
159    let config = Config::read().unwrap_or_default();
160    let Some(server_config) = config.kasl_server else {
161        msg_print!(Message::KaslServerNotConnected);
162        return Ok(());
163    };
164
165    msg_info!(Message::KaslServerConfigured(server_config.url.clone()));
166
167    let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
168    let Some(token) = secret.try_get_cached() else {
169        // The config says connected and the keyring disagrees: reconnecting is
170        // the fix, and saying so beats a 401 at the next upload.
171        msg_warning!(Message::KaslServerTokenMissing);
172        return Ok(());
173    };
174
175    let client = KaslServer::new(&server_config)?;
176
177    match client.health().await {
178        Ok(health) => msg_info!(Message::KaslServerReached {
179            url: server_config.url.clone(),
180            version: health.version,
181        }),
182        Err(error) => {
183            msg_warning!(Message::KaslServerUnreachable(error.to_string()));
184            return Ok(());
185        }
186    }
187
188    match client.identify(&token).await {
189        Ok(identity) => msg_success!(Message::KaslServerConnected {
190            user_name: identity.user_name,
191            agent_name: identity.agent_name,
192        }),
193        Err(error) => msg_warning!(Message::KaslServerTokenRejected(error.to_string())),
194    }
195
196    Ok(())
197}
198
199/// Forgets the connection: the token first, then the config.
200///
201/// In that order deliberately. If removing the config succeeded and the token
202/// removal then failed, a working credential would be left behind with nothing
203/// pointing at it - the one outcome this command exists to prevent.
204fn disconnect() -> Result<()> {
205    let mut config = Config::read().unwrap_or_default();
206
207    // An unreachable keyring must not stop the address being forgotten. On a
208    // headless machine - a container, a build agent, a server with no session
209    // keyring - there is no store to hold a token and nothing to remove, and
210    // refusing to disconnect there leaves the config pointing at a server for
211    // good. The failure is still reported, because on a machine that does have
212    // a keyring it means a credential survived.
213    if let Err(error) = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT).delete() {
214        msg_warning!(Message::KaslServerTokenNotRemoved(error.to_string()));
215    }
216
217    if config.kasl_server.take().is_some() {
218        config.save()?;
219        msg_success!(Message::KaslServerDisconnected);
220    } else {
221        // The token is gone either way, which is what was asked for.
222        msg_print!(Message::KaslServerNotConnected);
223    }
224
225    Ok(())
226}