feat(pair): initial release of Hermes Pair with Windows GUI and Linux terminal QR onboarding

This commit is contained in:
Ochenstarik 2026-08-24 10:45:58 +07:00
commit 2929a35261
16 changed files with 1942 additions and 0 deletions

76
.github/workflows/build.yml vendored Normal file
View file

@ -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/*

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/target/
Cargo.lock
dist/
.idea/
*.exe

34
Cargo.toml Normal file
View file

@ -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"] }

198
README.md Normal file
View file

@ -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:<port>/api/status` and `http://<lan_ip>:<port>/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=<BASE64URL_ENCODED_JSON>
```
### 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> Port of Hermes Agent [default: 9119]
--hermes-url <URL> Hermes status API URL (e.g. http://127.0.0.1:9119)
-i, --interface <INTERFACE> Specific network interface name or IPv4 address to advertise
--ttl <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).

380
src/app.rs Normal file
View file

@ -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<NetworkInterfaceInfo>,
selected_iface_index: usize,
current_payload: PairingPayloadV1,
current_uri: String,
generated_at: Instant,
qr_texture: Option<TextureHandle>,
probe_state: ProbeState,
probe_tx: Sender<ProbeState>,
probe_rx: Receiver<ProbeState>,
is_probing: bool,
copied_banner_timer: Option<Instant>,
}
impl HermesPairApp {
pub fn new(
cc: &eframe::CreationContext<'_>,
config: AppConfig,
port: u16,
explicit_interface: Option<String>,
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(&current_payload);
let qr_texture = Self::build_qr_texture(&cc.egui_ctx, &current_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<TextureHandle> {
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;
}
}
});
}
}

249
src/cli.rs Normal file
View file

@ -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<String>,
/// Specific network interface name or IPv4 address to advertise
#[arg(long, short = 'i')]
pub interface: Option<String>,
/// Pairing QR validity TTL in seconds (default 120)
#[arg(long, default_value = "120")]
pub ttl: u64,
#[command(subcommand)]
pub command: Option<CliCommand>,
}
#[derive(Subcommand, Debug, Clone)]
pub enum CliCommand {
/// Output QR code once to stdout and exit
Qr(QrArgs),
}
#[derive(Args, Debug, Clone)]
pub struct QrArgs {
/// Port of Hermes Agent
#[arg(long)]
pub port: Option<u16>,
/// Specific network interface name or IPv4 address
#[arg(long, short = 'i')]
pub interface: Option<String>,
/// Pairing QR validity TTL in seconds
#[arg(long)]
pub ttl: Option<u64>,
}
/// 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(&current_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 = &current_payload.host_id;
let short_id = if host_id.len() >= 8 {
format!("{}...", &host_id[..8])
} else {
host_id.clone()
};
println!("Host: {}", current_payload.name);
println!("Address: http://{}:{}", current_payload.host, current_payload.port);
println!("Host ID: {}", short_id);
println!("Expires in: {:02}:{:02}", remaining / 60, remaining % 60);
println!("\n{}", qr_rendered);
println!("Pairing Link: {}", uri);
println!("\nPress Ctrl+C to exit.");
sleep(Duration::from_secs(1)).await;
}
}

108
src/config.rs Normal file
View file

@ -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<String>,
}
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<AppConfig, std::io::Error> {
if path.exists() {
let content = fs::read_to_string(path)?;
if let Ok(mut config) = serde_json::from_str::<AppConfig>(&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<AppConfig, std::io::Error> {
let path = get_config_path();
load_or_create_config_from_path(&path)
}

117
src/hermes.rs Normal file
View file

@ -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<HermesStatusResponse, String> {
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::<HermesStatusResponse>()
.await
.map_err(|e| format!("Failed to parse status response: {}", e))?;
Ok(body)
}
pub async fn probe(&self, port: u16, lan_ip: Option<Ipv4Addr>) -> 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<HermesStatusResponse, String> {
let client = HermesProbeClient::new();
client.fetch_status(base_url).await
}
pub async fn probe_hermes(port: u16, lan_ip: Option<Ipv4Addr>) -> ProbeState {
let client = HermesProbeClient::new();
client.probe(port, lan_ip).await
}

54
src/identity.rs Normal file
View file

@ -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()
}

9
src/lib.rs Normal file
View file

@ -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;

62
src/main.rs Normal file
View file

@ -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<dyn std::error::Error>> {
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(())
}

38
src/models.rs Normal file
View file

@ -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<String>,
#[serde(rename = "authFlows", default)]
pub auth_flows: Vec<String>,
#[serde(default)]
pub version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NetworkInterfaceInfo {
pub name: String,
pub ip: Ipv4Addr,
pub is_loopback: bool,
pub is_virtual: bool,
}

86
src/network.rs Normal file
View file

@ -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<Item = NetworkInterfaceInfo>,
) -> Vec<NetworkInterfaceInfo> {
let mut filtered: Vec<NetworkInterfaceInfo> = 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<Vec<NetworkInterfaceInfo>, 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))
}

167
src/pairing.rs Normal file
View file

@ -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<PairingPayloadV1, PairingError> {
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<PairingPayloadV1, PairingError> {
let now = current_unix_timestamp();
decode_pairing_uri_at_time(uri, now)
}

102
src/qr.rs Normal file
View file

@ -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<Vec<Vec<bool>>, 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<String, 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 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<ColorImage, QrError> {
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,
})
}

View file

@ -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;
}