feat: integrate hermes-pair desktop helper into unified monorepo
This commit is contained in:
parent
7c90da222c
commit
ba5f0466f3
19 changed files with 6874 additions and 2 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -37,3 +37,7 @@ local.properties
|
|||
# OS & temporary files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Rust / Cargo
|
||||
hermes-pair/target/
|
||||
**/target/
|
||||
|
|
|
|||
35
README.md
35
README.md
|
|
@ -102,13 +102,13 @@ hermes serve --host 0.0.0.0 --port 9119
|
|||
Run the full automated test suite:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat testDebugUnitTest
|
||||
.\gradlew.bat test
|
||||
```
|
||||
|
||||
Run Android Lint:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat lintDebug
|
||||
.\gradlew.bat lint
|
||||
```
|
||||
|
||||
Assemble Debug APK:
|
||||
|
|
@ -116,3 +116,34 @@ Assemble Debug APK:
|
|||
```powershell
|
||||
.\gradlew.bat assembleDebug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 Instant QR Onboarding — Hermes Pair (`hermes-pair/`)
|
||||
|
||||
Inside the `hermes-pair/` directory is the cross-platform desktop companion application written in Rust. It runs on Windows and Linux to auto-discover your local IP and generate a secure QR code for instant onboarding with Hermes Android.
|
||||
|
||||
### Windows (GUI or CLI):
|
||||
```powershell
|
||||
# GUI window
|
||||
.\hermes-pair\dist\windows\HermesPair.exe
|
||||
|
||||
# Terminal QR output
|
||||
.\hermes-pair\dist\windows\HermesPair.exe qr --port 9119
|
||||
```
|
||||
|
||||
### Linux (GUI or Headless Server):
|
||||
```bash
|
||||
# GUI window
|
||||
./hermes-pair/dist/linux/hermes-pair
|
||||
|
||||
# Headless / Terminal QR
|
||||
./hermes-pair/dist/linux/hermes-pair --terminal --port 9119
|
||||
```
|
||||
|
||||
### Building Hermes Pair from Source:
|
||||
```bash
|
||||
cd hermes-pair
|
||||
cargo test
|
||||
cargo build --release
|
||||
```
|
||||
|
|
|
|||
4171
hermes-pair/Cargo.lock
generated
Normal file
4171
hermes-pair/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
34
hermes-pair/Cargo.toml
Normal file
34
hermes-pair/Cargo.toml
Normal 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
hermes-pair/README.md
Normal file
198
hermes-pair/README.md
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# Hermes Pair (`hermes-pair`)
|
||||
|
||||
[](https://github.com/ochenstarik-ui/hermes-pair/actions/workflows/build.yml)
|
||||
[](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).
|
||||
BIN
hermes-pair/dist/linux/hermes-pair
vendored
Normal file
BIN
hermes-pair/dist/linux/hermes-pair
vendored
Normal file
Binary file not shown.
BIN
hermes-pair/dist/windows/HermesPair.exe
vendored
Normal file
BIN
hermes-pair/dist/windows/HermesPair.exe
vendored
Normal file
Binary file not shown.
455
hermes-pair/src/app.rs
Normal file
455
hermes-pair/src/app.rs
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
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, MAX_TTL_SECONDS, MIN_TTL_SECONDS,
|
||||
};
|
||||
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,
|
||||
hermes_url: Option<String>,
|
||||
scheme: String,
|
||||
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,
|
||||
hermes_url: Option<String>,
|
||||
scheme: String,
|
||||
port: u16,
|
||||
explicit_interface: Option<String>,
|
||||
ttl: u64,
|
||||
) -> Self {
|
||||
let ttl = ttl.clamp(MIN_TTL_SECONDS, MAX_TTL_SECONDS);
|
||||
|
||||
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,
|
||||
scheme.clone(),
|
||||
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,
|
||||
hermes_url,
|
||||
scheme,
|
||||
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,
|
||||
self.scheme.clone(),
|
||||
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 hermes_url = self.hermes_url.clone();
|
||||
let scheme = self.scheme.clone();
|
||||
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(hermes_url.as_deref(), &scheme, 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 { lan_error, .. } = &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 bound to loopback only (127.0.0.1).\n\
|
||||
LAN connection failed: {}\n\
|
||||
Start Hermes with LAN access:\n\
|
||||
hermes serve --host 0.0.0.0 --port {}",
|
||||
lan_error, 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!(
|
||||
"{}://{}:{}",
|
||||
self.scheme,
|
||||
self.selected_ip(),
|
||||
self.port
|
||||
));
|
||||
});
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Center QR Code or Placeholder Card
|
||||
ui.vertical_centered(|ui| {
|
||||
match &self.probe_state {
|
||||
ProbeState::Online(_) => {
|
||||
if is_expired {
|
||||
egui::Frame::group(ui.style())
|
||||
.inner_margin(32.0)
|
||||
.show(ui, |ui| {
|
||||
ui.label(
|
||||
RichText::new("⚠️ QR Code Expired")
|
||||
.color(Color32::from_rgb(231, 76, 60))
|
||||
.strong()
|
||||
.size(16.0),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
"Click [ 🔄 Regenerate QR ] below to create a fresh pairing code.",
|
||||
);
|
||||
});
|
||||
} else if let Some(ref texture) = self.qr_texture {
|
||||
ui.image((texture.id(), Vec2::new(260.0, 260.0)));
|
||||
ui.add_space(6.0);
|
||||
let mins = remaining / 60;
|
||||
let secs = remaining % 60;
|
||||
ui.label(
|
||||
RichText::new(format!("Expires in {:02}:{:02}", mins, secs))
|
||||
.size(13.0)
|
||||
.color(Color32::LIGHT_GRAY),
|
||||
);
|
||||
} else {
|
||||
ui.label("Failed to render QR code");
|
||||
}
|
||||
}
|
||||
ProbeState::LoopbackOnly { .. } => {
|
||||
egui::Frame::group(ui.style())
|
||||
.inner_margin(32.0)
|
||||
.show(ui, |ui| {
|
||||
ui.colored_label(
|
||||
Color32::from_rgb(241, 196, 15),
|
||||
RichText::new("⚠️ Pairing QR Hidden").size(15.0).strong(),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
"Hermes is running locally on 127.0.0.1, but unreachable on this LAN interface.\n\
|
||||
Start Hermes with --host 0.0.0.0 to enable network pairing.",
|
||||
);
|
||||
});
|
||||
}
|
||||
ProbeState::Offline(err) => {
|
||||
egui::Frame::group(ui.style())
|
||||
.inner_margin(32.0)
|
||||
.show(ui, |ui| {
|
||||
ui.colored_label(
|
||||
Color32::from_rgb(231, 76, 60),
|
||||
RichText::new("● Hermes Offline").size(15.0).strong(),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.label(format!(
|
||||
"Hermes is not responding on this endpoint ({}).\n\
|
||||
Start Hermes Agent and click [ 🔄 Check ] to connect.",
|
||||
err
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
|
||||
let can_copy = self.probe_state.is_online() && !is_expired;
|
||||
|
||||
// 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]
|
||||
.add_enabled(can_copy, egui::Button::new("📋 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
309
hermes-pair/src/cli.rs
Normal file
309
hermes-pair/src/cli.rs
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
use crate::config::AppConfig;
|
||||
use crate::hermes::{HermesProbeClient, ProbeState};
|
||||
use crate::identity::{get_display_name, get_host_id};
|
||||
use crate::models::NetworkInterfaceInfo;
|
||||
use crate::network::discover_network_interfaces;
|
||||
use crate::pairing::{create_pairing_payload, encode_pairing_uri, validate_ttl};
|
||||
use crate::qr::render_terminal_qr;
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(
|
||||
name = "hermes-pair",
|
||||
version = "0.1.0",
|
||||
author = "ochenstarik-ui",
|
||||
about = "Fast, secure QR onboarding helper for Hermes Agent"
|
||||
)]
|
||||
pub struct CliArgs {
|
||||
/// Run interactive terminal mode with periodic refresh
|
||||
#[arg(long, short = 't')]
|
||||
pub terminal: bool,
|
||||
|
||||
/// Print QR once to stdout and exit (headless/script mode)
|
||||
#[arg(long = "no-gui")]
|
||||
pub no_gui: bool,
|
||||
|
||||
/// Port of Hermes Agent (default: 9119)
|
||||
#[arg(long)]
|
||||
pub port: Option<u16>,
|
||||
|
||||
/// Hermes status API URL (e.g. http://127.0.0.1:9119 or http://127.0.0.1:9222)
|
||||
#[arg(long = "hermes-url")]
|
||||
pub hermes_url: Option<String>,
|
||||
|
||||
/// Specific network interface name or IPv4 address to advertise
|
||||
#[arg(long, short = 'i')]
|
||||
pub interface: Option<String>,
|
||||
|
||||
/// Pairing QR validity TTL in seconds (default 120, range 10..=600)
|
||||
#[arg(long, default_value = "120")]
|
||||
pub ttl: u64,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<CliCommand>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum CliCommand {
|
||||
/// Output QR code once to stdout and exit
|
||||
Qr(QrArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone)]
|
||||
pub struct QrArgs {
|
||||
/// Port of Hermes Agent
|
||||
#[arg(long)]
|
||||
pub port: Option<u16>,
|
||||
|
||||
/// Hermes status API URL (e.g. http://127.0.0.1:9119 or http://127.0.0.1:9222)
|
||||
#[arg(long = "hermes-url")]
|
||||
pub hermes_url: Option<String>,
|
||||
|
||||
/// Specific network interface name or IPv4 address
|
||||
#[arg(long, short = 'i')]
|
||||
pub interface: Option<String>,
|
||||
|
||||
/// Pairing QR validity TTL in seconds (range 10..=600)
|
||||
#[arg(long)]
|
||||
pub ttl: Option<u64>,
|
||||
}
|
||||
|
||||
/// Parses a Hermes URL into its scheme, host, and port components.
|
||||
pub fn parse_hermes_url(raw_url: &str) -> Result<(String, String, u16), String> {
|
||||
let parsed = url::Url::parse(raw_url)
|
||||
.map_err(|e| format!("Invalid --hermes-url '{}': {}", raw_url, e))?;
|
||||
let scheme = parsed.scheme().to_lowercase();
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return Err(format!(
|
||||
"Invalid scheme '{}' in --hermes-url: must be 'http' or 'https'",
|
||||
scheme
|
||||
));
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| format!("Missing host in --hermes-url '{}'", raw_url))?
|
||||
.to_string();
|
||||
let port = parsed
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| format!("Could not determine port from --hermes-url '{}'", raw_url))?;
|
||||
Ok((scheme, host, port))
|
||||
}
|
||||
|
||||
/// Resolves the scheme and port based on --hermes-url and explicit --port arguments.
|
||||
pub fn resolve_cli_endpoint(
|
||||
hermes_url: Option<&str>,
|
||||
explicit_port: Option<u16>,
|
||||
) -> Result<(String, u16), String> {
|
||||
if let Some(url_str) = hermes_url {
|
||||
let (parsed_scheme, _parsed_host, parsed_port) = parse_hermes_url(url_str)?;
|
||||
let final_port = explicit_port.unwrap_or(parsed_port);
|
||||
Ok((parsed_scheme, final_port))
|
||||
} else {
|
||||
let final_port = explicit_port.unwrap_or(9119);
|
||||
Ok(("http".to_string(), final_port))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the selected IPv4 address based on user input or automatic interface detection.
|
||||
pub fn resolve_selected_ip(
|
||||
explicit_interface: Option<&str>,
|
||||
interfaces: &[NetworkInterfaceInfo],
|
||||
) -> (String, Ipv4Addr) {
|
||||
if let Some(target) = explicit_interface {
|
||||
if let Ok(ip) = Ipv4Addr::from_str(target) {
|
||||
return (format!("Manual ({})", ip), ip);
|
||||
}
|
||||
|
||||
let lower = target.to_lowercase();
|
||||
if let Some(matched) = interfaces
|
||||
.iter()
|
||||
.find(|i| i.name.to_lowercase().contains(&lower) || i.ip.to_string() == target)
|
||||
{
|
||||
return (matched.name.clone(), matched.ip);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(first) = interfaces.first() {
|
||||
return (first.name.clone(), first.ip);
|
||||
}
|
||||
|
||||
("Loopback".to_string(), Ipv4Addr::new(127, 0, 0, 1))
|
||||
}
|
||||
|
||||
/// Runs single-shot terminal output mode.
|
||||
pub async fn run_once(
|
||||
config: &AppConfig,
|
||||
hermes_url: Option<&str>,
|
||||
scheme: &str,
|
||||
port: u16,
|
||||
explicit_interface: Option<&str>,
|
||||
ttl: u64,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
validate_ttl(ttl)?;
|
||||
|
||||
let interfaces = discover_network_interfaces().unwrap_or_default();
|
||||
let (_iface_name, host_ip) = resolve_selected_ip(explicit_interface, &interfaces);
|
||||
|
||||
let client = HermesProbeClient::new();
|
||||
let probe_state = client.probe(hermes_url, scheme, port, Some(host_ip)).await;
|
||||
|
||||
let host_id = get_host_id(config);
|
||||
let display_name = get_display_name(config);
|
||||
|
||||
match &probe_state {
|
||||
ProbeState::Online(status) => {
|
||||
let ver = status.version.as_deref().unwrap_or("unknown");
|
||||
let auth = if status.auth_required {
|
||||
"Required"
|
||||
} else {
|
||||
"None"
|
||||
};
|
||||
println!("Hermes: Running (v{}, Auth: {})", ver, auth);
|
||||
|
||||
let payload = create_pairing_payload(
|
||||
host_id.clone(),
|
||||
display_name.clone(),
|
||||
host_ip.to_string(),
|
||||
port,
|
||||
scheme.to_string(),
|
||||
ttl,
|
||||
);
|
||||
|
||||
let uri = encode_pairing_uri(&payload);
|
||||
let qr_rendered =
|
||||
render_terminal_qr(&uri).map_err(|e| format!("QR Render Error: {}", e))?;
|
||||
|
||||
let short_id = if host_id.len() >= 8 {
|
||||
format!("{}...", &host_id[..8])
|
||||
} else {
|
||||
host_id.clone()
|
||||
};
|
||||
|
||||
println!("Host: {}", display_name);
|
||||
println!("Address: {}://{}:{}", scheme, host_ip, port);
|
||||
println!("Host ID: {}", short_id);
|
||||
println!("Expires in: {:02}:{:02}", ttl / 60, ttl % 60);
|
||||
println!("\n{}", qr_rendered);
|
||||
println!("Pairing Link: {}", uri);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
ProbeState::LoopbackOnly { lan_error, .. } => {
|
||||
eprintln!(
|
||||
"Hermes: Running locally (127.0.0.1), but LAN unreachable: {}",
|
||||
lan_error
|
||||
);
|
||||
eprintln!(
|
||||
"⚠️ Warning: Hermes is bound to loopback only. Start Hermes with LAN access, for example:"
|
||||
);
|
||||
eprintln!(" hermes serve --host 0.0.0.0 --port {}", port);
|
||||
eprintln!(
|
||||
"\n[Pairing QR not displayed because Hermes is unreachable from other devices]"
|
||||
);
|
||||
Err("Hermes is bound to loopback only (LAN unreachable)".into())
|
||||
}
|
||||
ProbeState::Offline(err) => {
|
||||
eprintln!("Hermes: Offline ({})", err);
|
||||
eprintln!("⚠️ Hermes Agent is unreachable. Please start Hermes before generating pairing QR.");
|
||||
eprintln!("\n[Pairing QR not displayed because Hermes is offline]");
|
||||
Err(format!("Hermes is offline: {}", err).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs interactive terminal UI mode that updates in a loop.
|
||||
pub async fn run_terminal_loop(
|
||||
config: &AppConfig,
|
||||
hermes_url: Option<&str>,
|
||||
scheme: &str,
|
||||
port: u16,
|
||||
explicit_interface: Option<&str>,
|
||||
ttl: u64,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
validate_ttl(ttl)?;
|
||||
|
||||
let mut last_generated_at = std::time::Instant::now();
|
||||
let client = HermesProbeClient::new();
|
||||
|
||||
loop {
|
||||
let now = std::time::Instant::now();
|
||||
let elapsed = now.duration_since(last_generated_at).as_secs();
|
||||
|
||||
let interfaces = discover_network_interfaces().unwrap_or_default();
|
||||
let (_iface_name, host_ip) = resolve_selected_ip(explicit_interface, &interfaces);
|
||||
|
||||
if elapsed >= ttl {
|
||||
last_generated_at = std::time::Instant::now();
|
||||
}
|
||||
let remaining = ttl.saturating_sub(now.duration_since(last_generated_at).as_secs());
|
||||
|
||||
let probe_state = client.probe(hermes_url, scheme, port, Some(host_ip)).await;
|
||||
|
||||
// Clear terminal screen (cross-platform ANSI)
|
||||
print!("\x1B[2J\x1B[1;1H");
|
||||
|
||||
match &probe_state {
|
||||
ProbeState::Online(status) => {
|
||||
let ver = status.version.as_deref().unwrap_or("unknown");
|
||||
let auth = if status.auth_required {
|
||||
"Required"
|
||||
} else {
|
||||
"None"
|
||||
};
|
||||
println!("Hermes: Running (v{}, Auth: {})", ver, auth);
|
||||
|
||||
let payload = create_pairing_payload(
|
||||
get_host_id(config),
|
||||
get_display_name(config),
|
||||
host_ip.to_string(),
|
||||
port,
|
||||
scheme.to_string(),
|
||||
ttl,
|
||||
);
|
||||
let uri = encode_pairing_uri(&payload);
|
||||
let qr_rendered = render_terminal_qr(&uri).unwrap_or_default();
|
||||
|
||||
let host_id = &payload.host_id;
|
||||
let short_id = if host_id.len() >= 8 {
|
||||
format!("{}...", &host_id[..8])
|
||||
} else {
|
||||
host_id.clone()
|
||||
};
|
||||
|
||||
println!("Host: {}", payload.name);
|
||||
println!("Address: {}://{}:{}", scheme, payload.host, payload.port);
|
||||
println!("Host ID: {}", short_id);
|
||||
println!("Expires in: {:02}:{:02}", remaining / 60, remaining % 60);
|
||||
println!("\n{}", qr_rendered);
|
||||
println!("Pairing Link: {}", uri);
|
||||
}
|
||||
ProbeState::LoopbackOnly { lan_error, .. } => {
|
||||
println!(
|
||||
"Hermes: Loopback Only (127.0.0.1) [LAN Error: {}]",
|
||||
lan_error
|
||||
);
|
||||
println!(
|
||||
"⚠️ Warning: Hermes is bound to loopback only. Start Hermes with LAN access:\n hermes serve --host 0.0.0.0 --port {}",
|
||||
port
|
||||
);
|
||||
println!("\n[QR Code hidden: Hermes is unreachable over LAN]");
|
||||
println!("Address: {}://{}:{}", scheme, host_ip, port);
|
||||
println!("Retrying probe every second...");
|
||||
}
|
||||
ProbeState::Offline(err) => {
|
||||
println!("Hermes: Offline ({})", err);
|
||||
println!("⚠️ Hermes Agent is unreachable. Please start Hermes.");
|
||||
println!("\n[QR Code hidden: Hermes is offline]");
|
||||
println!("Address: {}://{}:{}", scheme, host_ip, port);
|
||||
println!("Retrying probe every second...");
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nPress Ctrl+C to exit.");
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
115
hermes-pair/src/config.rs
Normal file
115
hermes-pair/src/config.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
199
hermes-pair/src/hermes.rs
Normal file
199
hermes-pair/src/hermes.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
use crate::models::HermesStatusResponse;
|
||||
use crate::network::is_loopback;
|
||||
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 is_offline(&self) -> bool {
|
||||
matches!(self, ProbeState::Offline(_))
|
||||
}
|
||||
|
||||
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 trimmed = base_url.trim().trim_end_matches('/');
|
||||
let url = if trimmed.ends_with("/api/status") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{}/api/status", trimmed)
|
||||
};
|
||||
|
||||
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))?;
|
||||
|
||||
if body.status.trim().is_empty() {
|
||||
return Err("Invalid status response: missing 'status' field".to_string());
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub async fn probe(
|
||||
&self,
|
||||
hermes_url: Option<&str>,
|
||||
scheme: &str,
|
||||
port: u16,
|
||||
lan_ip: Option<Ipv4Addr>,
|
||||
) -> ProbeState {
|
||||
if let Some(url_str) = hermes_url {
|
||||
let direct_res = self.fetch_status(url_str).await;
|
||||
|
||||
if is_url_loopback(url_str) {
|
||||
match direct_res {
|
||||
Ok(local_status) => {
|
||||
if let Some(lan) = lan_ip {
|
||||
if !is_loopback(&lan) {
|
||||
let lan_url = format!("{}://{}:{}", scheme, lan, port);
|
||||
match self.fetch_status(&lan_url).await {
|
||||
Ok(lan_status) => ProbeState::Online(lan_status),
|
||||
Err(lan_err) => ProbeState::LoopbackOnly {
|
||||
local_status,
|
||||
lan_error: lan_err,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
ProbeState::Online(local_status)
|
||||
}
|
||||
} else {
|
||||
ProbeState::Online(local_status)
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if let Some(lan) = lan_ip {
|
||||
if !is_loopback(&lan) {
|
||||
let lan_url = format!("{}://{}:{}", scheme, lan, port);
|
||||
match self.fetch_status(&lan_url).await {
|
||||
Ok(lan_status) => ProbeState::Online(lan_status),
|
||||
Err(_) => ProbeState::Offline(err),
|
||||
}
|
||||
} else {
|
||||
ProbeState::Offline(err)
|
||||
}
|
||||
} else {
|
||||
ProbeState::Offline(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match direct_res {
|
||||
Ok(status) => ProbeState::Online(status),
|
||||
Err(err) => ProbeState::Offline(err),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let local_url = format!("{}://127.0.0.1:{}", scheme, port);
|
||||
let local_result = self.fetch_status(&local_url).await;
|
||||
|
||||
match (local_result, lan_ip) {
|
||||
(Ok(local_status), Some(lan)) if !is_loopback(&lan) => {
|
||||
let lan_url = format!("{}://{}:{}", scheme, lan, port);
|
||||
match self.fetch_status(&lan_url).await {
|
||||
Ok(lan_status) => ProbeState::Online(lan_status),
|
||||
Err(lan_err) => ProbeState::LoopbackOnly {
|
||||
local_status,
|
||||
lan_error: lan_err,
|
||||
},
|
||||
}
|
||||
}
|
||||
(Ok(local_status), _) => ProbeState::Online(local_status),
|
||||
(Err(local_err), Some(lan)) if !is_loopback(&lan) => {
|
||||
let lan_url = format!("{}://{}:{}", scheme, lan, port);
|
||||
match self.fetch_status(&lan_url).await {
|
||||
Ok(lan_status) => ProbeState::Online(lan_status),
|
||||
Err(_) => ProbeState::Offline(local_err),
|
||||
}
|
||||
}
|
||||
(Err(local_err), _) => ProbeState::Offline(local_err),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_url_loopback(url_or_host: &str) -> bool {
|
||||
let host = if let Ok(parsed) = url::Url::parse(url_or_host) {
|
||||
parsed.host_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
url_or_host.to_string()
|
||||
};
|
||||
let host_lower = host.trim().to_lowercase();
|
||||
host_lower == "127.0.0.1"
|
||||
|| host_lower == "localhost"
|
||||
|| host_lower == "::1"
|
||||
|| host_lower == "[::1]"
|
||||
|| host_lower.starts_with("127.")
|
||||
}
|
||||
|
||||
pub async fn probe_hermes_status(base_url: &str) -> Result<HermesStatusResponse, String> {
|
||||
let client = HermesProbeClient::new();
|
||||
client.fetch_status(base_url).await
|
||||
}
|
||||
|
||||
pub async fn probe_hermes(
|
||||
hermes_url: Option<&str>,
|
||||
scheme: &str,
|
||||
port: u16,
|
||||
lan_ip: Option<Ipv4Addr>,
|
||||
) -> ProbeState {
|
||||
let client = HermesProbeClient::new();
|
||||
client.probe(hermes_url, scheme, port, lan_ip).await
|
||||
}
|
||||
54
hermes-pair/src/identity.rs
Normal file
54
hermes-pair/src/identity.rs
Normal 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
hermes-pair/src/lib.rs
Normal file
9
hermes-pair/src/lib.rs
Normal 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;
|
||||
96
hermes-pair/src/main.rs
Normal file
96
hermes-pair/src/main.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
use clap::Parser;
|
||||
use eframe::egui::Vec2;
|
||||
use hermes_pair::app::HermesPairApp;
|
||||
use hermes_pair::cli::{resolve_cli_endpoint, run_once, run_terminal_loop, CliArgs, CliCommand};
|
||||
use hermes_pair::config::load_or_create_config;
|
||||
use hermes_pair::pairing::validate_ttl;
|
||||
|
||||
#[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(ref qr_args)) = args.command {
|
||||
let hermes_url = qr_args.hermes_url.as_deref().or(args.hermes_url.as_deref());
|
||||
let (scheme, port) = resolve_cli_endpoint(hermes_url, qr_args.port.or(args.port))?;
|
||||
let iface = qr_args.interface.as_deref().or(args.interface.as_deref());
|
||||
let ttl = qr_args.ttl.unwrap_or(args.ttl);
|
||||
validate_ttl(ttl)?;
|
||||
return run_once(&config, hermes_url, &scheme, port, iface, ttl).await;
|
||||
}
|
||||
|
||||
let hermes_url_str = args.hermes_url.as_deref();
|
||||
let (scheme, port) = resolve_cli_endpoint(hermes_url_str, args.port)?;
|
||||
let ttl = args.ttl;
|
||||
validate_ttl(ttl)?;
|
||||
|
||||
if args.no_gui {
|
||||
return run_once(
|
||||
&config,
|
||||
hermes_url_str,
|
||||
&scheme,
|
||||
port,
|
||||
args.interface.as_deref(),
|
||||
ttl,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if args.terminal {
|
||||
return run_terminal_loop(
|
||||
&config,
|
||||
hermes_url_str,
|
||||
&scheme,
|
||||
port,
|
||||
args.interface.as_deref(),
|
||||
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 hermes_url_owned = args.hermes_url.clone();
|
||||
let scheme_clone = scheme.clone();
|
||||
let iface = args.interface.clone();
|
||||
|
||||
let res = eframe::run_native(
|
||||
"Hermes Pair",
|
||||
native_options,
|
||||
Box::new(move |cc| {
|
||||
Ok(Box::new(HermesPairApp::new(
|
||||
cc,
|
||||
config_clone,
|
||||
hermes_url_owned,
|
||||
scheme_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.hermes_url.as_deref(),
|
||||
&scheme,
|
||||
port,
|
||||
args.interface.as_deref(),
|
||||
ttl,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
38
hermes-pair/src/models.rs
Normal file
38
hermes-pair/src/models.rs
Normal 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
hermes-pair/src/network.rs
Normal file
86
hermes-pair/src/network.rs
Normal 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))
|
||||
}
|
||||
360
hermes-pair/src/pairing.rs
Normal file
360
hermes-pair/src/pairing.rs
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
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;
|
||||
|
||||
pub const MIN_TTL_SECONDS: u64 = 10;
|
||||
pub const MAX_TTL_SECONDS: u64 = 600;
|
||||
pub const DEFAULT_TTL_SECONDS: u64 = 120;
|
||||
pub const MAX_CLOCK_SKEW_SECONDS: u64 = 30;
|
||||
pub const MAX_ENCODED_URI_BYTES: usize = 4096;
|
||||
pub const MAX_DECODED_JSON_BYTES: usize = 2048;
|
||||
pub const MAX_NAME_LENGTH: usize = 128;
|
||||
pub const MIN_NONCE_BYTES: usize = 16;
|
||||
pub const MAX_NONCE_BYTES: usize = 64;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PairingError {
|
||||
InvalidUriScheme(String),
|
||||
InvalidUriFormat(String),
|
||||
MissingDataParameter,
|
||||
PayloadTooLarge { size: usize, max: usize },
|
||||
Base64DecodeError(String),
|
||||
JsonDecodeError(String),
|
||||
UnsupportedVersion(u32),
|
||||
InvalidPayloadType(String),
|
||||
InvalidHostId(String),
|
||||
InvalidName(String),
|
||||
EmptyHost,
|
||||
InvalidHost(String),
|
||||
InvalidPort(u16),
|
||||
InvalidScheme(String),
|
||||
InvalidNonce(String),
|
||||
PayloadExpired { expires_at: u64, now: u64 },
|
||||
TtlExceedsMaximum { expires_at: u64, max_allowed: u64 },
|
||||
InvalidTtl { ttl: u64, min: u64, max: u64 },
|
||||
}
|
||||
|
||||
impl fmt::Display for PairingError {
|
||||
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::PayloadTooLarge { size, max } => {
|
||||
write!(
|
||||
f,
|
||||
"Payload size ({} bytes) exceeds maximum limit of {} bytes",
|
||||
size, max
|
||||
)
|
||||
}
|
||||
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::InvalidName(msg) => write!(f, "Invalid host display name: {}", msg),
|
||||
PairingError::EmptyHost => write!(f, "Host address cannot be empty"),
|
||||
PairingError::InvalidHost(msg) => write!(f, "Invalid host address: {}", msg),
|
||||
PairingError::InvalidPort(p) => write!(f, "Invalid port number: {}", p),
|
||||
PairingError::InvalidScheme(s) => {
|
||||
write!(f, "Invalid scheme '{}', expected 'http' or 'https'", s)
|
||||
}
|
||||
PairingError::InvalidNonce(msg) => write!(f, "Invalid nonce: {}", msg),
|
||||
PairingError::PayloadExpired { expires_at, now } => {
|
||||
write!(
|
||||
f,
|
||||
"Pairing payload expired at timestamp {} (current time: {})",
|
||||
expires_at, now
|
||||
)
|
||||
}
|
||||
PairingError::TtlExceedsMaximum {
|
||||
expires_at,
|
||||
max_allowed,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"Pairing payload expiry timestamp {} exceeds maximum allowed {}",
|
||||
expires_at, max_allowed
|
||||
)
|
||||
}
|
||||
PairingError::InvalidTtl { ttl, min, max } => {
|
||||
write!(
|
||||
f,
|
||||
"Invalid TTL {}s: TTL must be between {} and {} seconds",
|
||||
ttl, min, max
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
pub fn validate_ttl(ttl: u64) -> Result<(), PairingError> {
|
||||
if !(MIN_TTL_SECONDS..=MAX_TTL_SECONDS).contains(&ttl) {
|
||||
return Err(PairingError::InvalidTtl {
|
||||
ttl,
|
||||
min: MIN_TTL_SECONDS,
|
||||
max: MAX_TTL_SECONDS,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_payload(payload: &PairingPayloadV1, current_time: u64) -> Result<(), PairingError> {
|
||||
// 1. Version must be 1
|
||||
if payload.v != 1 {
|
||||
return Err(PairingError::UnsupportedVersion(payload.v));
|
||||
}
|
||||
|
||||
// 2. Payload type must be "hermes-pair"
|
||||
if payload.payload_type != "hermes-pair" {
|
||||
return Err(PairingError::InvalidPayloadType(
|
||||
payload.payload_type.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
// 3. Host ID must be a valid UUIDv4 string
|
||||
let host_uuid = Uuid::parse_str(&payload.host_id).map_err(|_| {
|
||||
PairingError::InvalidHostId(format!("'{}' is not a valid UUID", payload.host_id))
|
||||
})?;
|
||||
if host_uuid.get_version_num() != 4 {
|
||||
return Err(PairingError::InvalidHostId(format!(
|
||||
"UUID must be version 4 (random), got version {}",
|
||||
host_uuid.get_version_num()
|
||||
)));
|
||||
}
|
||||
|
||||
// 4. Name: not blank, trimmed <= 128 chars, no control characters
|
||||
let trimmed_name = payload.name.trim();
|
||||
if trimmed_name.is_empty() {
|
||||
return Err(PairingError::InvalidName(
|
||||
"Host display name cannot be blank".into(),
|
||||
));
|
||||
}
|
||||
if trimmed_name.chars().count() > MAX_NAME_LENGTH {
|
||||
return Err(PairingError::InvalidName(format!(
|
||||
"Host display name length ({}) exceeds maximum allowed {}",
|
||||
trimmed_name.chars().count(),
|
||||
MAX_NAME_LENGTH
|
||||
)));
|
||||
}
|
||||
if payload
|
||||
.name
|
||||
.chars()
|
||||
.any(|c| (c as u32) < 0x20 || (c as u32) == 0x7F)
|
||||
{
|
||||
return Err(PairingError::InvalidName(
|
||||
"Host display name contains forbidden control characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// 5. Host: not blank, no whitespace, no forbidden chars: / \ ? # @ : control chars
|
||||
let trimmed_host = payload.host.trim();
|
||||
if trimmed_host.is_empty() {
|
||||
return Err(PairingError::EmptyHost);
|
||||
}
|
||||
if payload.host.chars().any(|c| {
|
||||
c.is_whitespace()
|
||||
|| ['/', '\\', '?', '#', '@', ':'].contains(&c)
|
||||
|| (c as u32) < 0x20
|
||||
|| (c as u32) == 0x7F
|
||||
}) {
|
||||
return Err(PairingError::InvalidHost(format!(
|
||||
"Host '{}' contains forbidden characters (whitespace, delimiters, or control characters)",
|
||||
payload.host
|
||||
)));
|
||||
}
|
||||
|
||||
// 6. Port: 1..=65535 (u16 is <= 65535, port 0 is invalid)
|
||||
if payload.port == 0 {
|
||||
return Err(PairingError::InvalidPort(0));
|
||||
}
|
||||
|
||||
// 7. Scheme: "http" or "https"
|
||||
if payload.scheme != "http" && payload.scheme != "https" {
|
||||
return Err(PairingError::InvalidScheme(format!(
|
||||
"Invalid scheme '{}', must be 'http' or 'https'",
|
||||
payload.scheme
|
||||
)));
|
||||
}
|
||||
|
||||
// 8. Nonce: Base64URL-encoded, decodes to >= 16 bytes and <= 64 bytes
|
||||
let trimmed_nonce = payload.nonce.trim();
|
||||
if trimmed_nonce.is_empty() {
|
||||
return Err(PairingError::InvalidNonce("Nonce cannot be empty".into()));
|
||||
}
|
||||
let decoded_nonce = URL_SAFE_NO_PAD
|
||||
.decode(trimmed_nonce.as_bytes())
|
||||
.or_else(|_| URL_SAFE.decode(trimmed_nonce.as_bytes()))
|
||||
.or_else(|_| STANDARD.decode(trimmed_nonce.as_bytes()))
|
||||
.map_err(|e| PairingError::InvalidNonce(format!("Nonce Base64 decode failed: {}", e)))?;
|
||||
|
||||
if decoded_nonce.len() < MIN_NONCE_BYTES {
|
||||
return Err(PairingError::InvalidNonce(format!(
|
||||
"Nonce length {} bytes is below minimum {} bytes (128 bits)",
|
||||
decoded_nonce.len(),
|
||||
MIN_NONCE_BYTES
|
||||
)));
|
||||
}
|
||||
if decoded_nonce.len() > MAX_NONCE_BYTES {
|
||||
return Err(PairingError::InvalidNonce(format!(
|
||||
"Nonce length {} bytes exceeds maximum {} bytes",
|
||||
decoded_nonce.len(),
|
||||
MAX_NONCE_BYTES
|
||||
)));
|
||||
}
|
||||
|
||||
// 9. Expires at: now - 30 <= expires_at <= now + 600
|
||||
let min_allowed_expiry = current_time.saturating_sub(MAX_CLOCK_SKEW_SECONDS);
|
||||
if payload.expires_at < min_allowed_expiry {
|
||||
return Err(PairingError::PayloadExpired {
|
||||
expires_at: payload.expires_at,
|
||||
now: current_time,
|
||||
});
|
||||
}
|
||||
let max_allowed_expiry = current_time + MAX_TTL_SECONDS;
|
||||
if payload.expires_at > max_allowed_expiry {
|
||||
return Err(PairingError::TtlExceedsMaximum {
|
||||
expires_at: payload.expires_at,
|
||||
max_allowed: max_allowed_expiry,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_pairing_payload(
|
||||
host_id: String,
|
||||
name: String,
|
||||
host: String,
|
||||
port: u16,
|
||||
scheme: String,
|
||||
ttl_seconds: u64,
|
||||
) -> PairingPayloadV1 {
|
||||
let now = current_unix_timestamp();
|
||||
let ttl = ttl_seconds.clamp(MIN_TTL_SECONDS, MAX_TTL_SECONDS);
|
||||
let expires_at = now + ttl;
|
||||
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> {
|
||||
// Check encoded URI length bound
|
||||
if uri.len() > MAX_ENCODED_URI_BYTES {
|
||||
return Err(PairingError::PayloadTooLarge {
|
||||
size: uri.len(),
|
||||
max: MAX_ENCODED_URI_BYTES,
|
||||
});
|
||||
}
|
||||
|
||||
let data_str = if let Ok(url) = Url::parse(uri) {
|
||||
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()))?;
|
||||
|
||||
// Check decoded payload size limit
|
||||
if decoded_bytes.len() > MAX_DECODED_JSON_BYTES {
|
||||
return Err(PairingError::PayloadTooLarge {
|
||||
size: decoded_bytes.len(),
|
||||
max: MAX_DECODED_JSON_BYTES,
|
||||
});
|
||||
}
|
||||
|
||||
let payload: PairingPayloadV1 = serde_json::from_slice(&decoded_bytes)
|
||||
.map_err(|e| PairingError::JsonDecodeError(e.to_string()))?;
|
||||
|
||||
validate_payload(&payload, 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)
|
||||
}
|
||||
110
hermes-pair/src/qr.rs
Normal file
110
hermes-pair/src/qr.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
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,
|
||||
})
|
||||
}
|
||||
603
hermes-pair/tests/unit_and_contract_tests.rs
Normal file
603
hermes-pair/tests/unit_and_contract_tests.rs
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use hermes_pair::cli::{parse_hermes_url, resolve_cli_endpoint};
|
||||
use hermes_pair::config::load_or_create_config_from_path;
|
||||
use hermes_pair::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,
|
||||
validate_payload, validate_ttl, PairingError, MAX_DECODED_JSON_BYTES, MAX_ENCODED_URI_BYTES,
|
||||
};
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn test_canonical_cross_contract_fixture() {
|
||||
let payload = PairingPayloadV1 {
|
||||
v: 1,
|
||||
payload_type: "hermes-pair".to_string(),
|
||||
host_id: "58af1471-a0a2-4e2b-9426-5068f2a2deab".to_string(),
|
||||
name: "Office-PC".to_string(),
|
||||
host: "192.168.1.150".to_string(),
|
||||
port: 9119,
|
||||
scheme: "http".to_string(),
|
||||
expires_at: 1800000000,
|
||||
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
|
||||
};
|
||||
|
||||
let json_str = serde_json::to_string(&payload).expect("Serialization failed");
|
||||
assert!(json_str.contains("\"v\":1"));
|
||||
assert!(json_str.contains("\"type\":\"hermes-pair\""));
|
||||
assert!(json_str.contains("\"host_id\":\"58af1471-a0a2-4e2b-9426-5068f2a2deab\""));
|
||||
assert!(json_str.contains("\"name\":\"Office-PC\""));
|
||||
assert!(json_str.contains("\"host\":\"192.168.1.150\""));
|
||||
assert!(json_str.contains("\"port\":9119"));
|
||||
assert!(json_str.contains("\"scheme\":\"http\""));
|
||||
assert!(json_str.contains("\"expires_at\":1800000000"));
|
||||
assert!(json_str.contains("\"nonce\":\"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY\""));
|
||||
|
||||
let uri = encode_pairing_uri(&payload);
|
||||
assert!(uri.starts_with("hermes://pair?data="));
|
||||
|
||||
// Decode at current_time = expires_at - 60 (well within validity window)
|
||||
let decode_time = 1800000000 - 60;
|
||||
let decoded = decode_pairing_uri_at_time(&uri, decode_time)
|
||||
.expect("Canonical fixture must decode successfully");
|
||||
|
||||
assert_eq!(decoded.v, 1);
|
||||
assert_eq!(decoded.payload_type, "hermes-pair");
|
||||
assert_eq!(decoded.host_id, "58af1471-a0a2-4e2b-9426-5068f2a2deab");
|
||||
assert_eq!(decoded.name, "Office-PC");
|
||||
assert_eq!(decoded.host, "192.168.1.150");
|
||||
assert_eq!(decoded.port, 9119);
|
||||
assert_eq!(decoded.scheme, "http");
|
||||
assert_eq!(decoded.expires_at, 1800000000);
|
||||
assert_eq!(decoded.nonce, "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
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()));
|
||||
|
||||
let _ = std::fs::remove_file(&config_path);
|
||||
|
||||
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());
|
||||
|
||||
let config2 =
|
||||
load_or_create_config_from_path(&config_path).expect("Should load existing config");
|
||||
assert_eq!(config1.host_id, config2.host_id);
|
||||
|
||||
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: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".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);
|
||||
|
||||
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_ttl_validation_bounds() {
|
||||
assert!(validate_ttl(0).is_err());
|
||||
assert!(validate_ttl(5).is_err());
|
||||
assert!(validate_ttl(9).is_err());
|
||||
assert!(validate_ttl(10).is_ok());
|
||||
assert!(validate_ttl(120).is_ok());
|
||||
assert!(validate_ttl(600).is_ok());
|
||||
assert!(validate_ttl(601).is_err());
|
||||
assert!(validate_ttl(1000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
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: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".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_excessive_future_ttl_rejection() {
|
||||
let host_id = Uuid::new_v4().to_string();
|
||||
let payload = PairingPayloadV1 {
|
||||
v: 1,
|
||||
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: 1000 + 700, // Exceeds now + 600
|
||||
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
|
||||
};
|
||||
|
||||
let uri = encode_pairing_uri(&payload);
|
||||
let result = decode_pairing_uri_at_time(&uri, 1000);
|
||||
|
||||
match result {
|
||||
Err(PairingError::TtlExceedsMaximum { .. }) => {}
|
||||
other => panic!("Expected TtlExceedsMaximum error, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_version_rejection() {
|
||||
let host_id = Uuid::new_v4().to_string();
|
||||
let payload = PairingPayloadV1 {
|
||||
v: 2,
|
||||
payload_type: "hermes-pair".to_string(),
|
||||
host_id,
|
||||
name: "Future-Node".to_string(),
|
||||
host: "10.0.0.5".to_string(),
|
||||
port: 9119,
|
||||
scheme: "http".to_string(),
|
||||
expires_at: 1100,
|
||||
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
|
||||
};
|
||||
|
||||
let uri = encode_pairing_uri(&payload);
|
||||
let result = decode_pairing_uri_at_time(&uri, 1000);
|
||||
|
||||
match result {
|
||||
Err(PairingError::UnsupportedVersion(v)) => {
|
||||
assert_eq!(v, 2);
|
||||
}
|
||||
other => panic!("Expected UnsupportedVersion error, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_payload_type_rejection() {
|
||||
let host_id = Uuid::new_v4().to_string();
|
||||
let payload = PairingPayloadV1 {
|
||||
v: 1,
|
||||
payload_type: "hermes-auth".to_string(),
|
||||
host_id,
|
||||
name: "Bad-Type-Node".to_string(),
|
||||
host: "10.0.0.5".to_string(),
|
||||
port: 9119,
|
||||
scheme: "http".to_string(),
|
||||
expires_at: 1100,
|
||||
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
|
||||
};
|
||||
|
||||
let uri = encode_pairing_uri(&payload);
|
||||
let result = decode_pairing_uri_at_time(&uri, 1000);
|
||||
|
||||
match result {
|
||||
Err(PairingError::InvalidPayloadType(t)) => {
|
||||
assert_eq!(t, "hermes-auth");
|
||||
}
|
||||
other => panic!("Expected InvalidPayloadType error, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_uuid_rejection() {
|
||||
let payload = PairingPayloadV1 {
|
||||
v: 1,
|
||||
payload_type: "hermes-pair".to_string(),
|
||||
host_id: "not-a-valid-uuid".to_string(),
|
||||
name: "Bad-UUID-Node".to_string(),
|
||||
host: "10.0.0.5".to_string(),
|
||||
port: 9119,
|
||||
scheme: "http".to_string(),
|
||||
expires_at: 1100,
|
||||
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
|
||||
};
|
||||
|
||||
let uri = encode_pairing_uri(&payload);
|
||||
let result = decode_pairing_uri_at_time(&uri, 1000);
|
||||
|
||||
match result {
|
||||
Err(PairingError::InvalidHostId(_)) => {}
|
||||
other => panic!("Expected InvalidHostId error, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blank_or_oversized_name_rejection() {
|
||||
let host_id = Uuid::new_v4().to_string();
|
||||
|
||||
// Blank name
|
||||
let mut payload = PairingPayloadV1 {
|
||||
v: 1,
|
||||
payload_type: "hermes-pair".to_string(),
|
||||
host_id: host_id.clone(),
|
||||
name: " ".to_string(),
|
||||
host: "10.0.0.5".to_string(),
|
||||
port: 9119,
|
||||
scheme: "http".to_string(),
|
||||
expires_at: 1100,
|
||||
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
|
||||
};
|
||||
assert!(validate_payload(&payload, 1000).is_err());
|
||||
|
||||
// Control characters in name
|
||||
payload.name = "Office\x00PC".to_string();
|
||||
assert!(validate_payload(&payload, 1000).is_err());
|
||||
payload.name = "Office\nPC".to_string();
|
||||
assert!(validate_payload(&payload, 1000).is_err());
|
||||
|
||||
// Oversized name (>128 chars)
|
||||
payload.name = "A".repeat(129);
|
||||
assert!(validate_payload(&payload, 1000).is_err());
|
||||
|
||||
// Valid 128-char name
|
||||
payload.name = "A".repeat(128);
|
||||
assert!(validate_payload(&payload, 1000).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_malicious_host_rejection() {
|
||||
let host_id = Uuid::new_v4().to_string();
|
||||
let base_payload = PairingPayloadV1 {
|
||||
v: 1,
|
||||
payload_type: "hermes-pair".to_string(),
|
||||
host_id,
|
||||
name: "Node".to_string(),
|
||||
host: "".to_string(),
|
||||
port: 9119,
|
||||
scheme: "http".to_string(),
|
||||
expires_at: 1100,
|
||||
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
|
||||
};
|
||||
|
||||
let forbidden_hosts = vec![
|
||||
"",
|
||||
" ",
|
||||
"user@evil.com",
|
||||
"evil.com/path",
|
||||
"evil.com\\path",
|
||||
"evil.com?param=1",
|
||||
"evil.com#frag",
|
||||
"192.168.1.1:9119",
|
||||
"192.168.1.1 evil.com",
|
||||
"192.168.1.1\x00",
|
||||
];
|
||||
|
||||
for bad_host in forbidden_hosts {
|
||||
let mut p = base_payload.clone();
|
||||
p.host = bad_host.to_string();
|
||||
assert!(
|
||||
validate_payload(&p, 1000).is_err(),
|
||||
"Host '{}' should be rejected",
|
||||
bad_host
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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,
|
||||
scheme: "http".to_string(),
|
||||
expires_at: 1100,
|
||||
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
|
||||
};
|
||||
|
||||
let uri = encode_pairing_uri(&payload);
|
||||
let result = decode_pairing_uri_at_time(&uri, 1000);
|
||||
|
||||
match result {
|
||||
Err(PairingError::InvalidPort(p)) => {
|
||||
assert_eq!(p, 0);
|
||||
}
|
||||
other => panic!("Expected InvalidPort error, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_scheme_rejection() {
|
||||
let host_id = Uuid::new_v4().to_string();
|
||||
let mut payload = PairingPayloadV1 {
|
||||
v: 1,
|
||||
payload_type: "hermes-pair".to_string(),
|
||||
host_id,
|
||||
name: "Node".to_string(),
|
||||
host: "192.168.1.1".to_string(),
|
||||
port: 9119,
|
||||
scheme: "ftp".to_string(),
|
||||
expires_at: 1100,
|
||||
nonce: "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY".to_string(),
|
||||
};
|
||||
|
||||
assert!(validate_payload(&payload, 1000).is_err());
|
||||
|
||||
payload.scheme = "ws".to_string();
|
||||
assert!(validate_payload(&payload, 1000).is_err());
|
||||
|
||||
payload.scheme = "http".to_string();
|
||||
assert!(validate_payload(&payload, 1000).is_ok());
|
||||
|
||||
payload.scheme = "https".to_string();
|
||||
assert!(validate_payload(&payload, 1000).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonce_validation() {
|
||||
let host_id = Uuid::new_v4().to_string();
|
||||
let mut payload = PairingPayloadV1 {
|
||||
v: 1,
|
||||
payload_type: "hermes-pair".to_string(),
|
||||
host_id,
|
||||
name: "Node".to_string(),
|
||||
host: "192.168.1.1".to_string(),
|
||||
port: 9119,
|
||||
scheme: "http".to_string(),
|
||||
expires_at: 1100,
|
||||
nonce: "".to_string(),
|
||||
};
|
||||
|
||||
// Empty nonce rejected
|
||||
assert!(validate_payload(&payload, 1000).is_err());
|
||||
|
||||
// Nonce too short (< 16 bytes decoded)
|
||||
let short_bytes = [1u8; 15];
|
||||
payload.nonce = URL_SAFE_NO_PAD.encode(short_bytes);
|
||||
assert!(validate_payload(&payload, 1000).is_err());
|
||||
|
||||
// Valid 16-byte nonce
|
||||
let valid_16 = [1u8; 16];
|
||||
payload.nonce = URL_SAFE_NO_PAD.encode(valid_16);
|
||||
assert!(validate_payload(&payload, 1000).is_ok());
|
||||
|
||||
// Valid 32-byte nonce
|
||||
let valid_32 = [1u8; 32];
|
||||
payload.nonce = URL_SAFE_NO_PAD.encode(valid_32);
|
||||
assert!(validate_payload(&payload, 1000).is_ok());
|
||||
|
||||
// Valid 64-byte nonce
|
||||
let valid_64 = [1u8; 64];
|
||||
payload.nonce = URL_SAFE_NO_PAD.encode(valid_64);
|
||||
assert!(validate_payload(&payload, 1000).is_ok());
|
||||
|
||||
// Nonce too long (> 64 bytes decoded)
|
||||
let too_long = [1u8; 65];
|
||||
payload.nonce = URL_SAFE_NO_PAD.encode(too_long);
|
||||
assert!(validate_payload(&payload, 1000).is_err());
|
||||
|
||||
// Invalid base64 characters
|
||||
payload.nonce = "not-valid-base64!@#$%".to_string();
|
||||
assert!(validate_payload(&payload, 1000).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oversized_payload_rejection() {
|
||||
let huge_uri = format!(
|
||||
"hermes://pair?data={}",
|
||||
"A".repeat(MAX_ENCODED_URI_BYTES + 10)
|
||||
);
|
||||
let result = decode_pairing_uri(&huge_uri);
|
||||
match result {
|
||||
Err(PairingError::PayloadTooLarge { .. }) => {}
|
||||
other => panic!("Expected PayloadTooLarge error, got {:?}", other),
|
||||
}
|
||||
|
||||
// Huge JSON decoded payload
|
||||
let huge_data = vec![b' '; MAX_DECODED_JSON_BYTES + 100];
|
||||
let encoded = URL_SAFE_NO_PAD.encode(&huge_data);
|
||||
let uri = format!("hermes://pair?data={}", encoded);
|
||||
let result = decode_pairing_uri(&uri);
|
||||
match result {
|
||||
Err(PairingError::PayloadTooLarge { .. }) => {}
|
||||
other => panic!("Expected PayloadTooLarge error, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_parse_hermes_url_and_endpoint_resolution() {
|
||||
let (scheme, host, port) =
|
||||
parse_hermes_url("http://127.0.0.1:9222").expect("Should parse hermes url");
|
||||
assert_eq!(scheme, "http");
|
||||
assert_eq!(host, "127.0.0.1");
|
||||
assert_eq!(port, 9222);
|
||||
|
||||
let (scheme, host, port) =
|
||||
parse_hermes_url("https://localhost:8443").expect("Should parse https hermes url");
|
||||
assert_eq!(scheme, "https");
|
||||
assert_eq!(host, "localhost");
|
||||
assert_eq!(port, 8443);
|
||||
|
||||
assert!(parse_hermes_url("ftp://127.0.0.1:9119").is_err());
|
||||
|
||||
// Endpoint resolution
|
||||
let (s, p) = resolve_cli_endpoint(Some("http://127.0.0.1:9222"), None).unwrap();
|
||||
assert_eq!(s, "http");
|
||||
assert_eq!(p, 9222);
|
||||
|
||||
let (s, p) = resolve_cli_endpoint(Some("https://127.0.0.1:9222"), Some(8888)).unwrap();
|
||||
assert_eq!(s, "https");
|
||||
assert_eq!(p, 8888);
|
||||
|
||||
let (s, p) = resolve_cli_endpoint(None, None).unwrap();
|
||||
assert_eq!(s, "http");
|
||||
assert_eq!(p, 9119);
|
||||
|
||||
let (s, p) = resolve_cli_endpoint(None, Some(9555)).unwrap();
|
||||
assert_eq!(s, "http");
|
||||
assert_eq!(p, 9555);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in a new issue