增加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 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
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
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
|
# 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)
|
||||||
|
|||||||
18
README.md
18
README.md
@ -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 消息类型:
|
||||||
|
|
||||||
|
|||||||
@ -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 Cookie,CLI 使用 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 和 SessionManager;Scheduler 启用时幂等创建默认日常维护巡检。
|
3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManager;Scheduler 启用时幂等创建默认日常维护巡检。
|
||||||
4. 注册内置工具、渠道、MCP 工具和 Cron 工具。
|
4. 注册内置工具、渠道、MCP 工具和 Cron 工具。
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
## 上下文压缩
|
## 上下文压缩
|
||||||
|
|
||||||
|
|||||||
@ -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。
|
||||||
|
|||||||
@ -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 清理任务 |
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
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 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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
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)
|
/// 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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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; }
|
* { 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; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user