hermes-pair/src/cli.rs

309 lines
11 KiB
Rust

use crate::config::AppConfig;
use crate::hermes::{HermesProbeClient, ProbeState};
use crate::identity::{get_display_name, get_host_id};
use crate::models::NetworkInterfaceInfo;
use crate::network::discover_network_interfaces;
use crate::pairing::{create_pairing_payload, encode_pairing_uri, validate_ttl};
use crate::qr::render_terminal_qr;
use clap::{Args, Parser, Subcommand};
use std::net::Ipv4Addr;
use std::str::FromStr;
use std::time::Duration;
use tokio::time::sleep;
#[derive(Parser, Debug, Clone)]
#[command(
name = "hermes-pair",
version = "0.1.0",
author = "ochenstarik-ui",
about = "Fast, secure QR onboarding helper for Hermes Agent"
)]
pub struct CliArgs {
/// Run interactive terminal mode with periodic refresh
#[arg(long, short = 't')]
pub terminal: bool,
/// Print QR once to stdout and exit (headless/script mode)
#[arg(long = "no-gui")]
pub no_gui: bool,
/// Port of Hermes Agent (default: 9119)
#[arg(long)]
pub port: Option<u16>,
/// Hermes status API URL (e.g. http://127.0.0.1:9119 or http://127.0.0.1:9222)
#[arg(long = "hermes-url")]
pub hermes_url: Option<String>,
/// Specific network interface name or IPv4 address to advertise
#[arg(long, short = 'i')]
pub interface: Option<String>,
/// Pairing QR validity TTL in seconds (default 120, range 10..=600)
#[arg(long, default_value = "120")]
pub ttl: u64,
#[command(subcommand)]
pub command: Option<CliCommand>,
}
#[derive(Subcommand, Debug, Clone)]
pub enum CliCommand {
/// Output QR code once to stdout and exit
Qr(QrArgs),
}
#[derive(Args, Debug, Clone)]
pub struct QrArgs {
/// Port of Hermes Agent
#[arg(long)]
pub port: Option<u16>,
/// Hermes status API URL (e.g. http://127.0.0.1:9119 or http://127.0.0.1:9222)
#[arg(long = "hermes-url")]
pub hermes_url: Option<String>,
/// Specific network interface name or IPv4 address
#[arg(long, short = 'i')]
pub interface: Option<String>,
/// Pairing QR validity TTL in seconds (range 10..=600)
#[arg(long)]
pub ttl: Option<u64>,
}
/// Parses a Hermes URL into its scheme, host, and port components.
pub fn parse_hermes_url(raw_url: &str) -> Result<(String, String, u16), String> {
let parsed = url::Url::parse(raw_url)
.map_err(|e| format!("Invalid --hermes-url '{}': {}", raw_url, e))?;
let scheme = parsed.scheme().to_lowercase();
if scheme != "http" && scheme != "https" {
return Err(format!(
"Invalid scheme '{}' in --hermes-url: must be 'http' or 'https'",
scheme
));
}
let host = parsed
.host_str()
.ok_or_else(|| format!("Missing host in --hermes-url '{}'", raw_url))?
.to_string();
let port = parsed
.port_or_known_default()
.ok_or_else(|| format!("Could not determine port from --hermes-url '{}'", raw_url))?;
Ok((scheme, host, port))
}
/// Resolves the scheme and port based on --hermes-url and explicit --port arguments.
pub fn resolve_cli_endpoint(
hermes_url: Option<&str>,
explicit_port: Option<u16>,
) -> Result<(String, u16), String> {
if let Some(url_str) = hermes_url {
let (parsed_scheme, _parsed_host, parsed_port) = parse_hermes_url(url_str)?;
let final_port = explicit_port.unwrap_or(parsed_port);
Ok((parsed_scheme, final_port))
} else {
let final_port = explicit_port.unwrap_or(9119);
Ok(("http".to_string(), final_port))
}
}
/// Resolves the selected IPv4 address based on user input or automatic interface detection.
pub fn resolve_selected_ip(
explicit_interface: Option<&str>,
interfaces: &[NetworkInterfaceInfo],
) -> (String, Ipv4Addr) {
if let Some(target) = explicit_interface {
if let Ok(ip) = Ipv4Addr::from_str(target) {
return (format!("Manual ({})", ip), ip);
}
let lower = target.to_lowercase();
if let Some(matched) = interfaces
.iter()
.find(|i| i.name.to_lowercase().contains(&lower) || i.ip.to_string() == target)
{
return (matched.name.clone(), matched.ip);
}
}
if let Some(first) = interfaces.first() {
return (first.name.clone(), first.ip);
}
("Loopback".to_string(), Ipv4Addr::new(127, 0, 0, 1))
}
/// Runs single-shot terminal output mode.
pub async fn run_once(
config: &AppConfig,
hermes_url: Option<&str>,
scheme: &str,
port: u16,
explicit_interface: Option<&str>,
ttl: u64,
) -> Result<(), Box<dyn std::error::Error>> {
validate_ttl(ttl)?;
let interfaces = discover_network_interfaces().unwrap_or_default();
let (_iface_name, host_ip) = resolve_selected_ip(explicit_interface, &interfaces);
let client = HermesProbeClient::new();
let probe_state = client.probe(hermes_url, scheme, port, Some(host_ip)).await;
let host_id = get_host_id(config);
let display_name = get_display_name(config);
match &probe_state {
ProbeState::Online(status) => {
let ver = status.version.as_deref().unwrap_or("unknown");
let auth = if status.auth_required {
"Required"
} else {
"None"
};
println!("Hermes: Running (v{}, Auth: {})", ver, auth);
let payload = create_pairing_payload(
host_id.clone(),
display_name.clone(),
host_ip.to_string(),
port,
scheme.to_string(),
ttl,
);
let uri = encode_pairing_uri(&payload);
let qr_rendered =
render_terminal_qr(&uri).map_err(|e| format!("QR Render Error: {}", e))?;
let short_id = if host_id.len() >= 8 {
format!("{}...", &host_id[..8])
} else {
host_id.clone()
};
println!("Host: {}", display_name);
println!("Address: {}://{}:{}", scheme, host_ip, port);
println!("Host ID: {}", short_id);
println!("Expires in: {:02}:{:02}", ttl / 60, ttl % 60);
println!("\n{}", qr_rendered);
println!("Pairing Link: {}", uri);
Ok(())
}
ProbeState::LoopbackOnly { lan_error, .. } => {
eprintln!(
"Hermes: Running locally (127.0.0.1), but LAN unreachable: {}",
lan_error
);
eprintln!(
"⚠️ Warning: Hermes is bound to loopback only. Start Hermes with LAN access, for example:"
);
eprintln!(" hermes serve --host 0.0.0.0 --port {}", port);
eprintln!(
"\n[Pairing QR not displayed because Hermes is unreachable from other devices]"
);
Err("Hermes is bound to loopback only (LAN unreachable)".into())
}
ProbeState::Offline(err) => {
eprintln!("Hermes: Offline ({})", err);
eprintln!("⚠️ Hermes Agent is unreachable. Please start Hermes before generating pairing QR.");
eprintln!("\n[Pairing QR not displayed because Hermes is offline]");
Err(format!("Hermes is offline: {}", err).into())
}
}
}
/// Runs interactive terminal UI mode that updates in a loop.
pub async fn run_terminal_loop(
config: &AppConfig,
hermes_url: Option<&str>,
scheme: &str,
port: u16,
explicit_interface: Option<&str>,
ttl: u64,
) -> Result<(), Box<dyn std::error::Error>> {
validate_ttl(ttl)?;
let mut last_generated_at = std::time::Instant::now();
let client = HermesProbeClient::new();
loop {
let now = std::time::Instant::now();
let elapsed = now.duration_since(last_generated_at).as_secs();
let interfaces = discover_network_interfaces().unwrap_or_default();
let (_iface_name, host_ip) = resolve_selected_ip(explicit_interface, &interfaces);
if elapsed >= ttl {
last_generated_at = std::time::Instant::now();
}
let remaining = ttl.saturating_sub(now.duration_since(last_generated_at).as_secs());
let probe_state = client.probe(hermes_url, scheme, port, Some(host_ip)).await;
// Clear terminal screen (cross-platform ANSI)
print!("\x1B[2J\x1B[1;1H");
match &probe_state {
ProbeState::Online(status) => {
let ver = status.version.as_deref().unwrap_or("unknown");
let auth = if status.auth_required {
"Required"
} else {
"None"
};
println!("Hermes: Running (v{}, Auth: {})", ver, auth);
let payload = create_pairing_payload(
get_host_id(config),
get_display_name(config),
host_ip.to_string(),
port,
scheme.to_string(),
ttl,
);
let uri = encode_pairing_uri(&payload);
let qr_rendered = render_terminal_qr(&uri).unwrap_or_default();
let host_id = &payload.host_id;
let short_id = if host_id.len() >= 8 {
format!("{}...", &host_id[..8])
} else {
host_id.clone()
};
println!("Host: {}", payload.name);
println!("Address: {}://{}:{}", scheme, payload.host, payload.port);
println!("Host ID: {}", short_id);
println!("Expires in: {:02}:{:02}", remaining / 60, remaining % 60);
println!("\n{}", qr_rendered);
println!("Pairing Link: {}", uri);
}
ProbeState::LoopbackOnly { lan_error, .. } => {
println!(
"Hermes: Loopback Only (127.0.0.1) [LAN Error: {}]",
lan_error
);
println!(
"⚠️ Warning: Hermes is bound to loopback only. Start Hermes with LAN access:\n hermes serve --host 0.0.0.0 --port {}",
port
);
println!("\n[QR Code hidden: Hermes is unreachable over LAN]");
println!("Address: {}://{}:{}", scheme, host_ip, port);
println!("Retrying probe every second...");
}
ProbeState::Offline(err) => {
println!("Hermes: Offline ({})", err);
println!("⚠️ Hermes Agent is unreachable. Please start Hermes.");
println!("\n[QR Code hidden: Hermes is offline]");
println!("Address: {}://{}:{}", scheme, host_ip, port);
println!("Retrying probe every second...");
}
}
println!("\nPress Ctrl+C to exit.");
sleep(Duration::from_secs(1)).await;
}
}