horto_os_ui_shared/remote/runner/
token.rs1use 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
10pub const MIN_API_TOKEN_HEX_LEN: usize = 32;
13
14#[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#[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
49pub 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
96pub 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 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
134pub 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
151pub 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
163pub 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}