fix(security): hard contract parity, --hermes-url support, strict status probe gating, and canonical fixture

This commit is contained in:
Ochenstarik 2026-08-24 14:38:39 +07:00
parent 5c905a5c26
commit 538f255f07
6 changed files with 942 additions and 206 deletions

View file

@ -3,7 +3,9 @@ use crate::hermes::{HermesProbeClient, ProbeState};
use crate::identity::{get_display_name, get_host_id}; use crate::identity::{get_display_name, get_host_id};
use crate::models::{NetworkInterfaceInfo, PairingPayloadV1}; use crate::models::{NetworkInterfaceInfo, PairingPayloadV1};
use crate::network::discover_network_interfaces; use crate::network::discover_network_interfaces;
use crate::pairing::{create_pairing_payload, encode_pairing_uri}; use crate::pairing::{
create_pairing_payload, encode_pairing_uri, MAX_TTL_SECONDS, MIN_TTL_SECONDS,
};
use crate::qr::render_egui_image; use crate::qr::render_egui_image;
use eframe::egui::{self, Color32, RichText, TextureHandle, Vec2}; use eframe::egui::{self, Color32, RichText, TextureHandle, Vec2};
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
@ -12,6 +14,8 @@ use std::time::{Duration, Instant};
pub struct HermesPairApp { pub struct HermesPairApp {
config: AppConfig, config: AppConfig,
hermes_url: Option<String>,
scheme: String,
port: u16, port: u16,
ttl: u64, ttl: u64,
interfaces: Vec<NetworkInterfaceInfo>, interfaces: Vec<NetworkInterfaceInfo>,
@ -34,10 +38,14 @@ impl HermesPairApp {
pub fn new( pub fn new(
cc: &eframe::CreationContext<'_>, cc: &eframe::CreationContext<'_>,
config: AppConfig, config: AppConfig,
hermes_url: Option<String>,
scheme: String,
port: u16, port: u16,
explicit_interface: Option<String>, explicit_interface: Option<String>,
ttl: u64, ttl: u64,
) -> Self { ) -> Self {
let ttl = ttl.clamp(MIN_TTL_SECONDS, MAX_TTL_SECONDS);
let mut interfaces = discover_network_interfaces().unwrap_or_default(); let mut interfaces = discover_network_interfaces().unwrap_or_default();
if interfaces.is_empty() { if interfaces.is_empty() {
interfaces.push(NetworkInterfaceInfo { interfaces.push(NetworkInterfaceInfo {
@ -68,7 +76,7 @@ impl HermesPairApp {
display_name, display_name,
host_ip.to_string(), host_ip.to_string(),
port, port,
"http".to_string(), scheme.clone(),
ttl, ttl,
); );
let current_uri = encode_pairing_uri(&current_payload); let current_uri = encode_pairing_uri(&current_payload);
@ -79,6 +87,8 @@ impl HermesPairApp {
let mut app = Self { let mut app = Self {
config, config,
hermes_url,
scheme,
port, port,
ttl, ttl,
interfaces, interfaces,
@ -118,7 +128,7 @@ impl HermesPairApp {
display_name, display_name,
host_ip.to_string(), host_ip.to_string(),
self.port, self.port,
"http".to_string(), self.scheme.clone(),
self.ttl, self.ttl,
); );
self.current_uri = encode_pairing_uri(&self.current_payload); self.current_uri = encode_pairing_uri(&self.current_payload);
@ -139,6 +149,8 @@ impl HermesPairApp {
} }
self.is_probing = true; self.is_probing = true;
let hermes_url = self.hermes_url.clone();
let scheme = self.scheme.clone();
let port = self.port; let port = self.port;
let lan_ip = self.selected_ip(); let lan_ip = self.selected_ip();
let tx = self.probe_tx.clone(); let tx = self.probe_tx.clone();
@ -151,7 +163,9 @@ impl HermesPairApp {
if let Ok(rt) = rt { if let Ok(rt) = rt {
rt.block_on(async { rt.block_on(async {
let client = HermesProbeClient::new(); let client = HermesProbeClient::new();
let res = client.probe(port, Some(lan_ip)).await; let res = client
.probe(hermes_url.as_deref(), &scheme, port, Some(lan_ip))
.await;
let _ = tx.send(res); let _ = tx.send(res);
}); });
} }
@ -206,13 +220,22 @@ impl eframe::App for HermesPairApp {
let ver = resp.version.as_deref().unwrap_or("unknown"); let ver = resp.version.as_deref().unwrap_or("unknown");
ui.label(format!("v{}", ver)); ui.label(format!("v{}", ver));
if resp.auth_required { if resp.auth_required {
ui.colored_label(Color32::from_rgb(52, 152, 219), "[Auth: Required]"); ui.colored_label(
Color32::from_rgb(52, 152, 219),
"[Auth: Required]",
);
} else { } else {
ui.colored_label(Color32::from_rgb(230, 126, 34), "[Auth: None]"); ui.colored_label(
Color32::from_rgb(230, 126, 34),
"[Auth: None]",
);
} }
} }
ProbeState::LoopbackOnly { .. } => { ProbeState::LoopbackOnly { .. } => {
ui.colored_label(Color32::from_rgb(241, 196, 15), "● Loopback Only"); ui.colored_label(
Color32::from_rgb(241, 196, 15),
"● Loopback Only",
);
} }
ProbeState::Offline(err) => { ProbeState::Offline(err) => {
ui.colored_label(Color32::from_rgb(231, 76, 60), "● Offline"); ui.colored_label(Color32::from_rgb(231, 76, 60), "● Offline");
@ -229,7 +252,7 @@ impl eframe::App for HermesPairApp {
}); });
// Warning Banners // Warning Banners
if let ProbeState::LoopbackOnly { .. } = &self.probe_state { if let ProbeState::LoopbackOnly { lan_error, .. } = &self.probe_state {
ui.add_space(2.0); ui.add_space(2.0);
egui::Frame::NONE egui::Frame::NONE
.fill(Color32::from_rgb(60, 45, 10)) .fill(Color32::from_rgb(60, 45, 10))
@ -241,10 +264,11 @@ impl eframe::App for HermesPairApp {
ui.label(RichText::new("⚠️").size(16.0)); ui.label(RichText::new("⚠️").size(16.0));
ui.label( ui.label(
RichText::new(format!( RichText::new(format!(
"Hermes is currently only reachable from this computer (127.0.0.1).\n\ "Hermes is bound to loopback only (127.0.0.1).\n\
Start Hermes with LAN-accessible bind:\n\ LAN connection failed: {}\n\
Start Hermes with LAN access:\n\
hermes serve --host 0.0.0.0 --port {}", hermes serve --host 0.0.0.0 --port {}",
self.port lan_error, self.port
)) ))
.size(11.5) .size(11.5)
.color(Color32::from_rgb(241, 196, 15)), .color(Color32::from_rgb(241, 196, 15)),
@ -279,7 +303,8 @@ impl eframe::App for HermesPairApp {
// Network Interface Selector // Network Interface Selector
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Network Interface:"); ui.label("Network Interface:");
let current_label = if let Some(iface) = self.interfaces.get(self.selected_iface_index) { let current_label =
if let Some(iface) = self.interfaces.get(self.selected_iface_index) {
format!("{} ({})", iface.name, iface.ip) format!("{} ({})", iface.name, iface.ip)
} else { } else {
"None".to_string() "None".to_string()
@ -311,29 +336,38 @@ impl eframe::App for HermesPairApp {
// Address display // Address display
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label(RichText::new("Target Address:").strong()); ui.label(RichText::new("Target Address:").strong());
ui.monospace(format!("http://{}:{}", self.selected_ip(), self.port)); ui.monospace(format!(
"{}://{}:{}",
self.scheme,
self.selected_ip(),
self.port
));
}); });
ui.add_space(4.0); ui.add_space(4.0);
// Center QR Code // Center QR Code or Placeholder Card
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
if let Some(ref texture) = self.qr_texture { match &self.probe_state {
ui.image((texture.id(), Vec2::new(260.0, 260.0))); ProbeState::Online(_) => {
} else {
ui.label("Failed to load QR code");
}
ui.add_space(6.0);
// Expiry timer
if is_expired { if is_expired {
egui::Frame::group(ui.style())
.inner_margin(32.0)
.show(ui, |ui| {
ui.label( ui.label(
RichText::new("⚠️ QR Code Expired") RichText::new("⚠️ QR Code Expired")
.color(Color32::from_rgb(231, 76, 60)) .color(Color32::from_rgb(231, 76, 60))
.strong(), .strong()
.size(16.0),
); );
} else { ui.add_space(8.0);
ui.label(
"Click [ 🔄 Regenerate QR ] below to create a fresh pairing code.",
);
});
} else if let Some(ref texture) = self.qr_texture {
ui.image((texture.id(), Vec2::new(260.0, 260.0)));
ui.add_space(6.0);
let mins = remaining / 60; let mins = remaining / 60;
let secs = remaining % 60; let secs = remaining % 60;
ui.label( ui.label(
@ -341,11 +375,48 @@ impl eframe::App for HermesPairApp {
.size(13.0) .size(13.0)
.color(Color32::LIGHT_GRAY), .color(Color32::LIGHT_GRAY),
); );
} else {
ui.label("Failed to render QR code");
}
}
ProbeState::LoopbackOnly { .. } => {
egui::Frame::group(ui.style())
.inner_margin(32.0)
.show(ui, |ui| {
ui.colored_label(
Color32::from_rgb(241, 196, 15),
RichText::new("⚠️ Pairing QR Hidden").size(15.0).strong(),
);
ui.add_space(8.0);
ui.label(
"Hermes is running locally on 127.0.0.1, but unreachable on this LAN interface.\n\
Start Hermes with --host 0.0.0.0 to enable network pairing.",
);
});
}
ProbeState::Offline(err) => {
egui::Frame::group(ui.style())
.inner_margin(32.0)
.show(ui, |ui| {
ui.colored_label(
Color32::from_rgb(231, 76, 60),
RichText::new("● Hermes Offline").size(15.0).strong(),
);
ui.add_space(8.0);
ui.label(format!(
"Hermes is not responding on this endpoint ({}).\n\
Start Hermes Agent and click [ 🔄 Check ] to connect.",
err
));
});
}
} }
}); });
ui.add_space(6.0); ui.add_space(6.0);
let can_copy = self.probe_state.is_online() && !is_expired;
// Action Buttons // Action Buttons
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.columns(2, |cols| { ui.columns(2, |cols| {
@ -355,7 +426,10 @@ impl eframe::App for HermesPairApp {
self.trigger_probe(); self.trigger_probe();
} }
if cols[1].button("📋 Copy Link").clicked() { if cols[1]
.add_enabled(can_copy, egui::Button::new("📋 Copy Link"))
.clicked()
{
ctx.copy_text(self.current_uri.clone()); ctx.copy_text(self.current_uri.clone());
self.copied_banner_timer = Some(Instant::now()); self.copied_banner_timer = Some(Instant::now());
} }

View file

@ -3,7 +3,7 @@ use crate::hermes::{HermesProbeClient, ProbeState};
use crate::identity::{get_display_name, get_host_id}; use crate::identity::{get_display_name, get_host_id};
use crate::models::NetworkInterfaceInfo; use crate::models::NetworkInterfaceInfo;
use crate::network::discover_network_interfaces; use crate::network::discover_network_interfaces;
use crate::pairing::{create_pairing_payload, encode_pairing_uri}; use crate::pairing::{create_pairing_payload, encode_pairing_uri, validate_ttl};
use crate::qr::render_terminal_qr; use crate::qr::render_terminal_qr;
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
@ -27,11 +27,11 @@ pub struct CliArgs {
#[arg(long = "no-gui")] #[arg(long = "no-gui")]
pub no_gui: bool, pub no_gui: bool,
/// Port of Hermes Agent (default 9119) /// Port of Hermes Agent (default: 9119)
#[arg(long, default_value = "9119")] #[arg(long)]
pub port: u16, pub port: Option<u16>,
/// Hermes status API URL (e.g. http://127.0.0.1:9119) /// Hermes status API URL (e.g. http://127.0.0.1:9119 or http://127.0.0.1:9222)
#[arg(long = "hermes-url")] #[arg(long = "hermes-url")]
pub hermes_url: Option<String>, pub hermes_url: Option<String>,
@ -39,7 +39,7 @@ pub struct CliArgs {
#[arg(long, short = 'i')] #[arg(long, short = 'i')]
pub interface: Option<String>, pub interface: Option<String>,
/// Pairing QR validity TTL in seconds (default 120) /// Pairing QR validity TTL in seconds (default 120, range 10..=600)
#[arg(long, default_value = "120")] #[arg(long, default_value = "120")]
pub ttl: u64, pub ttl: u64,
@ -59,15 +59,55 @@ pub struct QrArgs {
#[arg(long)] #[arg(long)]
pub port: Option<u16>, 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 /// Specific network interface name or IPv4 address
#[arg(long, short = 'i')] #[arg(long, short = 'i')]
pub interface: Option<String>, pub interface: Option<String>,
/// Pairing QR validity TTL in seconds /// Pairing QR validity TTL in seconds (range 10..=600)
#[arg(long)] #[arg(long)]
pub ttl: Option<u64>, 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. /// Resolves the selected IPv4 address based on user input or automatic interface detection.
pub fn resolve_selected_ip( pub fn resolve_selected_ip(
explicit_interface: Option<&str>, explicit_interface: Option<&str>,
@ -97,33 +137,23 @@ pub fn resolve_selected_ip(
/// Runs single-shot terminal output mode. /// Runs single-shot terminal output mode.
pub async fn run_once( pub async fn run_once(
config: &AppConfig, config: &AppConfig,
hermes_url: Option<&str>,
scheme: &str,
port: u16, port: u16,
explicit_interface: Option<&str>, explicit_interface: Option<&str>,
ttl: u64, ttl: u64,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
validate_ttl(ttl)?;
let interfaces = discover_network_interfaces().unwrap_or_default(); let interfaces = discover_network_interfaces().unwrap_or_default();
let (_iface_name, host_ip) = resolve_selected_ip(explicit_interface, &interfaces); let (_iface_name, host_ip) = resolve_selected_ip(explicit_interface, &interfaces);
let client = HermesProbeClient::new(); let client = HermesProbeClient::new();
let probe_state = client.probe(port, Some(host_ip)).await; let probe_state = client.probe(hermes_url, scheme, port, Some(host_ip)).await;
let host_id = get_host_id(config); let host_id = get_host_id(config);
let display_name = get_display_name(config); let display_name = get_display_name(config);
let scheme = "http".to_string();
let payload = create_pairing_payload(
host_id.clone(),
display_name.clone(),
host_ip.to_string(),
port,
scheme,
ttl,
);
let uri = encode_pairing_uri(&payload);
let qr_rendered = render_terminal_qr(&uri).map_err(|e| format!("QR Render Error: {}", e))?;
// Format Hermes status line
match &probe_state { match &probe_state {
ProbeState::Online(status) => { ProbeState::Online(status) => {
let ver = status.version.as_deref().unwrap_or("unknown"); let ver = status.version.as_deref().unwrap_or("unknown");
@ -133,20 +163,19 @@ pub async fn run_once(
"None" "None"
}; };
println!("Hermes: Running (v{}, Auth: {})", ver, auth); println!("Hermes: Running (v{}, Auth: {})", ver, auth);
}
ProbeState::LoopbackOnly { lan_error, .. } => { let payload = create_pairing_payload(
println!( host_id.clone(),
"Hermes: Running locally (127.0.0.1), but LAN unreachable: {}", display_name.clone(),
lan_error host_ip.to_string(),
port,
scheme.to_string(),
ttl,
); );
println!(
"⚠️ Warning: Hermes is bound to loopback only. Start Hermes with --host 0.0.0.0" let uri = encode_pairing_uri(&payload);
); let qr_rendered =
} render_terminal_qr(&uri).map_err(|e| format!("QR Render Error: {}", e))?;
ProbeState::Offline(err) => {
println!("Hermes: Offline ({})", err);
}
}
let short_id = if host_id.len() >= 8 { let short_id = if host_id.len() >= 8 {
format!("{}...", &host_id[..8]) format!("{}...", &host_id[..8])
@ -155,7 +184,7 @@ pub async fn run_once(
}; };
println!("Host: {}", display_name); println!("Host: {}", display_name);
println!("Address: http://{}:{}", host_ip, port); println!("Address: {}://{}:{}", scheme, host_ip, port);
println!("Host ID: {}", short_id); println!("Host ID: {}", short_id);
println!("Expires in: {:02}:{:02}", ttl / 60, ttl % 60); println!("Expires in: {:02}:{:02}", ttl / 60, ttl % 60);
println!("\n{}", qr_rendered); println!("\n{}", qr_rendered);
@ -163,28 +192,41 @@ pub async fn run_once(
Ok(()) 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. /// Runs interactive terminal UI mode that updates in a loop.
pub async fn run_terminal_loop( pub async fn run_terminal_loop(
config: &AppConfig, config: &AppConfig,
hermes_url: Option<&str>,
scheme: &str,
port: u16, port: u16,
explicit_interface: Option<&str>, explicit_interface: Option<&str>,
ttl: u64, ttl: u64,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mut last_generated_at = std::time::Instant::now(); validate_ttl(ttl)?;
let mut current_payload = {
let interfaces = discover_network_interfaces().unwrap_or_default();
let (_iface_name, host_ip) = resolve_selected_ip(explicit_interface, &interfaces);
create_pairing_payload(
get_host_id(config),
get_display_name(config),
host_ip.to_string(),
port,
"http".to_string(),
ttl,
)
};
let mut last_generated_at = std::time::Instant::now();
let client = HermesProbeClient::new(); let client = HermesProbeClient::new();
loop { loop {
@ -194,24 +236,12 @@ pub async fn run_terminal_loop(
let interfaces = discover_network_interfaces().unwrap_or_default(); let interfaces = discover_network_interfaces().unwrap_or_default();
let (_iface_name, host_ip) = resolve_selected_ip(explicit_interface, &interfaces); let (_iface_name, host_ip) = resolve_selected_ip(explicit_interface, &interfaces);
// Regenerate if expired
if elapsed >= ttl { if elapsed >= ttl {
current_payload = create_pairing_payload(
get_host_id(config),
get_display_name(config),
host_ip.to_string(),
port,
"http".to_string(),
ttl,
);
last_generated_at = std::time::Instant::now(); last_generated_at = std::time::Instant::now();
} }
let remaining = ttl.saturating_sub(now.duration_since(last_generated_at).as_secs()); let remaining = ttl.saturating_sub(now.duration_since(last_generated_at).as_secs());
let probe_state = client.probe(port, Some(host_ip)).await;
let uri = encode_pairing_uri(&current_payload); let probe_state = client.probe(hermes_url, scheme, port, Some(host_ip)).await;
let qr_rendered = render_terminal_qr(&uri).unwrap_or_default();
// Clear terminal screen (cross-platform ANSI) // Clear terminal screen (cross-platform ANSI)
print!("\x1B[2J\x1B[1;1H"); print!("\x1B[2J\x1B[1;1H");
@ -225,6 +255,31 @@ pub async fn run_terminal_loop(
"None" "None"
}; };
println!("Hermes: Running (v{}, Auth: {})", ver, auth); 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, .. } => { ProbeState::LoopbackOnly { lan_error, .. } => {
println!( println!(
@ -232,33 +287,23 @@ pub async fn run_terminal_loop(
lan_error lan_error
); );
println!( println!(
"⚠️ Run Hermes with: hermes serve --host 0.0.0.0 --port {}", "⚠️ Warning: Hermes is bound to loopback only. Start Hermes with LAN access:\n hermes serve --host 0.0.0.0 --port {}",
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) => { ProbeState::Offline(err) => {
println!("Hermes: 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...");
} }
} }
let host_id = &current_payload.host_id;
let short_id = if host_id.len() >= 8 {
format!("{}...", &host_id[..8])
} else {
host_id.clone()
};
println!("Host: {}", current_payload.name);
println!(
"Address: http://{}:{}",
current_payload.host, current_payload.port
);
println!("Host ID: {}", short_id);
println!("Expires in: {:02}:{:02}", remaining / 60, remaining % 60);
println!("\n{}", qr_rendered);
println!("Pairing Link: {}", uri);
println!("\nPress Ctrl+C to exit."); println!("\nPress Ctrl+C to exit.");
sleep(Duration::from_secs(1)).await; sleep(Duration::from_secs(1)).await;
} }
} }

View file

@ -1,4 +1,5 @@
use crate::models::HermesStatusResponse; use crate::models::HermesStatusResponse;
use crate::network::is_loopback;
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use std::time::Duration; use std::time::Duration;
@ -21,6 +22,10 @@ impl ProbeState {
matches!(self, ProbeState::LoopbackOnly { .. }) matches!(self, ProbeState::LoopbackOnly { .. })
} }
pub fn is_offline(&self) -> bool {
matches!(self, ProbeState::Offline(_))
}
pub fn status_response(&self) -> Option<&HermesStatusResponse> { pub fn status_response(&self) -> Option<&HermesStatusResponse> {
match self { match self {
ProbeState::Online(ref resp) => Some(resp), ProbeState::Online(ref resp) => Some(resp),
@ -53,10 +58,11 @@ impl HermesProbeClient {
} }
pub async fn fetch_status(&self, base_url: &str) -> Result<HermesStatusResponse, String> { pub async fn fetch_status(&self, base_url: &str) -> Result<HermesStatusResponse, String> {
let url = if base_url.ends_with('/') { let trimmed = base_url.trim().trim_end_matches('/');
format!("{}api/status", base_url) let url = if trimmed.ends_with("/api/status") {
trimmed.to_string()
} else { } else {
format!("{}/api/status", base_url) format!("{}/api/status", trimmed)
}; };
let response = self let response = self
@ -75,16 +81,72 @@ impl HermesProbeClient {
.await .await
.map_err(|e| format!("Failed to parse status response: {}", e))?; .map_err(|e| format!("Failed to parse status response: {}", e))?;
if body.status.trim().is_empty() {
return Err("Invalid status response: missing 'status' field".to_string());
}
Ok(body) Ok(body)
} }
pub async fn probe(&self, port: u16, lan_ip: Option<Ipv4Addr>) -> ProbeState { pub async fn probe(
let local_url = format!("http://127.0.0.1:{}", port); &self,
hermes_url: Option<&str>,
scheme: &str,
port: u16,
lan_ip: Option<Ipv4Addr>,
) -> ProbeState {
if let Some(url_str) = hermes_url {
let direct_res = self.fetch_status(url_str).await;
if is_url_loopback(url_str) {
match direct_res {
Ok(local_status) => {
if let Some(lan) = lan_ip {
if !is_loopback(&lan) {
let lan_url = format!("{}://{}:{}", scheme, lan, port);
match self.fetch_status(&lan_url).await {
Ok(lan_status) => ProbeState::Online(lan_status),
Err(lan_err) => ProbeState::LoopbackOnly {
local_status,
lan_error: lan_err,
},
}
} else {
ProbeState::Online(local_status)
}
} else {
ProbeState::Online(local_status)
}
}
Err(err) => {
if let Some(lan) = lan_ip {
if !is_loopback(&lan) {
let lan_url = format!("{}://{}:{}", scheme, lan, port);
match self.fetch_status(&lan_url).await {
Ok(lan_status) => ProbeState::Online(lan_status),
Err(_) => ProbeState::Offline(err),
}
} else {
ProbeState::Offline(err)
}
} else {
ProbeState::Offline(err)
}
}
}
} else {
match direct_res {
Ok(status) => ProbeState::Online(status),
Err(err) => ProbeState::Offline(err),
}
}
} else {
let local_url = format!("{}://127.0.0.1:{}", scheme, port);
let local_result = self.fetch_status(&local_url).await; let local_result = self.fetch_status(&local_url).await;
match (local_result, lan_ip) { match (local_result, lan_ip) {
(Ok(local_status), Some(lan)) => { (Ok(local_status), Some(lan)) if !is_loopback(&lan) => {
let lan_url = format!("http://{}:{}", lan, port); let lan_url = format!("{}://{}:{}", scheme, lan, port);
match self.fetch_status(&lan_url).await { match self.fetch_status(&lan_url).await {
Ok(lan_status) => ProbeState::Online(lan_status), Ok(lan_status) => ProbeState::Online(lan_status),
Err(lan_err) => ProbeState::LoopbackOnly { Err(lan_err) => ProbeState::LoopbackOnly {
@ -93,25 +155,45 @@ impl HermesProbeClient {
}, },
} }
} }
(Ok(local_status), None) => ProbeState::Online(local_status), (Ok(local_status), _) => ProbeState::Online(local_status),
(Err(local_err), Some(lan)) => { (Err(local_err), Some(lan)) if !is_loopback(&lan) => {
let lan_url = format!("http://{}:{}", lan, port); let lan_url = format!("{}://{}:{}", scheme, lan, port);
match self.fetch_status(&lan_url).await { match self.fetch_status(&lan_url).await {
Ok(lan_status) => ProbeState::Online(lan_status), Ok(lan_status) => ProbeState::Online(lan_status),
Err(_) => ProbeState::Offline(local_err), Err(_) => ProbeState::Offline(local_err),
} }
} }
(Err(local_err), None) => ProbeState::Offline(local_err), (Err(local_err), _) => ProbeState::Offline(local_err),
} }
} }
} }
}
fn is_url_loopback(url_or_host: &str) -> bool {
let host = if let Ok(parsed) = url::Url::parse(url_or_host) {
parsed.host_str().unwrap_or("").to_string()
} else {
url_or_host.to_string()
};
let host_lower = host.trim().to_lowercase();
host_lower == "127.0.0.1"
|| host_lower == "localhost"
|| host_lower == "::1"
|| host_lower == "[::1]"
|| host_lower.starts_with("127.")
}
pub async fn probe_hermes_status(base_url: &str) -> Result<HermesStatusResponse, String> { pub async fn probe_hermes_status(base_url: &str) -> Result<HermesStatusResponse, String> {
let client = HermesProbeClient::new(); let client = HermesProbeClient::new();
client.fetch_status(base_url).await client.fetch_status(base_url).await
} }
pub async fn probe_hermes(port: u16, lan_ip: Option<Ipv4Addr>) -> ProbeState { pub async fn probe_hermes(
hermes_url: Option<&str>,
scheme: &str,
port: u16,
lan_ip: Option<Ipv4Addr>,
) -> ProbeState {
let client = HermesProbeClient::new(); let client = HermesProbeClient::new();
client.probe(port, lan_ip).await client.probe(hermes_url, scheme, port, lan_ip).await
} }

View file

@ -1,27 +1,51 @@
use clap::Parser; use clap::Parser;
use eframe::egui::Vec2; use eframe::egui::Vec2;
use hermes_pair::app::HermesPairApp; use hermes_pair::app::HermesPairApp;
use hermes_pair::cli::{run_once, run_terminal_loop, CliArgs, CliCommand}; use hermes_pair::cli::{resolve_cli_endpoint, run_once, run_terminal_loop, CliArgs, CliCommand};
use hermes_pair::config::load_or_create_config; use hermes_pair::config::load_or_create_config;
use hermes_pair::pairing::validate_ttl;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = CliArgs::parse(); let args = CliArgs::parse();
let config = load_or_create_config()?; let config = load_or_create_config()?;
if let Some(CliCommand::Qr(qr_args)) = &args.command { if let Some(CliCommand::Qr(ref qr_args)) = args.command {
let port = qr_args.port.unwrap_or(args.port); let hermes_url = qr_args.hermes_url.as_deref().or(args.hermes_url.as_deref());
let (scheme, port) = resolve_cli_endpoint(hermes_url, qr_args.port.or(args.port))?;
let iface = qr_args.interface.as_deref().or(args.interface.as_deref()); let iface = qr_args.interface.as_deref().or(args.interface.as_deref());
let ttl = qr_args.ttl.unwrap_or(args.ttl); let ttl = qr_args.ttl.unwrap_or(args.ttl);
return run_once(&config, port, iface, ttl).await; validate_ttl(ttl)?;
return run_once(&config, hermes_url, &scheme, port, iface, ttl).await;
} }
let hermes_url_str = args.hermes_url.as_deref();
let (scheme, port) = resolve_cli_endpoint(hermes_url_str, args.port)?;
let ttl = args.ttl;
validate_ttl(ttl)?;
if args.no_gui { if args.no_gui {
return run_once(&config, args.port, args.interface.as_deref(), args.ttl).await; return run_once(
&config,
hermes_url_str,
&scheme,
port,
args.interface.as_deref(),
ttl,
)
.await;
} }
if args.terminal { if args.terminal {
return run_terminal_loop(&config, args.port, args.interface.as_deref(), args.ttl).await; return run_terminal_loop(
&config,
hermes_url_str,
&scheme,
port,
args.interface.as_deref(),
ttl,
)
.await;
} }
// Launch GUI // Launch GUI
@ -34,9 +58,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}; };
let config_clone = config.clone(); let config_clone = config.clone();
let port = args.port; let hermes_url_owned = args.hermes_url.clone();
let scheme_clone = scheme.clone();
let iface = args.interface.clone(); let iface = args.interface.clone();
let ttl = args.ttl;
let res = eframe::run_native( let res = eframe::run_native(
"Hermes Pair", "Hermes Pair",
@ -45,6 +69,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(Box::new(HermesPairApp::new( Ok(Box::new(HermesPairApp::new(
cc, cc,
config_clone, config_clone,
hermes_url_owned,
scheme_clone,
port, port,
iface, iface,
ttl, ttl,
@ -55,7 +81,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Err(e) = res { if let Err(e) = res {
eprintln!("Failed to launch GUI: {}", e); eprintln!("Failed to launch GUI: {}", e);
eprintln!("Falling back to terminal mode..."); eprintln!("Falling back to terminal mode...");
return run_terminal_loop(&config, args.port, args.interface.as_deref(), args.ttl).await; return run_terminal_loop(
&config,
args.hermes_url.as_deref(),
&scheme,
port,
args.interface.as_deref(),
ttl,
)
.await;
} }
Ok(()) Ok(())

View file

@ -7,19 +7,36 @@ use std::time::{SystemTime, UNIX_EPOCH};
use url::Url; use url::Url;
use uuid::Uuid; use uuid::Uuid;
pub const MIN_TTL_SECONDS: u64 = 10;
pub const MAX_TTL_SECONDS: u64 = 600;
pub const DEFAULT_TTL_SECONDS: u64 = 120;
pub const MAX_CLOCK_SKEW_SECONDS: u64 = 30;
pub const MAX_ENCODED_URI_BYTES: usize = 4096;
pub const MAX_DECODED_JSON_BYTES: usize = 2048;
pub const MAX_NAME_LENGTH: usize = 128;
pub const MIN_NONCE_BYTES: usize = 16;
pub const MAX_NONCE_BYTES: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum PairingError { pub enum PairingError {
InvalidUriScheme(String), InvalidUriScheme(String),
InvalidUriFormat(String), InvalidUriFormat(String),
MissingDataParameter, MissingDataParameter,
PayloadTooLarge { size: usize, max: usize },
Base64DecodeError(String), Base64DecodeError(String),
JsonDecodeError(String), JsonDecodeError(String),
UnsupportedVersion(u32), UnsupportedVersion(u32),
InvalidPayloadType(String), InvalidPayloadType(String),
InvalidHostId(String), InvalidHostId(String),
InvalidName(String),
EmptyHost, EmptyHost,
InvalidHost(String),
InvalidPort(u16), InvalidPort(u16),
InvalidScheme(String),
InvalidNonce(String),
PayloadExpired { expires_at: u64, now: u64 }, PayloadExpired { expires_at: u64, now: u64 },
TtlExceedsMaximum { expires_at: u64, max_allowed: u64 },
InvalidTtl { ttl: u64, min: u64, max: u64 },
} }
impl fmt::Display for PairingError { impl fmt::Display for PairingError {
@ -32,6 +49,13 @@ impl fmt::Display for PairingError {
PairingError::MissingDataParameter => { PairingError::MissingDataParameter => {
write!(f, "Missing 'data' query parameter in pairing URI") write!(f, "Missing 'data' query parameter in pairing URI")
} }
PairingError::PayloadTooLarge { size, max } => {
write!(
f,
"Payload size ({} bytes) exceeds maximum limit of {} bytes",
size, max
)
}
PairingError::Base64DecodeError(e) => { PairingError::Base64DecodeError(e) => {
write!(f, "Failed to decode Base64URL payload: {}", e) write!(f, "Failed to decode Base64URL payload: {}", e)
} }
@ -43,8 +67,14 @@ impl fmt::Display for PairingError {
write!(f, "Invalid payload type '{}', expected 'hermes-pair'", t) write!(f, "Invalid payload type '{}', expected 'hermes-pair'", t)
} }
PairingError::InvalidHostId(id) => write!(f, "Invalid host UUID: '{}'", id), PairingError::InvalidHostId(id) => write!(f, "Invalid host UUID: '{}'", id),
PairingError::InvalidName(msg) => write!(f, "Invalid host display name: {}", msg),
PairingError::EmptyHost => write!(f, "Host address cannot be empty"), PairingError::EmptyHost => write!(f, "Host address cannot be empty"),
PairingError::InvalidHost(msg) => write!(f, "Invalid host address: {}", msg),
PairingError::InvalidPort(p) => write!(f, "Invalid port number: {}", p), PairingError::InvalidPort(p) => write!(f, "Invalid port number: {}", p),
PairingError::InvalidScheme(s) => {
write!(f, "Invalid scheme '{}', expected 'http' or 'https'", s)
}
PairingError::InvalidNonce(msg) => write!(f, "Invalid nonce: {}", msg),
PairingError::PayloadExpired { expires_at, now } => { PairingError::PayloadExpired { expires_at, now } => {
write!( write!(
f, f,
@ -52,6 +82,23 @@ impl fmt::Display for PairingError {
expires_at, now expires_at, now
) )
} }
PairingError::TtlExceedsMaximum {
expires_at,
max_allowed,
} => {
write!(
f,
"Pairing payload expiry timestamp {} exceeds maximum allowed {}",
expires_at, max_allowed
)
}
PairingError::InvalidTtl { ttl, min, max } => {
write!(
f,
"Invalid TTL {}s: TTL must be between {} and {} seconds",
ttl, min, max
)
}
} }
} }
} }
@ -66,11 +113,145 @@ pub fn current_unix_timestamp() -> u64 {
} }
pub fn generate_nonce() -> String { pub fn generate_nonce() -> String {
let mut bytes = [0u8; 16]; let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes); rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes) URL_SAFE_NO_PAD.encode(bytes)
} }
pub fn validate_ttl(ttl: u64) -> Result<(), PairingError> {
if !(MIN_TTL_SECONDS..=MAX_TTL_SECONDS).contains(&ttl) {
return Err(PairingError::InvalidTtl {
ttl,
min: MIN_TTL_SECONDS,
max: MAX_TTL_SECONDS,
});
}
Ok(())
}
pub fn validate_payload(payload: &PairingPayloadV1, current_time: u64) -> Result<(), PairingError> {
// 1. Version must be 1
if payload.v != 1 {
return Err(PairingError::UnsupportedVersion(payload.v));
}
// 2. Payload type must be "hermes-pair"
if payload.payload_type != "hermes-pair" {
return Err(PairingError::InvalidPayloadType(
payload.payload_type.clone(),
));
}
// 3. Host ID must be a valid UUIDv4 string
let host_uuid = Uuid::parse_str(&payload.host_id).map_err(|_| {
PairingError::InvalidHostId(format!("'{}' is not a valid UUID", payload.host_id))
})?;
if host_uuid.get_version_num() != 4 {
return Err(PairingError::InvalidHostId(format!(
"UUID must be version 4 (random), got version {}",
host_uuid.get_version_num()
)));
}
// 4. Name: not blank, trimmed <= 128 chars, no control characters
let trimmed_name = payload.name.trim();
if trimmed_name.is_empty() {
return Err(PairingError::InvalidName(
"Host display name cannot be blank".into(),
));
}
if trimmed_name.chars().count() > MAX_NAME_LENGTH {
return Err(PairingError::InvalidName(format!(
"Host display name length ({}) exceeds maximum allowed {}",
trimmed_name.chars().count(),
MAX_NAME_LENGTH
)));
}
if payload
.name
.chars()
.any(|c| (c as u32) < 0x20 || (c as u32) == 0x7F)
{
return Err(PairingError::InvalidName(
"Host display name contains forbidden control characters".into(),
));
}
// 5. Host: not blank, no whitespace, no forbidden chars: / \ ? # @ : control chars
let trimmed_host = payload.host.trim();
if trimmed_host.is_empty() {
return Err(PairingError::EmptyHost);
}
if payload.host.chars().any(|c| {
c.is_whitespace()
|| ['/', '\\', '?', '#', '@', ':'].contains(&c)
|| (c as u32) < 0x20
|| (c as u32) == 0x7F
}) {
return Err(PairingError::InvalidHost(format!(
"Host '{}' contains forbidden characters (whitespace, delimiters, or control characters)",
payload.host
)));
}
// 6. Port: 1..=65535 (u16 is <= 65535, port 0 is invalid)
if payload.port == 0 {
return Err(PairingError::InvalidPort(0));
}
// 7. Scheme: "http" or "https"
if payload.scheme != "http" && payload.scheme != "https" {
return Err(PairingError::InvalidScheme(format!(
"Invalid scheme '{}', must be 'http' or 'https'",
payload.scheme
)));
}
// 8. Nonce: Base64URL-encoded, decodes to >= 16 bytes and <= 64 bytes
let trimmed_nonce = payload.nonce.trim();
if trimmed_nonce.is_empty() {
return Err(PairingError::InvalidNonce("Nonce cannot be empty".into()));
}
let decoded_nonce = URL_SAFE_NO_PAD
.decode(trimmed_nonce.as_bytes())
.or_else(|_| URL_SAFE.decode(trimmed_nonce.as_bytes()))
.or_else(|_| STANDARD.decode(trimmed_nonce.as_bytes()))
.map_err(|e| PairingError::InvalidNonce(format!("Nonce Base64 decode failed: {}", e)))?;
if decoded_nonce.len() < MIN_NONCE_BYTES {
return Err(PairingError::InvalidNonce(format!(
"Nonce length {} bytes is below minimum {} bytes (128 bits)",
decoded_nonce.len(),
MIN_NONCE_BYTES
)));
}
if decoded_nonce.len() > MAX_NONCE_BYTES {
return Err(PairingError::InvalidNonce(format!(
"Nonce length {} bytes exceeds maximum {} bytes",
decoded_nonce.len(),
MAX_NONCE_BYTES
)));
}
// 9. Expires at: now - 30 <= expires_at <= now + 600
let min_allowed_expiry = current_time.saturating_sub(MAX_CLOCK_SKEW_SECONDS);
if payload.expires_at < min_allowed_expiry {
return Err(PairingError::PayloadExpired {
expires_at: payload.expires_at,
now: current_time,
});
}
let max_allowed_expiry = current_time + MAX_TTL_SECONDS;
if payload.expires_at > max_allowed_expiry {
return Err(PairingError::TtlExceedsMaximum {
expires_at: payload.expires_at,
max_allowed: max_allowed_expiry,
});
}
Ok(())
}
pub fn create_pairing_payload( pub fn create_pairing_payload(
host_id: String, host_id: String,
name: String, name: String,
@ -80,7 +261,8 @@ pub fn create_pairing_payload(
ttl_seconds: u64, ttl_seconds: u64,
) -> PairingPayloadV1 { ) -> PairingPayloadV1 {
let now = current_unix_timestamp(); let now = current_unix_timestamp();
let expires_at = now + ttl_seconds; let ttl = ttl_seconds.clamp(MIN_TTL_SECONDS, MAX_TTL_SECONDS);
let expires_at = now + ttl;
let nonce = generate_nonce(); let nonce = generate_nonce();
PairingPayloadV1 { PairingPayloadV1 {
@ -107,6 +289,14 @@ pub fn decode_pairing_uri_at_time(
uri: &str, uri: &str,
current_time: u64, current_time: u64,
) -> Result<PairingPayloadV1, PairingError> { ) -> Result<PairingPayloadV1, PairingError> {
// Check encoded URI length bound
if uri.len() > MAX_ENCODED_URI_BYTES {
return Err(PairingError::PayloadTooLarge {
size: uri.len(),
max: MAX_ENCODED_URI_BYTES,
});
}
let data_str = if let Ok(url) = Url::parse(uri) { let data_str = if let Ok(url) = Url::parse(uri) {
if url.scheme() != "hermes" { if url.scheme() != "hermes" {
return Err(PairingError::InvalidUriScheme(url.scheme().to_string())); return Err(PairingError::InvalidUriScheme(url.scheme().to_string()));
@ -148,36 +338,18 @@ pub fn decode_pairing_uri_at_time(
.or_else(|_| STANDARD.decode(data_str.as_bytes())) .or_else(|_| STANDARD.decode(data_str.as_bytes()))
.map_err(|e| PairingError::Base64DecodeError(e.to_string()))?; .map_err(|e| PairingError::Base64DecodeError(e.to_string()))?;
// Check decoded payload size limit
if decoded_bytes.len() > MAX_DECODED_JSON_BYTES {
return Err(PairingError::PayloadTooLarge {
size: decoded_bytes.len(),
max: MAX_DECODED_JSON_BYTES,
});
}
let payload: PairingPayloadV1 = serde_json::from_slice(&decoded_bytes) let payload: PairingPayloadV1 = serde_json::from_slice(&decoded_bytes)
.map_err(|e| PairingError::JsonDecodeError(e.to_string()))?; .map_err(|e| PairingError::JsonDecodeError(e.to_string()))?;
// Validations validate_payload(&payload, current_time)?;
if payload.v != 1 {
return Err(PairingError::UnsupportedVersion(payload.v));
}
if payload.payload_type != "hermes-pair" {
return Err(PairingError::InvalidPayloadType(payload.payload_type));
}
if Uuid::parse_str(&payload.host_id).is_err() {
return Err(PairingError::InvalidHostId(payload.host_id));
}
if payload.host.trim().is_empty() {
return Err(PairingError::EmptyHost);
}
if payload.port == 0 {
return Err(PairingError::InvalidPort(payload.port));
}
if payload.expires_at <= current_time {
return Err(PairingError::PayloadExpired {
expires_at: payload.expires_at,
now: current_time,
});
}
Ok(payload) Ok(payload)
} }

View file

@ -1,12 +1,13 @@
use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine; use base64::Engine;
use hermes_pair::cli::{parse_hermes_url, resolve_cli_endpoint};
use hermes_pair::config::load_or_create_config_from_path; use hermes_pair::config::load_or_create_config_from_path;
use hermes_pair::hermes::HermesProbeClient; use hermes_pair::hermes::HermesProbeClient;
use hermes_pair::models::{NetworkInterfaceInfo, PairingPayloadV1}; use hermes_pair::models::{NetworkInterfaceInfo, PairingPayloadV1};
use hermes_pair::network::filter_and_sort_interfaces; use hermes_pair::network::filter_and_sort_interfaces;
use hermes_pair::pairing::{ use hermes_pair::pairing::{
create_pairing_payload, decode_pairing_uri, decode_pairing_uri_at_time, encode_pairing_uri, create_pairing_payload, decode_pairing_uri, decode_pairing_uri_at_time, encode_pairing_uri,
PairingError, validate_payload, validate_ttl, PairingError, MAX_DECODED_JSON_BYTES, MAX_ENCODED_URI_BYTES,
}; };
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use std::path::PathBuf; use std::path::PathBuf;
@ -14,25 +15,65 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use uuid::Uuid; use uuid::Uuid;
#[test]
fn test_canonical_cross_contract_fixture() {
let payload = PairingPayloadV1 {
v: 1,
payload_type: "hermes-pair".to_string(),
host_id: "58af1471-a0a2-4e2b-9426-5068f2a2deab".to_string(),
name: "Office-PC".to_string(),
host: "192.168.1.150".to_string(),
port: 9119,
scheme: "http".to_string(),
expires_at: 1800000000,
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
};
let json_str = serde_json::to_string(&payload).expect("Serialization failed");
assert!(json_str.contains("\"v\":1"));
assert!(json_str.contains("\"type\":\"hermes-pair\""));
assert!(json_str.contains("\"host_id\":\"58af1471-a0a2-4e2b-9426-5068f2a2deab\""));
assert!(json_str.contains("\"name\":\"Office-PC\""));
assert!(json_str.contains("\"host\":\"192.168.1.150\""));
assert!(json_str.contains("\"port\":9119"));
assert!(json_str.contains("\"scheme\":\"http\""));
assert!(json_str.contains("\"expires_at\":1800000000"));
assert!(json_str.contains("\"nonce\":\"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY\""));
let uri = encode_pairing_uri(&payload);
assert!(uri.starts_with("hermes://pair?data="));
// Decode at current_time = expires_at - 60 (well within validity window)
let decode_time = 1800000000 - 60;
let decoded = decode_pairing_uri_at_time(&uri, decode_time)
.expect("Canonical fixture must decode successfully");
assert_eq!(decoded.v, 1);
assert_eq!(decoded.payload_type, "hermes-pair");
assert_eq!(decoded.host_id, "58af1471-a0a2-4e2b-9426-5068f2a2deab");
assert_eq!(decoded.name, "Office-PC");
assert_eq!(decoded.host, "192.168.1.150");
assert_eq!(decoded.port, 9119);
assert_eq!(decoded.scheme, "http");
assert_eq!(decoded.expires_at, 1800000000);
assert_eq!(decoded.nonce, "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY");
}
#[test] #[test]
fn test_config_persistence() { fn test_config_persistence() {
let tmp_dir = std::env::temp_dir(); let tmp_dir = std::env::temp_dir();
let config_path: PathBuf = tmp_dir.join(format!("hermes_test_config_{}.json", Uuid::new_v4())); let config_path: PathBuf = tmp_dir.join(format!("hermes_test_config_{}.json", Uuid::new_v4()));
// Ensure clean state
let _ = std::fs::remove_file(&config_path); let _ = std::fs::remove_file(&config_path);
// First load -> generates new config and persists it
let config1 = load_or_create_config_from_path(&config_path).expect("Should create new config"); let config1 = load_or_create_config_from_path(&config_path).expect("Should create new config");
assert!(!config1.host_id.is_empty()); assert!(!config1.host_id.is_empty());
assert!(Uuid::parse_str(&config1.host_id).is_ok()); assert!(Uuid::parse_str(&config1.host_id).is_ok());
// Second load -> loads existing config and keeps same host_id
let config2 = let config2 =
load_or_create_config_from_path(&config_path).expect("Should load existing config"); load_or_create_config_from_path(&config_path).expect("Should load existing config");
assert_eq!(config1.host_id, config2.host_id); assert_eq!(config1.host_id, config2.host_id);
// Clean up
let _ = std::fs::remove_file(&config_path); let _ = std::fs::remove_file(&config_path);
} }
@ -48,7 +89,7 @@ fn test_pairing_payload_serde() {
port: 9119, port: 9119,
scheme: "http".to_string(), scheme: "http".to_string(),
expires_at: 1800000000, expires_at: 1800000000,
nonce: "test_nonce_1234".to_string(), nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
}; };
let json = serde_json::to_string(&payload).expect("Serialization failed"); let json = serde_json::to_string(&payload).expect("Serialization failed");
@ -56,7 +97,6 @@ fn test_pairing_payload_serde() {
serde_json::from_str(&json).expect("Deserialization failed"); serde_json::from_str(&json).expect("Deserialization failed");
assert_eq!(payload, deserialized); assert_eq!(payload, deserialized);
// Verify Base64URL round-trip
let b64 = URL_SAFE_NO_PAD.encode(json.as_bytes()); let b64 = URL_SAFE_NO_PAD.encode(json.as_bytes());
let decoded_bytes = URL_SAFE_NO_PAD let decoded_bytes = URL_SAFE_NO_PAD
.decode(b64.as_bytes()) .decode(b64.as_bytes())
@ -91,6 +131,18 @@ fn test_pairing_uri_encoding_and_validation() {
assert_eq!(decoded.scheme, "http"); assert_eq!(decoded.scheme, "http");
} }
#[test]
fn test_ttl_validation_bounds() {
assert!(validate_ttl(0).is_err());
assert!(validate_ttl(5).is_err());
assert!(validate_ttl(9).is_err());
assert!(validate_ttl(10).is_ok());
assert!(validate_ttl(120).is_ok());
assert!(validate_ttl(600).is_ok());
assert!(validate_ttl(601).is_err());
assert!(validate_ttl(1000).is_err());
}
#[test] #[test]
fn test_expired_payload_rejection() { fn test_expired_payload_rejection() {
let host_id = Uuid::new_v4().to_string(); let host_id = Uuid::new_v4().to_string();
@ -103,7 +155,7 @@ fn test_expired_payload_rejection() {
port: 9119, port: 9119,
scheme: "http".to_string(), scheme: "http".to_string(),
expires_at: 1000, expires_at: 1000,
nonce: "test_nonce".to_string(), nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
}; };
let uri = encode_pairing_uri(&payload); let uri = encode_pairing_uri(&payload);
@ -119,22 +171,46 @@ fn test_expired_payload_rejection() {
} }
#[test] #[test]
fn test_invalid_version_rejection() { fn test_excessive_future_ttl_rejection() {
let host_id = Uuid::new_v4().to_string(); let host_id = Uuid::new_v4().to_string();
let payload = PairingPayloadV1 { let payload = PairingPayloadV1 {
v: 2, // Unsupported version v: 1,
payload_type: "hermes-pair".to_string(), payload_type: "hermes-pair".to_string(),
host_id, host_id,
name: "Future-Node".to_string(), name: "Future-Node".to_string(),
host: "10.0.0.5".to_string(), host: "10.0.0.5".to_string(),
port: 9119, port: 9119,
scheme: "http".to_string(), scheme: "http".to_string(),
expires_at: 3000000000, expires_at: 1000 + 700, // Exceeds now + 600
nonce: "test_nonce".to_string(), nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
}; };
let uri = encode_pairing_uri(&payload); let uri = encode_pairing_uri(&payload);
let result = decode_pairing_uri_at_time(&uri, 10000); let result = decode_pairing_uri_at_time(&uri, 1000);
match result {
Err(PairingError::TtlExceedsMaximum { .. }) => {}
other => panic!("Expected TtlExceedsMaximum error, got {:?}", other),
}
}
#[test]
fn test_invalid_version_rejection() {
let host_id = Uuid::new_v4().to_string();
let payload = PairingPayloadV1 {
v: 2,
payload_type: "hermes-pair".to_string(),
host_id,
name: "Future-Node".to_string(),
host: "10.0.0.5".to_string(),
port: 9119,
scheme: "http".to_string(),
expires_at: 1100,
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
};
let uri = encode_pairing_uri(&payload);
let result = decode_pairing_uri_at_time(&uri, 1000);
match result { match result {
Err(PairingError::UnsupportedVersion(v)) => { Err(PairingError::UnsupportedVersion(v)) => {
@ -144,6 +220,127 @@ fn test_invalid_version_rejection() {
} }
} }
#[test]
fn test_invalid_payload_type_rejection() {
let host_id = Uuid::new_v4().to_string();
let payload = PairingPayloadV1 {
v: 1,
payload_type: "hermes-auth".to_string(),
host_id,
name: "Bad-Type-Node".to_string(),
host: "10.0.0.5".to_string(),
port: 9119,
scheme: "http".to_string(),
expires_at: 1100,
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
};
let uri = encode_pairing_uri(&payload);
let result = decode_pairing_uri_at_time(&uri, 1000);
match result {
Err(PairingError::InvalidPayloadType(t)) => {
assert_eq!(t, "hermes-auth");
}
other => panic!("Expected InvalidPayloadType error, got {:?}", other),
}
}
#[test]
fn test_invalid_uuid_rejection() {
let payload = PairingPayloadV1 {
v: 1,
payload_type: "hermes-pair".to_string(),
host_id: "not-a-valid-uuid".to_string(),
name: "Bad-UUID-Node".to_string(),
host: "10.0.0.5".to_string(),
port: 9119,
scheme: "http".to_string(),
expires_at: 1100,
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
};
let uri = encode_pairing_uri(&payload);
let result = decode_pairing_uri_at_time(&uri, 1000);
match result {
Err(PairingError::InvalidHostId(_)) => {}
other => panic!("Expected InvalidHostId error, got {:?}", other),
}
}
#[test]
fn test_blank_or_oversized_name_rejection() {
let host_id = Uuid::new_v4().to_string();
// Blank name
let mut payload = PairingPayloadV1 {
v: 1,
payload_type: "hermes-pair".to_string(),
host_id: host_id.clone(),
name: " ".to_string(),
host: "10.0.0.5".to_string(),
port: 9119,
scheme: "http".to_string(),
expires_at: 1100,
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
};
assert!(validate_payload(&payload, 1000).is_err());
// Control characters in name
payload.name = "Office\x00PC".to_string();
assert!(validate_payload(&payload, 1000).is_err());
payload.name = "Office\nPC".to_string();
assert!(validate_payload(&payload, 1000).is_err());
// Oversized name (>128 chars)
payload.name = "A".repeat(129);
assert!(validate_payload(&payload, 1000).is_err());
// Valid 128-char name
payload.name = "A".repeat(128);
assert!(validate_payload(&payload, 1000).is_ok());
}
#[test]
fn test_malicious_host_rejection() {
let host_id = Uuid::new_v4().to_string();
let base_payload = PairingPayloadV1 {
v: 1,
payload_type: "hermes-pair".to_string(),
host_id,
name: "Node".to_string(),
host: "".to_string(),
port: 9119,
scheme: "http".to_string(),
expires_at: 1100,
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
};
let forbidden_hosts = vec![
"",
" ",
"user@evil.com",
"evil.com/path",
"evil.com\\path",
"evil.com?param=1",
"evil.com#frag",
"192.168.1.1:9119",
"192.168.1.1 evil.com",
"192.168.1.1\x00",
];
for bad_host in forbidden_hosts {
let mut p = base_payload.clone();
p.host = bad_host.to_string();
assert!(
validate_payload(&p, 1000).is_err(),
"Host '{}' should be rejected",
bad_host
);
}
}
#[test] #[test]
fn test_invalid_port_rejection() { fn test_invalid_port_rejection() {
let host_id = Uuid::new_v4().to_string(); let host_id = Uuid::new_v4().to_string();
@ -153,14 +350,14 @@ fn test_invalid_port_rejection() {
host_id, host_id,
name: "Invalid-Port-Node".to_string(), name: "Invalid-Port-Node".to_string(),
host: "192.168.1.1".to_string(), host: "192.168.1.1".to_string(),
port: 0, // Port 0 is invalid port: 0,
scheme: "http".to_string(), scheme: "http".to_string(),
expires_at: 3000000000, expires_at: 1100,
nonce: "test_nonce".to_string(), nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
}; };
let uri = encode_pairing_uri(&payload); let uri = encode_pairing_uri(&payload);
let result = decode_pairing_uri_at_time(&uri, 10000); let result = decode_pairing_uri_at_time(&uri, 1000);
match result { match result {
Err(PairingError::InvalidPort(p)) => { Err(PairingError::InvalidPort(p)) => {
@ -170,6 +367,138 @@ fn test_invalid_port_rejection() {
} }
} }
#[test]
fn test_invalid_scheme_rejection() {
let host_id = Uuid::new_v4().to_string();
let mut payload = PairingPayloadV1 {
v: 1,
payload_type: "hermes-pair".to_string(),
host_id,
name: "Node".to_string(),
host: "192.168.1.1".to_string(),
port: 9119,
scheme: "ftp".to_string(),
expires_at: 1100,
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
};
assert!(validate_payload(&payload, 1000).is_err());
payload.scheme = "ws".to_string();
assert!(validate_payload(&payload, 1000).is_err());
payload.scheme = "http".to_string();
assert!(validate_payload(&payload, 1000).is_ok());
payload.scheme = "https".to_string();
assert!(validate_payload(&payload, 1000).is_ok());
}
#[test]
fn test_nonce_validation() {
let host_id = Uuid::new_v4().to_string();
let mut payload = PairingPayloadV1 {
v: 1,
payload_type: "hermes-pair".to_string(),
host_id,
name: "Node".to_string(),
host: "192.168.1.1".to_string(),
port: 9119,
scheme: "http".to_string(),
expires_at: 1100,
nonce: "".to_string(),
};
// Empty nonce rejected
assert!(validate_payload(&payload, 1000).is_err());
// Nonce too short (< 16 bytes decoded)
let short_bytes = [1u8; 15];
payload.nonce = URL_SAFE_NO_PAD.encode(short_bytes);
assert!(validate_payload(&payload, 1000).is_err());
// Valid 16-byte nonce
let valid_16 = [1u8; 16];
payload.nonce = URL_SAFE_NO_PAD.encode(valid_16);
assert!(validate_payload(&payload, 1000).is_ok());
// Valid 32-byte nonce
let valid_32 = [1u8; 32];
payload.nonce = URL_SAFE_NO_PAD.encode(valid_32);
assert!(validate_payload(&payload, 1000).is_ok());
// Valid 64-byte nonce
let valid_64 = [1u8; 64];
payload.nonce = URL_SAFE_NO_PAD.encode(valid_64);
assert!(validate_payload(&payload, 1000).is_ok());
// Nonce too long (> 64 bytes decoded)
let too_long = [1u8; 65];
payload.nonce = URL_SAFE_NO_PAD.encode(too_long);
assert!(validate_payload(&payload, 1000).is_err());
// Invalid base64 characters
payload.nonce = "not-valid-base64!@#$%".to_string();
assert!(validate_payload(&payload, 1000).is_err());
}
#[test]
fn test_oversized_payload_rejection() {
let huge_uri = format!(
"hermes://pair?data={}",
"A".repeat(MAX_ENCODED_URI_BYTES + 10)
);
let result = decode_pairing_uri(&huge_uri);
match result {
Err(PairingError::PayloadTooLarge { .. }) => {}
other => panic!("Expected PayloadTooLarge error, got {:?}", other),
}
// Huge JSON decoded payload
let huge_data = vec![b' '; MAX_DECODED_JSON_BYTES + 100];
let encoded = URL_SAFE_NO_PAD.encode(&huge_data);
let uri = format!("hermes://pair?data={}", encoded);
let result = decode_pairing_uri(&uri);
match result {
Err(PairingError::PayloadTooLarge { .. }) => {}
other => panic!("Expected PayloadTooLarge error, got {:?}", other),
}
}
#[test]
fn test_cli_parse_hermes_url_and_endpoint_resolution() {
let (scheme, host, port) =
parse_hermes_url("http://127.0.0.1:9222").expect("Should parse hermes url");
assert_eq!(scheme, "http");
assert_eq!(host, "127.0.0.1");
assert_eq!(port, 9222);
let (scheme, host, port) =
parse_hermes_url("https://localhost:8443").expect("Should parse https hermes url");
assert_eq!(scheme, "https");
assert_eq!(host, "localhost");
assert_eq!(port, 8443);
assert!(parse_hermes_url("ftp://127.0.0.1:9119").is_err());
// Endpoint resolution
let (s, p) = resolve_cli_endpoint(Some("http://127.0.0.1:9222"), None).unwrap();
assert_eq!(s, "http");
assert_eq!(p, 9222);
let (s, p) = resolve_cli_endpoint(Some("https://127.0.0.1:9222"), Some(8888)).unwrap();
assert_eq!(s, "https");
assert_eq!(p, 8888);
let (s, p) = resolve_cli_endpoint(None, None).unwrap();
assert_eq!(s, "http");
assert_eq!(p, 9119);
let (s, p) = resolve_cli_endpoint(None, Some(9555)).unwrap();
assert_eq!(s, "http");
assert_eq!(p, 9555);
}
#[test] #[test]
fn test_network_interface_filtering() { fn test_network_interface_filtering() {
let test_interfaces = vec![ let test_interfaces = vec![