feat(gateway): add paired devices API and dashboard tab

This commit is contained in:
argenis de la rosa 2026-02-28 13:15:58 -05:00 committed by Argenis
parent 0253752bc9
commit 3f70cbbf9b
14 changed files with 764 additions and 301 deletions

View File

@ -529,6 +529,48 @@ pub async fn handle_api_health(
Json(serde_json::json!({"health": snapshot})).into_response()
}
/// GET /api/pairing/devices — list paired devices
pub async fn handle_api_pairing_devices(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
let devices = state.pairing.paired_devices();
Json(serde_json::json!({ "devices": devices })).into_response()
}
/// DELETE /api/pairing/devices/:id — revoke paired device
pub async fn handle_api_pairing_device_revoke(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> impl IntoResponse {
if let Err(e) = require_auth(&state, &headers) {
return e.into_response();
}
if !state.pairing.revoke_device(&id) {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Paired device not found"})),
)
.into_response();
}
if let Err(e) = super::persist_pairing_tokens(state.config.clone(), &state.pairing).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to persist pairing state: {e}")})),
)
.into_response();
}
Json(serde_json::json!({"status": "ok", "revoked": true, "id": id})).into_response()
}
// ── Helpers ─────────────────────────────────────────────────────
fn normalize_dashboard_config_toml(root: &mut toml::Value) {

View File

@ -758,6 +758,11 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
.route("/api/memory", get(api::handle_api_memory_list))
.route("/api/memory", post(api::handle_api_memory_store))
.route("/api/memory/{key}", delete(api::handle_api_memory_delete))
.route("/api/pairing/devices", get(api::handle_api_pairing_devices))
.route(
"/api/pairing/devices/{id}",
delete(api::handle_api_pairing_device_revoke),
)
.route("/api/cost", get(api::handle_api_cost))
.route("/api/cli-tools", get(api::handle_api_cli_tools))
.route("/api/health", get(api::handle_api_health))

View File

@ -24,6 +24,8 @@ const MAX_TRACKED_CLIENTS: usize = 10_000;
const FAILED_ATTEMPT_RETENTION_SECS: u64 = 900; // 15 min
/// Minimum interval between full sweeps of the failed-attempt map.
const FAILED_ATTEMPT_SWEEP_INTERVAL_SECS: u64 = 300; // 5 min
/// Display length for stable paired-device IDs derived from token hash prefix.
const DEVICE_ID_PREFIX_LEN: usize = 16;
/// Per-client failed attempt state with optional absolute lockout deadline.
#[derive(Debug, Clone, Copy)]
@ -33,6 +35,41 @@ struct FailedAttemptState {
last_attempt: Instant,
}
#[derive(Debug, Clone)]
struct PairedDeviceMeta {
created_at: Option<String>,
last_seen_at: Option<String>,
paired_by: Option<String>,
}
impl PairedDeviceMeta {
fn legacy() -> Self {
Self {
created_at: None,
last_seen_at: None,
paired_by: None,
}
}
fn fresh(paired_by: Option<String>) -> Self {
let now = now_rfc3339();
Self {
created_at: Some(now.clone()),
last_seen_at: Some(now),
paired_by,
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PairedDevice {
pub id: String,
pub token_fingerprint: String,
pub created_at: Option<String>,
pub last_seen_at: Option<String>,
pub paired_by: Option<String>,
}
/// Manages pairing state for the gateway.
///
/// Bearer tokens are stored as SHA-256 hashes to prevent plaintext exposure
@ -47,6 +84,8 @@ pub struct PairingGuard {
pairing_code: Arc<Mutex<Option<String>>>,
/// Set of SHA-256 hashed bearer tokens (persisted across restarts).
paired_tokens: Arc<Mutex<HashSet<String>>>,
/// Non-secret per-device metadata keyed by token hash.
paired_device_meta: Arc<Mutex<HashMap<String, PairedDeviceMeta>>>,
/// Brute-force protection: per-client failed attempt state + last sweep timestamp.
failed_attempts: Arc<Mutex<(HashMap<String, FailedAttemptState>, Instant)>>,
}
@ -71,6 +110,10 @@ impl PairingGuard {
}
})
.collect();
let paired_device_meta: HashMap<String, PairedDeviceMeta> = tokens
.iter()
.map(|hash| (hash.clone(), PairedDeviceMeta::legacy()))
.collect();
let code = if require_pairing && tokens.is_empty() {
Some(generate_code())
} else {
@ -80,6 +123,7 @@ impl PairingGuard {
require_pairing,
pairing_code: Arc::new(Mutex::new(code)),
paired_tokens: Arc::new(Mutex::new(tokens)),
paired_device_meta: Arc::new(Mutex::new(paired_device_meta)),
failed_attempts: Arc::new(Mutex::new((HashMap::new(), Instant::now()))),
}
}
@ -132,8 +176,16 @@ impl PairingGuard {
guard.0.remove(&client_id);
}
let token = generate_token();
let hashed_token = hash_token(&token);
let mut tokens = self.paired_tokens.lock();
tokens.insert(hash_token(&token));
tokens.insert(hashed_token.clone());
drop(tokens);
let mut metadata = self.paired_device_meta.lock();
metadata.insert(
hashed_token,
PairedDeviceMeta::fresh(Some(client_id.clone())),
);
// Consume the pairing code so it cannot be reused
*pairing_code = None;
@ -205,8 +257,21 @@ impl PairingGuard {
return true;
}
let hashed = hash_token(token);
let tokens = self.paired_tokens.lock();
tokens.contains(&hashed)
let is_valid = {
let tokens = self.paired_tokens.lock();
tokens.contains(&hashed)
};
if is_valid {
let mut metadata = self.paired_device_meta.lock();
let now = now_rfc3339();
let entry = metadata
.entry(hashed)
.or_insert_with(PairedDeviceMeta::legacy);
entry.last_seen_at = Some(now);
}
is_valid
}
/// Returns true if the gateway is already paired (has at least one token).
@ -220,6 +285,80 @@ impl PairingGuard {
let tokens = self.paired_tokens.lock();
tokens.iter().cloned().collect()
}
/// List paired devices with non-secret metadata for dashboard management.
pub fn paired_devices(&self) -> Vec<PairedDevice> {
let token_hashes: Vec<String> = {
let tokens = self.paired_tokens.lock();
tokens.iter().cloned().collect()
};
let metadata = self.paired_device_meta.lock();
let mut devices: Vec<PairedDevice> = token_hashes
.into_iter()
.map(|hash| {
let meta = metadata
.get(&hash)
.cloned()
.unwrap_or_else(PairedDeviceMeta::legacy);
let id = device_id_from_hash(&hash);
PairedDevice {
id: id.clone(),
token_fingerprint: id,
created_at: meta.created_at,
last_seen_at: meta.last_seen_at,
paired_by: meta.paired_by,
}
})
.collect();
devices.sort_by(|a, b| {
b.last_seen_at
.cmp(&a.last_seen_at)
.then_with(|| b.created_at.cmp(&a.created_at))
.then_with(|| a.id.cmp(&b.id))
});
devices
}
/// Revoke a paired device by short ID (hash prefix) or full token hash.
///
/// Returns true when a device token was removed.
pub fn revoke_device(&self, device_id: &str) -> bool {
let requested = device_id.trim();
if requested.is_empty() {
return false;
}
let mut tokens = self.paired_tokens.lock();
let token_hash = tokens
.iter()
.find(|hash| {
let hash = hash.as_str();
hash == requested || device_id_from_hash(hash) == requested
})
.cloned();
let Some(token_hash) = token_hash else {
return false;
};
let removed = tokens.remove(&token_hash);
let tokens_empty = tokens.is_empty();
drop(tokens);
if removed {
self.paired_device_meta.lock().remove(&token_hash);
if self.require_pairing && tokens_empty {
let mut code = self.pairing_code.lock();
if code.is_none() {
*code = Some(generate_code());
}
}
}
removed
}
}
/// Normalize a client identifier: trim whitespace, map empty to `"unknown"`.
@ -232,6 +371,14 @@ fn normalize_client_key(key: &str) -> String {
}
}
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}
fn device_id_from_hash(hash: &str) -> String {
hash.chars().take(DEVICE_ID_PREFIX_LEN).collect()
}
/// Remove failed-attempt entries whose `last_attempt` is older than the retention window.
fn prune_failed_attempts(map: &mut HashMap<String, FailedAttemptState>, now: Instant) {
map.retain(|_, state| {
@ -418,6 +565,44 @@ mod tests {
assert!(!guard.is_authenticated("wrong"));
}
#[test]
async fn paired_devices_and_revoke_device_roundtrip() {
let guard = PairingGuard::new(true, &[]);
let code = guard.pairing_code().unwrap().to_string();
let token = guard.try_pair(&code, "test_client").await.unwrap().unwrap();
assert!(guard.is_authenticated(&token));
let devices = guard.paired_devices();
assert_eq!(devices.len(), 1);
assert_eq!(devices[0].paired_by.as_deref(), Some("test_client"));
assert!(devices[0].created_at.is_some());
assert!(devices[0].last_seen_at.is_some());
let revoked = guard.revoke_device(&devices[0].id);
assert!(revoked, "revoke should remove the paired token");
assert!(!guard.is_authenticated(&token));
assert!(!guard.is_paired());
assert!(
guard.pairing_code().is_some(),
"revoke of final device should regenerate one-time pairing code"
);
}
#[test]
async fn authenticate_updates_legacy_device_last_seen() {
let token = "zc_valid";
let token_hash = hash_token(token);
let guard = PairingGuard::new(true, &[token_hash]);
let before = guard.paired_devices();
assert_eq!(before.len(), 1);
assert!(before[0].last_seen_at.is_none());
assert!(guard.is_authenticated(token));
let after = guard.paired_devices();
assert!(after[0].last_seen_at.is_some());
}
// ── Token hashing ────────────────────────────────────────
#[test]

1
web/dist/assets/index-C70eaW2F.css vendored Normal file

File diff suppressed because one or more lines are too long

320
web/dist/assets/index-CJ6bGkAt.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

8
web/dist/index.html vendored
View File

@ -3,10 +3,14 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' ws: wss: https:; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
/>
<meta name="color-scheme" content="dark" />
<title>ZeroClaw</title>
<script type="module" crossorigin src="/_app/assets/index-Dam-egf7.js"></script>
<link rel="stylesheet" crossorigin href="/_app/assets/index-DEhGL4Jw.css">
<script type="module" crossorigin src="/_app/assets/index-CJ6bGkAt.js"></script>
<link rel="stylesheet" crossorigin href="/_app/assets/index-C70eaW2F.css">
</head>
<body>
<div id="root"></div>

View File

@ -7,6 +7,7 @@ import Tools from './pages/Tools';
import Cron from './pages/Cron';
import Integrations from './pages/Integrations';
import Memory from './pages/Memory';
import Devices from './pages/Devices';
import Config from './pages/Config';
import Cost from './pages/Cost';
import Logs from './pages/Logs';
@ -119,6 +120,7 @@ function AppContent() {
<Route path="/cron" element={<Cron />} />
<Route path="/integrations" element={<Integrations />} />
<Route path="/memory" element={<Memory />} />
<Route path="/devices" element={<Devices />} />
<Route path="/config" element={<Config />} />
<Route path="/cost" element={<Cost />} />
<Route path="/logs" element={<Logs />} />

View File

@ -6,6 +6,7 @@ import {
Clock,
Puzzle,
Brain,
Smartphone,
Settings,
DollarSign,
Activity,
@ -21,6 +22,7 @@ const navItems = [
{ to: '/cron', icon: Clock, labelKey: 'nav.cron' },
{ to: '/integrations', icon: Puzzle, labelKey: 'nav.integrations' },
{ to: '/memory', icon: Brain, labelKey: 'nav.memory' },
{ to: '/devices', icon: Smartphone, labelKey: 'nav.devices' },
{ to: '/config', icon: Settings, labelKey: 'nav.config' },
{ to: '/cost', icon: DollarSign, labelKey: 'nav.cost' },
{ to: '/logs', icon: Activity, labelKey: 'nav.logs' },

View File

@ -6,6 +6,7 @@ import type {
IntegrationSettingsPayload,
DiagResult,
MemoryEntry,
PairedDevice,
CostSummary,
CliTool,
HealthSnapshot,
@ -247,6 +248,22 @@ export function deleteMemory(key: string): Promise<void> {
});
}
// ---------------------------------------------------------------------------
// Paired Devices
// ---------------------------------------------------------------------------
export function getPairedDevices(): Promise<PairedDevice[]> {
return apiFetch<PairedDevice[] | { devices: PairedDevice[] }>('/api/pairing/devices').then(
(data) => unwrapField(data, 'devices'),
);
}
export function revokePairedDevice(id: string): Promise<void> {
return apiFetch<void>(`/api/pairing/devices/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
}
// ---------------------------------------------------------------------------
// Cost
// ---------------------------------------------------------------------------

View File

@ -16,6 +16,7 @@ const translations: Record<Locale, Record<string, string>> = {
'nav.cron': 'Scheduled Jobs',
'nav.integrations': 'Integrations',
'nav.memory': 'Memory',
'nav.devices': 'Devices',
'nav.config': 'Configuration',
'nav.cost': 'Cost Tracker',
'nav.logs': 'Logs',
@ -199,6 +200,7 @@ const translations: Record<Locale, Record<string, string>> = {
'nav.cron': 'Zamanlanmis Gorevler',
'nav.integrations': 'Entegrasyonlar',
'nav.memory': 'Hafiza',
'nav.devices': 'Cihazlar',
'nav.config': 'Yapilandirma',
'nav.cost': 'Maliyet Takibi',
'nav.logs': 'Kayitlar',
@ -382,6 +384,7 @@ const translations: Record<Locale, Record<string, string>> = {
'nav.cron': '定时任务',
'nav.integrations': '集成',
'nav.memory': '记忆',
'nav.devices': '设备',
'nav.config': '配置',
'nav.cost': '成本追踪',
'nav.logs': '日志',

170
web/src/pages/Devices.tsx Normal file
View File

@ -0,0 +1,170 @@
import { useEffect, useState } from 'react';
import { Smartphone, RefreshCw, ShieldX } from 'lucide-react';
import type { PairedDevice } from '@/types/api';
import { getPairedDevices, revokePairedDevice } from '@/lib/api';
function formatDate(value: string | null): string {
if (!value) return 'Unknown';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}
export default function Devices() {
const [devices, setDevices] = useState<PairedDevice[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingRevoke, setPendingRevoke] = useState<string | null>(null);
const loadDevices = async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const data = await getPairedDevices();
setDevices(data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load paired devices');
} finally {
if (isRefresh) {
setRefreshing(false);
} else {
setLoading(false);
}
}
};
useEffect(() => {
void loadDevices(false);
}, []);
const handleRevoke = async (id: string) => {
try {
await revokePairedDevice(id);
setDevices((prev) => prev.filter((device) => device.id !== id));
setPendingRevoke(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to revoke paired device');
setPendingRevoke(null);
}
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Smartphone className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Paired Devices ({devices.length})
</h2>
</div>
<button
onClick={() => {
void loadDevices(true);
}}
disabled={refreshing}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-60"
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{error && (
<div className="rounded-lg border border-red-700 bg-red-900/30 p-3 text-sm text-red-300">
{error}
</div>
)}
{loading ? (
<div className="flex h-32 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
) : devices.length === 0 ? (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-8 text-center">
<ShieldX className="mx-auto mb-3 h-10 w-10 text-gray-600" />
<p className="text-gray-400">No paired devices found.</p>
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-gray-800 bg-gray-900">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="px-4 py-3 text-left font-medium text-gray-400">
Device ID
</th>
<th className="px-4 py-3 text-left font-medium text-gray-400">
Paired By
</th>
<th className="px-4 py-3 text-left font-medium text-gray-400">
Created
</th>
<th className="px-4 py-3 text-left font-medium text-gray-400">
Last Seen
</th>
<th className="px-4 py-3 text-right font-medium text-gray-400">
Actions
</th>
</tr>
</thead>
<tbody>
{devices.map((device) => (
<tr
key={device.id}
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
>
<td className="px-4 py-3 font-mono text-xs text-white">
{device.token_fingerprint}
</td>
<td className="px-4 py-3 text-gray-300">
{device.paired_by ?? 'Unknown'}
</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-400">
{formatDate(device.created_at)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-400">
{formatDate(device.last_seen_at)}
</td>
<td className="px-4 py-3 text-right">
{pendingRevoke === device.id ? (
<div className="inline-flex items-center gap-2">
<span className="text-xs text-red-400">Revoke?</span>
<button
onClick={() => {
void handleRevoke(device.id);
}}
className="text-xs font-medium text-red-400 hover:text-red-300"
>
Yes
</button>
<button
onClick={() => setPendingRevoke(null)}
className="text-xs font-medium text-gray-400 hover:text-white"
>
No
</button>
</div>
) : (
<button
onClick={() => setPendingRevoke(device.id)}
className="text-xs font-medium text-red-400 hover:text-red-300"
>
Revoke
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@ -93,6 +93,14 @@ export interface MemoryEntry {
score: number | null;
}
export interface PairedDevice {
id: string;
token_fingerprint: string;
created_at: string | null;
last_seen_at: string | null;
paired_by: string | null;
}
export interface CostSummary {
session_cost_usd: number;
daily_cost_usd: number;