From 2929a352615ea86905bb344b5dc34c4dacf42f82 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Mon, 24 Aug 2026 10:45:58 +0700 Subject: [PATCH] feat(pair): initial release of Hermes Pair with Windows GUI and Linux terminal QR onboarding --- .github/workflows/build.yml | 76 +++++++ .gitignore | 5 + Cargo.toml | 34 +++ README.md | 198 ++++++++++++++++ src/app.rs | 380 +++++++++++++++++++++++++++++++ src/cli.rs | 249 ++++++++++++++++++++ src/config.rs | 108 +++++++++ src/hermes.rs | 117 ++++++++++ src/identity.rs | 54 +++++ src/lib.rs | 9 + src/main.rs | 62 +++++ src/models.rs | 38 ++++ src/network.rs | 86 +++++++ src/pairing.rs | 167 ++++++++++++++ src/qr.rs | 102 +++++++++ tests/unit_and_contract_tests.rs | 257 +++++++++++++++++++++ 16 files changed, 1942 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/app.rs create mode 100644 src/cli.rs create mode 100644 src/config.rs create mode 100644 src/hermes.rs create mode 100644 src/identity.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/models.rs create mode 100644 src/network.rs create mode 100644 src/pairing.rs create mode 100644 src/qr.rs create mode 100644 tests/unit_and_contract_tests.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..999ac8b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,76 @@ +name: Build and Test + +on: + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + check-and-test: + name: Test & Build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + artifact_name: HermesPair-windows-x64.exe + binary_path: target/release/hermes-pair.exe + upload_name: HermesPair-windows-x64 + - os: ubuntu-latest + artifact_name: hermes-pair-linux-x64 + binary_path: target/release/hermes-pair + upload_name: hermes-pair-linux-x64 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Install Linux GUI Dependencies + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libgtk-3-dev + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + + - name: Check code formatting + run: cargo fmt --check + + - name: Clippy lints + run: cargo clippy --all-targets -- -D warnings + + - name: Run unit & contract tests + run: cargo test --verbose + + - name: Build release binary + run: cargo build --release + + - name: Prepare Windows artifact + if: matrix.os == 'windows-latest' + run: | + mkdir dist + cp target/release/hermes-pair.exe dist/HermesPair-windows-x64.exe + + - name: Prepare Linux artifact + if: matrix.os == 'ubuntu-latest' + run: | + mkdir dist + cp target/release/hermes-pair dist/hermes-pair-linux-x64 + chmod +x dist/hermes-pair-linux-x64 + + - name: Upload Build Artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.upload_name }} + path: dist/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62d17b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target/ +Cargo.lock +dist/ +.idea/ +*.exe diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4ba5a1c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "hermes-pair" +version = "0.1.0" +edition = "2021" +authors = ["ochenstarik-ui"] +description = "Fast, secure QR onboarding helper for Hermes Agent" + +[lib] +name = "hermes_pair" +path = "src/lib.rs" + +[[bin]] +name = "hermes-pair" +path = "src/main.rs" + +[dependencies] +tokio = { version = "1.43", features = ["full"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +qrcode = { version = "0.14", default-features = false } +image = { version = "0.25", default-features = false, features = ["png"] } +rand = "0.8" +uuid = { version = "1.13", features = ["v4", "serde"] } +dirs = "6.0" +clap = { version = "4.5", features = ["derive"] } +eframe = { version = "0.31", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } +base64 = "0.22" +if-addrs = "0.13" +chrono = { version = "0.4", features = ["serde"] } +url = { version = "2.5", features = ["serde"] } + +[dev-dependencies] +tokio = { version = "1.43", features = ["full"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..72c5ba1 --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +# Hermes Pair (`hermes-pair`) + +[![Build and Test](https://github.com/ochenstarik-ui/hermes-pair/actions/workflows/build.yml/badge.svg)](https://github.com/ochenstarik-ui/hermes-pair/actions/workflows/build.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +Fast, lightweight, cross-platform pairing helper for **Hermes Agent** and the **Hermes Android App**. + +`hermes-pair` bridges your host computer (running Hermes) with the mobile companion app by generating high-contrast QR codes and standard pairing URIs (`hermes://pair?data=...`) containing host network information, authentication state, and cryptographically secure random nonces. + +--- + +## Features + +- 🖥️ **Native GUI & Terminal UI Modes**: Runs as a lightweight native GUI window (`eframe`/`egui`) on desktop or an interactive/single-shot ANSI terminal interface on headless servers. +- 🔍 **Smart Network Interface Discovery**: Automatically discovers and prioritizes physical LAN interfaces (`192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`) and Tailscale overlays (`100.x.x.x`), while filtering out loopback and link-local adapters. +- ⚡ **Real-Time Hermes Status Probing**: Connects to `http://127.0.0.1:/api/status` and `http://:/api/status` to detect whether Hermes is active, verify version and auth requirements, and warn if Hermes is mistakenly bound only to loopback (`127.0.0.1`). +- 🛡️ **Built-in Security Safeguards**: + - Automatically warns if Hermes is exposed over LAN without authentication. + - Generates single-use, 16-byte cryptographically secure random nonces. + - Enforces configurable TTL expiry (default: 120 seconds). + - Validates payload structure, versioning, and UUID integrity. +- 💾 **Persistent Host Identity**: Manages a persistent UUIDv4 `host_id` saved atomically to `%APPDATA%\HermesPair\config.json` (Windows) or `~/.config/hermes-pair/config.json` (Linux). + +--- + +## Pairing Protocol Specification (`v1`) + +The generated QR code encodes a custom URI: +```text +hermes://pair?data= +``` + +### Decoded JSON Payload (`PairingPayloadV1`) + +```json +{ + "v": 1, + "type": "hermes-pair", + "host_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + "name": "Gaming-PC", + "host": "192.168.1.34", + "port": 9119, + "scheme": "http", + "expires_at": 1756012800, + "nonce": "k7a_QW9jRz1M..." +} +``` + +| Field | Type | Description | +|---|---|---| +| `v` | `u32` | Protocol schema version (`1`). | +| `type` | `string` | Payload identifier (`hermes-pair`). | +| `host_id` | `string` | Persistent UUIDv4 identifying the host machine. | +| `name` | `string` | Human-readable computer or host display name. | +| `host` | `string` | Reachable IPv4 address or hostname for the mobile client. | +| `port` | `u16` | HTTP port on which Hermes Agent is listening (e.g. `9119`). | +| `scheme` | `string` | Connection scheme (`http` or `https`). | +| `expires_at` | `u64` | Unix epoch timestamp (seconds) after which this payload is rejected. | +| `nonce` | `string` | 16 cryptographically random bytes, Base64URL-encoded. | + +--- + +## Installation & Build + +### Prerequisites +- [Rust](https://rustup.rs/) 1.80+ (tested on Rust 1.98.0) + +### Building from Source + +```bash +# Clone the repository +git clone https://github.com/ochenstarik-ui/hermes-pair.git +cd hermes-pair + +# Run tests +cargo test + +# Build release binary +cargo build --release +``` + +Release binaries are produced at: +- **Windows**: `target/release/hermes-pair.exe` +- **Linux**: `target/release/hermes-pair` + +--- + +## Usage + +### 1. Native GUI Mode (Default) + +Simply execute `hermes-pair` without flags to open the native desktop window: + +```bash +hermes-pair +``` + +#### GUI Controls: +- **Status Badge**: Shows Hermes connectivity (Running, Offline, Loopback Only) and auth status. +- **Network Interface Dropdown**: Switch between Wi-Fi, Ethernet, Tailscale, or virtual adapters. +- **[ 🔄 Regenerate QR ]**: Generates a new payload with a fresh nonce and resets the countdown. +- **[ 📋 Copy Link ]**: Copies `hermes://pair?data=...` directly to the system clipboard. +- **[ 🔄 Check ]**: Retries probing the Hermes status endpoint immediately. + +### 2. Interactive Terminal UI Mode + +For remote SSH sessions or terminals without a display server: + +```bash +hermes-pair --terminal +# or short flag: +hermes-pair -t +``` + +Output: +```text +Hermes: Running (v1.2.0, Auth: Required) +Host: Gaming-PC +Address: http://192.168.1.34:9119 +Host ID: 7b31d044... +Expires in: 01:58 + +██████████████████████████████ +██ ██ ██ ██ +██ ██████ ██ ██ ██████ ██ +██ ██████ ██ ██ ██████ ██ +██ ██ ██ ██ +██████████████████████████████ +... +Pairing Link: hermes://pair?data=eyJ2Ijox... +``` + +### 3. Headless / Single-Shot Script Mode + +To print the QR code once to stdout (useful for automation, provisioning scripts, or terminal output piping): + +```bash +hermes-pair qr +# or +hermes-pair --no-gui +``` + +### 4. Command Line Options + +```text +Usage: hermes-pair [OPTIONS] [COMMAND] + +Commands: + qr Output QR code once to stdout and exit + help Print this message or the help of the given subcommand(s) + +Options: + -t, --terminal Run interactive terminal mode with periodic refresh + --no-gui Print QR once to stdout and exit (headless/script mode) + --port Port of Hermes Agent [default: 9119] + --hermes-url Hermes status API URL (e.g. http://127.0.0.1:9119) + -i, --interface Specific network interface name or IPv4 address to advertise + --ttl Pairing QR validity TTL in seconds [default: 120] + -h, --help Print help + -V, --version Print version +``` + +--- + +## Configuration & Storage + +`hermes-pair` generates a persistent `host_id` on first launch and stores it in JSON format: + +- **Windows**: `%APPDATA%\HermesPair\config.json` +- **Linux / macOS**: `~/.config/hermes-pair/config.json` + +Example configuration: +```json +{ + "host_id": "7b31d044-cf64-4e78-9df2-bb58a8f5e1a1", + "display_name": "Studio-Workstation" +} +``` + +--- + +## Troubleshooting & Warnings + +### Warning: Loopback Only +If Hermes is started with default `127.0.0.1` binding, other devices on the LAN cannot connect. +Start Hermes binding to all interfaces: +```bash +hermes serve --host 0.0.0.0 --port 9119 +``` + +### Warning: Unauthenticated Network Access +If Hermes has no authentication configured, any device on your local network could access the API. Consider enabling token or password authentication for production usage. + +--- + +## License + +Licensed under the [MIT License](LICENSE). diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..077555c --- /dev/null +++ b/src/app.rs @@ -0,0 +1,380 @@ +use crate::config::AppConfig; +use crate::hermes::{HermesProbeClient, ProbeState}; +use crate::identity::{get_display_name, get_host_id}; +use crate::models::{NetworkInterfaceInfo, PairingPayloadV1}; +use crate::network::discover_network_interfaces; +use crate::pairing::{create_pairing_payload, encode_pairing_uri}; +use crate::qr::render_egui_image; +use eframe::egui::{self, Color32, RichText, TextureHandle, Vec2}; +use std::net::Ipv4Addr; +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::time::{Duration, Instant}; + +pub struct HermesPairApp { + config: AppConfig, + port: u16, + ttl: u64, + interfaces: Vec, + selected_iface_index: usize, + + current_payload: PairingPayloadV1, + current_uri: String, + generated_at: Instant, + qr_texture: Option, + + probe_state: ProbeState, + probe_tx: Sender, + probe_rx: Receiver, + is_probing: bool, + + copied_banner_timer: Option, +} + +impl HermesPairApp { + pub fn new( + cc: &eframe::CreationContext<'_>, + config: AppConfig, + port: u16, + explicit_interface: Option, + ttl: u64, + ) -> Self { + let mut interfaces = discover_network_interfaces().unwrap_or_default(); + if interfaces.is_empty() { + interfaces.push(NetworkInterfaceInfo { + name: "Loopback".to_string(), + ip: Ipv4Addr::new(127, 0, 0, 1), + is_loopback: true, + is_virtual: false, + }); + } + + let mut selected_iface_index = 0; + if let Some(ref target) = explicit_interface { + let lower = target.to_lowercase(); + if let Some(idx) = interfaces.iter().position(|i| { + i.name.to_lowercase().contains(&lower) || i.ip.to_string() == *target + }) { + selected_iface_index = idx; + } + } + + let host_ip = interfaces[selected_iface_index].ip; + let host_id = get_host_id(&config); + let display_name = get_display_name(&config); + + let current_payload = create_pairing_payload( + host_id, + display_name, + host_ip.to_string(), + port, + "http".to_string(), + ttl, + ); + let current_uri = encode_pairing_uri(¤t_payload); + + let qr_texture = Self::build_qr_texture(&cc.egui_ctx, ¤t_uri); + + let (probe_tx, probe_rx) = channel(); + + let mut app = Self { + config, + port, + ttl, + interfaces, + selected_iface_index, + current_payload, + current_uri, + generated_at: Instant::now(), + qr_texture, + probe_state: ProbeState::Offline("Initial probe running...".to_string()), + probe_tx, + probe_rx, + is_probing: false, + copied_banner_timer: None, + }; + + app.trigger_probe(); + app + } + + fn build_qr_texture(ctx: &egui::Context, uri: &str) -> Option { + match render_egui_image(uri, 8) { + Ok(img) => Some(ctx.load_texture("qr_code", img, egui::TextureOptions::NEAREST)), + Err(e) => { + eprintln!("Failed to generate QR texture: {}", e); + None + } + } + } + + fn regenerate_payload(&mut self, ctx: &egui::Context) { + let host_ip = self.selected_ip(); + let host_id = get_host_id(&self.config); + let display_name = get_display_name(&self.config); + + self.current_payload = create_pairing_payload( + host_id, + display_name, + host_ip.to_string(), + self.port, + "http".to_string(), + self.ttl, + ); + self.current_uri = encode_pairing_uri(&self.current_payload); + self.generated_at = Instant::now(); + self.qr_texture = Self::build_qr_texture(ctx, &self.current_uri); + } + + fn selected_ip(&self) -> Ipv4Addr { + self.interfaces + .get(self.selected_iface_index) + .map(|i| i.ip) + .unwrap_or_else(|| Ipv4Addr::new(127, 0, 0, 1)) + } + + fn trigger_probe(&mut self) { + if self.is_probing { + return; + } + + self.is_probing = true; + let port = self.port; + let lan_ip = self.selected_ip(); + let tx = self.probe_tx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + + if let Ok(rt) = rt { + rt.block_on(async { + let client = HermesProbeClient::new(); + let res = client.probe(port, Some(lan_ip)).await; + let _ = tx.send(res); + }); + } + }); + } + + fn refresh_interfaces(&mut self) { + if let Ok(new_ifaces) = discover_network_interfaces() { + if !new_ifaces.is_empty() { + self.interfaces = new_ifaces; + if self.selected_iface_index >= self.interfaces.len() { + self.selected_iface_index = 0; + } + } + } + } +} + +impl eframe::App for HermesPairApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Poll background probe channel + while let Ok(state) = self.probe_rx.try_recv() { + self.probe_state = state; + self.is_probing = false; + } + + // Request repaint every 500ms for smooth timer update + ctx.request_repaint_after(Duration::from_millis(500)); + + let elapsed = self.generated_at.elapsed().as_secs(); + let remaining = self.ttl.saturating_sub(elapsed); + let is_expired = remaining == 0; + + egui::CentralPanel::default().show(ctx, |ui| { + ui.spacing_mut().item_spacing = Vec2::new(8.0, 8.0); + + // Title and Hermes Status Header + ui.vertical_centered(|ui| { + ui.heading(RichText::new("Hermes Pair").size(22.0).strong()); + }); + + ui.add_space(4.0); + + // Status Card + egui::Frame::group(ui.style()) + .inner_margin(8.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + match &self.probe_state { + ProbeState::Online(resp) => { + ui.colored_label(Color32::from_rgb(46, 204, 113), "● Running"); + let ver = resp.version.as_deref().unwrap_or("unknown"); + ui.label(format!("v{}", ver)); + if resp.auth_required { + ui.colored_label(Color32::from_rgb(52, 152, 219), "[Auth: Required]"); + } else { + ui.colored_label(Color32::from_rgb(230, 126, 34), "[Auth: None]"); + } + } + ProbeState::LoopbackOnly { .. } => { + ui.colored_label(Color32::from_rgb(241, 196, 15), "● Loopback Only"); + } + ProbeState::Offline(err) => { + ui.colored_label(Color32::from_rgb(231, 76, 60), "● Offline"); + ui.label(RichText::new(err).size(11.0).italics()); + } + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("🔄 Check").clicked() { + self.trigger_probe(); + } + }); + }); + }); + + // Warning Banners + if let ProbeState::LoopbackOnly { .. } = &self.probe_state { + ui.add_space(2.0); + egui::Frame::NONE + .fill(Color32::from_rgb(60, 45, 10)) + .stroke(egui::Stroke::new(1.0_f32, Color32::from_rgb(241, 196, 15))) + .inner_margin(8.0) + .corner_radius(4.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("⚠️").size(16.0)); + ui.label( + RichText::new(format!( + "Hermes is currently only reachable from this computer (127.0.0.1).\n\ + Start Hermes with LAN-accessible bind:\n\ + hermes serve --host 0.0.0.0 --port {}", + self.port + )) + .size(11.5) + .color(Color32::from_rgb(241, 196, 15)), + ); + }); + }); + } + + if let ProbeState::Online(resp) = &self.probe_state { + if !resp.auth_required { + ui.add_space(2.0); + egui::Frame::NONE + .fill(Color32::from_rgb(50, 25, 20)) + .stroke(egui::Stroke::new(1.0_f32, Color32::from_rgb(230, 126, 34))) + .inner_margin(8.0) + .corner_radius(4.0) + .show(ui, |ui| { + ui.label( + RichText::new( + "⚠️ Warning: Hermes is reachable over the network without authentication. \ + Do not expose beyond a trusted LAN.", + ) + .size(11.5) + .color(Color32::from_rgb(230, 126, 34)), + ); + }); + } + } + + ui.add_space(4.0); + + // Network Interface Selector + ui.horizontal(|ui| { + ui.label("Network Interface:"); + let current_label = if let Some(iface) = self.interfaces.get(self.selected_iface_index) { + format!("{} ({})", iface.name, iface.ip) + } else { + "None".to_string() + }; + + let prev_idx = self.selected_iface_index; + egui::ComboBox::from_id_salt("interface_select") + .selected_text(current_label) + .show_ui(ui, |ui| { + for (idx, iface) in self.interfaces.iter().enumerate() { + let tag = if iface.is_virtual { + "[Virt]" + } else if iface.ip.is_private() { + "[LAN]" + } else { + "" + }; + let label = format!("{} ({}) {}", iface.name, iface.ip, tag); + ui.selectable_value(&mut self.selected_iface_index, idx, label); + } + }); + + if prev_idx != self.selected_iface_index { + self.regenerate_payload(ctx); + self.trigger_probe(); + } + }); + + // Address display + ui.horizontal(|ui| { + ui.label(RichText::new("Target Address:").strong()); + ui.monospace(format!("http://{}:{}", self.selected_ip(), self.port)); + }); + + ui.add_space(4.0); + + // Center QR Code + ui.vertical_centered(|ui| { + if let Some(ref texture) = self.qr_texture { + ui.image((texture.id(), Vec2::new(260.0, 260.0))); + } else { + ui.label("Failed to load QR code"); + } + + ui.add_space(6.0); + + // Expiry timer + if is_expired { + ui.label( + RichText::new("⚠️ QR Code Expired") + .color(Color32::from_rgb(231, 76, 60)) + .strong(), + ); + } else { + let mins = remaining / 60; + let secs = remaining % 60; + ui.label( + RichText::new(format!("Expires in {:02}:{:02}", mins, secs)) + .size(13.0) + .color(Color32::LIGHT_GRAY), + ); + } + }); + + ui.add_space(6.0); + + // Action Buttons + ui.horizontal(|ui| { + ui.columns(2, |cols| { + if cols[0].button("🔄 Regenerate QR").clicked() { + self.refresh_interfaces(); + self.regenerate_payload(ctx); + self.trigger_probe(); + } + + if cols[1].button("📋 Copy Link").clicked() { + ctx.copy_text(self.current_uri.clone()); + self.copied_banner_timer = Some(Instant::now()); + } + }); + }); + + // Copied banner notification + if let Some(timer) = self.copied_banner_timer { + if timer.elapsed().as_secs() < 2 { + ui.vertical_centered(|ui| { + ui.label( + RichText::new("✓ Pairing link copied to clipboard!") + .color(Color32::from_rgb(46, 204, 113)) + .size(12.0), + ); + }); + } else { + self.copied_banner_timer = None; + } + } + }); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..9cd5c97 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,249 @@ +use crate::config::AppConfig; +use crate::hermes::{HermesProbeClient, ProbeState}; +use crate::identity::{get_display_name, get_host_id}; +use crate::models::NetworkInterfaceInfo; +use crate::network::discover_network_interfaces; +use crate::pairing::{create_pairing_payload, encode_pairing_uri}; +use crate::qr::render_terminal_qr; +use clap::{Args, Parser, Subcommand}; +use std::net::Ipv4Addr; +use std::str::FromStr; +use std::time::Duration; +use tokio::time::sleep; + +#[derive(Parser, Debug, Clone)] +#[command( + name = "hermes-pair", + version = "0.1.0", + author = "ochenstarik-ui", + about = "Fast, secure QR onboarding helper for Hermes Agent" +)] +pub struct CliArgs { + /// Run interactive terminal mode with periodic refresh + #[arg(long, short = 't')] + pub terminal: bool, + + /// Print QR once to stdout and exit (headless/script mode) + #[arg(long = "no-gui")] + pub no_gui: bool, + + /// Port of Hermes Agent (default 9119) + #[arg(long, default_value = "9119")] + pub port: u16, + + /// Hermes status API URL (e.g. http://127.0.0.1:9119) + #[arg(long = "hermes-url")] + pub hermes_url: Option, + + /// Specific network interface name or IPv4 address to advertise + #[arg(long, short = 'i')] + pub interface: Option, + + /// Pairing QR validity TTL in seconds (default 120) + #[arg(long, default_value = "120")] + pub ttl: u64, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum CliCommand { + /// Output QR code once to stdout and exit + Qr(QrArgs), +} + +#[derive(Args, Debug, Clone)] +pub struct QrArgs { + /// Port of Hermes Agent + #[arg(long)] + pub port: Option, + + /// Specific network interface name or IPv4 address + #[arg(long, short = 'i')] + pub interface: Option, + + /// Pairing QR validity TTL in seconds + #[arg(long)] + pub ttl: Option, +} + +/// Resolves the selected IPv4 address based on user input or automatic interface detection. +pub fn resolve_selected_ip( + explicit_interface: Option<&str>, + interfaces: &[NetworkInterfaceInfo], +) -> (String, Ipv4Addr) { + if let Some(target) = explicit_interface { + if let Ok(ip) = Ipv4Addr::from_str(target) { + return (format!("Manual ({})", ip), ip); + } + + let lower = target.to_lowercase(); + if let Some(matched) = interfaces.iter().find(|i| { + i.name.to_lowercase().contains(&lower) || i.ip.to_string() == target + }) { + return (matched.name.clone(), matched.ip); + } + } + + if let Some(first) = interfaces.first() { + return (first.name.clone(), first.ip); + } + + ("Loopback".to_string(), Ipv4Addr::new(127, 0, 0, 1)) +} + +/// Runs single-shot terminal output mode. +pub async fn run_once( + config: &AppConfig, + port: u16, + explicit_interface: Option<&str>, + ttl: u64, +) -> Result<(), Box> { + let interfaces = discover_network_interfaces().unwrap_or_default(); + let (_iface_name, host_ip) = resolve_selected_ip(explicit_interface, &interfaces); + + let client = HermesProbeClient::new(); + let probe_state = client.probe(port, Some(host_ip)).await; + + let host_id = get_host_id(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 { + ProbeState::Online(status) => { + let ver = status.version.as_deref().unwrap_or("unknown"); + let auth = if status.auth_required { + "Required" + } else { + "None" + }; + println!("Hermes: Running (v{}, Auth: {})", ver, auth); + } + ProbeState::LoopbackOnly { lan_error, .. } => { + println!("Hermes: Running locally (127.0.0.1), but LAN unreachable: {}", lan_error); + println!("⚠️ Warning: Hermes is bound to loopback only. Start Hermes with --host 0.0.0.0"); + } + ProbeState::Offline(err) => { + println!("Hermes: Offline ({})", err); + } + } + + 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. +pub async fn run_terminal_loop( + config: &AppConfig, + port: u16, + explicit_interface: Option<&str>, + ttl: u64, +) -> Result<(), Box> { + let mut last_generated_at = std::time::Instant::now(); + 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 client = HermesProbeClient::new(); + + loop { + let now = std::time::Instant::now(); + let elapsed = now.duration_since(last_generated_at).as_secs(); + + let interfaces = discover_network_interfaces().unwrap_or_default(); + let (_iface_name, host_ip) = resolve_selected_ip(explicit_interface, &interfaces); + + // Regenerate if expired + 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(); + } + + 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 qr_rendered = render_terminal_qr(&uri).unwrap_or_default(); + + // Clear terminal screen (cross-platform ANSI) + print!("\x1B[2J\x1B[1;1H"); + + match &probe_state { + ProbeState::Online(status) => { + let ver = status.version.as_deref().unwrap_or("unknown"); + let auth = if status.auth_required { + "Required" + } else { + "None" + }; + println!("Hermes: Running (v{}, Auth: {})", ver, auth); + } + ProbeState::LoopbackOnly { lan_error, .. } => { + println!("Hermes: Loopback Only (127.0.0.1) [LAN Error: {}]", lan_error); + println!("⚠️ Run Hermes with: hermes serve --host 0.0.0.0 --port {}", port); + } + ProbeState::Offline(err) => { + println!("Hermes: Offline ({})", err); + } + } + + 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."); + + sleep(Duration::from_secs(1)).await; + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..c401d0d --- /dev/null +++ b/src/config.rs @@ -0,0 +1,108 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppConfig { + pub host_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + host_id: Uuid::new_v4().to_string(), + display_name: None, + } + } +} + +/// Resolves configuration path according to platform conventions: +/// - Windows: `%APPDATA%\HermesPair\config.json` +/// - Linux/Unix: `~/.config/hermes-pair/config.json` +pub fn get_config_path() -> PathBuf { + #[cfg(target_os = "windows")] + { + if let Ok(appdata) = std::env::var("APPDATA") { + return PathBuf::from(appdata).join("HermesPair").join("config.json"); + } + if let Some(config_dir) = dirs::config_dir() { + return config_dir.join("HermesPair").join("config.json"); + } + PathBuf::from("config.json") + } + + #[cfg(not(target_os = "windows"))] + { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + return PathBuf::from(xdg).join("hermes-pair").join("config.json"); + } + if let Some(config_dir) = dirs::config_dir() { + return config_dir.join("hermes-pair").join("config.json"); + } + if let Some(home_dir) = dirs::home_dir() { + return home_dir.join(".config").join("hermes-pair").join("config.json"); + } + PathBuf::from("config.json") + } +} + +/// Saves the configuration atomically by writing to a temporary file and renaming it. +pub fn save_config_to_path(config: &AppConfig, path: &Path) -> Result<(), std::io::Error> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let json_bytes = serde_json::to_vec_pretty(config) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + + let tmp_file_name = format!( + "{}.tmp.{}", + path.file_name().and_then(|n| n.to_str()).unwrap_or("config"), + Uuid::new_v4() + ); + let tmp_path = match path.parent() { + Some(p) => p.join(tmp_file_name), + None => PathBuf::from(tmp_file_name), + }; + + fs::write(&tmp_path, json_bytes)?; + + // On Windows and Unix, fs::rename replaces the destination atomically if in the same directory. + if let Err(_err) = fs::rename(&tmp_path, path) { + // Fallback in case rename fails due to cross-platform replacement edge cases + let _ = fs::remove_file(path); + if let Err(fallback_err) = fs::rename(&tmp_path, path) { + let _ = fs::remove_file(&tmp_path); + return Err(fallback_err.into()); + } + } + + Ok(()) +} + +/// Loads an existing config or generates a new one with a persistent UUIDv4 `host_id` and saves it. +pub fn load_or_create_config_from_path(path: &Path) -> Result { + if path.exists() { + let content = fs::read_to_string(path)?; + if let Ok(mut config) = serde_json::from_str::(&content) { + if config.host_id.trim().is_empty() { + config.host_id = Uuid::new_v4().to_string(); + save_config_to_path(&config, path)?; + } + return Ok(config); + } + } + + let new_config = AppConfig::default(); + save_config_to_path(&new_config, path)?; + Ok(new_config) +} + +/// Convenience function to load or create configuration at the default system location. +pub fn load_or_create_config() -> Result { + let path = get_config_path(); + load_or_create_config_from_path(&path) +} diff --git a/src/hermes.rs b/src/hermes.rs new file mode 100644 index 0000000..a5f6ec3 --- /dev/null +++ b/src/hermes.rs @@ -0,0 +1,117 @@ +use crate::models::HermesStatusResponse; +use std::net::Ipv4Addr; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProbeState { + Online(HermesStatusResponse), + LoopbackOnly { + local_status: HermesStatusResponse, + lan_error: String, + }, + Offline(String), +} + +impl ProbeState { + pub fn is_online(&self) -> bool { + matches!(self, ProbeState::Online(_)) + } + + pub fn is_loopback_only(&self) -> bool { + matches!(self, ProbeState::LoopbackOnly { .. }) + } + + pub fn status_response(&self) -> Option<&HermesStatusResponse> { + match self { + ProbeState::Online(ref resp) => Some(resp), + ProbeState::LoopbackOnly { + ref local_status, .. + } => Some(local_status), + ProbeState::Offline(_) => None, + } + } +} + +#[derive(Clone)] +pub struct HermesProbeClient { + client: reqwest::Client, +} + +impl Default for HermesProbeClient { + fn default() -> Self { + Self::new() + } +} + +impl HermesProbeClient { + pub fn new() -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Self { client } + } + + pub async fn fetch_status(&self, base_url: &str) -> Result { + let url = if base_url.ends_with('/') { + format!("{}api/status", base_url) + } else { + format!("{}/api/status", base_url) + }; + + let response = self + .client + .get(&url) + .send() + .await + .map_err(|e| format!("Connection error: {}", e))?; + + if !response.status().is_success() { + return Err(format!("HTTP status {}", response.status())); + } + + let body = response + .json::() + .await + .map_err(|e| format!("Failed to parse status response: {}", e))?; + + Ok(body) + } + + pub async fn probe(&self, port: u16, lan_ip: Option) -> ProbeState { + let local_url = format!("http://127.0.0.1:{}", port); + let local_result = self.fetch_status(&local_url).await; + + match (local_result, lan_ip) { + (Ok(local_status), Some(lan)) => { + let lan_url = format!("http://{}:{}", 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), None) => ProbeState::Online(local_status), + (Err(local_err), Some(lan)) => { + let lan_url = format!("http://{}:{}", lan, port); + match self.fetch_status(&lan_url).await { + Ok(lan_status) => ProbeState::Online(lan_status), + Err(_) => ProbeState::Offline(local_err), + } + } + (Err(local_err), None) => ProbeState::Offline(local_err), + } + } +} + +pub async fn probe_hermes_status(base_url: &str) -> Result { + let client = HermesProbeClient::new(); + client.fetch_status(base_url).await +} + +pub async fn probe_hermes(port: u16, lan_ip: Option) -> ProbeState { + let client = HermesProbeClient::new(); + client.probe(port, lan_ip).await +} diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 0000000..5024ae9 --- /dev/null +++ b/src/identity.rs @@ -0,0 +1,54 @@ +use crate::config::AppConfig; + +/// Returns the persistent host_id from configuration. +pub fn get_host_id(config: &AppConfig) -> String { + config.host_id.clone() +} + +/// Detects the system hostname from environment variables or sensible fallbacks. +pub fn detect_system_hostname() -> String { + if let Ok(name) = std::env::var("COMPUTERNAME") { + let trimmed = name.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + + if let Ok(name) = std::env::var("HOSTNAME") { + let trimmed = name.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + + if let Ok(name) = std::env::var("HOST") { + let trimmed = name.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + + // Try reading /etc/hostname on Unix + #[cfg(unix)] + { + if let Ok(name) = std::fs::read_to_string("/etc/hostname") { + let trimmed = name.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + } + + "Hermes-Host".to_string() +} + +/// Returns the configured display name, or the detected system hostname if not set. +pub fn get_display_name(config: &AppConfig) -> String { + if let Some(ref name) = config.display_name { + let trimmed = name.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + detect_system_hostname() +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..088c8e1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +pub mod app; +pub mod cli; +pub mod config; +pub mod hermes; +pub mod identity; +pub mod models; +pub mod network; +pub mod pairing; +pub mod qr; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f857d92 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,62 @@ +use clap::Parser; +use eframe::egui::Vec2; +use hermes_pair::app::HermesPairApp; +use hermes_pair::cli::{run_once, run_terminal_loop, CliArgs, CliCommand}; +use hermes_pair::config::load_or_create_config; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = CliArgs::parse(); + let config = load_or_create_config()?; + + if let Some(CliCommand::Qr(qr_args)) = &args.command { + let port = qr_args.port.unwrap_or(args.port); + let iface = qr_args.interface.as_deref().or(args.interface.as_deref()); + let ttl = qr_args.ttl.unwrap_or(args.ttl); + return run_once(&config, port, iface, ttl).await; + } + + if args.no_gui { + return run_once(&config, args.port, args.interface.as_deref(), args.ttl).await; + } + + if args.terminal { + return run_terminal_loop(&config, args.port, args.interface.as_deref(), args.ttl).await; + } + + // Launch GUI + let native_options = eframe::NativeOptions { + viewport: eframe::egui::ViewportBuilder::default() + .with_inner_size(Vec2::new(420.0, 620.0)) + .with_min_inner_size(Vec2::new(380.0, 520.0)) + .with_title("Hermes Pair"), + ..Default::default() + }; + + let config_clone = config.clone(); + let port = args.port; + let iface = args.interface.clone(); + let ttl = args.ttl; + + let res = eframe::run_native( + "Hermes Pair", + native_options, + Box::new(move |cc| { + Ok(Box::new(HermesPairApp::new( + cc, + config_clone, + port, + iface, + ttl, + ))) + }), + ); + + if let Err(e) = res { + eprintln!("Failed to launch GUI: {}", e); + eprintln!("Falling back to terminal mode..."); + return run_terminal_loop(&config, args.port, args.interface.as_deref(), args.ttl).await; + } + + Ok(()) +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..24a9c9b --- /dev/null +++ b/src/models.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; +use std::net::Ipv4Addr; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PairingPayloadV1 { + pub v: u32, // 1 + #[serde(rename = "type")] + pub payload_type: String, // "hermes-pair" + pub host_id: String, // UUIDv4 string + pub name: String, // Display name / computer name + pub host: String, // Reachable IPv4 or hostname + pub port: u16, // Port (e.g. 9119) + pub scheme: String, // "http" or "https" + pub expires_at: u64, // Unix timestamp in seconds + pub nonce: String, // Base64URL-encoded cryptographically secure random 16 bytes +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct HermesStatusResponse { + #[serde(default)] + pub status: String, + #[serde(rename = "authRequired", default)] + pub auth_required: bool, + #[serde(rename = "authProviders", default)] + pub auth_providers: Vec, + #[serde(rename = "authFlows", default)] + pub auth_flows: Vec, + #[serde(default)] + pub version: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetworkInterfaceInfo { + pub name: String, + pub ip: Ipv4Addr, + pub is_loopback: bool, + pub is_virtual: bool, +} diff --git a/src/network.rs b/src/network.rs new file mode 100644 index 0000000..869c5a7 --- /dev/null +++ b/src/network.rs @@ -0,0 +1,86 @@ +pub use crate::models::NetworkInterfaceInfo; +use std::net::Ipv4Addr; + +pub fn is_loopback(ip: &Ipv4Addr) -> bool { + ip.is_loopback() || ip.octets()[0] == 127 +} + +pub fn is_link_local(ip: &Ipv4Addr) -> bool { + let octets = ip.octets(); + octets[0] == 169 && octets[1] == 254 +} + +pub fn is_tailscale_ip(ip: &Ipv4Addr) -> bool { + let octets = ip.octets(); + // CGNAT range 100.64.0.0/10 commonly used by Tailscale / WireGuard overlays + octets[0] == 100 && (octets[1] >= 64 && octets[1] <= 127) +} + +pub fn is_virtual_adapter(name: &str) -> bool { + let lower = name.to_lowercase(); + lower.contains("docker") + || lower.contains("veth") + || lower.contains("virbr") + || lower.contains("vbox") + || lower.contains("vmnet") + || lower.contains("hyper-v") + || lower.contains("wsl") + || lower.contains("vethernet") + || lower.contains("virtual") + || lower.contains("tap") + || lower.contains("tun") +} + +fn interface_priority(info: &NetworkInterfaceInfo) -> u32 { + let is_priv = info.ip.is_private(); + let is_ts = is_tailscale_ip(&info.ip); + + match (info.is_virtual, is_priv, is_ts) { + (false, true, _) => 100, // Physical LAN (192.168.x.x, 10.x.x.x, 172.16-31.x.x) + (false, false, true) => 80, // Tailscale / Overlay + (false, false, false) => 60, // Other physical (e.g. public or custom) + (true, true, _) => 40, // Virtual LAN (e.g. WSL, Hyper-V virtual switch) + (true, false, true) => 30, // Virtual Tailscale + (true, false, false) => 20, // Other virtual + } +} + +pub fn filter_and_sort_interfaces( + interfaces: impl IntoIterator, +) -> Vec { + let mut filtered: Vec = interfaces + .into_iter() + .filter(|iface| !iface.is_loopback && !is_loopback(&iface.ip) && !is_link_local(&iface.ip)) + .collect(); + + filtered.sort_by(|a, b| { + let prio_a = interface_priority(a); + let prio_b = interface_priority(b); + prio_b + .cmp(&prio_a) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.ip.cmp(&b.ip)) + }); + + filtered +} + +pub fn discover_network_interfaces() -> Result, std::io::Error> { + let if_addrs_list = if_addrs::get_if_addrs()?; + + let mut raw_interfaces = Vec::new(); + for iface in if_addrs_list { + if let if_addrs::IfAddr::V4(ref v4_addr) = iface.addr { + let is_virt = is_virtual_adapter(&iface.name); + let loopback = iface.is_loopback() || is_loopback(&v4_addr.ip); + raw_interfaces.push(NetworkInterfaceInfo { + name: iface.name, + ip: v4_addr.ip, + is_loopback: loopback, + is_virtual: is_virt, + }); + } + } + + Ok(filter_and_sort_interfaces(raw_interfaces)) +} diff --git a/src/pairing.rs b/src/pairing.rs new file mode 100644 index 0000000..a17c35b --- /dev/null +++ b/src/pairing.rs @@ -0,0 +1,167 @@ +pub use crate::models::PairingPayloadV1; +use base64::engine::general_purpose::{STANDARD, URL_SAFE, URL_SAFE_NO_PAD}; +use base64::Engine; +use rand::RngCore; +use std::fmt; +use std::time::{SystemTime, UNIX_EPOCH}; +use url::Url; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PairingError { + InvalidUriScheme(String), + InvalidUriFormat(String), + MissingDataParameter, + Base64DecodeError(String), + JsonDecodeError(String), + UnsupportedVersion(u32), + InvalidPayloadType(String), + InvalidHostId(String), + EmptyHost, + InvalidPort(u16), + PayloadExpired { expires_at: u64, now: u64 }, +} + +impl fmt::Display for PairingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PairingError::InvalidUriScheme(s) => write!(f, "Invalid URI scheme '{}', expected 'hermes'", s), + PairingError::InvalidUriFormat(s) => write!(f, "Invalid pairing URI format: {}", s), + PairingError::MissingDataParameter => write!(f, "Missing 'data' query parameter in pairing URI"), + PairingError::Base64DecodeError(e) => write!(f, "Failed to decode Base64URL payload: {}", e), + PairingError::JsonDecodeError(e) => write!(f, "Failed to parse JSON payload: {}", e), + PairingError::UnsupportedVersion(v) => write!(f, "Unsupported payload version {}, expected 1", v), + PairingError::InvalidPayloadType(t) => write!(f, "Invalid payload type '{}', expected 'hermes-pair'", t), + PairingError::InvalidHostId(id) => write!(f, "Invalid host UUID: '{}'", id), + PairingError::EmptyHost => write!(f, "Host address cannot be empty"), + PairingError::InvalidPort(p) => write!(f, "Invalid port number: {}", p), + PairingError::PayloadExpired { expires_at, now } => { + write!(f, "Pairing payload expired at timestamp {} (current time: {})", expires_at, now) + } + } + } +} + +impl std::error::Error for PairingError {} + +pub fn current_unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +pub fn generate_nonce() -> String { + let mut bytes = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +pub fn create_pairing_payload( + host_id: String, + name: String, + host: String, + port: u16, + scheme: String, + ttl_seconds: u64, +) -> PairingPayloadV1 { + let now = current_unix_timestamp(); + let expires_at = now + ttl_seconds; + let nonce = generate_nonce(); + + PairingPayloadV1 { + v: 1, + payload_type: "hermes-pair".to_string(), + host_id, + name, + host, + port, + scheme, + expires_at, + nonce, + } +} + +pub fn encode_pairing_uri(payload: &PairingPayloadV1) -> String { + let json = serde_json::to_string(payload).expect("Serialization of PairingPayloadV1 should never fail"); + let encoded = URL_SAFE_NO_PAD.encode(json.as_bytes()); + format!("hermes://pair?data={}", encoded) +} + +pub fn decode_pairing_uri_at_time(uri: &str, current_time: u64) -> Result { + let data_str = if let Ok(url) = Url::parse(uri) { + if url.scheme() != "hermes" { + return Err(PairingError::InvalidUriScheme(url.scheme().to_string())); + } + let host = url.host_str().unwrap_or_default(); + if host != "pair" && url.path() != "pair" && url.path() != "/pair" { + return Err(PairingError::InvalidUriFormat(uri.to_string())); + } + + url.query_pairs() + .find(|(k, _)| k == "data") + .map(|(_, v)| v.into_owned()) + .ok_or(PairingError::MissingDataParameter)? + } else { + // Fallback simple parsing for custom hermes:// URIs + if !uri.starts_with("hermes://") && !uri.starts_with("hermes:") { + return Err(PairingError::InvalidUriScheme("unknown".to_string())); + } + let query_part = uri.split_once('?').map(|x| x.1).ok_or(PairingError::MissingDataParameter)?; + let mut found = None; + for pair in query_part.split('&') { + if let Some((k, v)) = pair.split_once('=') { + if k == "data" { + found = Some(v.to_string()); + break; + } + } + } + found.ok_or(PairingError::MissingDataParameter)? + }; + + // Decode Base64 (supporting URL_SAFE_NO_PAD, URL_SAFE, and STANDARD) + let decoded_bytes = URL_SAFE_NO_PAD + .decode(data_str.as_bytes()) + .or_else(|_| URL_SAFE.decode(data_str.as_bytes())) + .or_else(|_| STANDARD.decode(data_str.as_bytes())) + .map_err(|e| PairingError::Base64DecodeError(e.to_string()))?; + + let payload: PairingPayloadV1 = serde_json::from_slice(&decoded_bytes) + .map_err(|e| PairingError::JsonDecodeError(e.to_string()))?; + + // Validations + 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) +} + +pub fn decode_pairing_uri(uri: &str) -> Result { + let now = current_unix_timestamp(); + decode_pairing_uri_at_time(uri, now) +} diff --git a/src/qr.rs b/src/qr.rs new file mode 100644 index 0000000..cbb3229 --- /dev/null +++ b/src/qr.rs @@ -0,0 +1,102 @@ +use eframe::egui::{Color32, ColorImage}; +use qrcode::{Color, QrCode}; + +pub type QrError = qrcode::types::QrError; + +/// Generates a 2D boolean matrix of QR modules (true = dark, false = light) including a quiet zone. +pub fn generate_qr_matrix(data: &str) -> Result>, QrError> { + let code = QrCode::new(data.as_bytes())?; + let colors = code.to_colors(); + let width = code.width(); + let quiet_zone = 2; + let total_size = width + quiet_zone * 2; + + let mut matrix = vec![vec![false; total_size]; total_size]; + + for y in 0..width { + for x in 0..width { + let is_dark = colors[y * width + x] == Color::Dark; + matrix[y + quiet_zone][x + quiet_zone] = is_dark; + } + } + + Ok(matrix) +} + +/// Renders a terminal-friendly QR code using Unicode full blocks and ANSI colors. +pub fn render_terminal_qr(data: &str) -> Result { + let code = QrCode::new(data.as_bytes())?; + let colors = code.to_colors(); + let width = code.width(); + let quiet_zone = 2; + let total_size = width + quiet_zone * 2; + + let mut out = String::new(); + + // Render using ANSI inverted / double-width blocks for standard aspect ratio + // Dark modules: "██", Light modules: " " + for y in 0..total_size { + for x in 0..total_size { + let is_dark = if x >= quiet_zone && x < quiet_zone + width && y >= quiet_zone && y < quiet_zone + width { + let qx = x - quiet_zone; + let qy = y - quiet_zone; + colors[qy * width + qx] == Color::Dark + } else { + false + }; + + if is_dark { + out.push_str("██"); + } else { + out.push_str(" "); + } + } + out.push('\n'); + } + + Ok(out) +} + +/// Renders a high-contrast ColorImage for egui rendering with a quiet zone and configurable scale. +pub fn render_egui_image(data: &str, scale: usize) -> Result { + let code = QrCode::new(data.as_bytes())?; + let colors = code.to_colors(); + let width = code.width(); + let quiet_zone = 3; + let total_modules = width + quiet_zone * 2; + + let scale = scale.max(1); + let image_width = total_modules * scale; + let image_height = total_modules * scale; + + let mut pixels = Vec::with_capacity(image_width * image_height); + + for my in 0..total_modules { + for _sy in 0..scale { + for mx in 0..total_modules { + let is_dark = if mx >= quiet_zone && mx < quiet_zone + width && my >= quiet_zone && my < quiet_zone + width { + let qx = mx - quiet_zone; + let qy = my - quiet_zone; + colors[qy * width + qx] == Color::Dark + } else { + false + }; + + let color = if is_dark { + Color32::from_rgb(18, 18, 20) + } else { + Color32::from_rgb(255, 255, 255) + }; + + for _sx in 0..scale { + pixels.push(color); + } + } + } + } + + Ok(ColorImage { + size: [image_width, image_height], + pixels, + }) +} diff --git a/tests/unit_and_contract_tests.rs b/tests/unit_and_contract_tests.rs new file mode 100644 index 0000000..e57714f --- /dev/null +++ b/tests/unit_and_contract_tests.rs @@ -0,0 +1,257 @@ +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use hermes_pair::config::load_or_create_config_from_path; +use hermes_pair::hermes::HermesProbeClient; +use hermes_pair::models::{NetworkInterfaceInfo, PairingPayloadV1}; +use hermes_pair::network::filter_and_sort_interfaces; +use hermes_pair::pairing::{ + create_pairing_payload, decode_pairing_uri, decode_pairing_uri_at_time, encode_pairing_uri, + PairingError, +}; +use std::net::Ipv4Addr; +use std::path::PathBuf; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use uuid::Uuid; + +#[test] +fn test_config_persistence() { + let tmp_dir = std::env::temp_dir(); + 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); + + // First load -> generates new config and persists it + let config1 = load_or_create_config_from_path(&config_path).expect("Should create new config"); + assert!(!config1.host_id.is_empty()); + assert!(Uuid::parse_str(&config1.host_id).is_ok()); + + // Second load -> loads existing config and keeps same host_id + let config2 = load_or_create_config_from_path(&config_path).expect("Should load existing config"); + assert_eq!(config1.host_id, config2.host_id); + + // Clean up + let _ = std::fs::remove_file(&config_path); +} + +#[test] +fn test_pairing_payload_serde() { + let host_id = Uuid::new_v4().to_string(); + let payload = PairingPayloadV1 { + v: 1, + payload_type: "hermes-pair".to_string(), + host_id: host_id.clone(), + name: "Test-Rig".to_string(), + host: "192.168.1.100".to_string(), + port: 9119, + scheme: "http".to_string(), + expires_at: 1800000000, + nonce: "test_nonce_1234".to_string(), + }; + + let json = serde_json::to_string(&payload).expect("Serialization failed"); + let deserialized: PairingPayloadV1 = serde_json::from_str(&json).expect("Deserialization failed"); + assert_eq!(payload, deserialized); + + // Verify Base64URL round-trip + let b64 = URL_SAFE_NO_PAD.encode(json.as_bytes()); + let decoded_bytes = URL_SAFE_NO_PAD.decode(b64.as_bytes()).expect("B64 decode failed"); + let from_b64: PairingPayloadV1 = serde_json::from_slice(&decoded_bytes).expect("JSON from b64 failed"); + assert_eq!(payload, from_b64); +} + +#[test] +fn test_pairing_uri_encoding_and_validation() { + let host_id = Uuid::new_v4().to_string(); + let payload = create_pairing_payload( + host_id.clone(), + "Studio-PC".to_string(), + "192.168.0.50".to_string(), + 9119, + "http".to_string(), + 300, + ); + + let uri = encode_pairing_uri(&payload); + assert!(uri.starts_with("hermes://pair?data=")); + + let decoded = decode_pairing_uri(&uri).expect("Decoding valid pairing URI must succeed"); + assert_eq!(decoded.v, 1); + assert_eq!(decoded.payload_type, "hermes-pair"); + assert_eq!(decoded.host_id, host_id); + assert_eq!(decoded.name, "Studio-PC"); + assert_eq!(decoded.host, "192.168.0.50"); + assert_eq!(decoded.port, 9119); + assert_eq!(decoded.scheme, "http"); +} + +#[test] +fn test_expired_payload_rejection() { + let host_id = Uuid::new_v4().to_string(); + let payload = PairingPayloadV1 { + v: 1, + payload_type: "hermes-pair".to_string(), + host_id, + name: "Old-Node".to_string(), + host: "10.0.0.5".to_string(), + port: 9119, + scheme: "http".to_string(), + expires_at: 1000, + nonce: "test_nonce".to_string(), + }; + + let uri = encode_pairing_uri(&payload); + let result = decode_pairing_uri_at_time(&uri, 2000); + + match result { + Err(PairingError::PayloadExpired { expires_at, now }) => { + assert_eq!(expires_at, 1000); + assert_eq!(now, 2000); + } + other => panic!("Expected PayloadExpired error, got {:?}", other), + } +} + +#[test] +fn test_invalid_version_rejection() { + let host_id = Uuid::new_v4().to_string(); + let payload = PairingPayloadV1 { + v: 2, // Unsupported version + 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: 3000000000, + nonce: "test_nonce".to_string(), + }; + + let uri = encode_pairing_uri(&payload); + let result = decode_pairing_uri_at_time(&uri, 10000); + + match result { + Err(PairingError::UnsupportedVersion(v)) => { + assert_eq!(v, 2); + } + other => panic!("Expected UnsupportedVersion error, got {:?}", other), + } +} + +#[test] +fn test_invalid_port_rejection() { + let host_id = Uuid::new_v4().to_string(); + let payload = PairingPayloadV1 { + v: 1, + payload_type: "hermes-pair".to_string(), + host_id, + name: "Invalid-Port-Node".to_string(), + host: "192.168.1.1".to_string(), + port: 0, // Port 0 is invalid + scheme: "http".to_string(), + expires_at: 3000000000, + nonce: "test_nonce".to_string(), + }; + + let uri = encode_pairing_uri(&payload); + let result = decode_pairing_uri_at_time(&uri, 10000); + + match result { + Err(PairingError::InvalidPort(p)) => { + assert_eq!(p, 0); + } + other => panic!("Expected InvalidPort error, got {:?}", other), + } +} + +#[test] +fn test_network_interface_filtering() { + let test_interfaces = vec![ + NetworkInterfaceInfo { + name: "lo".to_string(), + ip: Ipv4Addr::new(127, 0, 0, 1), + is_loopback: true, + is_virtual: false, + }, + NetworkInterfaceInfo { + name: "link-local".to_string(), + ip: Ipv4Addr::new(169, 254, 10, 20), + is_loopback: false, + is_virtual: false, + }, + NetworkInterfaceInfo { + name: "docker0".to_string(), + ip: Ipv4Addr::new(172, 17, 0, 1), + is_loopback: false, + is_virtual: true, + }, + NetworkInterfaceInfo { + name: "tailscale0".to_string(), + ip: Ipv4Addr::new(100, 80, 5, 6), + is_loopback: false, + is_virtual: false, + }, + NetworkInterfaceInfo { + name: "eth0".to_string(), + ip: Ipv4Addr::new(192, 168, 1, 10), + is_loopback: false, + is_virtual: false, + }, + ]; + + let sorted = filter_and_sort_interfaces(test_interfaces); + + // Loopback and link-local must be eliminated + assert!(!sorted.iter().any(|i| i.is_loopback || i.ip == Ipv4Addr::new(127, 0, 0, 1))); + assert!(!sorted.iter().any(|i| i.ip == Ipv4Addr::new(169, 254, 10, 20))); + + // Order: Physical LAN (eth0 192.168.1.10) -> Tailscale (100.80.5.6) -> Virtual LAN (docker0 172.17.0.1) + assert_eq!(sorted.len(), 3); + assert_eq!(sorted[0].name, "eth0"); + assert_eq!(sorted[1].name, "tailscale0"); + assert_eq!(sorted[2].name, "docker0"); +} + +#[tokio::test] +async fn test_mock_hermes_probe() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("Failed to bind mock listener"); + let port = listener.local_addr().unwrap().port(); + + let server_task = tokio::spawn(async move { + if let Ok((mut socket, _)) = listener.accept().await { + let mut buf = [0u8; 1024]; + let _ = socket.read(&mut buf).await; + + let response_body = serde_json::json!({ + "status": "running", + "authRequired": true, + "authProviders": ["bearer", "oauth2"], + "authFlows": ["token"], + "version": "1.2.0" + }) + .to_string(); + + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), + response_body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + + let client = HermesProbeClient::new(); + let base_url = format!("http://127.0.0.1:{}", port); + let status_res = client.fetch_status(&base_url).await; + + assert!(status_res.is_ok(), "Probe should succeed against mock server"); + let status = status_res.unwrap(); + assert_eq!(status.status, "running"); + assert!(status.auth_required); + assert_eq!(status.auth_providers, vec!["bearer".to_string(), "oauth2".to_string()]); + assert_eq!(status.auth_flows, vec!["token".to_string()]); + assert_eq!(status.version, Some("1.2.0".to_string())); + + let _ = server_task.await; +}