Skip to main content

horto_os_ui_shared/remote/runner/
token.rs

1//! Local status-api bearer token save path and prompts.
2
3use super::super::ecosystem::API_TOKEN_DROP_BASENAME;
4use super::super::process::{ProcessRunner, StdioMode};
5use super::options::{session_from, RemoteOptions};
6use super::reboot::wants_reboot_now;
7use crate::error::{HortoError, Result};
8use std::path::PathBuf;
9
10/// Minimum tip bearer length. Box install writes 64 hex chars; shorter leftovers
11/// (including unit-test junk) must never be treated as a real Status API token.
12pub const MIN_API_TOKEN_HEX_LEN: usize = 32;
13
14/// Return trimmed hex when it is usable as a tip Status API bearer.
15///
16/// Accepts `HORTO_API_TOKEN=<hex>` or bare hex. Rejects empty, non-hex, and
17/// anything shorter than [`MIN_API_TOKEN_HEX_LEN`].
18#[must_use]
19pub fn usable_api_token_hex(raw: &str) -> Option<&str> {
20    let hex = raw.strip_prefix("HORTO_API_TOKEN=").unwrap_or(raw).trim();
21    if hex.len() < MIN_API_TOKEN_HEX_LEN || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
22        None
23    } else {
24        Some(hex)
25    }
26}
27
28/// Parse drop-file contents: `HORTO_API_TOKEN=<hex>` or bare hex.
29#[must_use]
30pub fn parse_api_token_drop(raw: &str) -> Option<String> {
31    let line = raw.lines().map(str::trim).find(|l| !l.is_empty())?;
32    usable_api_token_hex(line).map(str::to_owned)
33}
34
35pub fn api_token_config_path() -> PathBuf {
36    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
37        let trimmed = xdg.trim();
38        if !trimmed.is_empty() {
39            return PathBuf::from(trimmed).join("horto-os-ui").join("api_token");
40        }
41    }
42    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
43    PathBuf::from(home)
44        .join(".config")
45        .join("horto-os-ui")
46        .join("api_token")
47}
48
49/// Write hex bearer to the local config path with mode `0o600`.
50///
51/// Rejects short / non-hex input **before** creating or truncating the tip file,
52/// so a bad caller cannot wipe a real PC token with junk.
53///
54/// # Errors
55///
56/// Returns [`crate::HortoError`] when the token is unusable, or the directory /
57/// file cannot be written.
58pub fn write_api_token_file(token: &str) -> Result<PathBuf> {
59    use std::fs;
60    use std::io::Write;
61
62    let hex = usable_api_token_hex(token).ok_or_else(|| {
63        HortoError::msg(format!(
64            "status-api bearer must be at least {MIN_API_TOKEN_HEX_LEN} hex characters"
65        ))
66    })?;
67
68    let path = api_token_config_path();
69    if let Some(parent) = path.parent() {
70        fs::create_dir_all(parent)
71            .map_err(|e| HortoError::msg(format!("create {}: {e}", parent.display())))?;
72    }
73    {
74        #[cfg(unix)]
75        {
76            use std::os::unix::fs::OpenOptionsExt;
77            let mut f = fs::OpenOptions::new()
78                .write(true)
79                .create(true)
80                .truncate(true)
81                .mode(0o600)
82                .open(&path)
83                .map_err(|e| HortoError::msg(format!("write {}: {e}", path.display())))?;
84            writeln!(f, "{hex}")
85                .map_err(|e| HortoError::msg(format!("write {}: {e}", path.display())))?;
86        }
87        #[cfg(not(unix))]
88        {
89            fs::write(&path, format!("{hex}\n"))
90                .map_err(|e| HortoError::msg(format!("write {}: {e}", path.display())))?;
91        }
92    }
93    Ok(path)
94}
95
96/// Pull the box Status API bearer over SSH (sudo) and save the tip file.
97///
98/// Uses an inherited TTY (`ssh -tt`) so remote `sudo` can prompt, then captures
99/// the drop file contents for the PC tip path.
100///
101/// # Errors
102///
103/// Returns [`HortoError`] when SSH, sudo, parse, or local write fails.
104pub fn pull_remote_api_token(runner: &dyn ProcessRunner, opts: &RemoteOptions) -> Result<String> {
105    let host = opts.host.trim();
106    if host.is_empty() {
107        return Err(HortoError::msg("remote host is empty"));
108    }
109    let session = session_from(opts)?;
110    let drop = API_TOKEN_DROP_BASENAME;
111    let write = format!(
112        r#"set -e
113DROP="$HOME/{drop}"
114sudo grep '^HORTO_API_TOKEN=' /etc/horto-os-ui/api.env > "$DROP"
115chmod 600 "$DROP"
116"#
117    );
118    // Inherit allocates a remote TTY so sudo can ask for a password.
119    session.exec(runner, &write, StdioMode::Inherit)?;
120    let cat_out = session.exec(
121        runner,
122        &format!("cat \"$HOME/{drop}\" 2>/dev/null; rm -f \"$HOME/{drop}\""),
123        StdioMode::Capture,
124    )?;
125    let token = parse_api_token_drop(&cat_out.stdout).ok_or_else(|| {
126        HortoError::msg(
127            "could not read Status API token from the box (sudo grep /etc/horto-os-ui/api.env)",
128        )
129    })?;
130    write_api_token_file(&token)?;
131    Ok(token)
132}
133
134/// Finish a save-token prompt given the raw answer (test seam).
135///
136/// # Errors
137///
138/// Returns [`crate::HortoError`] when the file cannot be written.
139pub fn finish_save_api_token(token: &str, answer: &str) -> Result<bool> {
140    if !wants_reboot_now(answer) {
141        return Ok(false);
142    }
143    let path = write_api_token_file(token)?;
144    tracing::info!(
145        path = %path.display(),
146        "saved status-api bearer (hex only; not logged)"
147    );
148    Ok(true)
149}
150
151/// Propose saving the status-api bearer to `~/.config/horto-os-ui/api_token`.
152///
153/// TTY: `[y/N]` prompt. Non-TTY: skip write and log the hex once for paste.
154///
155/// # Errors
156///
157/// Returns [`crate::HortoError`] when stdin cannot be read or the file write fails.
158pub fn offer_save_api_token(token: &str) -> Result<bool> {
159    use std::io::IsTerminal;
160    offer_save_api_token_with(token, std::io::stdin().is_terminal(), None)
161}
162
163/// Testable core of [`offer_save_api_token`] (`canned_answer` skips stdin when `Some`).
164///
165/// # Errors
166///
167/// Returns [`crate::HortoError`] when stdin cannot be read or the file write fails.
168pub fn offer_save_api_token_with(
169    token: &str,
170    is_tty: bool,
171    canned_answer: Option<&str>,
172) -> Result<bool> {
173    use std::io::{self, Write};
174
175    if !is_tty {
176        tracing::info!(
177            token,
178            "status-api bearer (non-TTY; paste into Desktop Connection or save manually)"
179        );
180        return Ok(false);
181    }
182    eprint!("Save status-api bearer to ~/.config/horto-os-ui/api_token? [y/N]: ");
183    let _ = io::stderr().flush();
184    let line = if let Some(answer) = canned_answer {
185        answer.to_owned()
186    } else {
187        let mut line = String::new();
188        io::stdin()
189            .read_line(&mut line)
190            .map_err(|e| HortoError::msg(format!("read token save prompt: {e}")))?;
191        line
192    };
193    finish_save_api_token(token, &line)
194}