增加tui和web的配对码要求。

This commit is contained in:
xiaoxixi 2026-07-16 15:18:21 +08:00
parent 7a144cea8a
commit e37581c909
20 changed files with 993 additions and 47 deletions

View File

@ -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 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 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 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 - **Providers** are pure HTTP clients; no bus/session/channel awareness
- **Tools** are executed by `AgentLoop`; they receive raw arguments and return string results - **Tools** are executed by `AgentLoop`; they receive raw arguments and return string results

View File

@ -26,6 +26,7 @@ tracing-appender = "0.2"
anyhow = "1.0" anyhow = "1.0"
mime_guess = "2.0" mime_guess = "2.0"
base64 = "0.22" base64 = "0.22"
sha2 = "0.10"
tempfile = "3" tempfile = "3"
cron = "0.16" cron = "0.16"
chrono-tz = "0.10" chrono-tz = "0.10"
@ -53,6 +54,7 @@ portable-pty = "0.9"
[dev-dependencies] [dev-dependencies]
dotenv = "0.15" dotenv = "0.15"
tower = "0.5"
[build-dependencies] [build-dependencies]
zstd = "0.13" zstd = "0.13"

View File

@ -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 # 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 RUN curl -sSL https://raw.githubusercontent.com/pimalaya/himalaya/master/install.sh | sh
# Install fd (alternative to find) # Install fd (alternative to find) and ripgrep from Debian. Debian names the
RUN curl -fsSL https://github.com/sharkdp/fd/releases/download/v9.0.0/fd-v9.0.0-x86_64-unknown-linux-gnu.tar.gz | \ # fd binary `fdfind`, so expose the conventional `fd` name as well.
tar -xz --strip-components=1 -C /usr/local/bin \ RUN apt-get update && apt-get install -y --no-install-recommends \
&& chmod +x /usr/local/bin/fd fd-find \
ripgrep \
# Install ripgrep (rg) && ln -sf /usr/bin/fdfind /usr/local/bin/fd \
RUN curl -fsSL https://github.com/BurntSushi/ripgrep/releases/download/14.1.0/ripgrep-14.1.0-x86_64-unknown-linux-musl.tar.gz | \ && rm -rf /var/lib/apt/lists/*
tar -xz --strip-components=1 -C /usr/local/bin \
&& chmod +x /usr/local/bin/rg
# Install Chromium and chromedriver for browser automation # Install Chromium and chromedriver for browser automation
# Debian's chromium package is real (not a snap shim like Ubuntu 24.04) # Debian's chromium package is real (not a snap shim like Ubuntu 24.04)

View File

@ -91,7 +91,7 @@ cargo run -- gateway
cargo run -- chat 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 <CODE>`;客户端令牌会以 `0600` 权限保存到 `~/.picobot/tui_auth_token`如需指定地址,可使用 `--gateway-url`
### 5.1 使用 WebUI ### 5.1 使用 WebUI
@ -101,6 +101,14 @@ Gateway 启动后直接打开:
http://127.0.0.1:19876/ http://127.0.0.1:19876/
``` ```
新浏览器默认不能直接进入。请在运行 Gateway 的同一台设备上生成一次性配对码:
```bash
picobot pair
```
在浏览器配对页输入输出的 8 位代码即可。配对码 5 分钟内有效且只能使用一次;浏览器凭据由 HttpOnly Cookie 保存。需要撤销全部浏览器和 CLI 客户端时运行 `picobot pair --revoke-all`,再用新代码重新配对。
WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件,提供: WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件,提供:
- 在线聊天、会话创建/切换、历史回放、Markdown 消息、可折叠工具调用卡片,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。 - 在线聊天、会话创建/切换、历史回放、Markdown 消息、可折叠工具调用卡片,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。
@ -113,7 +121,7 @@ WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件
配置接口会掩码 API Key、secret、password 和 token保留 `********` 再保存不会覆盖原密钥。运行配置采用原子写入并在 Gateway 重启后生效,`USER.md``AGENTS.md` 则会用于后续构建的 Agent 上下文。 配置接口会掩码 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 开发 #### WebUI 开发
@ -290,6 +298,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
|------|--------| |------|--------|
| `gateway.host` | `127.0.0.1` | | `gateway.host` | `127.0.0.1` |
| `gateway.port` | `19876` | | `gateway.port` | `19876` |
| `gateway.require_pairing` | `true` |
| `gateway.max_concurrent_background_tasks` | `10` | | `gateway.max_concurrent_background_tasks` | `10` |
| `gateway.scheduler.enabled` | `true` | | `gateway.scheduler.enabled` | `true` |
| `client.gateway_url` | `ws://127.0.0.1:19876/ws` | | `client.gateway_url` | `ws://127.0.0.1:19876/ws` |
@ -307,7 +316,10 @@ Gateway 暴露:
| Method | Path | 说明 | | Method | Path | 说明 |
|--------|------|------| |--------|------|------|
| `GET` | `/health` | 健康检查和版本信息 | | `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 消息类型: Inbound 消息类型:

View File

@ -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。 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 CookieCLI 使用 Bearer token服务端仅持久化 SHA-256 哈希。`--revoke-all` 的持久化成功后才清空内存令牌,活动 WebSocket 每 5 秒复核身份并回收已撤销连接。
同源 `/api/*` 管理接口只提供显式白名单能力: 同源 `/api/*` 管理接口只提供显式白名单能力:
- 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。运行配置只在重启后生效,不热替换运行中组件。 - 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。运行配置只在重启后生效,不热替换运行中组件。
@ -207,13 +209,13 @@ Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript不依赖外
- 任务与记忆读取复用 Storage API不允许 WebUI 直接持有 SQLite 连接或拼接任意查询。 - 任务与记忆读取复用 Storage API不允许 WebUI 直接持有 SQLite 连接或拼接任意查询。
- 前端依赖只存在于源码构建阶段;生产页面不加载 CDN。`build.rs``package-lock.json` 的依赖 stamp 判断是否需要 `npm ci`,并依靠 Cargo `rerun-if-changed` 避免后端代码变化触发前端重建。前端开发仍须运行 `npm run check`,并以 `cargo build` 验证最终嵌入路径。 - 前端依赖只存在于源码构建阶段;生产页面不加载 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. 启动与关停顺序 ## 8. 启动与关停顺序
### 启动 ### 启动
1. 加载配置和 `.env`解析 workspace 1. 加载配置和 `.env`初始化 WebUI 配对存储与本机管理密钥
2. 创建并切换到 workspace。 2. 创建并切换到 workspace。
3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManagerScheduler 启用时幂等创建默认日常维护巡检。 3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManagerScheduler 启用时幂等创建默认日常维护巡检。
4. 注册内置工具、渠道、MCP 工具和 Cron 工具。 4. 注册内置工具、渠道、MCP 工具和 Cron 工具。

View File

@ -48,7 +48,8 @@
}, },
"gateway": { "gateway": {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 19876 "port": 19876,
"require_pairing": true
}, },
"client": { "client": {
"gateway_url": "ws://127.0.0.1:19876/ws" "gateway_url": "ws://127.0.0.1:19876/ws"

View File

@ -61,7 +61,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
- 长生命周期后台任务由 TaskSupervisor 管理;连接局部任务由其 owner 限时 join 或 abort - 长生命周期后台任务由 TaskSupervisor 管理;连接局部任务由其 owner 限时 join 或 abort
- 外部建连、重试等待和关停 join 必须可取消且有硬超时 - 外部建连、重试等待和关停 join 必须可取消且有硬超时
- 不得记录 API Key、Authorization header 或包含临时凭据的完整连接 URL - 不得记录 API Key、Authorization header 或包含临时凭据的完整连接 URL
- WebUI 当前无独立认证,默认回环监听是安全前提;对外暴露时必须由外层提供 TLS 和访问控制 - WebUI 管理 API 与 `/ws` 默认要求设备配对;一次性代码由本机 CLI 签发,服务端只持久化令牌哈希。对外暴露时仍必须由外层提供 TLS
## 上下文压缩 ## 上下文压缩

View File

@ -10,6 +10,12 @@ cargo run -- gateway
# WebUI 随 Gateway 提供,浏览器打开 # WebUI 随 Gateway 提供,浏览器打开
# http://127.0.0.1:19876/ # http://127.0.0.1:19876/
# 为新浏览器生成 5 分钟有效的一次性配对码
picobot pair
# 撤销全部设备并生成新配对码
picobot pair --revoke-all
# 修改 WebUI 后独立检查Node.js 20+ # 修改 WebUI 后独立检查Node.js 20+
cd webui cd webui
npm ci npm ci
@ -20,7 +26,8 @@ npm run build
cd .. cd ..
cargo build cargo build
# 启动 CLI 客户端 (连接 ws://127.0.0.1:19876/ws) # 首次配对并启动 CLI 客户端;后续可直接运行 chat
cargo run -- chat --pair-code <CODE>
cargo run -- chat cargo run -- chat
# 安装并启动 Linux systemd 用户服务 # 安装并启动 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` `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

View File

@ -57,6 +57,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|------|------|------|------| |------|------|------|------|
| `host` | string | 127.0.0.1 | 监听地址 | | `host` | string | 127.0.0.1 | 监听地址 |
| `port` | int | 19876 | 监听端口 | | `port` | int | 19876 | 监听端口 |
| `require_pairing` | bool | true | 是否要求 WebUI 与 CLI 设备先使用一次性代码配对 |
| `session_ttl_hours` | int | - | 兼容/预留字段;当前没有会话 TTL 清理循环 | | `session_ttl_hours` | int | - | 兼容/预留字段;当前没有会话 TTL 清理循环 |
| `session_db_path` | string | - | SQLite 数据库路径,默认在 workspace 下 | | `session_db_path` | string | - | SQLite 数据库路径,默认在 workspace 下 |
| `cleanup_interval_minutes` | int | - | 兼容/预留字段;当前没有按此间隔运行的 session 清理任务 | | `cleanup_interval_minutes` | int | - | 兼容/预留字段;当前没有按此间隔运行的 session 清理任务 |

View File

@ -48,7 +48,8 @@
}, },
"gateway": { "gateway": {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 19876 "port": 19876,
"require_pairing": true
}, },
"client": { "client": {
"gateway_url": "ws://127.0.0.1:19876/ws" "gateway_url": "ws://127.0.0.1:19876/ws"

View File

@ -16,13 +16,36 @@ use futures_util::StreamExt;
use ratatui::{Terminal, prelude::CrosstermBackend}; use ratatui::{Terminal, prelude::CrosstermBackend};
use std::io; use std::io;
use std::{fs, path::PathBuf}; 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<dyn std::error::Error>> { pub async fn run(
gateway_url: &str,
pair_code: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client_id = load_or_create_client_id(); let client_id = load_or_create_client_id();
let separator = if gateway_url.contains('?') { '&' } else { '?' }; let separator = if gateway_url.contains('?') { '&' } else { '?' };
let connect_url = format!("{gateway_url}{separator}client_id={client_id}"); 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 <CODE>`"
)
})?;
tracing::info!("Connected to gateway"); tracing::info!("Connected to gateway");
let (ws_sender, ws_receiver) = ws_stream.split(); let (ws_sender, ws_receiver) = ws_stream.split();
@ -52,6 +75,78 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
result result
} }
async fn exchange_pairing_code(
gateway_url: &str,
code: &str,
) -> Result<String, Box<dyn std::error::Error>> {
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<PathBuf> {
dirs::home_dir().map(|home| home.join(".picobot").join("tui_auth_token"))
}
fn load_auth_token() -> Option<String> {
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<dyn std::error::Error>> {
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 { fn load_or_create_client_id() -> String {
let generated = uuid::Uuid::new_v4().simple().to_string(); let generated = uuid::Uuid::new_v4().simple().to_string();
let Some(home) = dirs::home_dir() else { let Some(home) = dirs::home_dir() else {

View File

@ -146,6 +146,8 @@ pub struct GatewayConfig {
pub host: String, pub host: String,
#[serde(default = "default_gateway_port")] #[serde(default = "default_gateway_port")]
pub port: u16, pub port: u16,
#[serde(default = "default_require_pairing")]
pub require_pairing: bool,
#[serde(default, rename = "session_ttl_hours")] #[serde(default, rename = "session_ttl_hours")]
pub session_ttl_hours: Option<u64>, pub session_ttl_hours: Option<u64>,
#[serde(default, rename = "cleanup_interval_minutes")] #[serde(default, rename = "cleanup_interval_minutes")]
@ -163,6 +165,7 @@ impl Default for GatewayConfig {
Self { Self {
host: default_gateway_host(), host: default_gateway_host(),
port: default_gateway_port(), port: default_gateway_port(),
require_pairing: default_require_pairing(),
session_ttl_hours: None, session_ttl_hours: None,
cleanup_interval_minutes: None, cleanup_interval_minutes: None,
session_db_path: None, session_db_path: None,
@ -229,6 +232,10 @@ fn default_gateway_port() -> u16 {
19876 19876
} }
fn default_require_pairing() -> bool {
true
}
fn default_gateway_url() -> String { fn default_gateway_url() -> String {
"ws://127.0.0.1:19876/ws".to_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(); let config = Config::load(file.path().to_str().unwrap()).unwrap();
assert_eq!(config.gateway.host, "0.0.0.0"); assert_eq!(config.gateway.host, "0.0.0.0");
assert_eq!(config.gateway.port, 19876); assert_eq!(config.gateway.port, 19876);
assert!(config.gateway.require_pairing);
} }
#[test] #[test]

596
src/gateway/auth.rs Normal file
View File

@ -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<String>,
}
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<Instant>,
last_attempt: Instant,
}
#[derive(Debug)]
struct AuthState {
token_hashes: HashSet<String>,
pending_code: Option<PendingCode>,
failures: HashMap<String, FailedAttempts>,
}
#[derive(Debug, Clone)]
pub struct AuthManager {
required: bool,
path: PathBuf,
admin_token_hash: String,
state: Arc<Mutex<AuthState>>,
}
#[derive(Debug, Clone)]
pub struct AuthIdentity(pub Option<String>);
#[derive(Debug)]
pub enum PairError {
Invalid,
Locked(u64),
Capacity,
Persistence(std::io::Error),
}
impl AuthManager {
pub async fn load(required: bool, path: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
let store = match tokio::fs::read(&path).await {
Ok(bytes) => serde_json::from_slice::<AuthStore>(&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<AuthIdentity> {
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<String, PairError> {
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<AuthManager>,
mut request: Request<Body>,
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<Arc<GatewayState>>,
headers: HeaderMap,
) -> Json<AuthStatus> {
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<SocketAddr>,
State(state): State<Arc<GatewayState>>,
Json(request): Json<PairRequest>,
) -> 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<SocketAddr>,
State(state): State<Arc<GatewayState>>,
Query(query): Query<CodeQuery>,
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<String, FailedAttempts>, 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<String, FailedAttempts>, now: Instant) {
failures.retain(|_, attempts| now.duration_since(attempts.last_attempt) < LOCKOUT_DURATION * 2);
}
async fn persist_hashes(path: &Path, hashes: &HashSet<String>) -> 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<String, std::io::Error> {
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);
}
}

View File

@ -1,7 +1,9 @@
pub mod auth;
pub mod http; pub mod http;
pub mod ws; pub mod ws;
use axum::{Router, routing}; use axum::{Router, middleware, routing};
use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use tokio::net::TcpListener; use tokio::net::TcpListener;
@ -25,6 +27,7 @@ pub struct GatewayState {
pub storage: Arc<crate::storage::Storage>, pub storage: Arc<crate::storage::Storage>,
pub task_supervisor: TaskSupervisor, pub task_supervisor: TaskSupervisor,
pub connection_shutdown: tokio_util::sync::CancellationToken, pub connection_shutdown: tokio_util::sync::CancellationToken,
pub auth: auth::AuthManager,
} }
impl GatewayState { impl GatewayState {
@ -33,6 +36,11 @@ impl GatewayState {
let config = Config::load_default()?; let config = Config::load_default()?;
let task_supervisor = TaskSupervisor::new(); let task_supervisor = TaskSupervisor::new();
let connection_shutdown = tokio_util::sync::CancellationToken::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 // Initialize workspace directory: expand path and ensure it exists
let workspace_path = expand_path(&config.workspace_dir); let workspace_path = expand_path(&config.workspace_dir);
@ -192,6 +200,7 @@ impl GatewayState {
storage, storage,
task_supervisor, task_supervisor,
connection_shutdown, 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_host = host.unwrap_or_else(|| state.config.gateway.host.clone());
let bind_port = port.unwrap_or(state.config.gateway.port); let bind_port = port.unwrap_or(state.config.gateway.port);
let app = Router::new() let protected = 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/health", routing::get(http::health)) .route("/api/health", routing::get(http::health))
.route( .route(
"/api/config", "/api/config",
@ -476,6 +481,20 @@ pub async fn run(
.route("/api/jobs/{id}/runs", routing::get(http::get_job_runs)) .route("/api/jobs/{id}/runs", routing::get(http::get_job_runs))
.route("/api/memories", routing::get(http::get_memories)) .route("/api/memories", routing::get(http::get_memories))
.route("/ws", routing::get(ws::ws_handler)) .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()); .with_state(state.clone());
let addr = format!("{}:{}", bind_host, bind_port); let addr = format!("{}:{}", bind_host, bind_port);
@ -483,13 +502,16 @@ pub async fn run(
tracing::info!(address = %addr, "Gateway listening"); tracing::info!(address = %addr, "Gateway listening");
let connection_shutdown = state.connection_shutdown.clone(); let connection_shutdown = state.connection_shutdown.clone();
let serve_result = axum::serve(listener, app) let serve_result = axum::serve(
.with_graceful_shutdown(async move { listener,
wait_for_shutdown_signal().await; app.into_make_service_with_connect_info::<SocketAddr>(),
tracing::info!("Shutdown signal received"); )
connection_shutdown.cancel(); .with_graceful_shutdown(async move {
}) wait_for_shutdown_signal().await;
.await; tracing::info!("Shutdown signal received");
connection_shutdown.cancel();
})
.await;
// Stop external intake before waiting for internal work to finish. // Stop external intake before waiting for internal work to finish.
if let Err(error) = state.channel_manager.stop_all().await { if let Err(error) = state.channel_manager.stop_all().await {

View File

@ -2,7 +2,7 @@ use super::GatewayState;
use crate::protocol::WsOutbound; use crate::protocol::WsOutbound;
use crate::protocol::serialize_outbound; use crate::protocol::serialize_outbound;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; 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 axum::response::Response;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use serde::Deserialize; use serde::Deserialize;
@ -19,9 +19,10 @@ pub async fn ws_handler(
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
Query(query): Query<WsQuery>, Query(query): Query<WsQuery>,
State(state): State<Arc<GatewayState>>, State(state): State<Arc<GatewayState>>,
Extension(identity): Extension<super::auth::AuthIdentity>,
) -> Response { ) -> Response {
ws.on_upgrade(|socket| async move { 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<String>) -> Option<String> {
}) })
} }
async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>, client_id: Option<String>) { async fn handle_socket(
ws: WebSocket,
state: Arc<GatewayState>,
client_id: Option<String>,
identity: super::auth::AuthIdentity,
) {
// Create channel for sending outbound messages to this client // Create channel for sending outbound messages to this client
let (sender, mut receiver) = mpsc::channel::<WsOutbound>(100); let (sender, mut receiver) = mpsc::channel::<WsOutbound>(100);
@ -71,9 +77,17 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>, client_id: Optio
// Main loop: receive WebSocket messages and forward to CliChatChannel // Main loop: receive WebSocket messages and forward to CliChatChannel
let cancellation = state.connection_shutdown.clone(); let cancellation = state.connection_shutdown.clone();
let mut writer_finished = false; let mut writer_finished = false;
let mut auth_check = tokio::time::interval(Duration::from_secs(5));
auth_check.tick().await;
loop { loop {
tokio::select! { tokio::select! {
_ = cancellation.cancelled() => break, _ = 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 => { result = &mut writer_task => {
writer_finished = true; writer_finished = true;
if let Err(error) = result { if let Err(error) = result {

View File

@ -26,6 +26,9 @@ enum Command {
/// Gateway WebSocket URL (e.g., ws://127.0.0.1:19876/ws) /// Gateway WebSocket URL (e.g., ws://127.0.0.1:19876/ws)
#[arg(long)] #[arg(long)]
gateway_url: Option<String>, gateway_url: Option<String>,
/// One-time pairing code; saves the issued client token locally
#[arg(long)]
pair_code: Option<String>,
}, },
/// Start gateway server /// Start gateway server
Gateway { Gateway {
@ -36,6 +39,15 @@ enum Command {
#[arg(long)] #[arg(long)]
port: Option<u16>, port: Option<u16>,
}, },
/// Generate a one-time browser pairing code from the local gateway
Pair {
/// Gateway WebSocket or HTTP URL
#[arg(long)]
gateway_url: Option<String>,
/// Revoke every paired browser and CLI token before issuing the code
#[arg(long)]
revoke_all: bool,
},
/// Manage the PicoBot systemd user service /// Manage the PicoBot systemd user service
Service { Service {
#[command(subcommand)] #[command(subcommand)]
@ -55,16 +67,63 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
match Command::parse() { match Command::parse() {
Command::Chat { gateway_url } => { Command::Chat {
gateway_url,
pair_code,
} => {
let config = picobot::config::Config::load_default().ok(); let config = picobot::config::Config::load_default().ok();
let url = gateway_url let url = gateway_url
.or_else(|| config.as_ref().map(|c| c.client.gateway_url.clone())) .or_else(|| config.as_ref().map(|c| c.client.gateway_url.clone()))
.unwrap_or_else(|| "ws://127.0.0.1:19876/ws".to_string()); .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 } => { Command::Gateway { host, port } => {
picobot::gateway::run(host, port).await?; 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 } => { Command::Service { command } => {
let command = match command { let command = match command {
ServiceCommand::Install => picobot::service::ServiceCommand::Install, ServiceCommand::Install => picobot::service::ServiceCommand::Install,
@ -79,3 +138,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
Ok(()) Ok(())
} }
fn gateway_api_url(
gateway_url: &str,
path: &str,
) -> Result<reqwest::Url, Box<dyn std::error::Error>> {
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)
}

View File

@ -8,6 +8,7 @@
import MemoryPage from "./pages/MemoryPage.svelte"; import MemoryPage from "./pages/MemoryPage.svelte";
import LogsPage from "./pages/LogsPage.svelte"; import LogsPage from "./pages/LogsPage.svelte";
import SettingsPage from "./pages/SettingsPage.svelte"; import SettingsPage from "./pages/SettingsPage.svelte";
import PairingPage from "./pages/PairingPage.svelte";
const pages = [ const pages = [
["chat", "◉", "在线聊天", "与你的 PicoBot 实时对话"], ["chat", "◉", "在线聊天", "与你的 PicoBot 实时对话"],
@ -21,7 +22,9 @@
let online = $state(false); let online = $state(false);
let version = $state("Gateway"); let version = $state("Gateway");
let theme = $state("dark"); 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]); const meta = $derived(pages.find(([name]) => name === current) || pages[0]);
async function health() { async function health() {
@ -47,16 +50,42 @@
localStorage.setItem("picobot-theme", next); 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(() => { onMount(() => {
const saved = localStorage.getItem("picobot-theme"); const saved = localStorage.getItem("picobot-theme");
applyTheme(saved === "light" || saved === "dark" ? saved : (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")); applyTheme(saved === "light" || saved === "dark" ? saved : (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"));
health(); checkAuth().then(() => authenticated && health());
const timer = setInterval(health, 30_000); const authRequired = () => authenticated = false;
return () => clearInterval(timer); window.addEventListener("picobot-auth-required", authRequired);
const timer = setInterval(() => authenticated && health(), 30_000);
return () => {
clearInterval(timer);
window.removeEventListener("picobot-auth-required", authRequired);
};
}); });
</script> </script>
<Tooltip.Provider delayDuration={350}> {#if !authReady}
<main class="pairing-screen"><div class="auth-loading"><span class="pulse"></span>正在检查设备授权…</div></main>
{:else if !authenticated}
<PairingPage onpaired={paired} />
{:else}
<Tooltip.Provider delayDuration={350}>
<div class="shell"> <div class="shell">
<aside class:open={menuOpen} class="sidebar"> <aside class:open={menuOpen} class="sidebar">
<div class="brand"><span class="brand-mark">P</span><div><strong>PicoBot</strong><small>Local agent console</small></div></div> <div class="brand"><span class="brand-mark">P</span><div><strong>PicoBot</strong><small>Local agent console</small></div></div>
@ -83,4 +112,5 @@
</main> </main>
</div> </div>
<Toast bind:this={toast} /> <Toast bind:this={toast} />
</Tooltip.Provider> </Tooltip.Provider>
{/if}

View File

@ -1,10 +1,16 @@
export async function api(path, options = {}) { export async function api(path, options = {}) {
const response = await fetch(path, { const response = await fetch(path, {
...options, ...options,
credentials: "same-origin",
headers: { "Content-Type": "application/json", ...(options.headers || {}) } headers: { "Content-Type": "application/json", ...(options.headers || {}) }
}); });
const data = await response.json().catch(() => ({})); 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; return data;
} }

View File

@ -0,0 +1,57 @@
<script>
import { api } from "../lib/api.js";
let { onpaired } = $props();
let code = $state("");
let submitting = $state(false);
let error = $state("");
function input(event) {
code = event.currentTarget.value.replace(/\D/g, "").slice(0, 8);
error = "";
}
async function submit() {
if (code.length !== 8 || submitting) return;
submitting = true;
error = "";
try {
await api("/api/auth/pair", {
method: "POST",
body: JSON.stringify({ code })
});
onpaired();
} catch (cause) {
error = cause.message || "配对失败,请重新生成配对码后再试";
} finally {
submitting = false;
}
}
</script>
<main class="pairing-screen">
<section class="pairing-card">
<div class="pairing-brand"><span class="brand-mark">P</span><strong>PicoBot</strong></div>
<div class="pairing-icon" aria-hidden="true"></div>
<h1>配对此浏览器</h1>
<p>这是一个新浏览器。请在运行 Gateway 的设备上生成一次性配对码,然后在下方输入。</p>
<code class="pairing-command">picobot pair</code>
<form onsubmit={(event) => { event.preventDefault(); submit(); }}>
<label for="pairing-code">8 位配对码</label>
<input
id="pairing-code"
value={code}
oninput={input}
inputmode="numeric"
autocomplete="one-time-code"
placeholder="00000000"
maxlength="8"
/>
{#if error}<div class="pairing-error" role="alert">{error}</div>{/if}
<button class="primary" type="submit" disabled={code.length !== 8 || submitting}>
{submitting ? "正在验证…" : "配对并进入"}
</button>
</form>
<small>配对码 5 分钟内有效且只能使用一次。设备凭据将以 HttpOnly Cookie 保存。</small>
</section>
</main>

View File

@ -59,6 +59,20 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; min-width: 320px; color: var(--text); background: var(--bg); transition: color .2s, background .2s; } 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, 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: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; } button:disabled { cursor: not-allowed; opacity: .45; }