增加tui和web的配对码要求。
This commit is contained in:
parent
7a144cea8a
commit
e37581c909
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
16
Dockerfile
16
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)
|
||||
|
||||
18
README.md
18
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 <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 消息类型:
|
||||
|
||||
|
||||
@ -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 工具。
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
## 上下文压缩
|
||||
|
||||
|
||||
@ -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 <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。
|
||||
|
||||
@ -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 清理任务 |
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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<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 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 <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<dyn std::error::Error>> {
|
||||
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 {
|
||||
let generated = uuid::Uuid::new_v4().simple().to_string();
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
|
||||
@ -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<u64>,
|
||||
#[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]
|
||||
|
||||
596
src/gateway/auth.rs
Normal file
596
src/gateway/auth.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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<crate::storage::Storage>,
|
||||
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::<SocketAddr>(),
|
||||
)
|
||||
.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 {
|
||||
|
||||
@ -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<WsQuery>,
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Extension(identity): Extension<super::auth::AuthIdentity>,
|
||||
) -> 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<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
|
||||
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
|
||||
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 {
|
||||
|
||||
83
src/main.rs
83
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<String>,
|
||||
/// One-time pairing code; saves the issued client token locally
|
||||
#[arg(long)]
|
||||
pair_code: Option<String>,
|
||||
},
|
||||
/// Start gateway server
|
||||
Gateway {
|
||||
@ -36,6 +39,15 @@ enum Command {
|
||||
#[arg(long)]
|
||||
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
|
||||
Service {
|
||||
#[command(subcommand)]
|
||||
@ -55,16 +67,63 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
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<dyn std::error::Error>> {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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);
|
||||
};
|
||||
});
|
||||
</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">
|
||||
<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>
|
||||
@ -83,4 +112,5 @@
|
||||
</main>
|
||||
</div>
|
||||
<Toast bind:this={toast} />
|
||||
</Tooltip.Provider>
|
||||
</Tooltip.Provider>
|
||||
{/if}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
57
webui/src/pages/PairingPage.svelte
Normal file
57
webui/src/pages/PairingPage.svelte
Normal 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>
|
||||
@ -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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user