fix(security): hard contract parity, --hermes-url support, strict status probe gating, and canonical fixture
This commit is contained in:
parent
5c905a5c26
commit
538f255f07
6 changed files with 942 additions and 206 deletions
158
src/app.rs
158
src/app.rs
|
|
@ -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(¤t_payload);
|
let current_uri = encode_pairing_uri(¤t_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,11 +303,12 @@ 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 =
|
||||||
format!("{} ({})", iface.name, iface.ip)
|
if let Some(iface) = self.interfaces.get(self.selected_iface_index) {
|
||||||
} else {
|
format!("{} ({})", iface.name, iface.ip)
|
||||||
"None".to_string()
|
} else {
|
||||||
};
|
"None".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
let prev_idx = self.selected_iface_index;
|
let prev_idx = self.selected_iface_index;
|
||||||
egui::ComboBox::from_id_salt("interface_select")
|
egui::ComboBox::from_id_salt("interface_select")
|
||||||
|
|
@ -311,41 +336,87 @@ 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 {
|
if is_expired {
|
||||||
ui.label("Failed to load QR code");
|
egui::Frame::group(ui.style())
|
||||||
}
|
.inner_margin(32.0)
|
||||||
|
.show(ui, |ui| {
|
||||||
ui.add_space(6.0);
|
ui.label(
|
||||||
|
RichText::new("⚠️ QR Code Expired")
|
||||||
// Expiry timer
|
.color(Color32::from_rgb(231, 76, 60))
|
||||||
if is_expired {
|
.strong()
|
||||||
ui.label(
|
.size(16.0),
|
||||||
RichText::new("⚠️ QR Code Expired")
|
);
|
||||||
.color(Color32::from_rgb(231, 76, 60))
|
ui.add_space(8.0);
|
||||||
.strong(),
|
ui.label(
|
||||||
);
|
"Click [ 🔄 Regenerate QR ] below to create a fresh pairing code.",
|
||||||
} else {
|
);
|
||||||
let mins = remaining / 60;
|
});
|
||||||
let secs = remaining % 60;
|
} else if let Some(ref texture) = self.qr_texture {
|
||||||
ui.label(
|
ui.image((texture.id(), Vec2::new(260.0, 260.0)));
|
||||||
RichText::new(format!("Expires in {:02}:{:02}", mins, secs))
|
ui.add_space(6.0);
|
||||||
.size(13.0)
|
let mins = remaining / 60;
|
||||||
.color(Color32::LIGHT_GRAY),
|
let secs = remaining % 60;
|
||||||
);
|
ui.label(
|
||||||
|
RichText::new(format!("Expires in {:02}:{:02}", mins, secs))
|
||||||
|
.size(13.0)
|
||||||
|
.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());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
215
src/cli.rs
215
src/cli.rs
|
|
@ -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,58 +163,70 @@ pub async fn run_once(
|
||||||
"None"
|
"None"
|
||||||
};
|
};
|
||||||
println!("Hermes: Running (v{}, Auth: {})", ver, auth);
|
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, .. } => {
|
ProbeState::LoopbackOnly { lan_error, .. } => {
|
||||||
println!(
|
eprintln!(
|
||||||
"Hermes: Running locally (127.0.0.1), but LAN unreachable: {}",
|
"Hermes: Running locally (127.0.0.1), but LAN unreachable: {}",
|
||||||
lan_error
|
lan_error
|
||||||
);
|
);
|
||||||
println!(
|
eprintln!(
|
||||||
"⚠️ Warning: Hermes is bound to loopback only. Start Hermes with --host 0.0.0.0"
|
"⚠️ 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) => {
|
ProbeState::Offline(err) => {
|
||||||
println!("Hermes: 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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let short_id = if host_id.len() >= 8 {
|
|
||||||
format!("{}...", &host_id[..8])
|
|
||||||
} else {
|
|
||||||
host_id.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("Host: {}", display_name);
|
|
||||||
println!("Address: http://{}:{}", 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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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(¤t_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 = ¤t_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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
130
src/hermes.rs
130
src/hermes.rs
|
|
@ -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,43 +81,119 @@ 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,
|
||||||
let local_result = self.fetch_status(&local_url).await;
|
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;
|
||||||
|
|
||||||
match (local_result, lan_ip) {
|
if is_url_loopback(url_str) {
|
||||||
(Ok(local_status), Some(lan)) => {
|
match direct_res {
|
||||||
let lan_url = format!("http://{}:{}", lan, port);
|
Ok(local_status) => {
|
||||||
match self.fetch_status(&lan_url).await {
|
if let Some(lan) = lan_ip {
|
||||||
Ok(lan_status) => ProbeState::Online(lan_status),
|
if !is_loopback(&lan) {
|
||||||
Err(lan_err) => ProbeState::LoopbackOnly {
|
let lan_url = format!("{}://{}:{}", scheme, lan, port);
|
||||||
local_status,
|
match self.fetch_status(&lan_url).await {
|
||||||
lan_error: lan_err,
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(Ok(local_status), None) => ProbeState::Online(local_status),
|
} else {
|
||||||
(Err(local_err), Some(lan)) => {
|
let local_url = format!("{}://127.0.0.1:{}", scheme, port);
|
||||||
let lan_url = format!("http://{}:{}", lan, port);
|
let local_result = self.fetch_status(&local_url).await;
|
||||||
match self.fetch_status(&lan_url).await {
|
|
||||||
Ok(lan_status) => ProbeState::Online(lan_status),
|
match (local_result, lan_ip) {
|
||||||
Err(_) => ProbeState::Offline(local_err),
|
(Ok(local_status), Some(lan)) 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
(Ok(local_status), _) => ProbeState::Online(local_status),
|
||||||
|
(Err(local_err), Some(lan)) 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(local_err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Err(local_err), _) => ProbeState::Offline(local_err),
|
||||||
}
|
}
|
||||||
(Err(local_err), None) => 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
src/main.rs
52
src/main.rs
|
|
@ -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(())
|
||||||
|
|
|
||||||
230
src/pairing.rs
230
src/pairing.rs
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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![
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue