diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2e3b1c..8a82651 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,10 @@ permissions: jobs: go: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -23,6 +26,21 @@ jobs: - name: Vet working-directory: dev run: go vet ./... + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: dev/web/package-lock.json + - name: 构建便携包 + run: node build.mjs + - name: 上传便携包 + uses: actions/upload-artifact@v4 + with: + name: easybotce-${{ runner.os }}-${{ runner.arch }} + path: | + release/*.zip + release/*.tar.gz + if-no-files-found: error web: runs-on: ubuntu-latest diff --git a/.github/workflows/commit-language.yml b/.github/workflows/commit-language.yml new file mode 100644 index 0000000..4556521 --- /dev/null +++ b/.github/workflows/commit-language.yml @@ -0,0 +1,44 @@ +name: 中文提交检查 + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + push: + +permissions: + contents: read + +jobs: + commit-language: + name: 提交标题使用中文 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: 检查新增提交 + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + python3 - <<'PY' + import os + import re + import subprocess + + base, head = os.environ['BASE_SHA'], os.environ['HEAD_SHA'] + if not base or set(base) == {'0'}: + # 新分支只检查不在其他远程分支上的提交,避免追溯检查历史。 + others = subprocess.check_output(['git', 'for-each-ref', '--format=%(objectname)', 'refs/remotes/origin/'], text=True).splitlines() + others = [sha for sha in others if sha != head] + revisions = [head, '--not', *others] if others else ['-1', head] + else: + revisions = [f'{base}..{head}'] + commits = subprocess.check_output(['git', 'log', '--format=%h %s', *revisions], text=True).splitlines() + invalid = [line for line in commits if not re.search(r'[\u4e00-\u9fff]', line.partition(' ')[2])] + if invalid: + print('以下提交标题必须改为中文:\n' + '\n'.join(invalid)) + raise SystemExit(1) + print(f'已检查 {len(commits)} 个新增提交。') + PY diff --git a/.gitignore b/.gitignore index b658295..2ffaa7f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Local runtime data and secrets /dev/appdata/ +appsettings.json +token.txt .env .env.* !.env.example @@ -7,6 +9,7 @@ # Go outputs /dev/bin/ /dev/obj/ +/release/ # Frontend outputs and local tooling state /dev/web/node_modules/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6ced2d7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ +# 开发协作约定 + +本文件适用于整个 Core 仓库及后续开发代理。 + +- 项目目标:在已获授权的范围内复刻闭源 EasyBot 的功能和使用流程;以可验证的协议、行为和测试为依据。 +- 中文交流、中文开发文档、中文 commit 标题及正文。技术名、路径、类型标记可以保留英文,例如 `fix(web): 修复管理员登录失败`。 +- 不得改写既有历史以追溯修改英文提交。 +- 反编译参考仓库位于同组织的 EasyBot-Recovered。本地建议与 Core 并列;只用于功能、协议和接口对照。不得直接把私有参考源码或 DLL 提交到 Core。 +- 不把配置 CRUD、页面入口或模拟器通过描述为真实运行时已完成。状态说明必须区分已实现、模拟验证、真实环境验证和待实现。 +- WebUI 与管理 API 共用默认端口 5001,由 Go 进程提供构建后的页面;Bridge 独立使用 26991。Vite 仅供可选热更新,不是产品运行依赖。Bridge 运行时尚未实现时不得宣称已监听或已兼容原版插件。 +- 修改入口、配置或协议时,同步检查调用方、测试、文档及构建流程。保持修复最小,不为目录规范一次性迁移整个项目。 +- 提交前运行与改动相关的 Go 测试、go vet、前端构建;不能运行的检查写明原因。运行数据、凭据、构建产物及反编译文件不得进入版本控制。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c8a92f3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# 贡献规范 + +## 中文提交规范 + +所有后续提交的标题和正文使用中文,技术术语、代码标识和类型前缀可保留英文。推荐格式:`类型(范围): 中文摘要`,例如 `feat(bridge): 增加连接身份校验`。正文说明原因、行为变化与验证结果。合并和回滚提交同样使用中文;不追溯重写历史英文提交。 + +CI 的中文提交检查校验每个新增提交标题是否含汉字;它只能做基本格式检查,正文语言和语义仍由人工评审。要强制所有贡献者遵守中文提交规范,需要在 GitHub 分支保护中将该检查设置为必需,并限制直接推送与绕过权限。 + +## 开发与验证 + +当前实现位于 `dev/`:`cmd/easybot` 为入口,`internal` 为后端,`web` 为前端。使用 Go 1.24+ 与 Node.js 20.19+,依赖以 `go.mod/go.sum` 和 `package-lock.json` 为准。详细运行方法见 [开发说明](dev/README.md)。 + +后端在 `dev` 执行 `go test ./...` 和 `go vet ./...`;前端在 `dev/web` 执行 `npm ci` 和 `npm run build`。涉及 MySQL/PostgreSQL 时使用空的专用测试库执行集成检查。Windows 启动改动需要验证从其他工作目录启动 exe,并在验证后停止服务。 + +PR 使用中文说明问题、最终行为和验证结果。为非平凡修复保留能暴露原问题的测试;不要把默认跳过的外部数据库测试描述为已验证通过。 + +## 复刻依据与边界 + +EasyBot-Recovered 是同组织的私有参考仓库,包含发布包恢复记录、反编译模块以及独立 .NET 重建脚手架。它不是可直接构建的原版源码;Go Core 是独立实现。 + +新增功能先记录原版模块或接口依据,再定义当前实现的协议、数据及验收标准。NativeAOT 无法恢复的内容标注为未知,不推断实现细节。参考代码及 DLL 不复制进公开 Core;如确有复制需求,先确认授权与分发范围。 diff --git a/PLAN.md b/PLAN.md index 2a0ff9a..748a23f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -46,7 +46,7 @@ EasyBot 是连接 Minecraft 服务器与 QQ 等社交平台的机器人管理工 - 明确配置格式、环境变量覆盖规则、数据目录、日志格式和版本策略。 - 建立 Go 模块边界:配置、存储、HTTP API、运行时、适配器、Minecraft 网关、消息路由、日志与安全。 - 建立数据库迁移机制、健康检查、优雅关闭、结构化日志、API 错误格式和分页规范。 -- 完成首次管理员初始化、密码/API 凭据安全存储、会话/令牌策略、CSRF/CORS/来源约束及敏感配置脱敏。 +- 完成原版式 Token 登录、凭据安全存储、会话/令牌策略、CSRF/CORS/来源约束及敏感配置脱敏。 - 交付:可启动的单体服务、Vue 管理壳、安装文档、SQLite 默认配置和三种数据库的 CI 测试。 ### 阶段 1:最小可用消息闭环(MVP) @@ -180,13 +180,13 @@ Vue 管理台 --HTTP/SSE--> Go API 与认证 ## 11. 当前仓库基线与下一步 -截至 2026-11-01,`dev/` 已有 Go HTTP 主机、SQLite/MySQL/PostgreSQL 驱动、版本化迁移、基础 CRUD、适配器类型登记、适配器启动接口占位、有界内存运行日志、仪表盘汇总接口,以及 Vue 3/TypeScript/Tailwind v4 管理台。当前迁移版本为 v2:保留原通用 `records` 表,并新增单管理员 `users`、哈希会话 `sessions` 和一次性初始化状态 `setup_state`。首次管理员初始化要求回环远端地址及本机 Host/Origin;密码以 bcrypt 保存,会话仅存 SHA-256 摘要,浏览器使用 HttpOnly、SameSite=Strict Cookie,12 小时过期;登录按远端 IP 限流。管理 API 默认要求会话,显式设置 `EASYBOT_API_KEY` 时也接受 Bearer 认证。前端现有首次初始化、登录、退出和受保护控制台入口。监听地址校验仍要求非回环监听配置 API Key 与 TLS,服务支持优雅退出。 +截至 2026-11-07,`dev/` 已有 Go HTTP 主机、SQLite/MySQL/PostgreSQL 驱动、版本化迁移、基础 CRUD、OneBot 11 正向 WebSocket 适配器、有界内存与持久化运行日志、仪表盘汇总接口,以及 Vue 3/TypeScript/Tailwind v4 管理台。当前迁移版本为 v4。回环监听且访问来源、Host/Origin 均为本机时免登录;非回环监听统一使用 Token 登录,无需用户名或独立 API Key,支持 HTTP 和可选 HTTPS。`token.txt` 沿用原版 SHA-256 原始 32 字节格式,缺失时首次生成 12 位 Token 并在控制台显示一次。会话只保存 SHA-256 摘要,浏览器使用 HttpOnly、SameSite=Strict Cookie,7 天过期;更换 Token 后重启会清除旧会话,登录按远端 IP 限流。前端提供 Token 登录、退出和受保护控制台入口,服务支持优雅退出。 -这些交付只完成了管理底座的一部分,不代表规划功能已完成:目前只有单管理员认证,没有角色/多用户、CSRF Token、权限矩阵和持久审计;适配器/服务器/群组/玩家/命令等业务数据仍使用通用 `records` 表;MySQL/PostgreSQL 尚未完成集成迁移验证;适配器和 Minecraft Bridge 运行时未实现;群组发现、消息路由投递、玩家绑定、命令执行、持久化统计、备份恢复和扩展能力均未形成端到端功能。概览只显示数据库配置数和明确标注的待连接状态,不能把配置存在推断成连接在线或消息成功。 +这些交付只完成了管理底座的一部分,不代表规划功能已完成:目前使用单 Token 认证,没有角色/多用户、CSRF Token、权限矩阵和持久审计;适配器/服务器/群组/玩家/命令等业务数据仍使用通用 `records` 表;MySQL/PostgreSQL 尚未完成本机集成迁移验证;OneBot 11 已有模拟器验证,真实账号尚未验证,其他平台适配器和 Minecraft Bridge 运行时未实现;跨平台消息路由投递、玩家绑定、命令执行、持久化统计、备份恢复和扩展能力均未形成端到端功能。概览不能把配置存在推断成连接在线或消息成功。 建议按以下次序继续实施: -1. 明确单管理员首版范围,补充会话密钥/轮换、CSRF 策略和操作审计;将通用 `records` 逐步拆为正式领域模型,并验证 SQLite/MySQL/PostgreSQL 同一迁移契约。 +1. 补充单 Token 认证的 CSRF 策略和操作审计;将通用 `records` 逐步拆为正式领域模型,并验证 SQLite/MySQL/PostgreSQL 同一迁移契约。 2. 冻结平台事件、消息、路由规则和 Minecraft 桥接协议的数据契约。 3. 选定一个目标社交适配器和一种服务器桥接端,完成最小端到端消息闭环,并以真实健康状态驱动概览。 4. 将账号绑定、访问控制和命令纳入该闭环,再开展 AI、图片模板和脚本扩展。 @@ -195,15 +195,15 @@ Vue 管理台 --HTTP/SSE--> Go API 与认证 ## 12. 关键业务流程 -### 12.1 首次启动与管理员初始化 +### 12.1 首次启动与 Token 登录 1. 程序读取配置文件,叠加环境变量,校验监听地址、数据库 DSN、数据目录和密钥引用。 2. 建立数据库连接,读取迁移版本;执行向前迁移。迁移失败时停止启动并输出可诊断错误,不启动半初始化服务。 -3. 如果没有管理员账号,服务只开放健康检查和本机初始化页面/API;初始化凭据只能在本机回环访问,成功创建后立即失效。 -4. 管理员设置用户名、密码和可选的恢复密钥;系统生成会话密钥并提示配置备份策略。 +3. 回环监听时允许本机直接进入管理台;非回环监听时读取原版格式的 `token.txt`,缺失则自动生成 12 位 Token,明文仅在首次启动控制台显示。 +4. 非本地模式在网页输入 Token 登录,获得 7 天会话;忘记 Token 时停止程序并删除 `token.txt`,重启生成新 Token 并清除旧会话。 5. 进入控制台后按引导顺序配置社交适配器、服务器桥接端和同步规则。 -验收点:未初始化服务不能从非本机地址创建管理员;初始化入口只能成功使用一次;敏感值在日志和 API 响应中不可见。 +验收点:非本地模式不能绕过 Token 访问管理 API;重启沿用 Token,替换 Token 后旧会话失效;除首次控制台提示外,不在运行日志或 API JSON 响应中暴露凭据。 ### 12.2 消息同步 @@ -308,7 +308,7 @@ Vue 管理台 --HTTP/SSE--> Go API 与认证 ```yaml server: - listen: 127.0.0.1:26990 + listen: 127.0.0.1:5001 public_url: "" database: driver: sqlite diff --git a/README.md b/README.md index 63e0ef7..21308c0 100644 --- a/README.md +++ b/README.md @@ -9,27 +9,53 @@ EasybotCE 的主程序。当前重建项目由 Go HTTP API 和 Vue 3 管理台 - `REMAINING_PLAN.md`: 后续版本产品范围、验收标准和工作包。 - `.github/workflows/`: 本地构建及数据库集成测试。 -当前运行时已覆盖管理员初始化/认证、管理台概览、基础配置 CRUD 和内存运行日志。OneBot 11 已选为首发社交平台;协议连接、Minecraft 网络桥接和消息同步仍在开发中。MySQL/PostgreSQL 是否正式支持以对应 CI 集成测试通过为准。 +当前运行时已覆盖管理员初始化/认证、管理台概览、配置 CRUD、持久化运行日志及 OneBot 11 正向 WebSocket 连接;OneBot 模拟器验证已完成,真实账号仍待验收。Minecraft 网络桥接和消息同步尚未实现。MySQL/PostgreSQL 是否正式支持以对应 CI 集成测试通过为准。 ## License and attribution This repository is licensed under [CC BY-NC 4.0 (`CC-BY-NC-4.0`)](https://creativecommons.org/licenses/by-nc/4.0/). Commercial use is not permitted by that license. When sharing or adapting this project, credit EasybotCE and link to this repository and the license, identify changes, and retain existing author, copyright, and license notices. See [`AUTHORS.md`](AUTHORS.md). +## 运行 + +Windows 用户下载便携压缩包,解压到可写目录后双击 `easybotce.exe`,浏览器打开 `http://127.0.0.1:5001`。默认本机回环监听直接进入管理台,无需注册或登录。包内已包含页面资源,默认使用 SQLite,无需安装 Go、Node.js 或数据库服务器,也无需启动脚本。运行期间保持程序窗口打开。 + +非本地监听使用原版式 Token 登录,不需要用户名或独立 API Key。首次自动生成 12 位登录 Token,仅在控制台显示一次;`token.txt` 保存原版格式的 SHA-256 哈希,后续沿用。非本地地址可直接使用 HTTP,HTTPS 可选;公网传输 Token 时建议使用 HTTPS。 + +保留包内的 `web` 目录。首次启动自动创建 `appdata` 数据目录;升级前停止程序、备份并保留 `appdata`,再替换程序和页面资源。已有数据库可通过 `EASYBOT_SQLITE_PATH` 指定。 + +Windows 双击启动失败时,窗口会保留并显示原因,按回车后关闭。从已有终端或自动化环境运行时直接返回错误码。 + +## 启动配置与原版迁移 + +便携包自带 `appsettings.json`,缺失时首次启动自动生成。修改后重启生效,程序不会重写已有配置。沿用原版字段: + +- `Kestrel.Endpoints.web_api.Url`:WebUI 地址,CE 默认 `http://127.0.0.1:5001`。 +- `ServerOptions.Host`、`ServerOptions.Port`:Bridge 地址,CE 默认 `127.0.0.1:26991`;运行时尚未实现。 +- `Serilog.MinimumLevel.Default`:控制台日志级别。 +- `AllowedHosts`:允许的 HTTP Host,支持 `*`、分号分隔的主机名及 `*.example.com`。 + +迁移启动配置时,将原版 `appsettings.json` 直接复制到 CE 的 exe 旁边即可,现有字段及端口按原值读取。已有原版 `token.txt` 也可一并复制,继续使用同一个登录 Token。你本机原版使用的 5000/26990 会保留;同时运行原版时请改成不同端口。CE 新增配置缺失时使用默认值,原版数据库需要单独迁移,不能直接替换 CE 数据库。 + +忘记 Token 时,先停止程序并删除 `token.txt`,非本地模式重启后会生成新的 Token,并使旧登录会话失效。升级请保留自己的 `appsettings.json`、`token.txt` 和 `appdata`。旧 CE 用户名/密码登录由 Token 登录替代,业务数据保留。 + +`EASYBOT_CONFIG` 可指定其他配置文件,原有环境变量优先于 JSON 配置。开发构建默认生成 `dev/appsettings.json`;exe 旁已有配置时优先读取它。配置示例见 [appsettings.example.json](dev/internal/config/appsettings.example.json)。 + ## 开发 环境要求:Go 1.24+、Node.js 20.19+。 -```powershell -cd dev/web -npm ci -npm run dev -``` +运行端口:`127.0.0.1:5001` 同时提供 WebUI 和管理 API;`26991` 用于 Bridge 与插件/模组通信(运行时尚未实现,当前不监听)。构建后的程序不需要独立前端服务器。WebUI 地址可通过 `EASYBOT_ADDR` 覆盖。 + +贡献者与开发代理须遵守 [贡献规范](CONTRIBUTING.md) 和 [协作约定](AGENTS.md):所有后续 commit 使用中文。 -另开终端启动 API: +在仓库根目录执行一条命令,完成依赖下载、前端构建、Go 编译和便携包打包: ```powershell -cd dev -go run ./cmd/easybot +node build.mjs ``` +Windows 输出 `release/easybotce-windows-amd64.zip`,Linux 输出对应平台的 `.tar.gz`(程序名为 `easybotce`);CI 生成的便携包可从运行记录的 Artifacts 下载。本地编译程序同时保存在 `dev/bin`。 + +浏览器打开 `http://127.0.0.1:5001`。可选热更新开发方式见开发说明,日常运行无需启动 Vite。 + 运行方式、配置项和安全说明见 [`dev/README.md`](dev/README.md)。 diff --git a/build.mjs b/build.mjs new file mode 100644 index 0000000..1d5541e --- /dev/null +++ b/build.mjs @@ -0,0 +1,71 @@ +import { spawnSync } from 'node:child_process' +import { copyFileSync, cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = dirname(fileURLToPath(import.meta.url)) +const dev = join(root, 'dev') +const env = { ...process.env, CGO_ENABLED: '0' } + +function run(command, args, cwd) { + if (process.platform === 'win32' && command === 'npm') { + // npm 的参数均为下方固定命令,不拼接用户输入。 + args = ['/d', '/s', '/c', `npm ${args.join(' ')}`] + command = 'cmd.exe' + } + const result = spawnSync(command, args, { + cwd, + env, + stdio: 'inherit', + }) + if (result.error || result.status !== 0) { + throw new Error(`${command} 执行失败(${result.status ?? result.error?.message})`) + } +} + +run('npm', ['ci', '--no-audit', '--no-fund'], join(dev, 'web')) +run('npm', ['run', 'build'], join(dev, 'web')) +mkdirSync(join(dev, 'bin'), { recursive: true }) +const target = env.GOOS || (process.platform === 'win32' ? 'windows' : process.platform) +const output = join('bin', target === 'windows' ? 'easybotce.exe' : 'easybotce') +run('go', ['build', '-trimpath', '-o', output, './cmd/easybot'], dev) + +const arch = env.GOARCH || ({ x64: 'amd64', ia32: '386' }[process.arch] || process.arch) +const name = `easybotce-${target}-${arch}` +const release = join(root, 'release') +if (!/^easybotce-[a-z0-9]+-[a-z0-9]+$/.test(name)) throw new Error('无效的目标平台') +mkdirSync(release, { recursive: true }) +const folder = mkdtempSync(join(release, '.package-')) +try { + copyFileSync(join(dev, output), join(folder, target === 'windows' ? 'easybotce.exe' : 'easybotce')) + copyFileSync(join(dev, 'internal', 'config', 'appsettings.example.json'), join(folder, 'appsettings.json')) + cpSync(join(dev, 'web', 'dist'), join(folder, 'web', 'dist'), { recursive: true }) + for (const file of ['LICENSE', 'AUTHORS.md']) copyFileSync(join(root, file), join(folder, file)) + writeFileSync(join(folder, '使用说明.txt'), [ + 'EasybotCE 便携包', + target === 'windows' ? '解压到可写目录后,双击 easybotce.exe 启动,运行期间保持窗口打开。' : '解压后运行 ./easybotce;若缺少执行权限,先执行 chmod +x easybotce。', + '浏览器打开 http://127.0.0.1:5001,默认本机监听直接进入管理台,无需注册或登录。', + '修改同目录 appsettings.json 中 Kestrel.Endpoints.web_api.Url 可调整 WebUI 地址,保存后重启生效。', + 'ServerOptions 是 Bridge 配置,默认端口 26991;当前 Bridge 运行时尚未实现。', + '迁移时可复制原版 appsettings.json,已有文件不会被重写;旧端口按原值保留。', + '非本地监听可直接使用 HTTP;网页只需输入登录 Token,不需要用户名或独立 API Key。', + '首次自动生成 12 位 Token,在控制台显示一次;token.txt 保存哈希,后续沿用。已有原版 token.txt 可直接复制到 exe 旁。', + '双击启动失败时,窗口显示错误原因并等待回车关闭。', + '请保留包内 web 目录。运行数据自动保存到 appdata 目录。', + '升级前停止程序、备份并保留 appsettings.json、token.txt 和 appdata,再替换程序和 web 目录。', + '默认使用 SQLite,无需安装开发环境或数据库服务器。', + ].join('\n') + '\n', 'utf8') + + if (target === 'windows' && process.platform === 'win32') { + env.EASYBOT_PACKAGE_DIR = folder + env.EASYBOT_ARCHIVE_PATH = join(release, `${name}.zip`) + run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Compress-Archive -Path (Join-Path $env:EASYBOT_PACKAGE_DIR "*") -DestinationPath $env:EASYBOT_ARCHIVE_PATH -Force'], root) + } else { + run('tar', ['-czf', join(release, `${name}.tar.gz`), '-C', folder, '.'], root) + } +} finally { + // 只删除本次新建的临时打包目录,已解压的程序及数据不受影响。 + if (dirname(folder) !== release) throw new Error('无效的清理目录') + rmSync(folder, { recursive: true, force: true }) +} +console.log(`便携包已生成:${release}`) diff --git a/dev/README.md b/dev/README.md index 0ba15e3..fa48ccd 100644 --- a/dev/README.md +++ b/dev/README.md @@ -6,37 +6,69 @@ Development rebuild using Go for the HTTP/API service and Vue 3, TypeScript, Vit Requirements: Go 1.24+ and Node.js 20.19+. +从仓库根目录执行 `node build.mjs`,自动安装前端依赖、构建页面、编译程序并生成 `release` 下的便携压缩包。普通用户解压包后直接运行程序,无需安装开发环境。以下命令用于手动开发调试。 + ```powershell cd web -npm install -npm run dev +npm ci +npm run build +cd .. +$env:EASYBOT_CONFIG = Join-Path (Get-Location) 'appsettings.json' +go run ./cmd/easybot ``` -In another terminal: +Open `http://127.0.0.1:5001`. The Go process serves both the API and the built UI from `web/dist`. -```powershell -$env:PATH = "C:\Program Files\Go\bin;$env:PATH" -go run ./cmd/easybot +正常运行只使用 WebUI/API 5001 和 Bridge 26991 两个端口。前端执行 `npm run build` 后由 Go 直接提供页面,不启动 Vite。需要热更新时才在 `web` 目录另开终端执行 `npm run dev`,它将 `/api` 代理到 5001,其临时端口不是产品部署端口。Bridge 当前仅保留端口约定,尚无监听器、握手或协议兼容实现。不得把 WebUI/API 或 OneBot 适配器端口当作 Bridge 端口。 + +### Windows executable + +发布便携包包含 `easybotce.exe`、`appsettings.json`、`web/dist`、许可证和使用说明。解压到可写目录后双击 exe,浏览器打开配置中的地址,默认 `http://127.0.0.1:5001`,保持控制台窗口打开。保留包内目录结构,数据库自动创建在程序旁的 `appdata` 中;不需要额外启动脚本。开发构建 `dev/bin/easybotce.exe` 默认读取 `dev/appsettings.json`;exe 旁已有配置时优先读取。 + +## appsettings.json + +可直接复制原版配置到 exe 旁;读取后不会重写,CE 默认值只补充缺失的配置项。支持原版的 `Kestrel.Endpoints`(一个 HTTP/HTTPS 端点)、`ServerOptions`、`Serilog.MinimumLevel`(字符串或带 Default 的对象)和 `AllowedHosts`。Bridge 的配置会读取并显示,但当前不建立桥接监听;HeartbeatInterval 保留给桥接运行时。当前配置文件使用标准 JSON,可带 UTF-8 BOM。 + +CE 扩展项:`Database.Url` 设置 MySQL/PostgreSQL DSN,`Database.SQLitePath` 设置 SQLite 文件。JSON 中的相对文件路径基于配置文件目录;环境变量中的相对路径仍基于启动目录。原版配置没有 Database 扩展项时,开发用 bin 布局继续使用父目录的 appdata。登录凭据统一使用 token.txt;旧 Security.ApiKey 和 EASYBOT_API_KEY 不再参与认证。 + +HTTPS 配置示例(PEM 文件,相对路径基于配置文件): + +```json +{ + "Kestrel": { + "Endpoints": { + "web_api": { + "Url": "https://0.0.0.0:5001", + "Certificate": { "Path": "cert.pem", "KeyPath": "key.pem" } + } + } + } +} ``` -The API listens on `http://127.0.0.1:26990`; Vite serves the UI on its displayed local port and proxies `/api` requests to the Go service. The production UI is served by the Go process from `web/dist` after `npm run build`. +原版 token.txt 是 SHA-256 原始 32 字节文件,可直接复制,不能手写明文。CE 优先读取 exe 旁的 token.txt,否则读取配置目录中的同名文件。缺失时仅在非本地模式自动生成 12 位 Token 并在控制台显示一次。更换或删除 Token 文件后重启会清除旧会话;旧 CE 用户名/密码登录由 Token 登录替代。 + +复制原版启动配置不会导入原版数据库、适配器和插件数据。升级便携包时请保留自己的 appsettings.json、token.txt 与 appdata,不要用包内模板覆盖。 ## Database and access -- Default: `EASYBOT_SQLITE_PATH=appdata/easybot.db` +- 默认数据库由 appsettings.json 的 Database.SQLitePath 指定为 `appdata/easybot.db`。位置不依赖前端资源是否存在或启动时的工作目录。 +- `EASYBOT_SQLITE_PATH` 可覆盖 JSON 中的路径,相对路径按启动目录解析。`go run` 使用临时可执行文件,因此按上面的命令指定开发配置文件;已有 CE 数据库应设置为其实际路径继续使用。 - MySQL: `EASYBOT_DATABASE=mysql://user:password@host:3306/database` - PostgreSQL: `EASYBOT_DATABASE=postgres://user:password@host:5432/database?sslmode=disable` - Database CI is configured for MySQL 8.4.11 (LTS), MySQL 9.6.0 (Innovation), and PostgreSQL 18.6. These versions are validation targets, not a support claim until the corresponding CI runs pass. -- Listen address: `EASYBOT_ADDR=127.0.0.1:26990` -- Loopback binds may omit a key. Non-loopback binds require `EASYBOT_API_KEY`, `EASYBOT_TLS_CERT`, and `EASYBOT_TLS_KEY`; use a reverse proxy that forwards to loopback if TLS terminates elsewhere. Health checks and static UI files remain public. -- On a fresh database, open the console from the same machine and create the initial administrator. The one-time setup endpoint only accepts loopback connections. Passwords are bcrypt-hashed; browser sessions use HttpOnly, SameSite=Strict cookies and expire after 12 hours. Management APIs require a valid session or the explicitly configured `EASYBOT_API_KEY` bearer token. +- Listen address: `EASYBOT_ADDR=127.0.0.1:5001` +- 本地与非本地监听均可使用 HTTP,不需要独立 API Key。非本地管理 API 通过 Token 登录后获得的会话 Cookie 访问。选择 HTTPS 时必须提供有效证书与私钥;可以使用反向代理终止 TLS。公网传输凭据建议使用 HTTPS。健康检查和静态页面仍公开。 +- Windows 双击启动失败时保留独立控制台,显示错误并等待回车关闭。已有终端或无控制台任务不暂停,返回非零退出码;配置错误、数据库错误和端口占用都经过同一失败处理。 +- 认证模式按实际监听地址决定:仅绑定回环地址(127.0.0.0/8、::1 等)时,本机直接进入管理台,无需注册或登录;Host、Origin 和连接来源仍需匹配本地访问。绑定非本地地址时,即使从本机访问也需要登录。 +- Token 文件保存 SHA-256 哈希,不保存明文;自动生成的 Token 重启不变,首次凭据输出不受日志级别过滤。浏览器会话使用 HttpOnly、SameSite=Strict Cookie,有效期 7 天。注销和更换 Token 都会使对应旧会话失效。 - Login is rate-limited to 10 attempts per remote IP in each 5-minute window. The API never returns password hashes or session tokens in JSON responses. ## Current implementation The dashboard and persistent management API cover adapter configurations, server/player/account/group records, allow/deny lists, command definitions, AI provider settings, image-template and JavaScript-plugin catalogs, and instance settings. CRUD data is stored in one typed record table behind versioned startup migrations; adapters are exposed at the compatibility routes `/api/adapter/*`. The first active adapter runtime is OneBot 11 over forward WebSocket, with Bearer authentication, identity handshake, group/private message event normalization, send/group-list calls, lifecycle controls, status and capabilities APIs, and bounded reconnect backoff. Enabled OneBot 11 configurations are restored at startup. Adapter credentials are omitted from adapter API responses and blank edits preserve a saved token. Runtime events are kept in a bounded in-memory tail and written through a bounded asynchronous queue to the `runtime_logs` table. The authenticated `/api/logs` endpoint supports level, component, UTC time range, keyword, and offset pagination filters; `/api/logs/status` reports the current retention policy and write failure counters. The log page controls persistent storage, retention days, and the maximum row count; cleanup runs hourly. Log messages redact common token, password, DSN, and verification-code forms; message bodies are not included by default. CRUD configuration does not imply the corresponding runtime is installed. -Real OneBot 11 account verification, Milky, Telegram and OpenQQ runtimes, game-server Bridge plugins, message forwarding and account-binding workflows, JavaScript plugin execution, AI chat execution, browser-based image rendering/template marketplace, PlaceholderAPI/statistics evaluation, and platform installers/Docker packaging remain outstanding. The API and UI expose only the implemented OneBot 11 adapter; unsupported platform types are rejected instead of being saved as runnable configurations. The OneBot protocol simulator covers authentication, identity handshake, event receipt, sending, and reconnect; no real test bot is configured, and platform rate-limit behavior has not been validated. The original NativeAOT executable cannot be reliably reconstructed from the available decompilation; `../decompiled` is used as behavioral reference for its WebUI API and module names. +Real OneBot 11 account verification, Milky, Telegram and OpenQQ runtimes, game-server Bridge plugins, message forwarding and account-binding workflows, JavaScript plugin execution, AI chat execution, browser-based image rendering/template marketplace, PlaceholderAPI/statistics evaluation, and platform installers/Docker packaging remain outstanding. The API and UI expose only the implemented OneBot 11 adapter; unsupported platform types are rejected instead of being saved as runnable configurations. The OneBot protocol simulator covers authentication, identity handshake, event receipt, sending, and reconnect; no real test bot is configured, and platform rate-limit behavior has not been validated. The original NativeAOT executable cannot be reliably reconstructed from the available decompilation; the separate EasyBot-Recovered repository is used as behavioral reference for its WebUI API and module names. ## Verify diff --git a/dev/cmd/easybot/console.go b/dev/cmd/easybot/console.go new file mode 100644 index 0000000..98ca151 --- /dev/null +++ b/dev/cmd/easybot/console.go @@ -0,0 +1,124 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net" + "os" + "strings" + "sync" +) + +type consoleHandler struct { + minimum slog.Level + mu *sync.Mutex + attrs []slog.Attr + group string +} + +var useConsoleColors = os.Getenv("NO_COLOR") == "" && enableConsoleColors() + +func consoleLogger(levels ...slog.Level) *slog.Logger { + minimum := slog.LevelInfo + if len(levels) != 0 { + minimum = levels[0] + } + return slog.New(&consoleHandler{mu: &sync.Mutex{}, minimum: minimum}) +} + +func (h *consoleHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.minimum +} + +func (h *consoleHandler) Handle(_ context.Context, record slog.Record) error { + var line strings.Builder + level, color := "INFO", "97" + switch { + case record.Level >= slog.LevelError+4: + level, color = "FATL", "97;41" + case record.Level >= slog.LevelError: + level, color = "EROR", "97;41" + case record.Level >= slog.LevelWarn: + level, color = "WARN", "93" + case record.Level < slog.LevelInfo: + level, color = "DBUG", "37" + } + fmt.Fprint(&line, consoleColor("[", "90"), consoleColor(record.Time.Format("15:04:05"), "37"), consoleColor("][", "90"), consoleColor(level, color), consoleColor("]: ", "90"), consoleColor(record.Message, "97")) + appendAttr := func(attr slog.Attr) bool { + // 原版模板仅显示消息和异常,不重复追加结构化属性。 + if attr.Key == "error" { + fmt.Fprint(&line, "\n", consoleColor(attr.Value.Resolve().String(), "97;41")) + } + return true + } + for _, attr := range h.attrs { + appendAttr(attr) + } + record.Attrs(appendAttr) + h.mu.Lock() + defer h.mu.Unlock() + _, err := fmt.Fprintln(os.Stdout, line.String()) + return err +} + +func (h *consoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + next := *h + next.attrs = append(append([]slog.Attr(nil), h.attrs...), attrs...) + return &next +} + +func (h *consoleHandler) WithGroup(name string) slog.Handler { + next := *h + if name != "" { + next.group += name + "." + } + return &next +} + +func consoleColor(text, color string) string { + if !useConsoleColors { + return text + } + return "\x1b[" + color + "m" + text + "\x1b[0m" +} + +func printBanner(logger *slog.Logger) { + // 使用与原版 FigletText 相同的字形,保留 CE 的产品名称。 + logo := " _____ _ _ ____ _____ \n" + + " | ____| __ _ ___ _ _ | |__ ___ | |_ / ___| | ____|\n" + + " | _| / _` | / __| | | | | | '_ \\ / _ \\ | __| | | | _| \n" + + " | |___ | (_| | \\__ \\ | |_| | | |_) | | (_) | | |_ | |___ | |___ \n" + + " |_____| \\__,_| |___/ \\__, | |_.__/ \\___/ \\__| \\____| |_____|\n" + + " |___/ " + fmt.Fprintln(os.Stdout, consoleColor(logo, "96")) + logger.Info("EasybotCE v0.1.0-dev") +} + +func printStartup(logger *slog.Logger, address, boundAddress string, tls, localOnly bool, token string) { + host, _, _ := net.SplitHostPort(address) + _, port, _ := net.SplitHostPort(boundAddress) + if host == "" || host == "0.0.0.0" || host == "::" { + host = "127.0.0.1" + } + scheme := "http" + if tls { + scheme = "https" + } + logger.Info("[WebUI] 管理地址: " + scheme + "://" + net.JoinHostPort(host, port)) + logger.Info("[WebUI] 监听地址: " + boundAddress) + if localOnly { + logger.Info("[WebUI] 本机免登录,直接进入管理台") + } else { + logger.Info("[WebUI] 已启用 Token 登录") + if token != "" { + // 首次凭据必须可见,不受用户配置的日志级别过滤。 + credentials := consoleLogger() + credentials.Info("[WebUI] 登录 Token: " + token) + credentials.Warn("[WebUI] 登录 Token 仅显示一次,请妥善保存") + } else { + logger.Info("[WebUI] 使用 token.txt 对应的登录 Token") + } + } + logger.Info("[WebUI] 服务已启动,按 Ctrl+C 停止") +} diff --git a/dev/cmd/easybot/console_other.go b/dev/cmd/easybot/console_other.go new file mode 100644 index 0000000..321e4b9 --- /dev/null +++ b/dev/cmd/easybot/console_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package main + +import "os" + +func enableConsoleColors() bool { + info, err := os.Stdout.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 && os.Getenv("TERM") != "dumb" +} + +func waitAfterFailure() {} diff --git a/dev/cmd/easybot/console_windows.go b/dev/cmd/easybot/console_windows.go new file mode 100644 index 0000000..08fe883 --- /dev/null +++ b/dev/cmd/easybot/console_windows.go @@ -0,0 +1,38 @@ +//go:build windows + +package main + +import ( + "bufio" + "fmt" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +func enableConsoleColors() bool { + handle := windows.Handle(os.Stdout.Fd()) + var mode uint32 + if windows.GetConsoleMode(handle, &mode) != nil { + return false + } + return windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) == nil +} + +func waitAfterFailure() { + var processes [2]uint32 + getProcesses := windows.NewLazySystemDLL("kernel32.dll").NewProc("GetConsoleProcessList") + count, _, _ := getProcesses.Call(uintptr(unsafe.Pointer(&processes[0])), uintptr(len(processes))) + // Explorer 双击创建的独立控制台只有当前程序;已有终端和无控制台任务直接返回错误码。 + if count != 1 { + return + } + input, err := os.OpenFile("CONIN$", os.O_RDONLY, 0) + if err != nil { + return + } + defer input.Close() + fmt.Print("\n启动失败,错误原因见上方。按回车键关闭窗口...") + _, _ = bufio.NewReader(input).ReadString('\n') +} diff --git a/dev/cmd/easybot/main.go b/dev/cmd/easybot/main.go index e559378..0c0fb3f 100644 --- a/dev/cmd/easybot/main.go +++ b/dev/cmd/easybot/main.go @@ -2,14 +2,18 @@ package main import ( "context" + "fmt" "log/slog" + "net" "net/http" "os" "os/signal" + "path/filepath" "syscall" "time" "easybot/internal/adapter/onebot11" + "easybot/internal/auth" "easybot/internal/config" "easybot/internal/runtime" "easybot/internal/store" @@ -17,61 +21,166 @@ import ( ) func main() { - logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - db, err := store.Open(os.Getenv("EASYBOT_DATABASE")) + code := run() + if code != 0 { + waitAfterFailure() + os.Exit(code) + } +} + +func run() (code int) { + defer func() { + if recovered := recover(); recovered != nil { + consoleLogger().Error("程序异常退出", "error", fmt.Errorf("%v", recovered)) + code = 1 + } + }() + logger := consoleLogger() + started := time.Now() + settings, created, err := config.Load() + if err != nil { + consoleLogger().Error("启动配置读取失败", "error", err) + return 1 + } + logger = consoleLogger(settings.LogLevel) + printBanner(logger) + if created { + logger.Info("[配置] 检测到首次启动,已生成默认配置文件: " + settings.Path) + } else { + logger.Info("[配置] 已加载配置文件: " + settings.Path) + } + for name, value := range map[string]string{ + "EASYBOT_ADDR": settings.Address, + "EASYBOT_SQLITE_PATH": settings.SQLitePath, + "EASYBOT_ALLOWED_HOSTS": settings.AllowedHosts, + } { + if err := os.Setenv(name, value); err != nil { + consoleLogger().Error("配置应用失败", "error", err) + return 1 + } + } + logger.Info("[数据库] 正在初始化数据库...") + db, err := store.Open(settings.Database) if err != nil { - logger.Error("database initialization failed", "error", err) - os.Exit(1) + consoleLogger().Error("数据库初始化失败", "error", err) + return 1 } defer db.Close() + logger.Info("[数据库] 初始化完成,已连接 " + db.Dialect()) - address := os.Getenv("EASYBOT_ADDR") - if address == "" { - address = "127.0.0.1:26990" + address := settings.Address + cert, key := settings.Certificate, settings.PrivateKey + if err := config.ValidateListener(address, cert, key); err != nil { + consoleLogger().Error("监听配置无效: "+address, "error", err) + return 1 } - cert, key := os.Getenv("EASYBOT_TLS_CERT"), os.Getenv("EASYBOT_TLS_KEY") - if err := config.ValidateListener(address, os.Getenv("EASYBOT_API_KEY"), cert, key); err != nil { - logger.Error("listener configuration rejected", "address", address, "error", err) - os.Exit(1) + listener, err := net.Listen("tcp", address) + if err != nil { + consoleLogger().Error("服务监听失败", "address", address, "error", err) + return 1 + } + defer listener.Close() + localOnly := config.IsLoopbackListener(listener.Addr().String()) + logger.Info("[认证] 正在初始化访问控制...") + tokenPath := filepath.Join(filepath.Dir(settings.Path), "token.txt") + if executable, err := os.Executable(); err == nil { + adjacent := filepath.Join(filepath.Dir(executable), "token.txt") + if _, err := os.Stat(adjacent); err == nil { + tokenPath = adjacent + } + } + loginToken, initialToken, err := auth.Load(tokenPath, !localOnly) + if err != nil { + consoleLogger().Error("登录 Token 读取失败", "error", err) + return 1 + } + if loginToken != nil { + identity, err := db.EnsureTokenUser(context.Background(), loginToken.Fingerprint()) + if err != nil { + consoleLogger().Error("登录会话初始化失败", "error", err) + return 1 + } + loginToken.UserID = identity.ID } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + logger.Info("[认证] 访问控制初始化完成") defer stop() + logger.Info("[日志] 正在初始化运行日志...") logs := runtime.NewLogBuffer(500) logSettings, err := db.GetLogSettings(context.Background()) if err != nil { - logger.Error("log settings initialization failed", "error", err) - os.Exit(1) + consoleLogger().Error("日志设置初始化失败", "error", err) + return 1 } writer := runtime.NewAsyncLogWriter(db, 2048) writer.SetEnabled(logSettings.Persistent) logs.SetSink(writer.TryWrite) + if logSettings.Persistent { + logger.Info(fmt.Sprintf("[日志] 持久化已启用,保留 %d 天,最多 %d 条", logSettings.RetentionDays, logSettings.MaxRows)) + } else { + logger.Info("[日志] 持久化已关闭,使用内存日志") + } defer func() { flushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() writer.Close(flushCtx) }() - logs.Add("info", "runtime", "EasyBot API started") + logs.Add("info", "runtime", "EasybotCE 正在初始化运行模块") go maintainLogs(db, logger) + logger.Info("[StartupAdapterService] 已启用适配器掉线自动重连") + logger.Info("[AdapterManager] 正在初始化适配器管理器...") + adapterConfigs, listErr := db.List(ctx, "adapters") + if listErr != nil { + logger.Error("[AdapterManager] 读取适配器配置失败", "error", listErr) + } else { + logger.Info(fmt.Sprintf("[AdapterManager] 发现 %d 个适配器配置", len(adapterConfigs))) + } adapters := onebot11.NewManager(ctx, db, func(id string, event onebot11.Event) { logs.Add("info", "adapter.onebot11", "OneBot event received: "+event.Type+" (adapter "+id+")") }) if err := adapters.StartEnabled(ctx); err != nil { - logger.Error("adapter restoration failed", "error", err) + logger.Error("[AdapterManager] 适配器初始化失败", "error", err) + } else { + for _, item := range adapterConfigs { + if !item.Enabled { + logger.Info(fmt.Sprintf("[AdapterManager] 适配器 %q 未启用,跳过加载", item.Name)) + continue + } + state, err := adapters.Status(item.ID) + if err != nil { + logger.Warn(fmt.Sprintf("[AdapterManager] 适配器 %q 状态读取失败", item.Name), "error", err) + } else if state.State == onebot11.StateFailed { + logger.Warn(fmt.Sprintf("[AdapterManager] 适配器 %q 加载失败: %s", item.Name, runtime.Redact(state.LastError))) + } else if state.State == onebot11.StateStopped { + logger.Warn(fmt.Sprintf("[AdapterManager] 适配器 %q 未加载,当前仅支持 OneBot 11", item.Name)) + } else { + logger.Info(fmt.Sprintf("[AdapterManager] 已加载适配器 %q,连接状态以运行时结果为准", item.Name)) + } + } + logger.Info(fmt.Sprintf("[AdapterManager] 初始化完成,已加载 %d 个适配器实例", adapters.RuntimeCount())) } + logger.Warn("[桥接] 模块尚未实现,未监听配置地址 " + settings.BridgeAddress) + logger.Info("[插件] 脚本执行模块尚未实现,跳过加载") + logger.Info("[图片模板] 浏览器渲染模块尚未实现,跳过加载") + logger.Info("[AI] 对话执行模块尚未实现,跳过加载") + logger.Info("[WebUI] 正在初始化管理服务...") server := &http.Server{ Addr: address, - Handler: web.NewWithAdapterManager(db, logger, logs, adapters, writer), + Handler: web.NewForListener(db, logger, logs, adapters, listener.Addr().String(), loginToken, writer), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second, } - logger.Info("EasyBot web service listening", "address", address, "database", db.Dialect()) + logger.Info("[WebUI] 管理路由初始化完成") + printStartup(logger, address, listener.Addr().String(), cert != "", localOnly, initialToken) + logger.Info(fmt.Sprintf("[启动] 初始化完成,耗时 %d ms", time.Since(started).Milliseconds())) + logs.Add("info", "runtime", "EasybotCE 管理服务初始化完成") serveErr := make(chan error, 1) go func() { if cert != "" { - serveErr <- server.ListenAndServeTLS(cert, key) + serveErr <- server.ServeTLS(listener, cert, key) return } - serveErr <- server.ListenAndServe() + serveErr <- server.Serve(listener) }() select { case <-ctx.Done(): @@ -79,15 +188,16 @@ func main() { defer cancel() adapters.Shutdown(shutdownCtx) if err := server.Shutdown(shutdownCtx); err != nil { - logger.Error("graceful shutdown failed", "error", err) + logger.Error("服务关闭失败", "error", err) } logs.Add("info", "runtime", "EasyBot API stopped") case err := <-serveErr: if err != nil && err != http.ErrServerClosed { - logger.Error("http server stopped", "error", err) - os.Exit(1) + consoleLogger().Error("管理服务已停止", "error", err) + return 1 } } + return 0 } func maintainLogs(db *store.Store, logger *slog.Logger) { @@ -98,17 +208,17 @@ func maintainLogs(db *store.Store, logger *slog.Logger) { settings, err := db.GetLogSettings(ctx) if err != nil { cancel() - logger.Error("log settings read failed", "error", err) + logger.Error("日志设置读取失败", "error", err) continue } removed, err := db.CleanupLogs(ctx, time.Now().UTC().AddDate(0, 0, -settings.RetentionDays), settings.MaxRows) cancel() if err != nil { - logger.Error("log retention cleanup failed", "error", err) + logger.Error("历史日志清理失败", "error", err) continue } if removed > 0 { - logger.Info("expired runtime logs removed", "count", removed) + logger.Info(fmt.Sprintf("已清理 %d 条过期日志", removed)) } } } diff --git a/dev/go.mod b/dev/go.mod index 36d25fd..f321505 100644 --- a/dev/go.mod +++ b/dev/go.mod @@ -8,6 +8,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.7.6 golang.org/x/crypto v0.37.0 + golang.org/x/sys v0.36.0 modernc.org/sqlite v1.39.1 ) @@ -22,7 +23,6 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.24.0 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/dev/internal/auth/token.go b/dev/internal/auth/token.go new file mode 100644 index 0000000..d08558e --- /dev/null +++ b/dev/internal/auth/token.go @@ -0,0 +1,62 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "os" +) + +// Token 使用原版 token.txt 的原始 SHA-256 字节格式,不保存明文。 +type Token struct { + hash [sha256.Size]byte + UserID string +} + +func Load(path string, generate bool) (*Token, string, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + if !generate { + return nil, "", nil + } + plain := rand.Text()[:12] + hash := sha256.Sum256([]byte(plain)) + file, createErr := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if os.IsExist(createErr) { + return Load(path, false) + } + if createErr != nil { + return nil, "", createErr + } + _, writeErr := file.Write(hash[:]) + closeErr := file.Close() + if writeErr != nil { + return nil, "", writeErr + } + if closeErr != nil { + return nil, "", closeErr + } + return &Token{hash: hash}, plain, nil + } + if err != nil { + return nil, "", err + } + if len(data) != sha256.Size { + return nil, "", fmt.Errorf("token.txt 必须是原版格式的 32 字节 SHA-256 文件,不能填写明文;可停止程序并删除该文件后重新生成") + } + var hash [sha256.Size]byte + copy(hash[:], data) + return &Token{hash: hash}, "", nil +} + +func (t *Token) Valid(plain string) bool { + if t == nil || plain == "" || len(plain) > 4096 { + return false + } + hash := sha256.Sum256([]byte(plain)) + return subtle.ConstantTimeCompare(hash[:], t.hash[:]) == 1 +} + +func (t *Token) Fingerprint() string { return hex.EncodeToString(t.hash[:]) } diff --git a/dev/internal/config/appsettings.example.json b/dev/internal/config/appsettings.example.json new file mode 100644 index 0000000..89d7a23 --- /dev/null +++ b/dev/internal/config/appsettings.example.json @@ -0,0 +1,24 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Information" + } + }, + "AllowedHosts": "*", + "ServerOptions": { + "Host": "127.0.0.1", + "Port": 26991, + "HeartbeatInterval": "0.00:02:00" + }, + "Kestrel": { + "Endpoints": { + "web_api": { + "Url": "http://127.0.0.1:5001" + } + } + }, + "Database": { + "Url": "", + "SQLitePath": "appdata/easybot.db" + } +} diff --git a/dev/internal/config/listen.go b/dev/internal/config/listen.go index a8feb60..ef54f56 100644 --- a/dev/internal/config/listen.go +++ b/dev/internal/config/listen.go @@ -6,23 +6,22 @@ import ( "strings" ) -func ValidateListener(address, apiKey, certFile, keyFile string) error { +func IsLoopbackListener(address string) bool { host, _, err := net.SplitHostPort(address) if err != nil { - return fmt.Errorf("invalid listen address: %w", err) - } - if (certFile == "") != (keyFile == "") { - return fmt.Errorf("EASYBOT_TLS_CERT and EASYBOT_TLS_KEY must be configured together") + return false } ip := net.ParseIP(strings.Trim(host, "[]")) - loopback := host == "localhost" || (ip != nil && ip.IsLoopback()) - if !loopback { - if apiKey == "" { - return fmt.Errorf("EASYBOT_API_KEY is required when listening beyond loopback") - } - if certFile == "" { - return fmt.Errorf("EASYBOT_TLS_CERT and EASYBOT_TLS_KEY are required when listening beyond loopback") - } + return strings.EqualFold(strings.TrimSuffix(host, "."), "localhost") || (ip != nil && ip.IsLoopback()) +} + +func ValidateListener(address, certFile, keyFile string) error { + _, _, err := net.SplitHostPort(address) + if err != nil { + return fmt.Errorf("监听地址无效: %w", err) + } + if (certFile == "") != (keyFile == "") { + return fmt.Errorf("TLS 证书和私钥必须成对配置;使用 HTTP 时可同时留空") } return nil } diff --git a/dev/internal/config/listen_test.go b/dev/internal/config/listen_test.go index a16b10f..355708e 100644 --- a/dev/internal/config/listen_test.go +++ b/dev/internal/config/listen_test.go @@ -1,27 +1,95 @@ package config -import "testing" +import ( + "log/slog" + "os" + "path/filepath" + "testing" +) + +func TestSettingsFileCompatibilityAndOverrides(t *testing.T) { + path := filepath.Join(t.TempDir(), "appsettings.json") + settings, created, err := LoadFile(path) + if err != nil || !created || settings.Address != "127.0.0.1:5001" || settings.BridgeAddress != "127.0.0.1:26991" { + t.Fatalf("default settings: %#v, created %v, error %v", settings, created, err) + } + legacy := []byte("\xef\xbb\xbf" + `{"Serilog":{"MinimumLevel":{"Default":"Warning"}},"AllowedHosts":"localhost;*.example.test","ServerOptions":{"Host":"127.0.0.1","Port":26990,"HeartbeatInterval":"0.00:02:00"},"Kestrel":{"Endpoints":{"web_api":{"Url":"http://localhost:5000"}}}}`) + if err := os.WriteFile(path, legacy, 0600); err != nil { + t.Fatal(err) + } + settings, created, err = LoadFile(path) + if err != nil || created || settings.Address != "localhost:5000" || settings.BridgeAddress != "127.0.0.1:26990" || settings.LogLevel != slog.LevelWarn { + t.Fatalf("legacy settings: %#v, created %v, error %v", settings, created, err) + } + if settings.SQLitePath != filepath.Join(filepath.Dir(path), "appdata", "easybot.db") { + t.Fatalf("database path: %s", settings.SQLitePath) + } + after, err := os.ReadFile(path) + if err != nil || string(after) != string(legacy) { + t.Fatal("existing configuration was rewritten") + } + t.Setenv("EASYBOT_ADDR", "127.0.0.2:5003") + t.Setenv("EASYBOT_SQLITE_PATH", "custom.db") + settings, _, err = LoadFile(path) + if err != nil || settings.Address != "127.0.0.2:5003" || settings.SQLitePath != "custom.db" { + t.Fatalf("environment overrides: %#v, %v", settings, err) + } +} + +func TestSettingsRejectInvalidEndpoints(t *testing.T) { + for _, content := range []string{ + `{"Kestrel":{"Endpoints":{}}}`, + `{"Kestrel":{"Endpoints":{"a":{"Url":"http://localhost:5001"},"b":{"Url":"http://localhost:5002"}}}}`, + `{"Kestrel":{"Endpoints":{"web_api":{"Url":"file:///tmp/server"}}}}`, + `{"Kestrel":{"Endpoints":{"web_api":{"Url":"https://localhost:5001"}}}}`, + `{"ServerOptions":{"Port":70000}}`, + } { + path := filepath.Join(t.TempDir(), "appsettings.json") + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + if _, _, err := LoadFile(path); err == nil { + t.Fatalf("accepted invalid configuration: %s", content) + } + } +} + +func TestAllowedHost(t *testing.T) { + for _, test := range []struct { + host, patterns string + allowed bool + }{ + {"localhost:5001", "localhost;127.0.0.1", true}, + {"[::1]:5001", "[::1]", true}, + {"app.example.test:5001", "*.example.test", true}, + {"example.test.evil:5001", "*.example.test", false}, + {"evil.test:5001", "localhost", false}, + } { + if actual := AllowedHost(test.host, test.patterns); actual != test.allowed { + t.Errorf("AllowedHost(%q, %q) = %v", test.host, test.patterns, actual) + } + } +} func TestValidateListener(t *testing.T) { tests := []struct { name string address string - key string cert string tlsKey string wantErr bool }{ - {name: "default loopback", address: "127.0.0.1:26990"}, - {name: "loopback TLS pair", address: "[::1]:26990", cert: "cert.pem", tlsKey: "key.pem"}, - {name: "external requires key", address: "0.0.0.0:26990", wantErr: true}, - {name: "external requires TLS", address: "0.0.0.0:26990", key: "secret", wantErr: true}, - {name: "external configured", address: "0.0.0.0:26990", key: "secret", cert: "cert.pem", tlsKey: "key.pem"}, - {name: "partial TLS pair", address: "127.0.0.1:26990", cert: "cert.pem", wantErr: true}, + {name: "default loopback", address: "127.0.0.1:5001"}, + {name: "loopback TLS pair", address: "[::1]:5001", cert: "cert.pem", tlsKey: "key.pem"}, + {name: "external HTTP without API key or TLS", address: "0.0.0.0:5001"}, + {name: "LAN HTTP", address: "192.0.2.1:5001"}, + {name: "external HTTPS", address: "0.0.0.0:5001", cert: "cert.pem", tlsKey: "key.pem"}, + {name: "partial TLS pair", address: "127.0.0.1:5001", cert: "cert.pem", wantErr: true}, {name: "invalid address", address: "localhost", wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := ValidateListener(tt.address, tt.key, tt.cert, tt.tlsKey) + err := ValidateListener(tt.address, tt.cert, tt.tlsKey) if (err != nil) != tt.wantErr { t.Fatalf("ValidateListener() error = %v, wantErr %v", err, tt.wantErr) } diff --git a/dev/internal/config/settings.go b/dev/internal/config/settings.go new file mode 100644 index 0000000..86f8dd1 --- /dev/null +++ b/dev/internal/config/settings.go @@ -0,0 +1,222 @@ +package config + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "log/slog" + "net" + "net/url" + "os" + "path/filepath" + "strings" +) + +//go:embed appsettings.example.json +var defaultSettings []byte + +type settingsFile struct { + Serilog struct{ MinimumLevel json.RawMessage } + AllowedHosts string + ServerOptions struct { + Host string + Port int + HeartbeatInterval string + } + Kestrel struct { + Endpoints map[string]struct { + URL string + Certificate struct{ Path, KeyPath, Password string } + } + } + Database struct{ URL, SQLitePath string } +} + +type Settings struct { + Path, Address, Certificate, PrivateKey, Database, SQLitePath, AllowedHosts string + BridgeAddress string + LogLevel slog.Level +} + +func BaseDirectory() (string, error) { + executable, err := os.Executable() + if err != nil { + return "", err + } + dir := filepath.Dir(executable) + if filepath.Base(dir) == "bin" { + dir = filepath.Dir(dir) + } + return dir, nil +} + +func Load() (Settings, bool, error) { + path := os.Getenv("EASYBOT_CONFIG") + if path == "" { + dir, err := BaseDirectory() + if err != nil { + return Settings{}, false, err + } + path = filepath.Join(dir, "appsettings.json") + executable, err := os.Executable() + if err != nil { + return Settings{}, false, err + } + adjacent := filepath.Join(filepath.Dir(executable), "appsettings.json") + if _, err := os.Stat(adjacent); err == nil { + path = adjacent + } else if !os.IsNotExist(err) { + return Settings{}, false, err + } + } + return LoadFile(path) +} + +func LoadFile(path string) (Settings, bool, error) { + path, err := filepath.Abs(path) + if err != nil { + return Settings{}, false, err + } + created := false + if file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600); err == nil { + _, writeErr := file.Write(defaultSettings) + closeErr := file.Close() + if writeErr != nil { + return Settings{}, false, writeErr + } + if closeErr != nil { + return Settings{}, false, closeErr + } + created = true + } else if !os.IsExist(err) { + return Settings{}, false, err + } + data, err := os.ReadFile(path) + if err != nil { + return Settings{}, created, err + } + data = bytes.TrimPrefix(data, []byte{0xef, 0xbb, 0xbf}) + var overrides struct { + Kestrel struct{ Endpoints json.RawMessage } + Database struct{ SQLitePath *string } + } + if err := json.Unmarshal(data, &overrides); err != nil { + return Settings{}, created, fmt.Errorf("appsettings.json 格式错误: %w", err) + } + var file settingsFile + if err := json.Unmarshal(defaultSettings, &file); err != nil { + return Settings{}, created, err + } + // 旧配置没有 CE 扩展项时使用默认值;已有文件始终只读,不重写。 + // Endpoints 必须整体替换,不能与默认 web_api 合并出第二个监听器。 + if len(overrides.Kestrel.Endpoints) != 0 { + file.Kestrel.Endpoints = nil + } + if err := json.Unmarshal(data, &file); err != nil { + return Settings{}, created, fmt.Errorf("appsettings.json 格式错误: %w", err) + } + if len(file.Kestrel.Endpoints) != 1 { + return Settings{}, created, fmt.Errorf("Kestrel.Endpoints 必须配置一个 WebUI 端点") + } + settings := Settings{Path: path, AllowedHosts: file.AllowedHosts} + base := filepath.Dir(path) + resolve := func(value string) string { + if value == "" || filepath.IsAbs(value) { + return value + } + return filepath.Join(base, value) + } + var https bool + for _, endpoint := range file.Kestrel.Endpoints { + parsed, err := url.Parse(endpoint.URL) + if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { + return Settings{}, created, fmt.Errorf("Kestrel 端点 Url 必须是 http:// 或 https:// 监听地址") + } + host, port := parsed.Hostname(), parsed.Port() + if host == "*" || host == "+" { + host = "0.0.0.0" + } + if port == "" { + if parsed.Scheme == "https" { + port = "443" + } else { + port = "80" + } + } + settings.Address = net.JoinHostPort(host, port) + settings.Certificate, settings.PrivateKey = resolve(endpoint.Certificate.Path), resolve(endpoint.Certificate.KeyPath) + if endpoint.Certificate.Password != "" { + return Settings{}, created, fmt.Errorf("证书请使用 PEM Path 和 KeyPath;不支持带密码的 PFX") + } + https = parsed.Scheme == "https" + } + if file.ServerOptions.Port < 1 || file.ServerOptions.Port > 65535 || file.ServerOptions.Host == "" { + return Settings{}, created, fmt.Errorf("ServerOptions 必须配置有效的 Host 和 1 到 65535 的 Port") + } + settings.BridgeAddress = net.JoinHostPort(file.ServerOptions.Host, fmt.Sprint(file.ServerOptions.Port)) + settings.Database, settings.SQLitePath = file.Database.URL, resolve(file.Database.SQLitePath) + // 将旧配置复制到开发用 bin 旁边时,保持原有 CE 数据目录。 + if overrides.Database.SQLitePath == nil && filepath.Base(base) == "bin" { + settings.SQLitePath = filepath.Join(filepath.Dir(base), "appdata", "easybot.db") + } + for name, target := range map[string]*string{ + "EASYBOT_ADDR": &settings.Address, "EASYBOT_DATABASE": &settings.Database, + "EASYBOT_SQLITE_PATH": &settings.SQLitePath, + "EASYBOT_TLS_CERT": &settings.Certificate, "EASYBOT_TLS_KEY": &settings.PrivateKey, + "EASYBOT_ALLOWED_HOSTS": &settings.AllowedHosts, + } { + if value, ok := os.LookupEnv(name); ok { + *target = value + } + } + if settings.Address == "" { + settings.Address = "127.0.0.1:5001" + } + if settings.SQLitePath == "" { + settings.SQLitePath = resolve("appdata/easybot.db") + } + if https && settings.Certificate == "" { + return Settings{}, created, fmt.Errorf("HTTPS 端点需要 Certificate.Path 和 Certificate.KeyPath,或对应 TLS 环境变量") + } + var minimum string + if err := json.Unmarshal(file.Serilog.MinimumLevel, &minimum); err != nil { + var levels struct{ Default string } + if err := json.Unmarshal(file.Serilog.MinimumLevel, &levels); err != nil { + return Settings{}, created, fmt.Errorf("Serilog.MinimumLevel 格式错误") + } + minimum = levels.Default + } + switch strings.ToLower(minimum) { + case "verbose", "debug": + settings.LogLevel = slog.LevelDebug + case "", "information", "info": + settings.LogLevel = slog.LevelInfo + case "warning", "warn": + settings.LogLevel = slog.LevelWarn + case "error": + settings.LogLevel = slog.LevelError + case "fatal": + settings.LogLevel = slog.LevelError + 4 + default: + return Settings{}, created, fmt.Errorf("不支持的 Serilog 日志级别: %s", minimum) + } + return settings, created, nil +} + +func AllowedHost(host, patterns string) bool { + if parsed, _, err := net.SplitHostPort(host); err == nil { + host = parsed + } + host = strings.ToLower(strings.TrimSuffix(strings.Trim(host, "[]"), ".")) + if patterns == "" { + return true + } + for _, pattern := range strings.Split(strings.ToLower(patterns), ";") { + pattern = strings.Trim(strings.TrimSpace(pattern), "[]") + if pattern == "*" || host == pattern || (strings.HasPrefix(pattern, "*.") && strings.HasSuffix(host, pattern[1:])) { + return true + } + } + return false +} diff --git a/dev/internal/store/store.go b/dev/internal/store/store.go index 67dbc7d..ee33736 100644 --- a/dev/internal/store/store.go +++ b/dev/internal/store/store.go @@ -46,7 +46,16 @@ func Open(dsn string) (*Store, error) { if source == "" { source = os.Getenv("EASYBOT_SQLITE_PATH") if source == "" { - source = "appdata/easybot.db" + executable, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("resolve database directory: %w", err) + } + dir := filepath.Dir(executable) + // 保留开发目录 bin/easybotce 与 appdata 并列的布局。 + if filepath.Base(dir) == "bin" { + dir = filepath.Dir(dir) + } + source = filepath.Join(dir, "appdata", "easybot.db") } if err := os.MkdirAll(filepath.Dir(source), 0750); err != nil { return nil, err @@ -72,6 +81,11 @@ func Open(dsn string) (*Store, error) { } db.SetMaxOpenConns(16) db.SetMaxIdleConns(4) + if dialect == "sqlite" { + // SQLite 只有一个写入者,串行使用连接,避免异步日志与管理写入争抢锁。 + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + } db.SetConnMaxLifetime(30 * time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -405,8 +419,10 @@ func (s *Store) CreateInitialAdmin(ctx context.Context, username, password, toke if _, err = tx.ExecContext(ctx, s.query(`INSERT INTO users(id,username,password_hash,created_at) VALUES(?,?,?,?)`), user.ID, user.Username, string(hash), time.Now().UTC()); err != nil { return Admin{}, err } - if _, err = tx.ExecContext(ctx, s.query(`INSERT INTO sessions(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`), tokenHash, user.ID, time.Now().UTC(), expiresAt.UTC()); err != nil { - return Admin{}, err + if tokenHash != "" { + if _, err = tx.ExecContext(ctx, s.query(`INSERT INTO sessions(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`), tokenHash, user.ID, time.Now().UTC(), expiresAt.UTC()); err != nil { + return Admin{}, err + } } if err := tx.Commit(); err != nil { return Admin{}, err @@ -435,6 +451,44 @@ func (s *Store) CreateSession(ctx context.Context, userID, tokenHash string, exp return err } +// 沿用已有会话表的内部身份;更换 token.txt 时清除旧会话,不改变业务记录。 +func (s *Store) EnsureTokenUser(ctx context.Context, fingerprint string) (Admin, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return Admin{}, err + } + defer tx.Rollback() + var user Admin + var previous string + err = tx.QueryRowContext(ctx, `SELECT id,username,password_hash FROM users ORDER BY created_at,id LIMIT 1`).Scan(&user.ID, &user.Username, &previous) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return Admin{}, err + } + if err == nil && bcrypt.CompareHashAndPassword([]byte(previous), []byte(fingerprint)) == nil { + return user, tx.Commit() + } + hash, err := bcrypt.GenerateFromPassword([]byte(fingerprint), bcrypt.DefaultCost) + if err != nil { + return Admin{}, err + } + if user.ID == "" { + user = Admin{ID: uuid.NewString(), Username: "admin"} + _, err = tx.ExecContext(ctx, s.query(`INSERT INTO users(id,username,password_hash,created_at) VALUES(?,?,?,?)`), user.ID, user.Username, string(hash), time.Now().UTC()) + } else { + _, err = tx.ExecContext(ctx, s.query(`UPDATE users SET password_hash=? WHERE id=?`), string(hash), user.ID) + } + if err != nil { + return Admin{}, err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM sessions`); err != nil { + return Admin{}, err + } + if _, err := tx.ExecContext(ctx, s.query(`UPDATE setup_state SET setup_complete=? WHERE id=1`), true); err != nil { + return Admin{}, err + } + return user, tx.Commit() +} + func (s *Store) SessionAdmin(ctx context.Context, tokenHash string) (Admin, bool, error) { var user Admin err := s.db.QueryRowContext(ctx, s.query(`SELECT users.id,users.username FROM sessions JOIN users ON users.id=sessions.user_id WHERE sessions.token_hash=? AND sessions.revoked_at IS NULL AND sessions.expires_at>?`), tokenHash, time.Now().UTC()).Scan(&user.ID, &user.Username) diff --git a/dev/internal/web/web.go b/dev/internal/web/web.go index e6655cf..857e3b7 100644 --- a/dev/internal/web/web.go +++ b/dev/internal/web/web.go @@ -4,7 +4,6 @@ import ( "context" "crypto/rand" "crypto/sha256" - "crypto/subtle" "database/sql" "encoding/base64" "encoding/hex" @@ -16,6 +15,7 @@ import ( "net/http" "net/url" "os" + "path/filepath" "sort" "strconv" "strings" @@ -23,11 +23,15 @@ import ( "time" "easybot/internal/adapter/onebot11" + "easybot/internal/auth" + "easybot/internal/config" "easybot/internal/runtime" "easybot/internal/store" ) type server struct { + loginToken *auth.Token + localOnly bool store *store.Store logger *slog.Logger logs *runtime.LogBuffer @@ -43,14 +47,20 @@ type loginAttempt struct { } const sessionCookie = "easybot_session" -const sessionLifetime = 12 * time.Hour +const sessionLifetime = 7 * 24 * time.Hour func New(db *store.Store, logger *slog.Logger, logs *runtime.LogBuffer, writers ...*runtime.AsyncLogWriter) http.Handler { return NewWithAdapterManager(db, logger, logs, nil, writers...) } func NewWithAdapterManager(db *store.Store, logger *slog.Logger, logs *runtime.LogBuffer, adapters *onebot11.Manager, writers ...*runtime.AsyncLogWriter) http.Handler { + return NewForListener(db, logger, logs, adapters, "", nil, writers...) +} + +func NewForListener(db *store.Store, logger *slog.Logger, logs *runtime.LogBuffer, adapters *onebot11.Manager, address string, token *auth.Token, writers ...*runtime.AsyncLogWriter) http.Handler { s := &server{store: db, logger: logger, logs: logs, adapterManager: adapters, loginAttempts: make(map[string]loginAttempt)} + s.localOnly = config.IsLoopbackListener(address) + s.loginToken = token if len(writers) > 0 { s.logWriter = writers[0] } @@ -58,7 +68,6 @@ func NewWithAdapterManager(db *store.Store, logger *slog.Logger, logs *runtime.L mux.HandleFunc("GET /api/health", s.health) mux.HandleFunc("GET /api/app/info", s.info) mux.HandleFunc("GET /api/setup/status", s.setupStatus) - mux.HandleFunc("POST /api/setup/admin", s.setupAdmin) mux.HandleFunc("GET /api/auth/session", s.authSession) mux.HandleFunc("POST /api/auth/login", s.login) mux.HandleFunc("POST /api/auth/logout", s.logout) @@ -82,12 +91,34 @@ func NewWithAdapterManager(db *store.Store, logger *slog.Logger, logs *runtime.L mux.HandleFunc("GET /api/logs", s.recentLogs) mux.HandleFunc("GET /api/logs/status", s.logStatus) mux.HandleFunc("PUT /api/logs/settings", s.updateLogSettings) - mux.Handle("/", http.FileServer(http.Dir("web/dist"))) - return s.observe(s.security(s.cors(mux))) + mux.Handle("/", http.FileServer(http.Dir(staticDirectory()))) + handler := s.security(s.cors(mux)) + allowedHosts := os.Getenv("EASYBOT_ALLOWED_HOSTS") + return s.observe(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !config.AllowedHost(r.Host, allowedHosts) { + writeJSON(w, http.StatusBadRequest, map[string]string{"message": "host not allowed"}) + return + } + handler.ServeHTTP(w, r) + })) +} + +func staticDirectory() string { + if executable, err := os.Executable(); err == nil { + dir := filepath.Dir(executable) + for _, base := range []string{dir, filepath.Dir(dir)} { + candidate := filepath.Join(base, "web", "dist") + if info, err := os.Stat(filepath.Join(candidate, "index.html")); err == nil && !info.IsDir() { + return candidate + } + } + } + // go run 的临时可执行文件不与前端资源放在一起。 + return "web/dist" } func (s *server) health(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{"name": "EasyBot", "status": "running", "version": "0.1.0-dev", "database": s.store.Dialect()}) + writeJSON(w, http.StatusOK, map[string]any{"name": "EasybotCE", "status": "running", "version": "0.1.0-dev", "database": s.store.Dialect()}) } func (s *server) info(w http.ResponseWriter, r *http.Request) { @@ -424,7 +455,7 @@ func (s *server) updateAdapter(w http.ResponseWriter, r *http.Request) { if s.adapterManager != nil { if status, statusErr := s.adapterManager.Status(item.ID); statusErr == nil && status.State != onebot11.StateStopped { if restartErr := s.adapterManager.Restart(r.Context(), item.ID); restartErr != nil { - s.logger.Warn("adapter restart after config update failed", "adapter_id", item.ID, "error", restartErr) + s.logger.Warn("适配器 "+item.ID+" 更新配置后重启失败", "error", restartErr) } } } @@ -657,15 +688,15 @@ func writeAdapterError(w http.ResponseWriter, err error) { } func (s *server) security(next http.Handler) http.Handler { - key := os.Getenv("EASYBOT_API_KEY") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.localOnly && requestIsLocal(r) { + next.ServeHTTP(w, r) + return + } if strings.HasPrefix(r.URL.Path, "/api/") && r.Method != http.MethodOptions { switch r.URL.Path { - case "/api/health", "/api/setup/status", "/api/setup/admin", "/api/auth/session", "/api/auth/login": + case "/api/health", "/api/setup/status", "/api/auth/session", "/api/auth/login": default: - if key != "" && secureEqual(r.Header.Get("Authorization"), "Bearer "+key) { - break - } cookie, err := r.Cookie(sessionCookie) if err != nil { writeJSON(w, http.StatusUnauthorized, map[string]string{"message": "authentication required"}) @@ -682,29 +713,8 @@ func (s *server) security(next http.Handler) http.Handler { } func (s *server) setupStatus(w http.ResponseWriter, r *http.Request) { - complete, err := s.store.SetupComplete(r.Context()) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "setup status unavailable"}) - return - } - writeJSON(w, http.StatusOK, map[string]bool{"initialized": complete}) -} - -func (s *server) setupAdmin(w http.ResponseWriter, r *http.Request) { - if !requestIsLocal(r) { - writeJSON(w, http.StatusForbidden, map[string]string{"message": "initial setup is only available from localhost"}) - return - } - var body struct { - Username string `json:"username"` - Password string `json:"password"` - } - if !decode(w, r, &body) { - return - } - body.Username = strings.ToLower(strings.TrimSpace(body.Username)) - if !validUsername(body.Username) || len(body.Password) < 12 || len(body.Password) > 72 { - writeJSON(w, http.StatusBadRequest, map[string]string{"message": "username must be 3-32 characters; password must be 12-72 bytes"}) + if s.localOnly && requestIsLocal(r) { + writeJSON(w, http.StatusOK, map[string]bool{"initialized": true}) return } complete, err := s.store.SetupComplete(r.Context()) @@ -712,83 +722,52 @@ func (s *server) setupAdmin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "setup status unavailable"}) return } - if complete { - writeJSON(w, http.StatusConflict, map[string]string{"message": "initial setup is already complete"}) - return - } - token, err := newSessionToken() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "could not create session"}) - return - } - expires := time.Now().UTC().Add(sessionLifetime) - user, err := s.store.CreateInitialAdmin(r.Context(), body.Username, body.Password, sessionHash(token), expires) - if err != nil { - if strings.Contains(err.Error(), "already complete") { - writeJSON(w, http.StatusConflict, map[string]string{"message": "initial setup is already complete"}) - return - } - writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "could not initialize administrator"}) - return - } - setSessionCookie(w, r, token, expires) - writeJSON(w, http.StatusCreated, map[string]string{"id": user.ID, "username": user.Username}) + writeJSON(w, http.StatusOK, map[string]bool{"initialized": complete}) } func (s *server) authSession(w http.ResponseWriter, r *http.Request) { + if s.localOnly && requestIsLocal(r) { + writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "authRequired": false}) + return + } cookie, err := r.Cookie(sessionCookie) if err == nil { - if user, ok, lookupErr := s.store.SessionAdmin(r.Context(), sessionHash(cookie.Value)); lookupErr == nil && ok { - writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "username": user.Username}) + if _, ok, lookupErr := s.store.SessionAdmin(r.Context(), sessionHash(cookie.Value)); lookupErr == nil && ok { + writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "authRequired": true}) return } } - writeJSON(w, http.StatusOK, map[string]bool{"authenticated": false}) + writeJSON(w, http.StatusOK, map[string]bool{"authenticated": false, "authRequired": true}) } func (s *server) login(w http.ResponseWriter, r *http.Request) { if !s.allowLogin(r.RemoteAddr) { w.Header().Set("Retry-After", "300") - writeJSON(w, http.StatusTooManyRequests, map[string]string{"message": "too many login attempts; try again later"}) + writeJSON(w, http.StatusTooManyRequests, map[string]string{"message": "尝试次数过多,请稍后重试"}) return } var body struct { - Username string `json:"username"` - Password string `json:"password"` + Token string `json:"token"` } if !decode(w, r, &body) { return } - complete, err := s.store.SetupComplete(r.Context()) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "authentication unavailable"}) - return - } - if !complete { - writeJSON(w, http.StatusConflict, map[string]string{"message": "initial setup is required"}) - return - } - user, ok, err := s.store.AuthenticateAdmin(r.Context(), strings.ToLower(strings.TrimSpace(body.Username)), body.Password) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "authentication unavailable"}) - return - } - if !ok { - writeJSON(w, http.StatusUnauthorized, map[string]string{"message": "invalid username or password"}) + if !s.loginToken.Valid(body.Token) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"message": "登录 Token 无效"}) return } token, err := newSessionToken() if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "could not create session"}) + writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "无法创建登录会话"}) return } expires := time.Now().UTC().Add(sessionLifetime) - if err := s.store.CreateSession(r.Context(), user.ID, sessionHash(token), expires); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "could not create session"}) + if err := s.store.CreateSession(r.Context(), s.loginToken.UserID, sessionHash(token), expires); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "无法保存登录会话"}) return } setSessionCookie(w, r, token, expires) - writeJSON(w, http.StatusOK, map[string]string{"id": user.ID, "username": user.Username}) + writeJSON(w, http.StatusOK, map[string]bool{"authenticated": true}) } func (s *server) logout(w http.ResponseWriter, r *http.Request) { @@ -832,18 +811,6 @@ func (s *server) allowLogin(remote string) bool { return true } -func validUsername(username string) bool { - if len(username) < 3 || len(username) > 32 { - return false - } - for _, char := range username { - if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '_' && char != '-' && char != '.' { - return false - } - } - return true -} - func remoteIsLoopback(remote string) bool { host, _, err := net.SplitHostPort(remote) if err != nil { @@ -877,7 +844,7 @@ func hostIsLoopback(value string) bool { if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { host = strings.Trim(host, "[]") } - if strings.EqualFold(host, "localhost") { + if strings.EqualFold(strings.TrimSuffix(host, "."), "localhost") { return true } ip := net.ParseIP(host) @@ -901,10 +868,6 @@ func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expi http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: "/", Expires: expires, MaxAge: int(time.Until(expires).Seconds()), HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteStrictMode}) } -func secureEqual(actual, expected string) bool { - return len(actual) == len(expected) && subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1 -} - func (s *server) cors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") diff --git a/dev/internal/web/web_test.go b/dev/internal/web/web_test.go index 4e0577b..0ad3096 100644 --- a/dev/internal/web/web_test.go +++ b/dev/internal/web/web_test.go @@ -2,26 +2,38 @@ package web import ( "context" + "crypto/sha256" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" + "time" "easybot/internal/adapter/onebot11" + "easybot/internal/auth" "easybot/internal/runtime" "easybot/internal/store" ) -func TestRecordCRUDAndApiKey(t *testing.T) { +func createTestSession(t *testing.T, db *store.Store) { + t.Helper() + if _, err := db.CreateInitialAdmin(context.Background(), "admin", "internal-test-identity", sessionHash("test-session"), time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } +} + +func TestRecordCRUDAndSession(t *testing.T) { db, err := store.Open(t.TempDir() + "/easybot.db") if err != nil { t.Fatal(err) } defer db.Close() - t.Setenv("EASYBOT_API_KEY", "test-key") + createTestSession(t, db) logs := runtime.NewLogBuffer(20) handler := New(db, slog.New(slog.NewTextHandler(io.Discard, nil)), logs) @@ -33,7 +45,7 @@ func TestRecordCRUDAndApiKey(t *testing.T) { } request = httptest.NewRequest(http.MethodPost, "/api/records", strings.NewReader(`{"kind":"servers","name":"survival","detail":"{}","enabled":true}`)) - request.Header.Set("Authorization", "Bearer test-key") + request.AddCookie(&http.Cookie{Name: sessionCookie, Value: "test-session"}) request.Header.Set("Content-Type", "application/json") response = httptest.NewRecorder() handler.ServeHTTP(response, request) @@ -42,7 +54,7 @@ func TestRecordCRUDAndApiKey(t *testing.T) { } request = httptest.NewRequest(http.MethodGet, "/api/records?kind=servers", nil) - request.Header.Set("Authorization", "Bearer test-key") + request.AddCookie(&http.Cookie{Name: sessionCookie, Value: "test-session"}) response = httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "survival") { @@ -50,7 +62,7 @@ func TestRecordCRUDAndApiKey(t *testing.T) { } request = httptest.NewRequest(http.MethodPost, "/api/adapter/create", strings.NewReader(`{"adapterType":"Onebot11","displayName":"QQ Bridge","configJson":"{\"url\":\"ws://localhost:3001\"}"}`)) - request.Header.Set("Authorization", "Bearer test-key") + request.AddCookie(&http.Cookie{Name: sessionCookie, Value: "test-session"}) request.Header.Set("Content-Type", "application/json") response = httptest.NewRecorder() handler.ServeHTTP(response, request) @@ -58,7 +70,7 @@ func TestRecordCRUDAndApiKey(t *testing.T) { t.Fatalf("adapter create returned %d: %s", response.Code, response.Body.String()) } request = httptest.NewRequest(http.MethodPost, "/api/adapter/create", strings.NewReader(`{"adapterType":"Telegram","displayName":"unsupported","configJson":"{}"}`)) - request.Header.Set("Authorization", "Bearer test-key") + request.AddCookie(&http.Cookie{Name: sessionCookie, Value: "test-session"}) request.Header.Set("Content-Type", "application/json") response = httptest.NewRecorder() handler.ServeHTTP(response, request) @@ -66,14 +78,14 @@ func TestRecordCRUDAndApiKey(t *testing.T) { t.Fatalf("unsupported adapter returned %d: %s", response.Code, response.Body.String()) } request = httptest.NewRequest(http.MethodGet, "/api/logs?limit=10", nil) - request.Header.Set("Authorization", "Bearer test-key") + request.AddCookie(&http.Cookie{Name: sessionCookie, Value: "test-session"}) response = httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "POST /api/records") { t.Fatalf("logs returned %d: %s", response.Code, response.Body.String()) } request = httptest.NewRequest(http.MethodPut, "/api/logs/settings", strings.NewReader(`{"persistent":false,"retentionDays":45,"maxRows":2000}`)) - request.Header.Set("Authorization", "Bearer test-key") + request.AddCookie(&http.Cookie{Name: sessionCookie, Value: "test-session"}) request.Header.Set("Content-Type", "application/json") response = httptest.NewRecorder() handler.ServeHTTP(response, request) @@ -81,7 +93,7 @@ func TestRecordCRUDAndApiKey(t *testing.T) { t.Fatalf("log settings update returned %d: %s", response.Code, response.Body.String()) } request = httptest.NewRequest(http.MethodGet, "/api/logs/status", nil) - request.Header.Set("Authorization", "Bearer test-key") + request.AddCookie(&http.Cookie{Name: sessionCookie, Value: "test-session"}) response = httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"persistent":false`) { @@ -95,10 +107,10 @@ func TestAdapterCredentialsAreRedactedAndPreservedOnUpdate(t *testing.T) { t.Fatal(err) } defer db.Close() - t.Setenv("EASYBOT_API_KEY", "test-key") + createTestSession(t, db) handler := New(db, slog.New(slog.NewTextHandler(io.Discard, nil)), runtime.NewLogBuffer(20)) request := httptest.NewRequest(http.MethodPost, "/api/adapter/create", strings.NewReader(`{"adapterType":"Onebot11","displayName":"Mock","configJson":"{\"url\":\"ws://127.0.0.1:3001/\",\"accessToken\":\"never-return-this\"}"}`)) - request.Header.Set("Authorization", "Bearer test-key") + request.AddCookie(&http.Cookie{Name: sessionCookie, Value: "test-session"}) request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() handler.ServeHTTP(response, request) @@ -111,7 +123,7 @@ func TestAdapterCredentialsAreRedactedAndPreservedOnUpdate(t *testing.T) { } for _, path := range []string{"/api/adapter/list", "/api/records?kind=adapters"} { request = httptest.NewRequest(http.MethodGet, path, nil) - request.Header.Set("Authorization", "Bearer test-key") + request.AddCookie(&http.Cookie{Name: sessionCookie, Value: "test-session"}) response = httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "never-return-this") { @@ -119,7 +131,7 @@ func TestAdapterCredentialsAreRedactedAndPreservedOnUpdate(t *testing.T) { } } request = httptest.NewRequest(http.MethodPut, "/api/adapter/"+created.ID, strings.NewReader(`{"displayName":"Mock updated","configJson":"{\"url\":\"ws://127.0.0.1:3002/\",\"accessTokenConfigured\":true}"}`)) - request.Header.Set("Authorization", "Bearer test-key") + request.AddCookie(&http.Cookie{Name: sessionCookie, Value: "test-session"}) request.Header.Set("Content-Type", "application/json") response = httptest.NewRecorder() handler.ServeHTTP(response, request) @@ -148,12 +160,12 @@ func TestAdapterAPIReportsStoppedAndRejectsUnknownID(t *testing.T) { t.Fatal(err) } defer db.Close() - t.Setenv("EASYBOT_API_KEY", "test-key") + createTestSession(t, db) manager := onebot11.NewManager(context.Background(), db, nil) defer manager.Shutdown(context.Background()) handler := NewWithAdapterManager(db, slog.New(slog.NewTextHandler(io.Discard, nil)), runtime.NewLogBuffer(20), manager) request := httptest.NewRequest(http.MethodGet, "/api/adapter/missing/status", nil) - request.Header.Set("Authorization", "Bearer test-key") + request.AddCookie(&http.Cookie{Name: sessionCookie, Value: "test-session"}) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusNotFound { @@ -161,108 +173,133 @@ func TestAdapterAPIReportsStoppedAndRejectsUnknownID(t *testing.T) { } } -func TestInitialSetupLoginAndSessionAuthorization(t *testing.T) { +func TestTokenLoginAndSessionAuthorization(t *testing.T) { db, err := store.Open(t.TempDir() + "/easybot.db") if err != nil { t.Fatal(err) } defer db.Close() - t.Setenv("EASYBOT_API_KEY", "") - handler := New(db, slog.New(slog.NewTextHandler(io.Discard, nil)), runtime.NewLogBuffer(20)) - request := httptest.NewRequest(http.MethodGet, "/api/setup/status", nil) - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"initialized":false`) { - t.Fatalf("setup status returned %d: %s", response.Code, response.Body.String()) + path := filepath.Join(t.TempDir(), "token.txt") + plain := "original-login-token" + hash := sha256.Sum256([]byte(plain)) + if err := os.WriteFile(path, hash[:], 0600); err != nil { + t.Fatal(err) } - - request = httptest.NewRequest(http.MethodGet, "/api/records?kind=servers", nil) - response = httptest.NewRecorder() - handler.ServeHTTP(response, request) - if response.Code != http.StatusUnauthorized { - t.Fatalf("unauthenticated management request returned %d", response.Code) + token, generated, err := auth.Load(path, true) + if err != nil || generated != "" || !token.Valid(plain) { + t.Fatalf("original token.txt compatibility: %v", err) } - - body := `{"username":"admin","password":"correct-horse-battery"}` - request = httptest.NewRequest(http.MethodPost, "/api/setup/admin", strings.NewReader(body)) - request.RemoteAddr = "192.0.2.4:1234" - response = httptest.NewRecorder() - handler.ServeHTTP(response, request) - if response.Code != http.StatusForbidden { - t.Fatalf("remote initialization returned %d: %s", response.Code, response.Body.String()) + identity, err := db.EnsureTokenUser(context.Background(), token.Fingerprint()) + if err != nil { + t.Fatal(err) } - - request = httptest.NewRequest(http.MethodPost, "/api/setup/admin", strings.NewReader(body)) - request.RemoteAddr = "127.0.0.1:1234" - request.Host = "console.example.com" - response = httptest.NewRecorder() - handler.ServeHTTP(response, request) - if response.Code != http.StatusForbidden { - t.Fatalf("forwarded public-host initialization returned %d: %s", response.Code, response.Body.String()) + token.UserID = identity.ID + makeHandler := func(token *auth.Token) http.Handler { + return NewForListener(db, slog.New(slog.NewTextHandler(io.Discard, nil)), runtime.NewLogBuffer(20), nil, "0.0.0.0:5001", token) } - request = httptest.NewRequest(http.MethodPost, "/api/setup/admin", strings.NewReader(body)) - request.RemoteAddr = "127.0.0.1:1234" - request.Host = "127.0.0.1:5173" - request.Header.Set("Origin", "https://console.example.com") - response = httptest.NewRecorder() + handler := makeHandler(token) + request := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"token":"wrong"}`)) + response := httptest.NewRecorder() handler.ServeHTTP(response, request) - if response.Code != http.StatusForbidden { - t.Fatalf("forwarded public-origin initialization returned %d: %s", response.Code, response.Body.String()) + if response.Code != http.StatusUnauthorized { + t.Fatalf("invalid token returned %d", response.Code) } - - request = httptest.NewRequest(http.MethodPost, "/api/setup/admin", strings.NewReader(body)) - request.RemoteAddr = "127.0.0.1:1234" - request.Host = "127.0.0.1:5173" - response = httptest.NewRecorder() - handler.ServeHTTP(response, request) - if response.Code != http.StatusCreated { - t.Fatalf("initialization returned %d: %s", response.Code, response.Body.String()) + login := func(handler http.Handler, plain string) *http.Cookie { + body, _ := json.Marshal(map[string]string{"token": plain}) + request := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(string(body))) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || len(response.Result().Cookies()) != 1 { + t.Fatalf("token login: %d %s", response.Code, response.Body.String()) + } + cookie := response.Result().Cookies()[0] + if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || strings.Contains(response.Body.String(), cookie.Value) || strings.Contains(response.Body.String(), plain) { + t.Fatal("invalid cookie or credential leak") + } + return cookie } - cookies := response.Result().Cookies() - if len(cookies) != 1 || cookies[0].Name != sessionCookie || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode { - t.Fatalf("expected session cookie attributes, got %#v", cookies) + check := func(handler http.Handler, cookie *http.Cookie, want int) { + request := httptest.NewRequest(http.MethodGet, "/api/records?kind=servers", nil) + if cookie != nil { + request.AddCookie(cookie) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != want { + t.Fatalf("management returned %d, want %d", response.Code, want) + } } - if strings.Contains(response.Body.String(), cookies[0].Value) { - t.Fatal("session token leaked in response body") + check(handler, nil, http.StatusUnauthorized) + cookie := login(handler, plain) + check(handler, cookie, http.StatusOK) + if _, err := db.EnsureTokenUser(context.Background(), token.Fingerprint()); err != nil { + t.Fatal(err) } - - request = httptest.NewRequest(http.MethodGet, "/api/records?kind=servers", nil) - request.AddCookie(cookies[0]) + check(makeHandler(token), cookie, http.StatusOK) + request = httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) + request.AddCookie(cookie) response = httptest.NewRecorder() handler.ServeHTTP(response, request) - if response.Code != http.StatusOK { - t.Fatalf("authenticated management request returned %d: %s", response.Code, response.Body.String()) + if response.Code != http.StatusNoContent { + t.Fatalf("logout returned %d", response.Code) } - - request = httptest.NewRequest(http.MethodPost, "/api/setup/admin", strings.NewReader(body)) - request.RemoteAddr = "127.0.0.1:1234" - request.Host = "127.0.0.1:5173" - response = httptest.NewRecorder() - handler.ServeHTTP(response, request) - if response.Code != http.StatusConflict { - t.Fatalf("second initialization returned %d: %s", response.Code, response.Body.String()) + check(handler, cookie, http.StatusUnauthorized) + cookie = login(handler, plain) + if err := os.Remove(path); err != nil { + t.Fatal(err) } - - request = httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"admin","password":"correct-horse-battery"}`)) - response = httptest.NewRecorder() - handler.ServeHTTP(response, request) - if response.Code != http.StatusOK || len(response.Result().Cookies()) != 1 { - t.Fatalf("login returned %d: %s", response.Code, response.Body.String()) + next, generated, err := auth.Load(path, true) + if err != nil || len(generated) != 12 { + t.Fatalf("token generation: %v", err) } + identity, err = db.EnsureTokenUser(context.Background(), next.Fingerprint()) + if err != nil { + t.Fatal(err) + } + next.UserID = identity.ID + check(makeHandler(next), cookie, http.StatusUnauthorized) + login(makeHandler(next), generated) +} - request = httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) - request.AddCookie(cookies[0]) - response = httptest.NewRecorder() - handler.ServeHTTP(response, request) - if response.Code != http.StatusNoContent { - t.Fatalf("logout returned %d: %s", response.Code, response.Body.String()) +func TestLocalListenerAuthenticationBoundary(t *testing.T) { + db, err := store.Open(t.TempDir() + "/easybot.db") + if err != nil { + t.Fatal(err) } - request = httptest.NewRequest(http.MethodGet, "/api/records?kind=servers", nil) - request.AddCookie(cookies[0]) - response = httptest.NewRecorder() - handler.ServeHTTP(response, request) - if response.Code != http.StatusUnauthorized { - t.Fatalf("revoked session returned %d", response.Code) + defer db.Close() + for _, test := range []struct { + name, listener, remote, host, origin string + allowed bool + }{ + {name: "IPv4", listener: "127.0.0.1:5001", remote: "127.0.0.1:1234", host: "127.0.0.1:5001", allowed: true}, + {name: "IPv4 loopback range", listener: "127.0.0.2:5001", remote: "127.0.0.1:1234", host: "127.0.0.2:5001", allowed: true}, + {name: "IPv6", listener: "[::1]:5001", remote: "[::1]:1234", host: "[::1]:5001", allowed: true}, + {name: "localhost", listener: "localhost:5001", remote: "127.0.0.1:1234", host: "localhost:5001", allowed: true}, + {name: "external listener still requires login", listener: "0.0.0.0:5001", remote: "127.0.0.1:1234", host: "127.0.0.1:5001"}, + {name: "forged local host", listener: "127.0.0.1:5001", remote: "192.0.2.1:1234", host: "localhost:5001"}, + {name: "public host", listener: "127.0.0.1:5001", remote: "127.0.0.1:1234", host: "evil.example:5001"}, + {name: "public origin", listener: "127.0.0.1:5001", remote: "127.0.0.1:1234", host: "localhost:5001", origin: "https://evil.example"}, + } { + t.Run(test.name, func(t *testing.T) { + handler := NewForListener(db, slog.New(slog.NewTextHandler(io.Discard, nil)), runtime.NewLogBuffer(20), nil, test.listener, nil) + for _, path := range []string{"/api/records?kind=servers", "/api/auth/session"} { + request := httptest.NewRequest(http.MethodGet, path, nil) + request.RemoteAddr, request.Host = test.remote, test.host + if test.origin != "" { + request.Header.Set("Origin", test.origin) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if path == "/api/auth/session" { + var session struct{ Authenticated bool } + if err := json.Unmarshal(response.Body.Bytes(), &session); err != nil || session.Authenticated != test.allowed { + t.Fatalf("session: %s, error %v", response.Body.String(), err) + } + } else if (response.Code == http.StatusOK) != test.allowed { + t.Fatalf("management request returned %d", response.Code) + } + } + }) } } diff --git a/dev/web/README.md b/dev/web/README.md index 33895ab..8b49fac 100644 --- a/dev/web/README.md +++ b/dev/web/README.md @@ -1,5 +1,11 @@ -# Vue 3 + TypeScript + Vite +# CE 管理台 -This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `