From e37581c90908c3454fe5f4183dc65f9cc1e74bdf Mon Sep 17 00:00:00 2001 From: xiaoxixi Date: Thu, 16 Jul 2026 15:18:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0tui=E5=92=8Cweb=E7=9A=84?= =?UTF-8?q?=E9=85=8D=E5=AF=B9=E7=A0=81=E8=A6=81=E6=B1=82=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + Cargo.toml | 2 + Dockerfile | 16 +- README.md | 18 +- docs/ARCHITECTURE.md | 6 +- .../about-picobot/assets/config.example.json | 3 +- .../about-picobot/references/architecture.md | 2 +- .../about-picobot/references/commands.md | 11 +- .../skills/about-picobot/references/config.md | 1 + resources/templates/config.example.json | 3 +- src/client/mod.rs | 101 ++- src/config/mod.rs | 8 + src/gateway/auth.rs | 596 ++++++++++++++++++ src/gateway/mod.rs | 48 +- src/gateway/ws.rs | 20 +- src/main.rs | 83 ++- webui/src/App.svelte | 42 +- webui/src/lib/api.js | 8 +- webui/src/pages/PairingPage.svelte | 57 ++ webui/src/styles.css | 14 + 20 files changed, 993 insertions(+), 47 deletions(-) create mode 100644 src/gateway/auth.rs create mode 100644 webui/src/pages/PairingPage.svelte diff --git a/AGENTS.md b/AGENTS.md index 92659cb..5200274 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del - **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact - **WebUI slash completion** must consume the existing `get_slash_commands` WebSocket response; do not duplicate the backend command list in frontend source - **WebUI chat rendering** sanitizes Markdown before inserting HTML; session history must preserve structured tool-call metadata so calls and results remain independently collapsible +- **WebUI authentication** protects every management API and `/ws`; only static pairing assets, public health/status, and pairing submission may bypass device auth. Pair-code issuance requires both a real loopback peer and the filesystem-held admin token; never put bearer tokens in URLs or logs - **Providers** are pure HTTP clients; no bus/session/channel awareness - **Tools** are executed by `AgentLoop`; they receive raw arguments and return string results diff --git a/Cargo.toml b/Cargo.toml index 14196b0..657fa99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ tracing-appender = "0.2" anyhow = "1.0" mime_guess = "2.0" base64 = "0.22" +sha2 = "0.10" tempfile = "3" cron = "0.16" chrono-tz = "0.10" @@ -53,6 +54,7 @@ portable-pty = "0.9" [dev-dependencies] dotenv = "0.15" +tower = "0.5" [build-dependencies] zstd = "0.13" diff --git a/Dockerfile b/Dockerfile index 1743cee..734a239 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,15 +61,13 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ # Install himalaya (CLI email client) from the official pre-built binary release RUN curl -sSL https://raw.githubusercontent.com/pimalaya/himalaya/master/install.sh | sh -# Install fd (alternative to find) -RUN curl -fsSL https://github.com/sharkdp/fd/releases/download/v9.0.0/fd-v9.0.0-x86_64-unknown-linux-gnu.tar.gz | \ - tar -xz --strip-components=1 -C /usr/local/bin \ - && chmod +x /usr/local/bin/fd - -# Install ripgrep (rg) -RUN curl -fsSL https://github.com/BurntSushi/ripgrep/releases/download/14.1.0/ripgrep-14.1.0-x86_64-unknown-linux-musl.tar.gz | \ - tar -xz --strip-components=1 -C /usr/local/bin \ - && chmod +x /usr/local/bin/rg +# Install fd (alternative to find) and ripgrep from Debian. Debian names the +# fd binary `fdfind`, so expose the conventional `fd` name as well. +RUN apt-get update && apt-get install -y --no-install-recommends \ + fd-find \ + ripgrep \ + && ln -sf /usr/bin/fdfind /usr/local/bin/fd \ + && rm -rf /var/lib/apt/lists/* # Install Chromium and chromedriver for browser automation # Debian's chromium package is real (not a snap shim like Ubuntu 24.04) diff --git a/README.md b/README.md index 019407d..71b1351 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ cargo run -- gateway cargo run -- chat ``` -CLI 默认连接 `ws://127.0.0.1:19876/ws`。如需指定地址,可使用 `--gateway-url`。 +CLI 默认连接 `ws://127.0.0.1:19876/ws`。首次使用先运行 `picobot pair`,再执行 `picobot chat --pair-code `;客户端令牌会以 `0600` 权限保存到 `~/.picobot/tui_auth_token`。如需指定地址,可使用 `--gateway-url`。 ### 5.1 使用 WebUI @@ -101,6 +101,14 @@ Gateway 启动后直接打开: http://127.0.0.1:19876/ ``` +新浏览器默认不能直接进入。请在运行 Gateway 的同一台设备上生成一次性配对码: + +```bash +picobot pair +``` + +在浏览器配对页输入输出的 8 位代码即可。配对码 5 分钟内有效且只能使用一次;浏览器凭据由 HttpOnly Cookie 保存。需要撤销全部浏览器和 CLI 客户端时运行 `picobot pair --revoke-all`,再用新代码重新配对。 + WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件,提供: - 在线聊天、会话创建/切换、历史回放、Markdown 消息、可折叠工具调用卡片,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。 @@ -113,7 +121,7 @@ WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件 配置接口会掩码 API Key、secret、password 和 token;保留 `********` 再保存不会覆盖原密钥。运行配置采用原子写入并在 Gateway 重启后生效,`USER.md` 和 `AGENTS.md` 则会用于后续构建的 Agent 上下文。 -WebUI 当前与 Gateway 使用同一信任边界,不额外提供登录认证。默认只监听 `127.0.0.1`;如果通过 `--host 0.0.0.0`、反向代理或端口转发暴露 Gateway,必须在外层配置 TLS 和访问认证,否则聊天及管理 API 会对网络访问者开放。 +WebUI 默认启用设备配对鉴权,管理 API 与 `/ws` 都拒绝未配对客户端;静态配对页、公开健康检查和配对提交接口除外。令牌只以 SHA-256 哈希写入 `~/.picobot/web_auth.json`,本地配对码管理密钥位于权限为 `0600` 的 `~/.picobot/web_admin_token`。鉴权不提供传输加密;如果通过 `--host 0.0.0.0`、反向代理或端口转发暴露 Gateway,仍必须使用 TLS。可通过 `gateway.require_pairing=false` 显式关闭配对,但不建议在非隔离环境使用。 #### WebUI 开发 @@ -290,6 +298,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低: |------|--------| | `gateway.host` | `127.0.0.1` | | `gateway.port` | `19876` | +| `gateway.require_pairing` | `true` | | `gateway.max_concurrent_background_tasks` | `10` | | `gateway.scheduler.enabled` | `true` | | `client.gateway_url` | `ws://127.0.0.1:19876/ws` | @@ -307,7 +316,10 @@ Gateway 暴露: | Method | Path | 说明 | |--------|------|------| | `GET` | `/health` | 健康检查和版本信息 | -| `GET` | `/ws` | WebSocket 聊天协议 | +| `GET` | `/api/auth/status` | 当前设备配对状态 | +| `POST` | `/api/auth/pair` | 用一次性代码配对设备 | +| `POST` | `/api/auth/code` | 本机 CLI 签发配对码;要求回环来源和管理密钥 | +| `GET` | `/ws` | WebSocket 聊天协议;要求配对 Cookie 或 Bearer token | Inbound 消息类型: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d7a93f3..4560363 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -199,6 +199,8 @@ WebSocket 每个客户端的 writer task 是连接局部任务,由连接 handl Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript,不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建,Bits UI 提供无样式的可访问组件原语。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`,Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws` 和 `cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane;会话历史帧保留工具调用 ID、名称、参数和工具结果角色,WebUI 在本轮完成后刷新历史并将其渲染为默认折叠的工具卡片;聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 `session_plan`/`plan_updated` 帧刷新,计划变化不会写入聊天历史;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单,不在前端重复定义;WebUI 不直接调用 Provider 或 SessionManager。 +`AuthManager` 默认保护所有管理 API 和 `/ws`。静态资源、`/health`、`/api/auth/status` 与 `/api/auth/pair` 保持公开,使未配对浏览器只能加载配对界面。`picobot pair` 使用权限为 `0600` 的本机管理密钥调用仅接受真实回环连接的 `/api/auth/code`;反向代理即使从回环连接也无法在没有该密钥时签发代码。配对码为 8 位、5 分钟有效、单次消费,并按来源实施失败锁定。浏览器收到 HttpOnly、SameSite=Strict Cookie,CLI 使用 Bearer token;服务端仅持久化 SHA-256 哈希。`--revoke-all` 的持久化成功后才清空内存令牌,活动 WebSocket 每 5 秒复核身份并回收已撤销连接。 + 同源 `/api/*` 管理接口只提供显式白名单能力: - 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。运行配置只在重启后生效,不热替换运行中组件。 @@ -207,13 +209,13 @@ Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript,不依赖外 - 任务与记忆读取复用 Storage API,不允许 WebUI 直接持有 SQLite 连接或拼接任意查询。 - 前端依赖只存在于源码构建阶段;生产页面不加载 CDN。`build.rs` 以 `package-lock.json` 的依赖 stamp 判断是否需要 `npm ci`,并依靠 Cargo `rerun-if-changed` 避免后端代码变化触发前端重建。前端开发仍须运行 `npm run check`,并以 `cargo build` 验证最终嵌入路径。 -WebUI 与 Gateway 当前属于同一信任边界,没有内置认证。默认回环绑定是安全前提;非回环部署必须由反向代理或其他外层提供 TLS、认证和访问控制。 +配对鉴权只证明设备持有凭据,不提供机密性。非回环部署仍必须由反向代理或其他外层提供 TLS;显式设置 `gateway.require_pairing=false` 会恢复无鉴权模式,仅适合隔离环境。 ## 8. 启动与关停顺序 ### 启动 -1. 加载配置和 `.env`,解析 workspace。 +1. 加载配置和 `.env`,初始化 WebUI 配对存储与本机管理密钥。 2. 创建并切换到 workspace。 3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManager;Scheduler 启用时幂等创建默认日常维护巡检。 4. 注册内置工具、渠道、MCP 工具和 Cron 工具。 diff --git a/resources/skills/about-picobot/assets/config.example.json b/resources/skills/about-picobot/assets/config.example.json index 11e631a..c4dbbca 100644 --- a/resources/skills/about-picobot/assets/config.example.json +++ b/resources/skills/about-picobot/assets/config.example.json @@ -48,7 +48,8 @@ }, "gateway": { "host": "127.0.0.1", - "port": 19876 + "port": 19876, + "require_pairing": true }, "client": { "gateway_url": "ws://127.0.0.1:19876/ws" diff --git a/resources/skills/about-picobot/references/architecture.md b/resources/skills/about-picobot/references/architecture.md index 6d407b2..560e41b 100644 --- a/resources/skills/about-picobot/references/architecture.md +++ b/resources/skills/about-picobot/references/architecture.md @@ -61,7 +61,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message - 长生命周期后台任务由 TaskSupervisor 管理;连接局部任务由其 owner 限时 join 或 abort - 外部建连、重试等待和关停 join 必须可取消且有硬超时 - 不得记录 API Key、Authorization header 或包含临时凭据的完整连接 URL -- WebUI 当前无独立认证,默认回环监听是安全前提;对外暴露时必须由外层提供 TLS 和访问控制 +- WebUI 管理 API 与 `/ws` 默认要求设备配对;一次性代码由本机 CLI 签发,服务端只持久化令牌哈希。对外暴露时仍必须由外层提供 TLS ## 上下文压缩 diff --git a/resources/skills/about-picobot/references/commands.md b/resources/skills/about-picobot/references/commands.md index a26246d..f88645b 100644 --- a/resources/skills/about-picobot/references/commands.md +++ b/resources/skills/about-picobot/references/commands.md @@ -10,6 +10,12 @@ cargo run -- gateway # WebUI 随 Gateway 提供,浏览器打开 # http://127.0.0.1:19876/ +# 为新浏览器生成 5 分钟有效的一次性配对码 +picobot pair + +# 撤销全部设备并生成新配对码 +picobot pair --revoke-all + # 修改 WebUI 后独立检查(Node.js 20+) cd webui npm ci @@ -20,7 +26,8 @@ npm run build cd .. cargo build -# 启动 CLI 客户端 (连接 ws://127.0.0.1:19876/ws) +# 首次配对并启动 CLI 客户端;后续可直接运行 chat +cargo run -- chat --pair-code cargo run -- chat # 安装并启动 Linux systemd 用户服务 @@ -52,4 +59,4 @@ cargo test --test test_tool_calling -- --ignored `test_scheduler` 和 `test_request_format` 不需要 API Key,也没有标记 `#[ignore]`。只有会真实调用 Provider 的测试需要从 `tests/test.env.example` 创建 `tests/test.env` 后使用 `-- --ignored`。 -最终用户使用 WebUI 不需要单独构建;开发源码采用 Svelte 5、Vite 和 Bits UI,`cargo build` 会增量生成前端到 Cargo `OUT_DIR` 并嵌入二进制,生成产物不提交。WebUI 支持在线聊天、动态斜杠命令补全、日志、任务、记忆以及 `config.json`、`USER.md`、`AGENTS.md` 编辑。它与 Gateway 属于同一信任边界;非回环部署需要在外层配置 TLS 和认证。 +最终用户使用 WebUI 不需要单独构建;开发源码采用 Svelte 5、Vite 和 Bits UI,`cargo build` 会增量生成前端到 Cargo `OUT_DIR` 并嵌入二进制,生成产物不提交。WebUI 支持在线聊天、动态斜杠命令补全、日志、任务、记忆以及 `config.json`、`USER.md`、`AGENTS.md` 编辑。新设备默认必须配对,管理 API 与 WebSocket 共用设备鉴权;非回环部署仍需要 TLS。 diff --git a/resources/skills/about-picobot/references/config.md b/resources/skills/about-picobot/references/config.md index 2a8b4d5..b6c3dfb 100644 --- a/resources/skills/about-picobot/references/config.md +++ b/resources/skills/about-picobot/references/config.md @@ -57,6 +57,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取 |------|------|------|------| | `host` | string | 127.0.0.1 | 监听地址 | | `port` | int | 19876 | 监听端口 | +| `require_pairing` | bool | true | 是否要求 WebUI 与 CLI 设备先使用一次性代码配对 | | `session_ttl_hours` | int | - | 兼容/预留字段;当前没有会话 TTL 清理循环 | | `session_db_path` | string | - | SQLite 数据库路径,默认在 workspace 下 | | `cleanup_interval_minutes` | int | - | 兼容/预留字段;当前没有按此间隔运行的 session 清理任务 | diff --git a/resources/templates/config.example.json b/resources/templates/config.example.json index 11e631a..c4dbbca 100644 --- a/resources/templates/config.example.json +++ b/resources/templates/config.example.json @@ -48,7 +48,8 @@ }, "gateway": { "host": "127.0.0.1", - "port": 19876 + "port": 19876, + "require_pairing": true }, "client": { "gateway_url": "ws://127.0.0.1:19876/ws" diff --git a/src/client/mod.rs b/src/client/mod.rs index 251be12..722062e 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -16,13 +16,36 @@ use futures_util::StreamExt; use ratatui::{Terminal, prelude::CrosstermBackend}; use std::io; use std::{fs, path::PathBuf}; -use tokio_tungstenite::{connect_async, tungstenite::Message}; +use tokio_tungstenite::{ + connect_async, + tungstenite::{Message, client::IntoClientRequest, http::header}, +}; -pub async fn run(gateway_url: &str) -> Result<(), Box> { +pub async fn run( + gateway_url: &str, + pair_code: Option<&str>, +) -> Result<(), Box> { let client_id = load_or_create_client_id(); let separator = if gateway_url.contains('?') { '&' } else { '?' }; let connect_url = format!("{gateway_url}{separator}client_id={client_id}"); - let (ws_stream, _) = connect_async(&connect_url).await?; + let token = if let Some(code) = pair_code { + let token = exchange_pairing_code(gateway_url, code).await?; + save_auth_token(&token)?; + Some(token) + } else { + load_auth_token() + }; + let mut request = connect_url.into_client_request()?; + if let Some(token) = token { + request + .headers_mut() + .insert(header::AUTHORIZATION, format!("Bearer {token}").parse()?); + } + let (ws_stream, _) = connect_async(request).await.map_err(|error| { + format!( + "gateway connection failed: {error}. If pairing is required, run `picobot pair` then `picobot chat --pair-code `" + ) + })?; tracing::info!("Connected to gateway"); let (ws_sender, ws_receiver) = ws_stream.split(); @@ -52,6 +75,78 @@ pub async fn run(gateway_url: &str) -> Result<(), Box> { result } +async fn exchange_pairing_code( + gateway_url: &str, + code: &str, +) -> Result> { + let mut url = reqwest::Url::parse(gateway_url)?; + let scheme = match url.scheme() { + "ws" => "http", + "wss" => "https", + "http" => "http", + "https" => "https", + other => return Err(format!("unsupported gateway URL scheme: {other}").into()), + }; + url.set_scheme(scheme) + .map_err(|_| "failed to set gateway URL scheme")?; + url.set_path("/api/auth/pair"); + url.set_query(None); + url.set_fragment(None); + let response = reqwest::Client::new() + .post(url) + .json(&serde_json::json!({ "code": code })) + .send() + .await?; + let status = response.status(); + let body: serde_json::Value = response.json().await?; + if !status.is_success() { + return Err(body + .get("error") + .and_then(serde_json::Value::as_str) + .unwrap_or("pairing failed") + .to_string() + .into()); + } + body.get("token") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .ok_or_else(|| "gateway did not return an auth token".into()) +} + +fn auth_token_path() -> Option { + dirs::home_dir().map(|home| home.join(".picobot").join("tui_auth_token")) +} + +fn load_auth_token() -> Option { + fs::read_to_string(auth_token_path()?) + .ok() + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()) +} + +fn save_auth_token(token: &str) -> Result<(), Box> { + let path = auth_token_path().ok_or("home directory is unavailable")?; + let parent = path.parent().ok_or("invalid auth token path")?; + fs::create_dir_all(parent)?; + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(&path)?; + file.write_all(token.as_bytes())?; + file.sync_all()?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + } + #[cfg(not(unix))] + fs::write(&path, token)?; + Ok(()) +} + fn load_or_create_client_id() -> String { let generated = uuid::Uuid::new_v4().simple().to_string(); let Some(home) = dirs::home_dir() else { diff --git a/src/config/mod.rs b/src/config/mod.rs index 1745792..08c7404 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -146,6 +146,8 @@ pub struct GatewayConfig { pub host: String, #[serde(default = "default_gateway_port")] pub port: u16, + #[serde(default = "default_require_pairing")] + pub require_pairing: bool, #[serde(default, rename = "session_ttl_hours")] pub session_ttl_hours: Option, #[serde(default, rename = "cleanup_interval_minutes")] @@ -163,6 +165,7 @@ impl Default for GatewayConfig { Self { host: default_gateway_host(), port: default_gateway_port(), + require_pairing: default_require_pairing(), session_ttl_hours: None, cleanup_interval_minutes: None, session_db_path: None, @@ -229,6 +232,10 @@ fn default_gateway_port() -> u16 { 19876 } +fn default_require_pairing() -> bool { + true +} + fn default_gateway_url() -> String { "ws://127.0.0.1:19876/ws".to_string() } @@ -631,6 +638,7 @@ mod tests { let config = Config::load(file.path().to_str().unwrap()).unwrap(); assert_eq!(config.gateway.host, "0.0.0.0"); assert_eq!(config.gateway.port, 19876); + assert!(config.gateway.require_pairing); } #[test] diff --git a/src/gateway/auth.rs b/src/gateway/auth.rs new file mode 100644 index 0000000..8719d63 --- /dev/null +++ b/src/gateway/auth.rs @@ -0,0 +1,596 @@ +use super::GatewayState; +use axum::Json; +use axum::body::Body; +use axum::extract::{ConnectInfo, Query, State}; +use axum::http::{HeaderMap, Request, StatusCode, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex; + +const CODE_TTL: Duration = Duration::from_secs(5 * 60); +const LOCKOUT_DURATION: Duration = Duration::from_secs(5 * 60); +const MAX_FAILED_ATTEMPTS: u32 = 5; +const MAX_TRACKED_CLIENTS: usize = 4096; +const MAX_PAIRED_TOKENS: usize = 128; +const AUTH_COOKIE: &str = "picobot_auth"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AuthStore { + version: u32, + token_hashes: Vec, +} + +impl Default for AuthStore { + fn default() -> Self { + Self { + version: 1, + token_hashes: Vec::new(), + } + } +} + +#[derive(Debug, Clone)] +struct PendingCode { + value: String, + expires_at: Instant, +} + +#[derive(Debug, Clone)] +struct FailedAttempts { + count: u32, + locked_until: Option, + last_attempt: Instant, +} + +#[derive(Debug)] +struct AuthState { + token_hashes: HashSet, + pending_code: Option, + failures: HashMap, +} + +#[derive(Debug, Clone)] +pub struct AuthManager { + required: bool, + path: PathBuf, + admin_token_hash: String, + state: Arc>, +} + +#[derive(Debug, Clone)] +pub struct AuthIdentity(pub Option); + +#[derive(Debug)] +pub enum PairError { + Invalid, + Locked(u64), + Capacity, + Persistence(std::io::Error), +} + +impl AuthManager { + pub async fn load(required: bool, path: PathBuf) -> Result> { + let store = match tokio::fs::read(&path).await { + Ok(bytes) => serde_json::from_slice::(&bytes) + .map_err(|error| format!("invalid WebUI auth store {}: {error}", path.display()))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => AuthStore::default(), + Err(error) => return Err(error.into()), + }; + if store.version != 1 { + return Err(format!("unsupported WebUI auth store version: {}", store.version).into()); + } + let admin_token_path = path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("web_admin_token"); + let admin_token = load_or_create_admin_token(&admin_token_path).await?; + Ok(Self { + required, + path, + admin_token_hash: hash_token(&admin_token), + state: Arc::new(Mutex::new(AuthState { + token_hashes: store.token_hashes.into_iter().collect(), + pending_code: None, + failures: HashMap::new(), + })), + }) + } + + pub fn required(&self) -> bool { + self.required + } + + pub async fn authenticate(&self, token: Option<&str>) -> Option { + if !self.required { + return Some(AuthIdentity(None)); + } + let hash = hash_token(token?); + self.state + .lock() + .await + .token_hashes + .contains(&hash) + .then_some(AuthIdentity(Some(hash))) + } + + pub async fn identity_is_active(&self, identity: &AuthIdentity) -> bool { + if !self.required { + return true; + } + let Some(hash) = identity.0.as_ref() else { + return false; + }; + self.state.lock().await.token_hashes.contains(hash) + } + + pub fn authenticate_admin(&self, token: Option<&str>) -> bool { + token.is_some_and(|token| constant_time_eq(&hash_token(token), &self.admin_token_hash)) + } + + pub async fn issue_code(&self, revoke_all: bool) -> Result<(String, i64), std::io::Error> { + let mut state = self.state.lock().await; + if revoke_all && !state.token_hashes.is_empty() { + persist_hashes(&self.path, &HashSet::new()).await?; + state.token_hashes.clear(); + } + let code = generate_code(); + let expires_at_unix = chrono::Utc::now().timestamp() + CODE_TTL.as_secs() as i64; + state.pending_code = Some(PendingCode { + value: code.clone(), + expires_at: Instant::now() + CODE_TTL, + }); + Ok((code, expires_at_unix)) + } + + pub async fn try_pair(&self, code: &str, client: &str) -> Result { + let now = Instant::now(); + let mut state = self.state.lock().await; + prune_failures(&mut state.failures, now); + if let Some(attempts) = state.failures.get(client) + && let Some(until) = attempts.locked_until + && now < until + { + return Err(PairError::Locked((until - now).as_secs().max(1))); + } + + let valid = state.pending_code.as_ref().is_some_and(|pending| { + now < pending.expires_at && constant_time_eq(code.trim(), pending.value.as_str()) + }); + if !valid { + record_failure(&mut state.failures, client.to_string(), now); + return Err(PairError::Invalid); + } + if state.token_hashes.len() >= MAX_PAIRED_TOKENS { + return Err(PairError::Capacity); + } + + let token = generate_token(); + let mut next_hashes = state.token_hashes.clone(); + next_hashes.insert(hash_token(&token)); + persist_hashes(&self.path, &next_hashes) + .await + .map_err(PairError::Persistence)?; + state.token_hashes = next_hashes; + state.pending_code = None; + state.failures.remove(client); + Ok(token) + } +} + +fn token_from_headers(headers: &HeaderMap) -> Option<&str> { + if let Some(token) = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .filter(|value| !value.is_empty()) + { + return Some(token); + } + headers + .get(header::COOKIE) + .and_then(|value| value.to_str().ok()) + .and_then(|cookies| { + cookies.split(';').find_map(|cookie| { + let (name, value) = cookie.trim().split_once('=')?; + (name == AUTH_COOKIE && !value.is_empty()).then_some(value) + }) + }) +} + +pub async fn require_auth( + State(auth): State, + mut request: Request, + next: Next, +) -> Response { + let identity = auth + .authenticate(token_from_headers(request.headers())) + .await; + let Some(identity) = identity else { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "browser is not paired" })), + ) + .into_response(); + }; + request.extensions_mut().insert(identity); + next.run(request).await +} + +#[derive(Serialize)] +pub struct AuthStatus { + require_pairing: bool, + authenticated: bool, +} + +pub async fn status( + State(state): State>, + headers: HeaderMap, +) -> Json { + let authenticated = state + .auth + .authenticate(token_from_headers(&headers)) + .await + .is_some(); + Json(AuthStatus { + require_pairing: state.auth.required(), + authenticated, + }) +} + +#[derive(Debug, Deserialize)] +pub struct PairRequest { + code: String, +} + +pub async fn pair( + ConnectInfo(peer): ConnectInfo, + State(state): State>, + Json(request): Json, +) -> Response { + if !state.auth.required() { + return Json(serde_json::json!({ "paired": true })).into_response(); + } + match state + .auth + .try_pair(&request.code, &peer.ip().to_string()) + .await + { + Ok(token) => { + tracing::info!(client_ip = %peer.ip(), "New browser device paired"); + let cookie = format!( + "{AUTH_COOKIE}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000" + ); + ( + StatusCode::OK, + [(header::SET_COOKIE, cookie)], + Json(serde_json::json!({ "paired": true, "token": token })), + ) + .into_response() + } + Err(PairError::Locked(seconds)) => ( + StatusCode::TOO_MANY_REQUESTS, + Json(serde_json::json!({ "error": "too many attempts", "retry_after": seconds })), + ) + .into_response(), + Err(PairError::Capacity) => ( + StatusCode::CONFLICT, + Json(serde_json::json!({ "error": "paired device limit reached" })), + ) + .into_response(), + Err(PairError::Persistence(error)) => { + tracing::error!(error = %error, "Failed to persist browser pairing"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "pairing could not be persisted" })), + ) + .into_response() + } + Err(PairError::Invalid) => ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "invalid or expired pairing code" })), + ) + .into_response(), + } +} + +#[derive(Debug, Default, Deserialize)] +pub struct CodeQuery { + #[serde(default)] + revoke_all: bool, +} + +pub async fn issue_code( + ConnectInfo(peer): ConnectInfo, + State(state): State>, + Query(query): Query, + headers: HeaderMap, +) -> Response { + let admin_token = headers + .get("X-Picobot-Admin-Token") + .and_then(|value| value.to_str().ok()); + if !peer.ip().is_loopback() || !state.auth.authenticate_admin(admin_token) { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "pairing codes can only be issued locally" })), + ) + .into_response(); + } + if !state.auth.required() { + return ( + StatusCode::CONFLICT, + Json(serde_json::json!({ "error": "pairing is disabled" })), + ) + .into_response(); + } + match state.auth.issue_code(query.revoke_all).await { + Ok((code, expires_at)) => Json(serde_json::json!({ + "pairing_code": code, + "expires_at": expires_at, + "revoke_all": query.revoke_all, + })) + .into_response(), + Err(error) => { + tracing::error!(error = %error, "Failed to persist pairing token revocation"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "could not update pairing state" })), + ) + .into_response() + } + } +} + +fn generate_code() -> String { + let bytes = *uuid::Uuid::new_v4().as_bytes(); + let value = u64::from_le_bytes(bytes[..8].try_into().expect("eight UUID bytes")); + format!("{:08}", value % 100_000_000) +} + +fn generate_token() -> String { + let mut bytes = Vec::with_capacity(32); + bytes.extend_from_slice(uuid::Uuid::new_v4().as_bytes()); + bytes.extend_from_slice(uuid::Uuid::new_v4().as_bytes()); + format!( + "pb_{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + ) +} + +fn hash_token(token: &str) -> String { + let digest = Sha256::digest(token.as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn constant_time_eq(left: &str, right: &str) -> bool { + if left.len() != right.len() { + return false; + } + left.bytes() + .zip(right.bytes()) + .fold(0_u8, |diff, (a, b)| diff | (a ^ b)) + == 0 +} + +fn record_failure(failures: &mut HashMap, client: String, now: Instant) { + if failures.len() >= MAX_TRACKED_CLIENTS + && !failures.contains_key(&client) + && let Some(oldest) = failures + .iter() + .min_by_key(|(_, value)| value.last_attempt) + .map(|(key, _)| key.clone()) + { + failures.remove(&oldest); + } + let attempts = failures.entry(client).or_insert(FailedAttempts { + count: 0, + locked_until: None, + last_attempt: now, + }); + attempts.count += 1; + attempts.last_attempt = now; + if attempts.count >= MAX_FAILED_ATTEMPTS { + attempts.locked_until = Some(now + LOCKOUT_DURATION); + } +} + +fn prune_failures(failures: &mut HashMap, now: Instant) { + failures.retain(|_, attempts| now.duration_since(attempts.last_attempt) < LOCKOUT_DURATION * 2); +} + +async fn persist_hashes(path: &Path, hashes: &HashSet) -> Result<(), std::io::Error> { + let store = AuthStore { + version: 1, + token_hashes: hashes.iter().cloned().collect(), + }; + let bytes = serde_json::to_vec_pretty(&store) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + tokio::fs::create_dir_all(parent).await?; + let temporary = parent.join(format!(".web-auth-{}.tmp", uuid::Uuid::new_v4().simple())); + let result = async { + tokio::fs::write(&temporary, &bytes).await?; + #[cfg(unix)] + tokio::fs::set_permissions( + &temporary, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + ) + .await?; + tokio::fs::rename(&temporary, path).await + } + .await; + if result.is_err() { + let _ = tokio::fs::remove_file(&temporary).await; + } + result +} + +async fn load_or_create_admin_token(path: &Path) -> Result { + match tokio::fs::read_to_string(path).await { + Ok(token) if !token.trim().is_empty() => { + #[cfg(unix)] + tokio::fs::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(0o600)) + .await?; + return Ok(token.trim().to_string()); + } + Ok(_) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "WebUI admin token file is empty", + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + tokio::fs::create_dir_all(parent).await?; + let token = generate_token(); + let temporary = parent.join(format!(".web-admin-{}.tmp", uuid::Uuid::new_v4().simple())); + let result = async { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&temporary)?; + file.write_all(token.as_bytes())?; + file.sync_all()?; + } + #[cfg(not(unix))] + tokio::fs::write(&temporary, &token).await?; + match tokio::fs::rename(&temporary, path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(error) => Err(error), + } + } + .await; + if let Err(error) = result { + let _ = tokio::fs::remove_file(&temporary).await; + return Err(error); + } + // Another process may have won the creation race. Always use what is on disk. + tokio::fs::read_to_string(path) + .await + .map(|value| value.trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{Router, middleware, routing}; + use tower::ServiceExt; + + #[tokio::test] + async fn pairing_is_one_time_and_persists_only_hashes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth.json"); + let manager = AuthManager::load(true, path.clone()).await.unwrap(); + let (code, _) = manager.issue_code(false).await.unwrap(); + + let token = manager.try_pair(&code, "127.0.0.1").await.unwrap(); + assert!(manager.authenticate(Some(&token)).await.is_some()); + assert!(matches!( + manager.try_pair(&code, "127.0.0.2").await, + Err(PairError::Invalid) + )); + let stored = tokio::fs::read_to_string(path).await.unwrap(); + assert!(!stored.contains(&token)); + + let restored = AuthManager::load(true, dir.path().join("auth.json")) + .await + .unwrap(); + assert!(restored.authenticate(Some(&token)).await.is_some()); + } + + #[tokio::test] + async fn failed_pairing_attempts_are_locked_out() { + let dir = tempfile::tempdir().unwrap(); + let manager = AuthManager::load(true, dir.path().join("auth.json")) + .await + .unwrap(); + manager.issue_code(false).await.unwrap(); + for _ in 0..MAX_FAILED_ATTEMPTS { + assert!(matches!( + manager.try_pair("wrong", "client").await, + Err(PairError::Invalid) + )); + } + assert!(matches!( + manager.try_pair("wrong", "client").await, + Err(PairError::Locked(_)) + )); + } + + #[tokio::test] + async fn revoke_all_is_durable_and_invalidates_active_identity() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("auth.json"); + let manager = AuthManager::load(true, path.clone()).await.unwrap(); + let (code, _) = manager.issue_code(false).await.unwrap(); + let token = manager.try_pair(&code, "client").await.unwrap(); + let identity = manager.authenticate(Some(&token)).await.unwrap(); + + manager.issue_code(true).await.unwrap(); + assert!(!manager.identity_is_active(&identity).await); + assert!(manager.authenticate(Some(&token)).await.is_none()); + + let restored = AuthManager::load(true, path).await.unwrap(); + assert!(restored.authenticate(Some(&token)).await.is_none()); + } + + #[test] + fn bearer_token_takes_precedence_over_cookie() { + let mut headers = HeaderMap::new(); + headers.insert(header::COOKIE, "picobot_auth=cookie-token".parse().unwrap()); + headers.insert( + header::AUTHORIZATION, + "Bearer header-token".parse().unwrap(), + ); + assert_eq!(token_from_headers(&headers), Some("header-token")); + } + + #[tokio::test] + async fn auth_middleware_rejects_unknown_devices_and_accepts_paired_tokens() { + let dir = tempfile::tempdir().unwrap(); + let manager = AuthManager::load(true, dir.path().join("auth.json")) + .await + .unwrap(); + let app = Router::new() + .route("/protected", routing::get(|| async { StatusCode::OK })) + .route_layer(middleware::from_fn_with_state( + manager.clone(), + require_auth, + )); + + let unauthorized = app + .clone() + .oneshot(Request::get("/protected").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + + let (code, _) = manager.issue_code(false).await.unwrap(); + let token = manager.try_pair(&code, "client").await.unwrap(); + let authorized = app + .oneshot( + Request::get("/protected") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(authorized.status(), StatusCode::OK); + } +} diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 04cf05b..772c785 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -1,7 +1,9 @@ +pub mod auth; pub mod http; pub mod ws; -use axum::{Router, routing}; +use axum::{Router, middleware, routing}; +use std::net::SocketAddr; use std::sync::Arc; use tokio::net::TcpListener; @@ -25,6 +27,7 @@ pub struct GatewayState { pub storage: Arc, pub task_supervisor: TaskSupervisor, pub connection_shutdown: tokio_util::sync::CancellationToken, + pub auth: auth::AuthManager, } impl GatewayState { @@ -33,6 +36,11 @@ impl GatewayState { let config = Config::load_default()?; let task_supervisor = TaskSupervisor::new(); let connection_shutdown = tokio_util::sync::CancellationToken::new(); + let auth = auth::AuthManager::load( + config.gateway.require_pairing, + crate::config::get_user_config_dir().join("web_auth.json"), + ) + .await?; // Initialize workspace directory: expand path and ensure it exists let workspace_path = expand_path(&config.workspace_dir); @@ -192,6 +200,7 @@ impl GatewayState { storage, task_supervisor, connection_shutdown, + auth, }) } @@ -456,11 +465,7 @@ pub async fn run( let bind_host = host.unwrap_or_else(|| state.config.gateway.host.clone()); let bind_port = port.unwrap_or(state.config.gateway.port); - let app = Router::new() - .route("/", routing::get(http::webui_index)) - .route("/app.js", routing::get(http::webui_script)) - .route("/styles.css", routing::get(http::webui_styles)) - .route("/health", routing::get(http::health)) + let protected = Router::new() .route("/api/health", routing::get(http::health)) .route( "/api/config", @@ -476,6 +481,20 @@ pub async fn run( .route("/api/jobs/{id}/runs", routing::get(http::get_job_runs)) .route("/api/memories", routing::get(http::get_memories)) .route("/ws", routing::get(ws::ws_handler)) + .route_layer(middleware::from_fn_with_state( + state.auth.clone(), + auth::require_auth, + )); + + let app = Router::new() + .route("/", routing::get(http::webui_index)) + .route("/app.js", routing::get(http::webui_script)) + .route("/styles.css", routing::get(http::webui_styles)) + .route("/health", routing::get(http::health)) + .route("/api/auth/status", routing::get(auth::status)) + .route("/api/auth/pair", routing::post(auth::pair)) + .route("/api/auth/code", routing::post(auth::issue_code)) + .merge(protected) .with_state(state.clone()); let addr = format!("{}:{}", bind_host, bind_port); @@ -483,13 +502,16 @@ pub async fn run( tracing::info!(address = %addr, "Gateway listening"); let connection_shutdown = state.connection_shutdown.clone(); - let serve_result = axum::serve(listener, app) - .with_graceful_shutdown(async move { - wait_for_shutdown_signal().await; - tracing::info!("Shutdown signal received"); - connection_shutdown.cancel(); - }) - .await; + let serve_result = axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + wait_for_shutdown_signal().await; + tracing::info!("Shutdown signal received"); + connection_shutdown.cancel(); + }) + .await; // Stop external intake before waiting for internal work to finish. if let Err(error) = state.channel_manager.stop_all().await { diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index cb0e871..e9c4c60 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -2,7 +2,7 @@ use super::GatewayState; use crate::protocol::WsOutbound; use crate::protocol::serialize_outbound; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; -use axum::extract::{Query, State}; +use axum::extract::{Extension, Query, State}; use axum::response::Response; use futures_util::{SinkExt, StreamExt}; use serde::Deserialize; @@ -19,9 +19,10 @@ pub async fn ws_handler( ws: WebSocketUpgrade, Query(query): Query, State(state): State>, + Extension(identity): Extension, ) -> Response { ws.on_upgrade(|socket| async move { - handle_socket(socket, state, valid_client_id(query.client_id)).await; + handle_socket(socket, state, valid_client_id(query.client_id), identity).await; }) } @@ -35,7 +36,12 @@ fn valid_client_id(client_id: Option) -> Option { }) } -async fn handle_socket(ws: WebSocket, state: Arc, client_id: Option) { +async fn handle_socket( + ws: WebSocket, + state: Arc, + client_id: Option, + identity: super::auth::AuthIdentity, +) { // Create channel for sending outbound messages to this client let (sender, mut receiver) = mpsc::channel::(100); @@ -71,9 +77,17 @@ async fn handle_socket(ws: WebSocket, state: Arc, client_id: Optio // Main loop: receive WebSocket messages and forward to CliChatChannel let cancellation = state.connection_shutdown.clone(); let mut writer_finished = false; + let mut auth_check = tokio::time::interval(Duration::from_secs(5)); + auth_check.tick().await; loop { tokio::select! { _ = cancellation.cancelled() => break, + _ = auth_check.tick() => { + if !state.auth.identity_is_active(&identity).await { + tracing::info!(session_id = %session_id, "WebSocket authorization was revoked"); + break; + } + } result = &mut writer_task => { writer_finished = true; if let Err(error) = result { diff --git a/src/main.rs b/src/main.rs index 3e8e5d7..f18a5d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,9 @@ enum Command { /// Gateway WebSocket URL (e.g., ws://127.0.0.1:19876/ws) #[arg(long)] gateway_url: Option, + /// One-time pairing code; saves the issued client token locally + #[arg(long)] + pair_code: Option, }, /// Start gateway server Gateway { @@ -36,6 +39,15 @@ enum Command { #[arg(long)] port: Option, }, + /// Generate a one-time browser pairing code from the local gateway + Pair { + /// Gateway WebSocket or HTTP URL + #[arg(long)] + gateway_url: Option, + /// Revoke every paired browser and CLI token before issuing the code + #[arg(long)] + revoke_all: bool, + }, /// Manage the PicoBot systemd user service Service { #[command(subcommand)] @@ -55,16 +67,63 @@ async fn main() -> Result<(), Box> { } match Command::parse() { - Command::Chat { gateway_url } => { + Command::Chat { + gateway_url, + pair_code, + } => { let config = picobot::config::Config::load_default().ok(); let url = gateway_url .or_else(|| config.as_ref().map(|c| c.client.gateway_url.clone())) .unwrap_or_else(|| "ws://127.0.0.1:19876/ws".to_string()); - picobot::client::run(&url).await?; + picobot::client::run(&url, pair_code.as_deref()).await?; } Command::Gateway { host, port } => { picobot::gateway::run(host, port).await?; } + Command::Pair { + gateway_url, + revoke_all, + } => { + let config = picobot::config::Config::load_default().ok(); + let url = gateway_url + .or_else(|| config.as_ref().map(|c| c.client.gateway_url.clone())) + .unwrap_or_else(|| "ws://127.0.0.1:19876/ws".to_string()); + let mut endpoint = gateway_api_url(&url, "/api/auth/code")?; + if revoke_all { + endpoint.query_pairs_mut().append_pair("revoke_all", "true"); + } + let admin_token_path = picobot::config::get_user_config_dir().join("web_admin_token"); + let admin_token = std::fs::read_to_string(&admin_token_path).map_err(|error| { + format!( + "cannot read local gateway admin token {}: {error}", + admin_token_path.display() + ) + })?; + let response = reqwest::Client::new() + .post(endpoint) + .header("X-Picobot-Admin-Token", admin_token.trim()) + .send() + .await?; + let status = response.status(); + let body: serde_json::Value = response.json().await?; + if !status.is_success() { + return Err(body + .get("error") + .and_then(serde_json::Value::as_str) + .unwrap_or("failed to generate pairing code") + .to_string() + .into()); + } + let code = body + .get("pairing_code") + .and_then(serde_json::Value::as_str) + .ok_or("gateway did not return a pairing code")?; + println!("Pairing code: {code}"); + println!("Expires in 5 minutes and can be used once."); + if revoke_all { + println!("All existing paired devices were revoked."); + } + } Command::Service { command } => { let command = match command { ServiceCommand::Install => picobot::service::ServiceCommand::Install, @@ -79,3 +138,23 @@ async fn main() -> Result<(), Box> { } Ok(()) } + +fn gateway_api_url( + gateway_url: &str, + path: &str, +) -> Result> { + let mut url = reqwest::Url::parse(gateway_url)?; + let scheme = match url.scheme() { + "ws" => "http", + "wss" => "https", + "http" => "http", + "https" => "https", + other => return Err(format!("unsupported gateway URL scheme: {other}").into()), + }; + url.set_scheme(scheme) + .map_err(|_| "failed to set gateway URL scheme")?; + url.set_path(path); + url.set_query(None); + url.set_fragment(None); + Ok(url) +} diff --git a/webui/src/App.svelte b/webui/src/App.svelte index bed4180..6f1453b 100644 --- a/webui/src/App.svelte +++ b/webui/src/App.svelte @@ -8,6 +8,7 @@ import MemoryPage from "./pages/MemoryPage.svelte"; import LogsPage from "./pages/LogsPage.svelte"; import SettingsPage from "./pages/SettingsPage.svelte"; + import PairingPage from "./pages/PairingPage.svelte"; const pages = [ ["chat", "◉", "在线聊天", "与你的 PicoBot 实时对话"], @@ -21,7 +22,9 @@ let online = $state(false); let version = $state("Gateway"); let theme = $state("dark"); - let toast; + let authReady = $state(false); + let authenticated = $state(false); + let toast = $state(); const meta = $derived(pages.find(([name]) => name === current) || pages[0]); async function health() { @@ -47,16 +50,42 @@ localStorage.setItem("picobot-theme", next); } + async function checkAuth() { + try { + const status = await api("/api/auth/status"); + authenticated = status.authenticated || !status.require_pairing; + } catch { + authenticated = false; + } finally { + authReady = true; + } + } + + function paired() { + authenticated = true; + health(); + } + onMount(() => { const saved = localStorage.getItem("picobot-theme"); applyTheme(saved === "light" || saved === "dark" ? saved : (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")); - health(); - const timer = setInterval(health, 30_000); - return () => clearInterval(timer); + checkAuth().then(() => authenticated && health()); + const authRequired = () => authenticated = false; + window.addEventListener("picobot-auth-required", authRequired); + const timer = setInterval(() => authenticated && health(), 30_000); + return () => { + clearInterval(timer); + window.removeEventListener("picobot-auth-required", authRequired); + }; }); - +{#if !authReady} +
正在检查设备授权…
+{:else if !authenticated} + +{:else} +
-
+
+{/if} diff --git a/webui/src/lib/api.js b/webui/src/lib/api.js index dd7e6ed..911fd5a 100644 --- a/webui/src/lib/api.js +++ b/webui/src/lib/api.js @@ -1,10 +1,16 @@ export async function api(path, options = {}) { const response = await fetch(path, { ...options, + credentials: "same-origin", headers: { "Content-Type": "application/json", ...(options.headers || {}) } }); const data = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(data.error || `${response.status} ${response.statusText}`); + if (!response.ok) { + if (response.status === 401 && path !== "/api/auth/pair") { + window.dispatchEvent(new CustomEvent("picobot-auth-required")); + } + throw new Error(data.error || `${response.status} ${response.statusText}`); + } return data; } diff --git a/webui/src/pages/PairingPage.svelte b/webui/src/pages/PairingPage.svelte new file mode 100644 index 0000000..abf2d0a --- /dev/null +++ b/webui/src/pages/PairingPage.svelte @@ -0,0 +1,57 @@ + + +
+
+
PPicoBot
+ +

配对此浏览器

+

这是一个新浏览器。请在运行 Gateway 的设备上生成一次性配对码,然后在下方输入。

+ picobot pair +
{ event.preventDefault(); submit(); }}> + + + {#if error}{/if} + +
+ 配对码 5 分钟内有效且只能使用一次。设备凭据将以 HttpOnly Cookie 保存。 +
+
diff --git a/webui/src/styles.css b/webui/src/styles.css index f5a952c..5efe6ea 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -59,6 +59,20 @@ * { box-sizing: border-box; } body { margin: 0; min-width: 320px; color: var(--text); background: var(--bg); transition: color .2s, background .2s; } +.pairing-screen { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at 50% 0%, var(--accent-soft), transparent 38%), var(--bg); } +.pairing-card { width: min(100%, 420px); padding: 30px; border: 1px solid var(--line); border-radius: 18px; background: var(--panel); box-shadow: var(--shadow); } +.pairing-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; } +.pairing-icon { width: 52px; height: 52px; display: grid; place-items: center; margin-bottom: 18px; border-radius: 14px; color: var(--accent); background: var(--accent-soft); font-size: 24px; } +.pairing-card h1 { margin: 0 0 9px; font-size: 24px; } +.pairing-card > p { margin: 0 0 18px; color: var(--muted); font-size: 13px; line-height: 1.65; } +.pairing-command { display: block; margin-bottom: 22px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; color: var(--text); background: var(--code-bg); font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; } +.pairing-card form { display: grid; gap: 10px; } +.pairing-card label { color: var(--text-soft); font-size: 11px; font-weight: 650; } +.pairing-card input { width: 100%; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; color: var(--text); background: var(--panel-2); font: 600 22px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .28em; text-align: center; } +.pairing-card form .primary { margin-top: 3px; padding: 11px 14px; } +.pairing-card > small { display: block; margin-top: 17px; color: var(--muted); font-size: 10px; line-height: 1.5; } +.pairing-error { padding: 8px 10px; border-radius: 8px; color: var(--danger); background: var(--danger-soft); font-size: 11px; } +.auth-loading { color: var(--muted); font-size: 13px; } button, input, textarea, select { font: inherit; } button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } button:disabled { cursor: not-allowed; opacity: .45; }