From b9ce82c8f2483642631f2dc1489e024e340bd7b0 Mon Sep 17 00:00:00 2001 From: BaiZhouziYou <111175934+BaizhouziYou@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:12:52 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(core):=20=E5=85=BC=E5=AE=B9=E5=8E=9F?= =?UTF-8?q?=E7=89=88=E9=85=8D=E7=BD=AE=E4=B8=8E=20Token=20=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=B9=B6=E5=AE=8C=E5=96=84=E5=8F=8C=E5=87=BB=E5=90=AF?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 读取原版 appsettings.json 和 token.txt,WebUI/API 默认使用 5001,Bridge 配置默认使用 26991。 统一 Token 登录,移除用户名注册和独立 API Key;回环监听允许本机免登录,非本地监听支持 HTTP,首次生成的 12 位 Token 仅在控制台显示一次,后续沿用。会话有效期为 7 天,更换 Token 后重启会使旧会话失效。 按程序位置解析数据与网页资源目录,完善模块启动日志和 Windows 双击失败提示,统一 EasybotCE 页面标识,并修复 SQLite 管理写入与日志写入的锁冲突。 验证:Go 测试、go vet、前端构建、Windows 启动与 Token 登录/轮换、数据保留检查及 Linux 交叉编译通过。 --- .gitignore | 3 + dev/README.md | 56 ++++- dev/cmd/easybot/console.go | 124 ++++++++++ dev/cmd/easybot/console_other.go | 12 + dev/cmd/easybot/console_windows.go | 38 +++ dev/cmd/easybot/main.go | 160 +++++++++++-- dev/go.mod | 2 +- dev/internal/auth/token.go | 62 +++++ dev/internal/config/appsettings.example.json | 24 ++ dev/internal/config/listen.go | 25 +- dev/internal/config/listen_test.go | 86 ++++++- dev/internal/config/settings.go | 222 ++++++++++++++++++ dev/internal/store/store.go | 60 ++++- dev/internal/web/web.go | 161 +++++-------- dev/internal/web/web_test.go | 229 +++++++++++-------- dev/web/README.md | 12 +- dev/web/index.html | 4 +- dev/web/src/App.vue | 21 +- dev/web/vite.config.ts | 2 +- 19 files changed, 1028 insertions(+), 275 deletions(-) create mode 100644 dev/cmd/easybot/console.go create mode 100644 dev/cmd/easybot/console_other.go create mode 100644 dev/cmd/easybot/console_windows.go create mode 100644 dev/internal/auth/token.go create mode 100644 dev/internal/config/appsettings.example.json create mode 100644 dev/internal/config/settings.go 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/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 `