diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..23830ee --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +data +bin +artifacts +web/node_modules +web/test-results +web/playwright-report diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5a1be70 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.go] +indent_style = tab +indent_size = 4 + +[*.{vue,ts,js,mjs,json,css,yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..37c0137 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Copy values into the service environment; this file is not loaded automatically. +# Development default: skip login, loopback management only. +# Set true to require administrator login (required for remote deployment). +AGENTDB_AUTH_ENABLED=false +# Generate a unique key with: agentdb generate-key +AGENTDB_MASTER_KEY= +AGENTDB_ADMIN_USER=admin +AGENTDB_ADMIN_PASSWORD= +AGENTDB_DATA=data/agentdb.sqlite +AGENTDB_HTTP_ADDR=127.0.0.1:8080 +AGENTDB_SECURE_COOKIE=false +# Database gateways require a certificate and key. SSH does not use these. +AGENTDB_TLS_CERT= +AGENTDB_TLS_KEY= + +# Windows without AGENTDB_MASTER_KEY: automatic local desktop startup. +# Data/key/TLS default to %APPDATA%\AgentDB; DPAPI key is bound to this Windows user. +# AGENTDB_NO_BROWSER=1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9823940 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf +*.png binary +*.jpg binary +*.ico binary +*.exe binary diff --git a/.gitignore b/.gitignore index 5b90e79..b640ae1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,13 @@ go.work.sum # env file .env +# Local data, credentials, tool outputs and frontend dependencies +/data/ +/bin/ +/artifacts/ +/web/node_modules/ +/web/test-results/ +/web/playwright-report/ +/web/dist/* +!/web/dist/.gitkeep +*.tsbuildinfo diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..db9979f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# AgentDB collaboration + +## Work ownership + +- Agree on file ownership before concurrent edits. Shared files include `web/src/App.vue`, `internal/api/server.go`, `internal/model/proxy.go`, `go.mod`, and `go.sum`. +- Assign independent features by directory or module. Protocol implementations belong in `internal/protocol/`, outbound transports in `internal/outbound`, persistence in `internal/store`, and HTTP endpoints in `internal/api`. +- Keep endpoint behavior in domain files (`targets.go`, `proxies.go`, `commands.go`, `events.go`). Keep `server.go` focused on server state, middleware, and routing. +- Put independent frontend state in `web/src/composables` and reusable views in separate Vue components. `App.vue` owns navigation and coordinates shared state. +- Announce shared-file handoffs. Read the current file before editing; preserve changes made by other agents. Do not restore entire files or stage unrelated work. +- Keep behavior-preserving moves separate from feature changes where possible. Update imports and run the affected package checks after each move. + +## Validation + +- Use Go 1.25.0 (`GOTOOLCHAIN=go1.25.0`); newer installed toolchains may not compile the pinned Vitess dependency. +- Backend: `go test ./... -count=1 -timeout=120s` and `go vet ./...`. +- Frontend: `npm.cmd --prefix web run build`. Run the Playwright suite in `web` against a freshly built executable using `AGENTDB_E2E_BINARY`. +- Do not build embedded Go assets while Vite is replacing `web/dist`. Restore tracked `web/dist/.gitkeep` after a frontend build if needed. +- Describe whether protocol tests used a real server or a fixture; do not present fixture coverage as live-server validation. + +## Local data + +- Preserve `data/agentdb.sqlite`, `data/audit`, TLS material, and `data/master-key.dpapi`. +- Stop the owned local service and back up the complete `data` directory before deploying a migration. Do not modify real target data for smoke tests. +- Use isolated test databases and ports. Do not start subagents unless the user has enabled them for the active work. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f9efb0d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM node:22.19.0-alpine AS frontend +WORKDIR /src/web +COPY web/package*.json ./ +RUN npm ci --no-audit --no-fund +COPY web/ ./ +RUN npm run build + +FROM golang:1.25.0-alpine AS backend +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=frontend /src/web/dist ./web/dist +ARG VERSION +ARG COMMIT=unknown +ARG BUILD_TIME +RUN version="${VERSION:-$(cat VERSION)}" && build_time="${BUILD_TIME:-$(date -u '+%Y-%m-%dT%H:%M:%SZ')}" && \ + CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X agentdb/internal/buildinfo.Version=$version -X agentdb/internal/buildinfo.Commit=$COMMIT -X agentdb/internal/buildinfo.BuildTime=$build_time" -o /agentdb ./cmd/agentdb + +FROM alpine:3.22.1 +RUN apk add --no-cache ca-certificates && adduser -D -u 10001 agentdb && mkdir /data && chown agentdb /data +USER agentdb +WORKDIR /app +COPY --from=backend /agentdb /app/agentdb +ENV AGENTDB_HTTP_ADDR=0.0.0.0:8080 AGENTDB_DATA=/data/agentdb.sqlite AGENTDB_AUTH_ENABLED=true +EXPOSE 8080 +ENTRYPOINT ["/app/agentdb"] diff --git a/README.md b/README.md index 677d9c5..37d92f0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,156 @@ -# agentdb +# AgentDB +开发时双击根目录 `start-dev.cmd`,打开 `http://127.0.0.1:8080`(默认免登录)。该入口固定使用项目根目录下的 `data/agentdb.sqlite`,与启动时的工作目录无关,重启和重新构建后继续保留目标服务和网关配置。数据库、`master-key.dpapi` 和 `tls` 均保存在 `data` 中,备份时关闭服务并复制整个目录;密钥绑定当前 Windows 用户。直接运行发布包仍使用 `%APPDATA%/AgentDB`。 + +使用 Go 和 Vue 3 构建的 MySQL、MongoDB、PostgreSQL、Redis、RabbitMQ、SSH 连接代理管理后台。目标服务独立保存地址、凭据及直连 / SOCKS5 / SSH 转发出口;网关引用目标,独立管理监听端口、访问凭据及可选只读策略。一个目标可被多个同协议网关复用。 + +目标配置的「代理出口」可选择 SSH 转发,填写跳板机地址、端口、账号密码和已核对的 SHA256 主机指纹。跳板机需允许 TCP 转发;目标地址和域名由跳板机访问,连接失败不回退直连。当前跳板机认证支持密码。网关来源 IP/CIDR 为可选项,留空允许所有来源,填写后仅放行匹配项。 + +**MySQL、MongoDB 默认读写,只读模式按代理可选启用。** 只读基于网关严格白名单,不自动修改目标数据库权限。 + +## 能力 + +目标服务支持可选分组,可按分组筛选,并在网关选择目标时显示分组。独立「代理」菜单维护通用 SOCKS5、SSH 转发与 K8s port-forward 配置。目标服务通过「代理出口」下拉框选择,或选择「直接连接」。多个目标可以共用同一代理;代理被引用时禁止删除,修改前须停止全部关联网关。旧目标内的出口配置升级时自动迁移,配置和凭据保留;升级前停止服务并备份整个数据目录。 + +- MySQL:统一账号、TLS、查询、事务、二进制预编译和可选只读;目标版本为 5.7.44、8.0、8.4。 +- MongoDB:双端 SCRAM-SHA-256、TLS、CRUD、聚合、游标及可选只读;目标版本为 6.0、7.0、8.0,客户端必须设置 `directConnection=true`。 +- SSH:密码/公钥登录网关,上游密码/私钥认证,固定主机指纹,交互终端、远程命令和 SFTP。 +- Redis:RESP 数组命令、AUTH/HELLO 凭据映射、流水线、订阅推送与命令审计;支持 standalone 目标,不自动处理 Cluster 重定向。 +- RabbitMQ:AMQP 0-9-1、PLAIN 凭据映射、队列/交换机/发布消费帧转发;审计方法及通道元数据,不保存消息正文。 +- PostgreSQL(pgsql):普通和扩展查询协议、上游 MD5/SCRAM 认证、网关密码认证、参数化查询及 SQL 审计。当前不代理独立 CancelRequest,也不提供只读策略。 +- SOCKS5:每条代理独立配置,支持无认证和用户名密码认证、远端 DNS;出口失败不回退直连。 +- 管理:单管理员、SQLite 加密配置、CSRF/会话、密码重新认证查看、来源白名单、连接限额、启停及 7 天审计。 + +目标版本与实测版本请区分,见 [验证说明](docs/testing.md)。 + +「拖拽连接」可将已停止的网关拖到同协议目标并保存关联,也支持点击选择。命令审计与连接事件按 UTC 日期保存至 `data/audit/YYYY-MM-DD.sqlite`(每库分别包含 commands/events),保留 7 天。旧记录启动时自动迁移;配置库继续固定使用 `data/agentdb.sqlite`。两类日志均支持跨天筛选、分页和 CSV 全量导出;命令内容仍加密保存,导出只包含当前保留且已记录的内容。备份请停止服务并复制整个 `data` 目录。 + +## 构建 + +项目版本统一维护在根目录 `VERSION`。`agentdb.exe version` 或 `agentdb.exe --version --json` 可查看版本、Git 提交、构建时间与平台,管理界面顶部也可查看版本信息。构建/打包脚本自动注入元数据;直接 `go build` 仍使用 VERSION 的版本值,但提交和时间可能显示 `unknown`。 + +目标出口新增内置 `K8s port-forward`,不依赖 kubectl,支持 Pod、Service 和 Deployment。配置与权限见 [Kubernetes 说明](docs/kubernetes.md)。 + +需要 Node.js 22.12+ 和 Go。当前 Vitess 解析器固定使用 **Go 1.25.0**;机器安装更新版 Go 时,设置 `GOTOOLCHAIN=go1.25.0` 自动使用兼容工具链。不要直接用 Go 1.27 编译该版本 Vitess。 + +Windows PowerShell: + +```powershell +.\scripts\build.ps1 +``` + +Linux / macOS: + +```sh +sh scripts/build.sh +``` + +脚本安装前端依赖、构建 Vue、执行 Go 测试并生成 `bin/agentdb`(Windows 为 `.exe`)。前端嵌入可执行文件,部署不需要 Node.js。 + +## 启动 + +Windows 本地使用:解压发行包后双击 `agentdb.exe`,浏览器自动打开,**开发阶段默认免登录**,直接进入控制台。无需安装 Go/Node.js,无需设置环境变量。保持程序窗口运行,按 Ctrl+C 停止服务。 + +先在「目标服务」中添加 MySQL/MongoDB/SSH 目标,再在「网关服务」中选择目标并配置监听、网关账号和访问策略。目标被引用时不能删除;编辑目标前需要停止所有引用它的网关。旧版配置会在事务中自动拆分,保留网关 ID、凭据、SSH 指纹和启用状态,升级前先停止服务并备份数据目录。 + +右上角可切换浅色、深色、海蓝及跟随系统主题,选择保存在当前浏览器中。 + +开发模式仅允许回环地址管理访问,仍校验请求来源与 CSRF。设置 `AGENTDB_AUTH_ENABLED=true` 可启用管理员登录;Windows 本地模式会在尚无管理员时打开首次设置页面,已有管理员直接登录。远程服务器部署必须开启认证。 + +未设置 `AGENTDB_MASTER_KEY` 时,Windows 自动进入本地模式:数据固定保存到 `%APPDATA%\AgentDB`,密钥使用当前 Windows 用户的 DPAPI 加密保存,自动创建有效期一年的 localhost TLS 证书。备份整个目录;该密钥绑定 Windows 用户,不能直接复制到其他电脑使用。高级部署及跨机器迁移应使用自行保管的环境变量主密钥。详见 [Windows 使用说明](packaging/windows/README.md)。 + +以下为服务器部署 / 手动管理密钥的 PowerShell 示例(设置主密钥后保留原有启动行为): + +```powershell +$env:AGENTDB_AUTH_ENABLED = 'true' +$env:AGENTDB_MASTER_KEY = .\bin\agentdb.exe generate-key +$env:AGENTDB_ADMIN_USER = 'admin' +$adminSecret = Read-Host '初始管理员密码(12–72 字节)' -AsSecureString +$env:AGENTDB_ADMIN_PASSWORD = [Net.NetworkCredential]::new('', $adminSecret).Password +.\bin\agentdb.exe +``` + +打开 `http://127.0.0.1:8080`。初始管理员只在首次运行创建,之后不会用环境变量覆盖密码。 + +首次生成的主密钥必须保存并在后续启动时复用,不能每次启动重新生成。数据库备份需要同时备份主密钥;换错密钥时已保存配置无法解密。 + +| 环境变量 | 默认值 / 用途 | +| --- | --- | +| `AGENTDB_AUTH_ENABLED` | 默认 `false`,开发阶段免登录且仅限本机;`true` 启用管理员登录 | +| `AGENTDB_MASTER_KEY` | 服务器模式必填,32 字节随机密钥的 Base64;Windows 未设置时启用本地模式 | +| `AGENTDB_ADMIN_USER` | 手动密钥模式且启用认证时,首次启动必填 | +| `AGENTDB_ADMIN_PASSWORD` | 手动密钥模式且启用认证时,首次启动必填,12–72 字节 | +| `AGENTDB_DATA` | `data/agentdb.sqlite` | +| `AGENTDB_HTTP_ADDR` | `127.0.0.1:8080` | +| `AGENTDB_NO_BROWSER` | Windows 本地模式设为 `1` 可禁用自动打开浏览器 | +| `AGENTDB_SECURE_COOKIE` | 默认 `false`;管理界面经 HTTPS 访问时设为 `true` | +| `AGENTDB_TLS_CERT` / `AGENTDB_TLS_KEY` | 数据库网关客户端 TLS 证书和私钥路径 | + +`.env.example` 仅为示例,程序不自动加载 `.env`。远程管理使用 HTTPS 反向代理。 + +### 数据库 TLS + +MySQL/MongoDB 网关允许普通连接;配置证书后同一端口也支持客户端选择 TLS。未配置证书仍可启动数据库网关并接受普通连接。客户端 TLS 与目标服务的上游 TLS 独立。 + +本机开发可生成只覆盖 `localhost`、`127.0.0.1` 和 `::1`、有效期 14 天的证书: + +```powershell +$env:GOTOOLCHAIN = 'go1.25.0' +go run ./cmd/devcert +$env:AGENTDB_TLS_CERT = 'data/tls/cert.pem' +$env:AGENTDB_TLS_KEY = 'data/tls/key.pem' +.\bin\agentdb.exe +``` + +客户端将 `cert.pem` 作为 CA 验证;局域网正式使用时换成覆盖实际网关域名的受信任证书。目标数据库 TLS 独立配置,默认验证系统 CA 或配置中提供的 PEM CA,不提供跳过验证模式。 + +### SSH 主机身份 + +保存 SSH 配置后,使用代理菜单“探测 / 确认主机指纹”。探测不发送目标账号凭据;通过独立可信渠道核对指纹后输入完整指纹确认,再启动代理。主机密钥变化会拒绝连接;修改目标地址或端口需要重新确认。 + +### 密码查看与重置 + +目标、网关、SOCKS5 密码和 SSH 私钥口令可在重新验证管理员密码后查看,30 秒自动隐藏并记录审计。SSH 私钥不可下载。管理员登录密码仅保存 bcrypt 哈希,只能重置: + +```powershell +.\bin\agentdb.exe reset-password +``` + +重置命令使用同一个数据路径和主密钥,交互输入密码并使旧会话失效。 + +## 开发与测试 + +```powershell +$env:GOTOOLCHAIN = 'go1.25.0' +go test ./... +go vet ./... +npm.cmd --prefix web run build +go build -o bin/agentdb.exe ./cmd/agentdb +npm.cmd --prefix web test +``` + +前端开发:`npm --prefix web run dev`,Vite 将 `/api` 转发到本机 8080。浏览器测试启动隔离的临时数据服务,默认使用已安装的 Edge;其他平台可设置 `AGENTDB_BROWSER_CHANNEL=chromium` 并先运行 `npx playwright install chromium`。 + +Docker 构建:`docker build -t agentdb .`。容器默认以 UID 10001 运行,数据卷须可写;代理监听端口需要自行映射。数据库版本矩阵见 [验证说明](docs/testing.md)。 + +## Windows 便携包 + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1 +``` + +打包脚本重新构建内嵌前端与 Windows x64 程序,在 `artifacts/releases/<时间>/` 生成 ZIP 及 SHA-256 校验文件。包内包含主程序、开发证书工具、独立启动说明和配置示例,不包含本地数据库或凭据。解压后可用 `node scripts/verify-package.mjs <解压后的包目录>` 验证文件校验值、隔离启动、网页资源与管理员登录。 + +## 文档 + +- [开发计划](docs/development-plan.md) +- [只读规则与兼容边界](docs/readonly.md) +- [测试、版本矩阵与验证状态](docs/testing.md) + +## 目标压缩与命令审计 + +MySQL/MongoDB 目标可选择 zlib 压缩(默认关闭),通过协议协商启用,目标不支持时保持未压缩连接;连接测试显示实际协商结果。压缩仅作用于 AgentDB 到目标服务一侧,直连和 SOCKS5 均适用。当前 SSH 库仅支持未压缩传输。 + +「命令审计」记录 MySQL SQL/预编译模板、MongoDB 请求、SSH exec、交互输入行和 SFTP 操作,可按网关/协议筛选并分页查看。内容加密保存,保留 7 天,每条最多 16 KiB,截断会标记。放行/拦截是网关决策,不表示目标执行成功。审计写入失败时不继续转发该命令。 + +不记录认证握手、SFTP 文件内容或命令输出。MySQL 二进制预编译执行记录 SQL 模板、statement ID 与参数包字节数,不展开参数值;MongoDB 文档序列批量正文不展开,只记录命令主体及总字节数。SSH 交互输入并非远端执行追踪:历史命令选择、光标编辑、脚本内部命令、全屏程序及密码提示输入无法可靠区分;需要严格远端执行审计时应配合服务器 auditd 等机制。 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0d91a54 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.3.0 diff --git a/cmd/agentdb/main.go b/cmd/agentdb/main.go new file mode 100644 index 0000000..e7a8cce --- /dev/null +++ b/cmd/agentdb/main.go @@ -0,0 +1,209 @@ +package main + +import ( + "agentdb/internal/api" + "agentdb/internal/buildinfo" + "agentdb/internal/desktop" + "agentdb/internal/gateway" + "agentdb/internal/localcert" + "agentdb/internal/model" + "agentdb/internal/protocol/mongoproxy" + "agentdb/internal/protocol/mysqlproxy" + "agentdb/internal/protocol/pgproxy" + "agentdb/internal/protocol/rabbitproxy" + "agentdb/internal/protocol/redisproxy" + "agentdb/internal/protocol/sshproxy" + "agentdb/internal/security" + "agentdb/internal/store" + "agentdb/web" + "context" + "crypto/rand" + "crypto/tls" + "encoding/base64" + "encoding/json" + "fmt" + "golang.org/x/term" + "log" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" +) + +func main() { + if err := run(); err != nil { + log.Print(err) + if desktop.Enabled() && len(os.Args) == 1 && term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Println("启动失败。请查看以上错误;按 Enter 关闭窗口。") + fmt.Scanln() + } + os.Exit(1) + } +} +func run() error { + if len(os.Args) > 1 && (os.Args[1] == "version" || os.Args[1] == "--version" || os.Args[1] == "-version") { + info := buildinfo.Current() + if len(os.Args) == 3 && os.Args[2] == "--json" { + return json.NewEncoder(os.Stdout).Encode(info) + } + if len(os.Args) != 2 { + return fmt.Errorf("usage: agentdb version [--json]") + } + fmt.Println(info.String()) + return nil + } + if len(os.Args) > 1 && os.Args[1] == "generate-key" { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return err + } + fmt.Println(base64.StdEncoding.EncodeToString(b)) + return nil + } + log.Print(buildinfo.Current().String()) + masterKey := os.Getenv("AGENTDB_MASTER_KEY") + path := env("AGENTDB_DATA", "data/agentdb.sqlite") + var local *desktop.Config + if desktop.Enabled() { + var err error + local, err = desktop.Prepare() + if err != nil { + return err + } + defer local.Close() + masterKey, path = local.Key, local.Data + } + vault, err := security.NewVault(masterKey) + if err != nil { + return err + } + if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + s, err := store.Open(path, vault) + if err != nil { + return err + } + defer s.Close() + if len(os.Args) > 1 && os.Args[1] == "reset-password" { + fmt.Print("New administrator password: ") + a, e := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if e != nil { + return e + } + fmt.Print("Confirm password: ") + b, e := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if e != nil { + return e + } + if string(a) != string(b) { + return fmt.Errorf("passwords do not match") + } + return s.ResetPassword(string(a)) + } + authEnabled := strings.EqualFold(os.Getenv("AGENTDB_AUTH_ENABLED"), "true") + if (authEnabled && local == nil) || os.Getenv("AGENTDB_ADMIN_PASSWORD") != "" { + if err = s.InitAdmin(os.Getenv("AGENTDB_ADMIN_USER"), os.Getenv("AGENTDB_ADMIN_PASSWORD")); err != nil { + return err + } + } + var tlsConfig *tls.Config + cert, key := os.Getenv("AGENTDB_TLS_CERT"), os.Getenv("AGENTDB_TLS_KEY") + if local != nil && cert == "" && key == "" { + dir := filepath.Join(local.Dir, "tls") + cert, key = filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem") + if _, e := os.Stat(cert); os.IsNotExist(e) { + if e = localcert.Generate(dir, 365*24*time.Hour); e != nil { + return e + } + } + } + if cert != "" || key != "" { + pair, e := tls.LoadX509KeyPair(cert, key) + if e != nil { + return fmt.Errorf("load gateway TLS certificate: %w", e) + } + tlsConfig = &tls.Config{Certificates: []tls.Certificate{pair}, MinVersion: tls.VersionTLS12} + } + handlers := map[string]model.Handler{"mysql": mysqlproxy.New(), "mongodb": mongoproxy.New(), "ssh": sshproxy.New(), "redis": redisproxy.New(), "rabbitmq": rabbitproxy.New(), "pgsql": pgproxy.New()} + m := gateway.New(s, tlsConfig, handlers) + defer m.Shutdown() + ps, err := s.List() + if err != nil { + return fmt.Errorf("cannot load configurations; verify master key: %w", err) + } + for _, p := range ps { + if p.Enabled { + if e := m.Start(p); e != nil { + log.Printf("proxy %s restore failed: %v", p.ID, e) + } + } + } + server := api.New(s, m, strings.EqualFold(os.Getenv("AGENTDB_SECURE_COOKIE"), "true"), authEnabled) + srv := &http.Server{Addr: env("AGENTDB_HTTP_ADDR", "127.0.0.1:8080"), Handler: server.Handler(web.Handler()), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 45 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 16} + if local != nil || !authEnabled { + host, _, e := net.SplitHostPort(srv.Addr) + if e != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() { + return fmt.Errorf("desktop/development mode requires a loopback AGENTDB_HTTP_ADDR, such as 127.0.0.1:8080; enable AGENTDB_AUTH_ENABLED for remote deployment") + } + } + listener, err := net.Listen("tcp", srv.Addr) + if err != nil { + return fmt.Errorf("cannot listen on %s (AgentDB may already be running): %w", srv.Addr, err) + } + defer listener.Close() + url := "http://" + listener.Addr().String() + if local != nil { + var setupPath string + if authEnabled { + srv.Handler, setupPath, err = desktop.Setup(s, srv.Handler, listener.Addr().String()) + if err != nil { + return err + } + } + url += setupPath + fmt.Printf("数据目录:%s\n关闭服务请按 Ctrl+C。\n", local.Dir) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + done := make(chan error, 1) + go func() { log.Printf("AgentDB management listening on %s", listener.Addr()); done <- srv.Serve(listener) }() + if local != nil { + fmt.Println("管理页面:" + url) + if os.Getenv("AGENTDB_NO_BROWSER") != "1" { + if e := desktop.OpenBrowser(url); e != nil { + log.Printf("请手动打开管理页面:%v", e) + } + } + } + s.Prune() + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + select { + case e := <-done: + if e == http.ErrServerClosed { + return nil + } + return e + case <-ticker.C: + s.Prune() + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return srv.Shutdown(shutdown) + } + } +} +func env(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/cmd/agentdb/version_test.go b/cmd/agentdb/version_test.go new file mode 100644 index 0000000..75955f7 --- /dev/null +++ b/cmd/agentdb/version_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "agentdb/internal/buildinfo" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" +) + +func TestVersionWithoutDataInitialization(t *testing.T) { + data := filepath.Join(t.TempDir(), "must-not-exist", "agentdb.sqlite") + t.Setenv("AGENTDB_DATA", data) + t.Setenv("AGENTDB_MASTER_KEY", "invalid") + oldArgs, oldStdout := os.Args, os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + defer w.Close() + t.Cleanup(func() { os.Args, os.Stdout = oldArgs, oldStdout }) + os.Args, os.Stdout = []string{"agentdb", "--version", "--json"}, w + if err := run(); err != nil { + t.Fatal(err) + } + w.Close() + body, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + var got buildinfo.Info + if err := json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + if got != buildinfo.Current() { + t.Fatalf("CLI metadata: %+v", got) + } + if _, err := os.Stat(filepath.Dir(data)); !os.IsNotExist(err) { + t.Fatalf("version initialized data: %v", err) + } +} diff --git a/cmd/devcert/main.go b/cmd/devcert/main.go new file mode 100644 index 0000000..cbf18b3 --- /dev/null +++ b/cmd/devcert/main.go @@ -0,0 +1,21 @@ +// devcert generates an isolated localhost-only development TLS certificate. +package main + +import ( + "agentdb/internal/localcert" + "fmt" + "os" + "time" +) + +func main() { + dir := "data/tls" + if len(os.Args) > 1 { + dir = os.Args[1] + } + if err := generate(dir); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +func generate(dir string) error { return localcert.Generate(dir, 14*24*time.Hour) } diff --git a/deploy/compose.test.yml b/deploy/compose.test.yml new file mode 100644 index 0000000..16dbca4 --- /dev/null +++ b/deploy/compose.test.yml @@ -0,0 +1,156 @@ +# Disposable integration targets only. All passwords below are public TEST credentials. +# No production directories or data volumes are mounted. +name: agentdb-test +services: + cert-init: + build: ./fixtures + command: ["certs"] + volumes: + - fixture-certs:/certs + mysql57: + image: mysql:5.7.44 + ports: ["127.0.0.1:13357:3306"] + environment: + MYSQL_ROOT_PASSWORD: agentdb-test-password + MYSQL_ROOT_HOST: "%" + command: ["--ssl-ca=/certs/ca.pem", "--ssl-cert=/certs/server-cert.pem", "--ssl-key=/certs/server-key.pem", "--require-secure-transport=ON"] + volumes: + - fixture-certs:/certs:ro + tmpfs: + - /var/lib/mysql + depends_on: + cert-init: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "MYSQL_PWD=agentdb-test-password mysqladmin ping -uroot --silent"] + interval: 5s + timeout: 5s + retries: 60 + mysql80: + image: mysql:8.0.43 + ports: ["127.0.0.1:13380:3306"] + environment: + MYSQL_ROOT_PASSWORD: agentdb-test-password + MYSQL_ROOT_HOST: "%" + command: ["--ssl-ca=/certs/ca.pem", "--ssl-cert=/certs/server-cert.pem", "--ssl-key=/certs/server-key.pem", "--require-secure-transport=ON"] + volumes: + - fixture-certs:/certs:ro + tmpfs: + - /var/lib/mysql + depends_on: + cert-init: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "MYSQL_PWD=agentdb-test-password mysqladmin ping -uroot --silent"] + interval: 5s + timeout: 5s + retries: 60 + mysql84: + image: mysql:8.4.6 + ports: ["127.0.0.1:13384:3306"] + environment: + MYSQL_ROOT_PASSWORD: agentdb-test-password + MYSQL_ROOT_HOST: "%" + command: ["--ssl-ca=/certs/ca.pem", "--ssl-cert=/certs/server-cert.pem", "--ssl-key=/certs/server-key.pem", "--require-secure-transport=ON"] + volumes: + - fixture-certs:/certs:ro + tmpfs: + - /var/lib/mysql + depends_on: + cert-init: + condition: service_completed_successfully + healthcheck: + test: ["CMD-SHELL", "MYSQL_PWD=agentdb-test-password mysqladmin ping -uroot --silent"] + interval: 5s + timeout: 5s + retries: 60 + mongo60: + image: mongo:6.0.26 + ports: ["127.0.0.1:27060:27017"] + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: agentdb-test-password + command: ["mongod", "--bind_ip_all", "--tlsMode", "requireTLS", "--tlsCertificateKeyFile", "/certs/mongo.pem", "--tlsCAFile", "/certs/ca.pem", "--tlsAllowConnectionsWithoutCertificates"] + volumes: + - fixture-certs:/certs:ro + tmpfs: + - /data/db + - /data/configdb + depends_on: + cert-init: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--host", "localhost", "--tls", "--tlsCAFile", "/certs/ca.pem", "-u", "root", "-p", "agentdb-test-password", "--authenticationDatabase", "admin", "--eval", "db.adminCommand({ping:1})"] + interval: 5s + timeout: 5s + retries: 60 + mongo70: + image: mongo:7.0.24 + ports: ["127.0.0.1:27070:27017"] + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: agentdb-test-password + command: ["mongod", "--bind_ip_all", "--tlsMode", "requireTLS", "--tlsCertificateKeyFile", "/certs/mongo.pem", "--tlsCAFile", "/certs/ca.pem", "--tlsAllowConnectionsWithoutCertificates"] + volumes: + - fixture-certs:/certs:ro + tmpfs: + - /data/db + - /data/configdb + depends_on: + cert-init: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--host", "localhost", "--tls", "--tlsCAFile", "/certs/ca.pem", "-u", "root", "-p", "agentdb-test-password", "--authenticationDatabase", "admin", "--eval", "db.adminCommand({ping:1})"] + interval: 5s + timeout: 5s + retries: 60 + mongo80: + image: mongo:8.0.13 + ports: ["127.0.0.1:27080:27017"] + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: agentdb-test-password + entrypoint: ["bash", "/fixture-start.sh"] + volumes: + - fixture-certs:/certs:ro + - ./mongo/start-replica.sh:/fixture-start.sh:ro + tmpfs: + - /data/db + - /data/configdb + depends_on: + cert-init: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--host", "localhost", "--tls", "--tlsCAFile", "/certs/ca.pem", "-u", "root", "-p", "agentdb-test-password", "--authenticationDatabase", "admin", "--eval", "db.adminCommand({ping:1})"] + interval: 5s + timeout: 5s + retries: 60 + mongo80-init: + image: mongo:8.0.13 + profiles: ["replica-init"] + entrypoint: ["mongosh", "--quiet", "--host", "mongo80", "--tls", "--tlsCAFile", "/certs/ca.pem", "-u", "root", "-p", "agentdb-test-password", "--authenticationDatabase", "admin", "/init-replica.js"] + volumes: + - fixture-certs:/certs:ro + - ./mongo/init-replica.js:/init-replica.js:ro + depends_on: + mongo80: + condition: service_healthy + ssh: + build: ./ssh + ports: ["127.0.0.1:12222:22"] + healthcheck: + test: ["CMD", "/usr/sbin/sshd", "-t"] + interval: 5s + timeout: 5s + retries: 12 + socks-open: + build: ./fixtures + ports: ["127.0.0.1:11080:1080"] + socks-auth: + build: ./fixtures + environment: + SOCKS_USER: socks + SOCKS_PASSWORD: socks-test-password + ports: ["127.0.0.1:11081:1080"] +volumes: + fixture-certs: diff --git a/deploy/fixtures/Dockerfile b/deploy/fixtures/Dockerfile new file mode 100644 index 0000000..1b581e2 --- /dev/null +++ b/deploy/fixtures/Dockerfile @@ -0,0 +1,7 @@ +FROM golang:1.25.0-bookworm AS build +WORKDIR /src +COPY main.go . +RUN CGO_ENABLED=0 go build -o /fixture main.go +FROM debian:12.11-slim +COPY --from=build /fixture /fixture +ENTRYPOINT ["/fixture"] diff --git a/deploy/fixtures/main.go b/deploy/fixtures/main.go new file mode 100644 index 0000000..c5c82c6 --- /dev/null +++ b/deploy/fixtures/main.go @@ -0,0 +1,215 @@ +// Disposable integration fixtures only. Never expose this SOCKS server publicly. +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/subtle" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/binary" + "encoding/pem" + "fmt" + "io" + "log" + "math/big" + "net" + "os" + "path/filepath" + "strconv" + "time" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "certs" { + if err := certs("/certs"); err != nil { + log.Fatal(err) + } + return + } + l, err := net.Listen("tcp", ":1080") + if err != nil { + log.Fatal(err) + } + for { + c, err := l.Accept() + if err != nil { + log.Fatal(err) + } + go func() { + defer c.Close() + if err := serve(c); err != nil { + log.Print("fixture connection ended: ", err) + } + }() + } +} +func certs(dir string) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + // Preserve one CA for a compose volume across repeated dependency starts. + complete := true + for _, name := range []string{"ca.pem", "server-cert.pem", "server-key.pem", "mongo.pem", "replica-key"} { + if info, err := os.Stat(filepath.Join(dir, name)); err != nil || info.Size() == 0 { + complete = false + break + } + } + if complete { + return nil + } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "agentdb disposable test CA"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().AddDate(1, 0, 0), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature} + der, err := x509.CreateCertificate(rand.Reader, ca, ca, &key.PublicKey, key) + if err != nil { + return err + } + caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + leaf := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "localhost"}, DNSNames: []string{"localhost", "mysql57", "mysql80", "mysql84", "mongo60", "mongo70", "mongo80"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}} + der, err = x509.CreateCertificate(rand.Reader, leaf, ca, &leafKey.PublicKey, key) + if err != nil { + return err + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + raw, err := x509.MarshalECPrivateKey(leafKey) + if err != nil { + return err + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: raw}) + replicaKey := make([]byte, 48) + if _, err := rand.Read(replicaKey); err != nil { + return err + } + for name, data := range map[string][]byte{"ca.pem": caPEM, "server-cert.pem": certPEM, "server-key.pem": keyPEM, "mongo.pem": append(certPEM, keyPEM...), "replica-key": []byte(base64.StdEncoding.EncodeToString(replicaKey))} { + if err := os.WriteFile(filepath.Join(dir, name), data, 0644); err != nil { + return err + } + } + return nil +} +func serve(c net.Conn) error { + c.SetDeadline(time.Now().Add(10 * time.Second)) + var header [2]byte + if _, err := io.ReadFull(c, header[:]); err != nil { + return err + } + if header[0] != 5 { + return fmt.Errorf("bad version") + } + methods := make([]byte, int(header[1])) + if _, err := io.ReadFull(c, methods); err != nil { + return err + } + method := byte(0) + user, pass := os.Getenv("SOCKS_USER"), os.Getenv("SOCKS_PASSWORD") + if user != "" { + method = 2 + } + found := false + for _, m := range methods { + found = found || m == method + } + if !found { + c.Write([]byte{5, 255}) + return fmt.Errorf("unsupported authentication") + } + if _, err := c.Write([]byte{5, method}); err != nil { + return err + } + if method == 2 { + if _, err := io.ReadFull(c, header[:]); err != nil { + return err + } + if header[0] != 1 { + return fmt.Errorf("bad auth version") + } + u := make([]byte, int(header[1])) + if _, err := io.ReadFull(c, u); err != nil { + return err + } + var n [1]byte + if _, err := io.ReadFull(c, n[:]); err != nil { + return err + } + p := make([]byte, int(n[0])) + if _, err := io.ReadFull(c, p); err != nil { + return err + } + if subtle.ConstantTimeCompare(u, []byte(user)) != 1 || subtle.ConstantTimeCompare(p, []byte(pass)) != 1 { + c.Write([]byte{1, 1}) + return fmt.Errorf("authentication rejected") + } + if _, err := c.Write([]byte{1, 0}); err != nil { + return err + } + } + var request [4]byte + if _, err := io.ReadFull(c, request[:]); err != nil { + return err + } + if request[0] != 5 || request[1] != 1 || request[2] != 0 { + return fmt.Errorf("CONNECT required") + } + var host string + switch request[3] { + case 1, 4: + n := 4 + if request[3] == 4 { + n = 16 + } + b := make([]byte, n) + if _, err := io.ReadFull(c, b); err != nil { + return err + } + host = net.IP(b).String() + case 3: + var n [1]byte + if _, err := io.ReadFull(c, n[:]); err != nil { + return err + } + b := make([]byte, int(n[0])) + if _, err := io.ReadFull(c, b); err != nil { + return err + } + host = string(b) + default: + return fmt.Errorf("bad address type") + } + var port [2]byte + if _, err := io.ReadFull(c, port[:]); err != nil { + return err + } + up, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(int(binary.BigEndian.Uint16(port[:])))), 5*time.Second) + if err != nil { + c.Write([]byte{5, 5, 0, 1, 0, 0, 0, 0, 0, 0}) + return fmt.Errorf("target unavailable") + } + defer up.Close() + if _, err = c.Write([]byte{5, 0, 0, 1, 0, 0, 0, 0, 0, 0}); err != nil { + return err + } + c.SetDeadline(time.Time{}) + done := make(chan struct{}) + go func() { + io.Copy(up, c) + if tcp, ok := up.(*net.TCPConn); ok { + tcp.CloseWrite() + } + close(done) + }() + io.Copy(c, up) + c.Close() + up.Close() + <-done + return nil +} diff --git a/deploy/fixtures/main_test.go b/deploy/fixtures/main_test.go new file mode 100644 index 0000000..4fbd070 --- /dev/null +++ b/deploy/fixtures/main_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "os" + "path/filepath" + "testing" +) + +func TestGeneratedCertificatesVerifyAllFixtureTargets(t *testing.T) { + dir := t.TempDir() + if err := certs(dir); err != nil { + t.Fatal(err) + } + pair, err := tls.LoadX509KeyPair(filepath.Join(dir, "server-cert.pem"), filepath.Join(dir, "server-key.pem")) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(pair.Certificate[0]) + if err != nil { + t.Fatal(err) + } + ca, err := os.ReadFile(filepath.Join(dir, "ca.pem")) + if err != nil { + t.Fatal(err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(ca) { + t.Fatal("invalid CA") + } + for _, host := range []string{"127.0.0.1", "::1", "localhost", "mysql57", "mysql80", "mysql84", "mongo60", "mongo70", "mongo80"} { + if _, err := cert.Verify(x509.VerifyOptions{Roots: pool, DNSName: host}); err != nil { + t.Fatalf("%s: %v", host, err) + } + } + if _, err := cert.Verify(x509.VerifyOptions{Roots: pool, DNSName: "wrong-target.invalid"}); err == nil { + t.Fatal("accepted wrong hostname") + } + if _, err := tls.LoadX509KeyPair(filepath.Join(dir, "mongo.pem"), filepath.Join(dir, "mongo.pem")); err != nil { + t.Fatal("invalid combined Mongo PEM", err) + } +} diff --git a/deploy/mongo/init-replica.js b/deploy/mongo/init-replica.js new file mode 100644 index 0000000..1f5522a --- /dev/null +++ b/deploy/mongo/init-replica.js @@ -0,0 +1,13 @@ +try { + rs.status(); +} catch (e) { + if (e.code !== 94) throw e; + const result = rs.initiate({_id: "agentdb-test", members: [{_id: 0, host: "mongo80:27017"}]}); + if (result.ok !== 1) throw new Error(JSON.stringify(result)); +} +let primary = false; +for (let attempt = 0; attempt < 60; attempt++) { + if (db.hello().isWritablePrimary) { primary = true; break; } + sleep(1000); +} +if (!primary) throw new Error("Replica fixture did not become primary"); diff --git a/deploy/mongo/start-replica.sh b/deploy/mongo/start-replica.sh new file mode 100644 index 0000000..b92d78b --- /dev/null +++ b/deploy/mongo/start-replica.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +cp /certs/replica-key /tmp/agentdb-replica-key +chown mongodb:mongodb /tmp/agentdb-replica-key +chmod 400 /tmp/agentdb-replica-key +exec /usr/local/bin/docker-entrypoint.sh mongod --bind_ip_all --replSet agentdb-test --keyFile /tmp/agentdb-replica-key --tlsMode requireTLS --tlsCertificateKeyFile /certs/mongo.pem --tlsCAFile /certs/ca.pem --tlsAllowConnectionsWithoutCertificates diff --git a/deploy/ssh/Dockerfile b/deploy/ssh/Dockerfile new file mode 100644 index 0000000..9c7675f --- /dev/null +++ b/deploy/ssh/Dockerfile @@ -0,0 +1,6 @@ +FROM debian:12.11-slim +RUN apt-get update && apt-get install -y --no-install-recommends openssh-server ca-certificates && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /run/sshd && useradd -m -s /bin/bash agentdb \ + && echo 'agentdb:agentdb-test-password' | chpasswd +COPY sshd_config /etc/ssh/sshd_config +CMD ["sh", "-c", "ssh-keygen -A && exec /usr/sbin/sshd -D -e"] diff --git a/deploy/ssh/sshd_config b/deploy/ssh/sshd_config new file mode 100644 index 0000000..8684d44 --- /dev/null +++ b/deploy/ssh/sshd_config @@ -0,0 +1,11 @@ +Port 22 +ListenAddress 0.0.0.0 +HostKey /etc/ssh/ssh_host_ed25519_key +PasswordAuthentication yes +PubkeyAuthentication yes +PermitRootLogin no +UsePAM no +AllowUsers agentdb +Subsystem sftp internal-sftp +PrintMotd no +LogLevel VERBOSE diff --git a/docs/development-plan.md b/docs/development-plan.md new file mode 100644 index 0000000..92c084f --- /dev/null +++ b/docs/development-plan.md @@ -0,0 +1,246 @@ +# AgentDB 开发计划 + +## 0.3.0:Kubernetes、版本与模块拆分 + +- 目标服务增加可选分组字段,列表支持分组和未分组筛选,网关关联目标下拉显示分组;旧配置无需数据迁移即可读取。 + +- 增加独立「代理」菜单管理共享出口,目标只保存 outboundId 引用,下拉选择或直接连接。旧内联出口自动迁移为独立加密代理记录;引用中的代理禁止删除,运行中关联网关阻止修改,共享出口变更使关联 SSH 身份确认失效。 + +- 内置 client-go port-forward,不依赖 kubectl;六类服务可选择 Pod、Service 或 Deployment 出口,支持 kubeconfig、context、命名空间配置,沿用目标端口和 TLS 主机名。 +- 通道按连接独立管理,关闭/失败时清理;Kubernetes 凭据与目标数据库凭据独立。配置、资源解析、转发故障返回 Kubernetes 失败阶段,详细使用见 [Kubernetes](kubernetes.md)。 +- 根 VERSION 作为版本来源,CLI、管理 API、界面和发布包展示同一程序版本,构建脚本注入提交及 UTC 构建时间。 +- API 拆出鉴权、网关配置、校验和 HTTP 辅助模块;前端拆出审计 composable、代理出口和版本组件。AGENTS.md 记录模块归属、共享文件交接及验证约定。 + +更新时间:2026-09-09。本文记录已确认的设计与已实现扩展;实现使用方式见 [README](../README.md),实测范围与未完成的外部兼容验证见 [验证说明](testing.md)。 + +## 1. 目标与技术方案 + +采用 **Vue 3 + TypeScript + Element Plus、Go、SQLite**,前端构建产物嵌入 Go 程序,面向本地及小团队,支持单程序部署。 + +| 服务 | 支持版本或功能 | 访问模式 | +| --- | --- | --- | +| MySQL | 5.7.44、8.0、8.4 | 默认读写,可选只读 | +| MongoDB | 6.0、7.0、8.0,单机或直连节点 | 默认读写,可选只读 | +| SSH | 交互终端、远程命令、SFTP | 不设置只读选项 | +| Redis | RESP、认证映射、流水线和订阅 | 读写 | +| RabbitMQ | AMQP 0-9-1、PLAIN 认证 | 读写 | +| PostgreSQL | 简单查询、扩展查询、上游 MD5/SCRAM | 读写 | + +每条代理独立监听端口,一个网关账号映射到一个目标账号,可独立选择直连或外部 SOCKS5 出口。客户端使用网关凭据,Go 使用保存的目标凭据连接上游。 + +**只读是 MySQL/MongoDB 网关的可选功能,默认关闭。** 未启用时正常支持协议范围内的读写操作;启用后执行严格查询白名单。Redis、RabbitMQ、PostgreSQL 暂不提供只读。 + +不包含多管理员、网页查询工具、网页终端、SOCKS5 服务端入口、连接池、负载均衡或自动故障切换。六类目标均可选择直连、SOCKS5 或 SSH 转发出口。 + +## 2. 管理界面与配置接口 + +- 提供管理员登录、概览、代理列表、配置、连接记录和安全审计页面,支持搜索、连接测试、启停及删除。 +- 概览展示代理数量、实际运行状态、当前连接数和近期错误。 +- 创建时选择 `mysql`、`mongodb`、`ssh`、`redis`、`rabbitmq` 或 `pgsql`,创建后不可切换类型。 +- 公共配置包括名称、监听地址、端口、来源 IP/CIDR 白名单、目标地址、认证信息、连接上限和出口配置。 +- MySQL、MongoDB 提供“只读模式”开关,默认关闭。API 使用 `accessMode: readwrite | readonly`,省略时默认 `readwrite`;已有记录迁移为读写,SSH 不接受该字段。 +- 启用只读时展示兼容范围及隐藏副作用边界,列表和详情显示访问模式及被拒绝请求数。 +- 切换模式必须先停止代理并断开现有连接,重新启动后生效。 +- MongoDB 目标认证库 `authSource` 默认 `admin`;客户端网关认证库固定为 `admin`。 +- SSH 表单提供目标认证方式、客户端公钥管理及主机指纹确认。 +- 提供不包含真实密码的连接示例;MongoDB 示例包含 `directConnection=true`、`authSource=admin`、`authMechanism=SCRAM-SHA-256`。 +- 连接测试返回数据库类型、版本和 TLS 状态;MongoDB 额外返回节点角色。SSH 未确认主机指纹前仅允许身份探测。 +- 管理 API 使用 `/api/v1`,覆盖会话、代理 CRUD、测试、启停、状态、记录和秘密查看;`/healthz` 提供存活检查。 +- SQLite 保存配置及期望启用状态,实际状态取自监听器。已有无类型记录迁移为 MySQL,已有无出口记录迁移为直连。 + +## 3. 密码、密钥与管理员认证 + +- 开发阶段默认关闭管理员登录;设置 AGENTDB_AUTH_ENABLED=true 后,通过环境变量或 Windows 首次设置页面初始化管理员。登录密码使用 bcrypt,不保存可恢复副本,不提供原文查看。 +- 提供本机交互式密码重置命令,重置后注销已有会话。 +- 使用服务端会话、HttpOnly Cookie 和 CSRF 校验。 +- 目标密码、网关密码、SOCKS5 密码、SSH 上游私钥及私钥口令使用 AES-GCM 加密保存,每次加密使用独立随机 nonce。 +- 手动密钥模式从环境变量读取主密钥,不存入 SQLite;Windows 未提供主密钥时使用当前用户 DPAPI 保护的自动本地密钥。备份须保留整个数据目录或 SQLite 与外部主密钥。 +- 管理员可以查看目标密码、网关密码、SOCKS5 密码和 SSH 私钥口令。SSH 私钥仅允许上传或替换,不提供原文下载;客户端公钥可正常查看。 +- `POST /api/v1/proxies/{id}/reveal-password` 接受秘密类别和管理员登录密码,重新认证后仅返回指定秘密;要求有效会话、CSRF 校验,响应设置 `Cache-Control: no-store`。 +- 查看接口按会话和来源 IP 分别限制每 5 分钟最多 5 次尝试。 +- 前端显示原文 30 秒后隐藏,离开页面即清除,不写入浏览器持久化存储,提供显式复制按钮。 +- 普通 API 仅返回秘密是否设置,不返回明文或密文;更新时省略秘密字段表示保留原值。 +- 审计记录秘密查看和 SSH 指纹确认的操作者、时间、来源 IP、对象及结果,不记录秘密。敏感接口的输入输出必须脱敏。 +- 仅有 bcrypt 哈希的旧凭据无法恢复原文,必须重新设置后才能支持查看。 + +## 4. 可选只读模式 + +### 4.1 公共规则 + +- 仅在 `accessMode=readonly` 时启用;读写模式的单语句不执行只读白名单,MySQL 多语句批次始终要求全部满足只读规则。 +- 在转发上游前解析请求,未知、无法解析或未列入白名单的操作一律拒绝,不采用关键词匹配。 +- 网关不自动修改目标数据库权限。此模式限制客户端请求,不能保证阻止视图、服务端扩展等数据库内部隐藏的写入副作用。 +- 白名单随代码版本维护,首版不提供管理员自定义放行规则。 +- 拒绝时返回服务原生协议错误并保留连接;事件日志记录代理、时间、来源 IP、命令类别和规则编号;命令正文另写入加密命令审计表,具体范围见逐命令审计说明。 +- 直连和 SOCKS5 使用同一检查逻辑;切换模式不得保留使用旧策略的连接。 + +### 4.2 MySQL 只读 + +- 使用固定版本的 Vitess SQL parser 构建 AST,先验证对目标版本的解析能力;解析失败不回退原文放行。 +- 允许单条 `SELECT`,递归检查子查询、UNION、CTE、表达式和函数,CTE 仅允许读取。 +- 允许 `SHOW DATABASES/TABLES/COLUMNS/INDEX/CREATE TABLE/CREATE VIEW/VARIABLES/STATUS` 和 `DESCRIBE`。 +- 普通 `EXPLAIN SELECT` 检查被解释查询;拒绝 `EXPLAIN ANALYZE`。 +- 拒绝 DML、DDL、权限及管理操作、`CALL`、`DO`、`LOAD DATA`、`SELECT INTO`、变量赋值、锁定读取和可执行版本注释。多语句最多 100 条,全部通过只读检查后才开始转发。 +- 函数白名单为 `COUNT/SUM/AVG/MIN/MAX`、`ABS/CEIL/CEILING/FLOOR/ROUND`、`COALESCE/IFNULL/NULLIF/IF`、`CONCAT/CONCAT_WS/LENGTH/CHAR_LENGTH/LOWER/UPPER/SUBSTRING/TRIM/REPLACE`、`CAST/CONVERT`、`DATE/YEAR/MONTH/DAY/NOW/CURRENT_TIMESTAMP`、`DATABASE/SCHEMA/VERSION/USER/CURRENT_USER`。拒绝其他函数、自定义函数及库限定函数。 +- 允许读取系统变量,拒绝用户变量;`SET` 仅允许 `NAMES` 和 `autocommit=0|1`,不允许任意会话配置或修改只读状态。 +- 允许 `BEGIN`、`START TRANSACTION`、`COMMIT`、`ROLLBACK`,拒绝显式 `READ WRITE`;事务内逐条检查。 +- 二进制预编译在 prepare 时检查 AST,按连接登记批准的 statement ID。执行、长参数和关闭操作仅接受已批准语句,连接重置后清空状态。 +- 拒绝 SQL 文本形式的动态 `PREPARE/EXECUTE`。 +- 协议控制仅放行认证、握手、ping、切换数据库、连接重置、退出及已支持的元数据读取路径,其他命令默认拒绝。 + +### 4.3 MongoDB 只读 + +- 按 BSON 结构检查命令及参数,拒绝重复字段、未知命令和未知操作符;驱动通用元数据字段维护独立白名单。 +- 允许握手、认证、`ping`、`buildInfo`、`listDatabases`、`listCollections`、`listIndexes`、`find`、`count`、`distinct`、`aggregate`、`getMore`、`killCursors` 和 `endSessions`。 +- 聚合阶段仅允许 `$match/$project/$addFields/$set/$unset/$group/$sort/$limit/$skip/$unwind/$count/$sortByCount/$replaceRoot/$replaceWith/$lookup/$facet/$unionWith`,递归检查嵌套 pipeline。 +- 查询操作符限 `$eq/$ne/$gt/$gte/$lt/$lte/$in/$nin/$and/$or/$nor/$not/$exists/$type/$regex/$options/$all/$elemMatch/$size/$expr`。 +- 聚合表达式允许字段引用、字面量,以及 `$literal/$eq/$ne/$gt/$gte/$lt/$lte/$cmp/$and/$or/$not/$add/$subtract/$multiply/$divide/$mod/$abs/$ceil/$floor/$round/$cond/$ifNull/$switch/$convert/$toString/$toInt/$toLong/$toDouble/$toDecimal/$toDate/$toBool/$concat/$toLower/$toUpper/$substrBytes/$substrCP/$strLenBytes/$strLenCP/$trim/$sum/$avg/$min/$max/$first/$last/$push/$addToSet`。 +- 递归检查表达式,按上下文区分操作符与数据;`$literal` 内按数据处理,不因普通数据包含操作符字符串而误判。 +- 明确拒绝 `$out`、`$merge`、`$where`、`$function`、`$accumulator`、`mapReduce`、写命令、DDL 和权限管理。 +- `explain` 仅允许 `queryPlanner`,内部命令必须通过同样检查。 +- 支持普通逻辑会话;只读模式首版拒绝多文档事务及其控制请求。 +- 游标按客户端连接和命名空间登记,`getMore`、`killCursors` 只能访问该连接已批准的游标;连接结束清理状态。 + +## 5. 协议实现 + +### 5.1 MySQL + +- 使用固定版本的 `go-mysql-org/go-mysql`,先完成认证、TLS、事务、预编译及连接注入的独立验证。 +- 网关认证使用 `mysql_native_password`;上游支持该机制及 `caching_sha2_password`,MySQL 8.4 不依赖开启旧插件。 +- 每个客户端独占上游连接,不共享连接池,保留事务、会话变量、默认数据库和预编译状态。 +- 支持查询、结果集、错误响应、事务、预编译执行与关闭、连接重置及会话隔离。 +- 按目标能力协商协议,兼容结果集终止方式、字符集和事务状态差异。 +- 默认使用 `utf8mb4`、`utf8mb4_general_ci`,不向 MySQL 5.7 自动发送 8.x 专属排序规则。 +- 原样转发已获准 SQL 和上游错误,不改写 SQL 以兼容不同数据库版本。 +- 不支持复制和 LOCAL INFILE。目标支持 zlib 压缩;多语句仅允许全部符合只读规则的批次(读写模式亦然),同一上游会话顺序执行并返回多个结果集,遇错停止,不自动回滚。 + +### 5.2 MongoDB + +- 实现独立 wire protocol 模块;官方 Go 驱动仅用于连接测试及验收,不作为任意命令转发层。 +- 支持现代 `hello`、初始 `isMaster` 兼容握手、`OP_MSG` 和推测认证。 +- 客户端和上游分别完成 SCRAM-SHA-256,不转发客户端认证消息。首版不支持 SCRAM-SHA-1、X.509 或云身份认证。 +- 每个客户端独占上游连接,保留 BSON 类型、请求关联、游标及会话标识,权限由目标账号执行。 +- 读写模式支持 CRUD、聚合、索引、批量写入、游标,以及目标节点支持的事务。 +- 客户端必须使用 `directConnection=true`;不进行副本集发现、节点地址改写、自动故障切换或分片路由。 +- 单机不支持多文档事务;直连副本集主节点时验证事务可用,主节点降级或切换后向客户端传递错误。 +- 不支持压缩和 exhaust,不保证变更流、复制及备份工具兼容性。 + +### 5.3 SSH + +- 使用固定版本的 `golang.org/x/crypto/ssh`,分别建立客户端到网关、网关到目标的 SSH 连接。 +- 客户端通过网关密码或登记公钥认证,每条代理至少启用一种方式;公钥支持备注、指纹、添加和移除。 +- 上游选择密码或私钥认证,支持私钥口令,不隐式回退其他认证方式。 +- 支持 Ed25519 和使用 SHA-2 签名的 RSA,不启用旧 SHA-1 算法。 +- 每条代理生成独立的 Ed25519 网关主机密钥,加密持久化;展示指纹,重启及普通修改不改变密钥。 +- 首次探测目标时只获取公钥,不发送目标凭据。管理员通过独立渠道核对 SHA-256 指纹并确认保存后,才能认证测试或启动。 +- 后续主机密钥不匹配立即拒绝连接;目标地址或端口变化必须重新确认。仅切换 SOCKS5 出口不清除指纹,新路径仍须匹配原指纹。 +- 每个客户端独占上游 SSH 连接,支持多个 session channel、PTY、窗口变化、shell、exec、必要的控制请求、退出状态和半关闭。 +- SFTP 通过 `sftp` subsystem 双向转发,文件权限由目标账号控制,不保存文件正文。 +- 拒绝 TCP 本地及远程转发、Agent 转发、X11 和非 SFTP subsystem;环境变量仅允许 `TERM`、`LANG`、`LC_*`。 +- 不保证旧式 SCP 协议兼容性,以 SFTP 及现代客户端基于 SFTP 的传输为验收范围。 + +## 6. SOCKS5 出口 + +- 每条代理的 `outbound.mode` 为 `direct` 或 `socks5`,默认 `direct`;SOCKS5 配置包含地址、端口、认证方式及可选用户名、密码。 +- 支持无认证和用户名密码认证;启用密码认证时不得降级为无认证,凭据按 UTF-8 字节数校验 1–255 字节限制。 +- 建立支持 `context` 的公共出站连接接口,供实际服务连接、连接测试和 SSH 指纹探测使用。协议库通过连接注入或适配接入,不得绕过配置直连。 +- 流程为连接 SOCKS5 → 认证协商 → `CONNECT` 目标 → 目标 TLS/SSH 握手和服务认证。 +- 仅支持 TCP `CONNECT`,不支持 UDP、`BIND`、多级代理链或额外的 SOCKS5-over-TLS。 +- 目标域名交给 SOCKS5 服务器解析,不提前本地解析;支持 IPv4/IPv6。SOCKS5 服务器自身域名由 Go 所在机器解析。 +- 数据库 TLS 服务器名称和证书校验仍使用原始目标主机名;SSH 指纹仍对应目标服务,不使用 SOCKS5 地址替代。 +- SOCKS5 失败不回退直连。 +- 直连建连阶段或 SOCKS5 连接、认证及 `CONNECT` 阶段共享 5 秒超时;目标握手和认证使用 15 秒超时。 +- 取消、超时和中间失败必须关闭已建立连接,避免连接及 goroutine 泄漏。 +- 连接测试执行完整出口路径及目标认证;管理端区分出口连接、SOCKS5 认证、目标连接和目标认证失败,客户端仅返回必要协议错误。 +- 修改出口与修改目标配置一样,必须先停止代理。 + +## 7. 运行与数据保留 + +- 管理端默认监听 `127.0.0.1:8080`,远程访问通过 HTTPS 反向代理。 +- 服务代理默认监听 `0.0.0.0`,来源 IP/CIDR 白名单可选:留空允许所有来源,填写后在认证前检查,仅放行匹配来源;非法 IP/CIDR 拒绝保存。 +- MySQL、MongoDB 客户端 TLS 可选,普通连接无需证书;配置证书后同一端口接受 TLS(最低 TLS 1.2)。取消网关强制 TLS,目标上游 TLS 仍按目标配置执行。 +- 修复 MySQL 客户端能力协商:客户端声明 MULTI_STATEMENTS、DEPRECATE_EOF 等未协商能力时不再直接断开;使用网关已声明的协议格式,上游仍禁用多语句。回归覆盖普通查询、预编译、ping 和只读拦截;官方 MySQL 5.7.44 CLI 非 TLS 连接实际网关并对 MySQL 8.4.11 目标执行 `SELECT 1` 成功。 +- 2026-09-09 取消强制 TLS 后回归:MySQL 5.7.44 真实目标(上游 TLS + zlib)同时验证网关普通连接与 TLS,读写、只读和审计通过;MySQL/MongoDB 的普通连接、有证书/无证书场景通过协议测试。日志:`artifacts/mysql-verification/optional-tls-mysql57.log`。 +- 数据库上游默认启用 TLS 并验证证书,可显式关闭,不提供跳过验证模式;MySQL 5.7 验收环境启用 TLS 1.2。 +- SSH 使用自身加密及主机密钥校验,不套用数据库 TLS 配置。 +- 每条代理默认最多 100 个客户端连接,SSH 每连接最多 10 个 session channel;不设置普通查询或 SSH 会话总时长限制。 +- 修改运行配置、切换只读或删除前必须停止,停止会断开已有连接,页面提前提示。 +- 启动时检查端口冲突并恢复已启用配置,单条启动失败不影响其他代理。 +- 进程退出时停止接受连接,最多等待 30 秒后关闭剩余连接。 +- 连接、安全事件和命令审计记录保留 7 天,定期清理。SQL、MongoDB 命令、SSH exec/交互输入及 SFTP 操作写入独立加密命令表;认证握手、命令输出和文件正文不记录。 + +## 8. 验证与验收 + +### 8.1 默认行为和只读 + +- 新建及迁移后的数据库代理默认读写;关闭只读正常写入,开启后执行白名单,SSH 不受影响。 +- 切换模式断开旧连接,新连接使用新策略;直连和 SOCKS5 行为一致。 +- MySQL 覆盖注释绕过、CTE/UNION、子查询、函数、锁定读取、文件输出、动态 SQL、多语句和 statement ID 状态。 +- MongoDB 覆盖嵌套 `$lookup/$facet/$unionWith`、`$out/$merge`、服务端脚本、重复字段、未知操作符、跨连接游标及事务拒绝。 +- 使用具备写权限的上游测试账号验证网关限制;通过上游请求计数或测试审计确认拒绝请求未被转发,并检查数据和结构未变化。 +- 对 SQL/BSON 检查器进行模糊测试,畸形、超深或异常输入不得崩溃或默认放行。 + +### 8.2 协议矩阵 + +- MySQL 5.7.44、8.0、8.4 使用 CLI 和 Go 驱动验证认证、TLS、字符集、查询类型、读写、事务、预编译及会话隔离;8.4 不开启旧认证插件。 +- MongoDB 6.0、7.0、8.0 使用 `mongosh` 和官方 Go 驱动验证握手、认证、CRUD、BSON、批量写入、跨批次游标、错误和直连副本集主节点事务。 +- SSH 使用 OpenSSH、`ssh`、`sftp` 验证密码、公钥、带口令私钥、首次指纹确认、指纹变化拒绝、重启指纹稳定、终端缩放、退出码、多 channel 和文件传输。 +- 验证不支持的 SSH 转发被拒绝,目标断开、半关闭及认证失败后正确清理。 + +### 8.3 出口、安全与界面 + +- 三类服务覆盖直连、无认证 SOCKS5、用户名密码 SOCKS5,以及出口不可达、认证失败、目标拒绝、超时和取消。 +- 使用仅 SOCKS5 环境可解析的目标域名验证远端 DNS;覆盖 IPv4/IPv6、禁止降级、失败不直连、TLS 主机名和 SSH 指纹校验。 +- 验证秘密查看的重新认证、会话、CSRF、限流、禁缓存、自动隐藏及 API/日志脱敏。 +- 覆盖白名单拒绝、端口冲突、连接限额、启停、重启恢复及异常断连后的资源清理。 +- 页面验收覆盖创建配置、连接测试、版本或指纹展示、模式选择、启动、复制连接示例、查看状态、停止及删除。 + +## 9. 开发顺序与交付物 + +1. 完成三类协议、公共出站适配和只读策略的独立验证,固定依赖及测试镜像版本。 +2. 建立 Go 服务、SQLite 自动迁移、秘密存储、管理员会话和配置 API。 +3. 实现公共监听器、出站连接层、协议模块及可选只读策略,接入生命周期和记录。 +4. 实现 Vue 管理界面、秘密查看、SSH 指纹确认和连接示例。 +5. 完成版本矩阵、端到端测试、构建嵌入和部署文档。 + +交付自动迁移、管理员密码重置命令、构建启动说明、环境变量示例、可选只读白名单及限制文档、TLS/SSH/SOCKS5 配置说明,以及包含数据库、OpenSSH 和 SOCKS5 的 Docker Compose 测试环境。 + +### Windows 双击启动(0.1.1) + +- 已实现无环境凭据启动:自动打开浏览器,首次页面设置管理员,后续复用账号。 +- 默认数据目录 `%APPDATA%\AgentDB`;随机主密钥使用当前 Windows 用户 DPAPI 加密保存,独占锁阻止多个进程同时启动。 +- 自动生成一年有效的 localhost TLS 证书;保持服务器环境变量部署兼容。 +- 本地初始化仅监听回环地址,校验 Host、Origin 和一次性随机 token;初始化完成后关闭设置入口。 +- 错误保留在控制台窗口;提供打包后的首次启动、浏览器设置、密钥持久化和重启验证脚本 `scripts/verify-desktop.mjs`。 + +### 0.2.0:服务解耦、主题与开发免登录 + +- 目标代理出口新增 SSH 转发(`outbound.mode=ssh`):支持跳板机账号密码及固定 SHA256 主机指纹,使用 direct-tcpip 连接目标,适用于 MySQL/MongoDB/SSH。域名由跳板机解析,连接独立管理 SSH 通道、超时和关闭,不回退直连。跳板机须允许 TCP 转发;本期跳板机认证为密码,目标 SSH 自身仍支持私钥。 +- 开发免登录模式下点击查看密码直接显示账号和密码,支持分别复制;启用管理员认证时保留重新认证。连接测试详情展示返回字段、目标地址、出口方式、耗时和失败阶段。 + +- 目标服务独立管理连接地址、认证凭据、上游 TLS、SSH 目标指纹及 SOCKS5 出口;多个同协议网关通过 targetId 引用同一目标。 +- 网关独立管理监听、来源白名单、连接上限、网关账号、SSH 网关身份及可选只读策略。删除网关不删除目标;被引用的目标不能删除;修改目标须先停止所有关联网关。 +- 旧数据通过事务自动拆分为 targets/gateways 两张加密配置表,引用有外键约束;迁移保留原网关 ID、密码、SSH 主机身份和启用状态。回滚版本须恢复升级前备份。 +- 新增 /api/v1/targets CRUD、连接测试、密码查看和 SSH 指纹确认;网关仍使用 /api/v1/proxies。旧版内联创建请求兼容导入为独立目标,但网关更新禁止修改目标参数;新界面只使用 targetId 创建网关。 +- 管理界面增加浅色、深色、海蓝及跟随系统主题,浏览器持久化选择。 +- 开发阶段默认 AGENTDB_AUTH_ENABLED=false:跳过管理员创建与登录,直达控制台;仅限回环访问,保留 Host/Origin/CSRF 校验。设置 true 恢复 bcrypt 管理员登录、会话及密码查看重新认证。远程部署必须显式开启认证。 + +### 目标压缩与逐命令审计 + +- 开发启动入口:根目录 `start-dev.cmd` 调用 `scripts/start-dev.ps1`,固定使用项目 `data/agentdb.sqlite` 与同目录 DPAPI 密钥、TLS 文件,重启复用配置,默认回环免登录。测试数据库单独存放,不使用开发配置库。 +- 2026-09-09 实测 Windows MySQL Community 5.7.44:目标 TLS 开启,分别使用 none/zlib 压缩,通过读写、预编译、事务提交/回滚、中文与 emoji、只读阻止 DELETE/UPDATE prepare、错误密码拒绝、网关明文连接拒绝和命令审计。临时数据库自动删除;日志在 `artifacts/mysql-verification/real-mysql57-*.log`。本次真实实例测试为直连,未覆盖 SOCKS5 或 MySQL 8.x。 + +- MySQL 5.7 兼容 zlib CLIENT_COMPRESS,先读取并回放目标 greeting,仅在目标声明支持时请求压缩;MongoDB hello 协商 zlib OP_COMPRESSED,限制解压尺寸、校验包结构/校验和,避免压缩认证与凭据管理命令。目标不支持时回退为未压缩,测试返回实际算法。SSH 当前仅 none。 +- 命令日志独立加密存储,记录网关、目标、来源 IP、协议、操作类别、内容和放行/拦截决策,支持 200 条游标分页,保留 7 天。单条内容 16 KiB 截断并标记;不能写入审计时阻止继续发送请求。 +- SQL 记录查询与预编译模板,执行包记录 statement ID/参数字节数;MongoDB 记录 Extended JSON 主体,批量文档序列只记录存在与字节数。认证握手不记。 +- SSH exec 记录请求命令,shell 按提交输入行记录并明确不等同执行确认;SFTP 解析请求类别、路径/句柄、偏移与长度,不保存文件正文。远端脚本内部命令和终端编辑不能由网关可靠还原。 + +### 审计分库、导出与连接编排 + +- 配置固定在 `data/agentdb.sqlite`;命令审计与连接/安全事件写入 `data/audit/YYYY-MM-DD.sqlite`,按 UTC 日期分库,同日分别使用 commands/events 表。 +- 启动自动迁移旧日志,命令密文保持不变,迁移中断可重试;升级前停止服务并备份整个 data 目录。日志保留 7 天,过期分库清理;查询和导出跨分库进行。 +- 命令审计支持网关、协议、目标、来源 IP、决策、操作类型、关键词和时间筛选;连接与审计支持服务、来源 IP、事件类型、关键词和时间筛选。两页均支持游标分页和按筛选条件导出全部 CSV,不受当前页限制。 +- CSV 使用 UTF-8 BOM、正确转义及公式防护,通过临时文件下载,避免完整导出占用服务端内存。 +- 拖拽连接支持把网关拖到同协议目标,也可依次点击连接;运行中的网关须先停止,保存后刷新仍保留关联。 +- MySQL 放行 SHOW TABLE STATUS(含库名、LIKE、WHERE)、SHOW FULL PROCESSLIST,以及 SHOW VARIABLES 与 information_schema.ENGINES 查询组成的只读批次。 +- Redis 暂不改写 Cluster 重定向;RabbitMQ 仅支持 AMQP 0-9-1;PostgreSQL 暂不支持独立 CancelRequest。三种新增服务暂无只读开关,压缩仍仅支持 MySQL/MongoDB。 diff --git a/docs/kubernetes.md b/docs/kubernetes.md new file mode 100644 index 0000000..e59a07a --- /dev/null +++ b/docs/kubernetes.md @@ -0,0 +1,26 @@ +# Kubernetes port-forward + +AgentDB 内置 Kubernetes 客户端,不需要安装 `kubectl`。先在独立「代理」菜单新增 `K8s port-forward` 代理,再在目标服务的「代理出口」下拉框选择它。MySQL、MongoDB、SSH、Redis、RabbitMQ、PostgreSQL 均可引用同一个代理配置。 + +## 配置 + +| 字段 | 含义 | +| --- | --- | +| Kubeconfig 路径 | AgentDB 所在机器上的配置文件路径;留空使用 `KUBECONFIG` 或当前用户的 `~/.kube/config`,集群内部可使用 ServiceAccount | +| Context | kubeconfig 中的 context 名称;留空使用当前 context | +| 命名空间 | 留空使用所选 context 的命名空间,未设置时为 `default` | +| Kubernetes 资源 | `pod/mysql-0`、`service/mysql` 或 `deployment/mysql` | +| 目标端口 | Pod/Deployment 使用容器 TCP 端口;Service 使用 Service 的 port,自动解析数字或命名 targetPort | +| 目标主机 | 保留数据库 TLS 的证书主机名;实际连接通过 Kubernetes API 转发,不在本机解析该主机 | + +例如 MySQL Service 在 `database` 命名空间暴露 `3306`:资源填 `service/mysql`,命名空间填 `database`,目标端口填 `3306`;TLS 开启时目标主机须匹配数据库证书,例如 `mysql.database.svc`。数据库账号密码仍填目标服务自己的凭据,客户端使用网关账号。 + +配置文件仅保存路径,不把 kubeconfig 内容上传到管理接口。AgentDB 运行用户须能读取该文件及它引用的证书、私钥。使用云厂商 exec 登录插件的配置仍依赖对应认证插件;不支持交互式登录提示。不要使用来源不可信的 kubeconfig。 + +## 权限与连接 + +集群 API 必须可从 AgentDB 所在机器访问。凭据需要所选资源的读取权限、Service/Deployment 对应 Pod 的列表权限,以及 `pods/portforward` 的创建权限。Service 必须有 Pod selector,选出的 Pod 必须处于 Running 状态,优先选择 Ready Pod。 + +每条上游连接使用独立的 port-forward 通道和随机本地回环端口。连接关闭、网关停止或建连失败时清理通道和监听端口,不回退直连。建立成功的数据库会话不受建连超时影响。Pod 重启或网络中断后,原会话断开;客户端重连时重新选择 Pod,不自动重放 SQL 或消息。 + +当前使用 Kubernetes SPDY port-forward 协议,仅支持 TCP,要求 AgentDB 直接访问集群 API;暂不支持 kubeconfig 的 `proxy-url` 或匹配 API 地址的 HTTP(S) 代理环境变量,配置这些代理时明确返回失败。集群 API 自身的反向代理需要允许流式连接升级。普通 kubeconfig 证书、Token 认证无需 kubectl;特定云平台认证和真实集群兼容性需在实际环境验证。 diff --git a/docs/readonly.md b/docs/readonly.md new file mode 100644 index 0000000..40b10a3 --- /dev/null +++ b/docs/readonly.md @@ -0,0 +1,29 @@ +# 可选只读模式 + +MySQL、MongoDB 新建及默认配置均为读写。每条代理可独立开启只读;SSH、Redis、RabbitMQ、PostgreSQL 当前没有此选项。切换前必须停止并断开现有连接,再启动使用新策略。 + +只读是在协议网关对请求执行白名单,并不是目标数据库账号的权限。无法解析或不在白名单的请求不会转发上游;已允许的视图、服务器扩展内部仍可能有副作用。需要数据库级绝对写权限隔离时,应同时使用目标数据库的只读账号。 + +## MySQL + +使用 AST 递归检查 SELECT、CTE、UNION、子查询、表达式和函数,允许计划中的常见 SHOW、DESCRIBE、普通 EXPLAIN SELECT 和有限会话/事务控制。包括 SHOW TABLE STATUS(可带数据库、LIKE、WHERE)、SHOW [FULL] PROCESSLIST、SHOW VARIABLES LIKE 和 information_schema.ENGINES 查询。 + +允许最多 100 条符合上述策略的批量语句,先检查整个批次,再在同一目标会话逐条执行并返回多个结果集;批量写入不支持,读写模式仍允许单条写入。数据库运行错误发生后停止剩余语句,已执行语句不会自动回滚。 + +拒绝写入、DDL、管理操作、CALL、动态 PREPARE/EXECUTE、可执行版本注释、SELECT INTO、锁定读取、用户变量和未知函数。二进制预编译在 prepare 时审批,后续仅允许该连接中已批准的 statement ID,重置后清空。 + +为防止字符集和 SQL 模式导致网关解析结果与上游执行不同,只读连接固定解析兼容的 SQL 模式;SET NAMES 仅允许 utf8mb4、utf8、utf8mb3、ascii。解析器不支持的有效 MySQL 语法也会被拒绝。 + +## MongoDB + +按 BSON 命令、查询操作符、聚合阶段和表达式上下文递归检查,拒绝未知操作、重复字段及超深结构。允许的读取包括 find、count、distinct、aggregate、列表、游标和有限元数据读取。 + +嵌套 `$lookup`、`$facet`、`$unionWith` 同样检查;拒绝 `$out`、`$merge`、`$where`、`$function`、`$accumulator`、mapReduce、写入、DDL 和权限管理。`$literal` 内按数据处理。 + +只读模式不支持多文档事务,普通逻辑会话可用;getMore、killCursors 只能操作同一客户端连接中登记的游标。连接池把游标迁到其他连接时可能被拒绝,调用方需适配此限制。 + +完整设计白名单见 [开发计划](development-plan.md#4-可选只读模式),实际策略位于 `internal/protocol/mysqlproxy/policy.go` 和 `internal/protocol/mongoproxy/policy.go`,由测试覆盖。白名单不支持通过管理界面扩展。 + +## 错误与记录 + +被拒绝请求使用数据库原生错误返回,普通策略拒绝不主动断开连接。连接事件记录规则编号;命令审计加密记录 SQL/BSON 请求内容(参数和正文覆盖范围见开发计划)。直连、SOCKS5 与 SSH 转发出口执行相同规则。 diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..d06b9be --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,129 @@ +# 测试与版本矩阵 + +## 0.3.0 验证 + +- 共享代理出口:Go 全套测试与静态检查通过,覆盖旧出口原子迁移/幂等性、凭据不重复保存、目标引用解析、共享更新、运行中修改阻止、引用删除阻止和密码查看权限。八项浏览器流程验证代理独立菜单、目标下拉、多目标共享、解绑删除及既有服务功能;主题测试遇到 Edge 进程退出后独立重跑通过。桌面/390px 手机截图无页面溢出或抽屉裁切。 + +- Go 全量测试和 go vet 通过;Vue 类型检查和生产构建通过。 +- 内置 Kubernetes 测试使用模拟 API 与真实 SPDY 流,覆盖 TLS/Token、双向数据、建连取消、成功连接不受拨号 context 取消影响、关闭清理,以及 Service selector 和命名 targetPort。未连接真实 Kubernetes 集群。 +- 七项浏览器流程覆盖既有功能、K8s 配置保存/编辑、缺失 kubeconfig 错误和版本信息;手机抽屉位置、宽度及截图验证通过。主题测试在整套运行时遇到 Edge 进程退出,独立重跑通过。 +- 版本 CLI/API、无数据初始化的版本查询及 Windows 元数据注入构建通过。Linux/Docker 构建尚未运行。 + +## 本次验证记录 + +2026-09-09,Windows / Go 1.25.0 / Node.js 24 / Microsoft Edge: + +- `go test ./...` 通过,真实服务测试在未提供环境变量时明确跳过。 +- `go vet ./...` 通过。 +- Vue 类型检查与生产构建通过,生成并嵌入 Go 可执行文件。 +- Playwright 两条完整流程通过:MySQL 默认读写/可选只读/密码查看,MongoDB 与 SSH 的 SOCKS5 配置及编辑后凭据保留。 +- 已检查桌面与移动端截图,移动端使用独立卡片布局且无页面横向溢出。 +- PowerShell、Bash、MongoDB 初始化脚本语法检查及主密钥生成检查通过。 +- MySQL AST 和 MongoDB wire/BSON 只读检查器完成短时模糊测试。 + +Docker 真实服务矩阵尚未在本机执行,不能据此宣称全部目标版本已完成兼容认证。 + +## 本地测试 + +统一使用 Go 1.25.0。Windows 已安装更高版本 Go 时设置 `GOTOOLCHAIN=go1.25.0`;首次使用需下载兼容工具链。 + +```powershell +$env:GOTOOLCHAIN = 'go1.25.0' +go test ./... +go vet ./... +npm.cmd --prefix web run build +go build -o bin/agentdb.exe ./cmd/agentdb +npm.cmd --prefix web test +``` + +浏览器测试使用隔离的临时 SQLite、随机主密钥和仅供测试的管理员密码,监听 `127.0.0.1:18081`,结束后清理。不连接用户的数据库。默认使用已安装的 Edge;其他平台可安装 Playwright Chromium 并设置 `AGENTDB_BROWSER_CHANNEL=chromium`。截图输出到忽略版本控制的 `web/test-results`。 + +## 已有自动化覆盖 + +| 模块 | 覆盖范围 | +| --- | --- | +| API | 登录、会话/CSRF、默认读写、CRUD 脱敏、密码保留和重新认证查看、限流、停止前禁止修改、主机指纹字段防绕过 | +| 存储 | AES-GCM 随机 nonce、篡改/用途/错误密钥拒绝、SQLite 加密持久化、管理员密码重置及会话版本 | +| 运行管理 | CIDR、来源限制、并发上限、端口冲突、可选 TLS、启停和连接清理 | +| SOCKS5 | 无认证/密码认证、禁止降级、远端 DNS、IPv4/IPv6、失败不直连、超时和取消 | +| MySQL | TLS 网关、raw packet 转发、二进制预编译、重置、可选只读、LOCAL INFILE 拒绝、AST 模糊测试 | +| MongoDB | 官方 Go 驱动连接、双端 SCRAM、推测认证、上下游 TLS、BSON/OP_MSG、批量写入/游标、只读递归检查与模糊测试 | +| SSH | 进程内真实 SSH 协议、密码/公钥、带口令私钥、固定指纹、PTY/window、exec/退出码、SFTP channel、限制转发及取消 | +| 管理界面 | 登录、新建三类代理、默认读写、切换只读、SOCKS5 配置、凭据保留、密码查看、桌面及移动端布局 | + +进程内协议测试使用可控服务端,不等同于真实数据库版本或 OpenSSH 的完整兼容认证。 + +## Docker 真实版本测试 + +需要安装并运行 Docker Engine/Compose。所有服务为独立测试实例,端口只绑定到本机;脚本使用独立 Compose 项目,测试会创建并删除临时数据库,结束后删除该项目的测试卷。不要将这些测试指向生产服务。 + +```powershell +.\scripts\test-matrix.ps1 +``` + +```sh +bash scripts/test-matrix.sh +``` + +固定镜像和端口以 [Compose 文件](../deploy/compose.test.yml) 为准: + +| 服务 | 版本 | 本机端口 | +| --- | --- | --- | +| MySQL | 5.7.44 / 8.0.43 / 8.4.6 | 13357 / 13380 / 13384 | +| MongoDB | 6.0.26 / 7.0.24 / 8.0.13 | 27060 / 27070 / 27080 | +| OpenSSH | 测试 Dockerfile 固定基础镜像 | 12222 | +| SOCKS5 | 仓库内测试实现,无认证 / 密码 | 11080 / 11081 | + +生成的测试 CA 用于目标 TLS 验证。MongoDB 8.0 测试实例配置单节点副本集,脚本负责初始化以验证事务。脚本对全部数据库版本及 SSH/SFTP 分别执行直连、无认证 SOCKS5、密码 SOCKS5 三条路径;SOCKS5 路径使用仅 Docker 内可解析的服务名,检查远端 DNS。服务凭据仅供这些一次性测试实例使用,详见 Compose。 + +手动启动测试实例: + +```sh +docker compose -f deploy/compose.test.yml up -d --build --wait +docker compose -f deploy/compose.test.yml run --rm --no-deps mongo80-init +``` + +独立连接已准备好的目标测试实例: + +- MySQL:`AGENTDB_MYSQL_TEST_ADDR`、`AGENTDB_MYSQL_TEST_USER`、`AGENTDB_MYSQL_TEST_PASSWORD`、可选 `AGENTDB_MYSQL_TEST_CA`(CA 文件路径),执行 `go test ./internal/protocol/mysqlproxy -run TestRealMySQL -count=1 -v`。 +- MongoDB:`AGENTDB_MONGO_TEST_ADDR`、`AGENTDB_MONGO_TEST_USER`、`AGENTDB_MONGO_TEST_PASSWORD`、可选 `AGENTDB_MONGO_TEST_CA`(PEM 内容),执行 `go test ./internal/protocol/mongoproxy -run TestRealMongo -count=1 -v`。 +- SSH:`AGENTDB_SSH_TEST_ADDR`、`AGENTDB_SSH_TEST_USER`、`AGENTDB_SSH_TEST_PASSWORD`,执行 `go test ./internal/protocol/sshproxy -run TestRealSSH -count=1 -v`。验证远程输出和退出码、SFTP 上传下载及清理、禁止 TCP 转发。 + +三类测试可额外设置 `AGENTDB_TEST_SOCKS_ADDR` 和可选 `AGENTDB_TEST_SOCKS_USER/PASSWORD`,使用真实生产出站层经 SOCKS5 连接上述目标。测试中的首次 SSH 指纹探测仅适用于已明确指定的一次性测试服务,不替代生产环境的管理员独立核对流程。 + +测试账号须允许创建和删除独立测试库;未设置地址时真实版本测试明确跳过。省略上游 CA 时测试按明文上游运行,但网关客户端仍执行 TLS。 + +## 验证边界 + +当前开发机器没有 Docker、MySQL 或 MongoDB 服务,真实版本矩阵和 OpenSSH/mongosh 客户端组合尚未在此机器执行。Docker 镜像启动、数据库各版本 TLS/认证差异、真实副本集事务以及所有服务经 SOCKS5 的端到端组合,需要运行测试环境后再确认。 + +只读是严格请求白名单,不等同数据库权限保证;MongoDB 只读游标按客户端连接限制,多文档事务不在只读兼容范围。完整边界见 [只读说明](readonly.md) 和 [开发计划](development-plan.md)。 + +## Windows 双击启动验证 + +已通过 `go test ./...`、`go vet ./...`,以及 Edge 无头浏览器的桌面启动回归。验证包括无管理员/主密钥环境变量启动、从其他工作目录启动、首次页面创建管理员、自动本机 TLS、DPAPI 密钥持久化、重启后原账号登录。初始化单元测试覆盖错误 token、跨来源请求和重复初始化;Windows 测试覆盖独占锁和数据库存在时禁止替换丢失密钥。 + +对解压后的发行包运行: + +```powershell +node scripts/verify-package.mjs <解压后的目录> +node scripts/verify-desktop.mjs <解压后的目录>/agentdb.exe +``` + +脚本只在临时用户数据目录中测试,不读取实际用户配置。桌面脚本用 Edge 自动操作首次设置页面,禁用系统浏览器弹窗并使用随机本机端口;系统默认浏览器关联及资源管理器双击交互未由此脚本覆盖。 + +## 0.2.0 新增验证范围 + +- 独立目标 CRUD、多网关复用、目标修改/删除约束、旧版事务迁移、密码/SSH 身份/启用状态保留、网关存储不重复保存上游秘密。 +- 本机免登录开发会话、Host/来源/CSRF 限制;认证启用时的既有登录与重新认证测试。 +- MySQL 模拟服务压缩协商/回退、流量缩减、结果集和预编译;MongoDB 压缩协商/回退、压缩报文边界、凭据命令不压缩、只读网关转发。真实目标压缩仍需实际版本矩阵验证。 +- SQL/MongoDB 放行与拦截审计、预编译模板、日志故障阻止转发;SSH exec/输入/SFTP 解析;日志内容加密、分页、筛选和 7 天保留。 +- Edge 验证默认免登录、独立目标与网关表单、共享目标/只读策略、MongoDB/SSH/SOCKS5、压缩配置、三种主题及系统跟随/持久化、移动布局和命令审计入口。 + +## 2026-09-09 扩展验收 + +- Go 1.25.0 全量单元、协议、API、迁移测试与 go vet 通过;Vue 类型检查与生产构建通过。 +- 隔离的真实 MySQL 5.7.44 实例通过 TLS + zlib、读写/只读、SHOW TABLE STATUS、SHOW FULL PROCESSLIST 和指定三语句批次测试;结果见 `artifacts/mysql-verification/show-batches-mysql57.log`。测试库自动清理,实例验证后关闭。 +- Redis 使用可控 RESP 服务验证认证、HELLO、流水线、订阅、取消及审计;RabbitMQ 使用 amqp091-go 客户端连接模拟 broker;PostgreSQL 使用 pgconn 客户端连接模拟后端,验证简单/扩展查询、参数化、认证及审计。尚未对真实 Redis、RabbitMQ、PostgreSQL 部署做版本兼容验收。 +- 审计测试覆盖跨三日 411 条记录分页、跨日关键词和完整导出、密文迁移、失败重试/冲突保护、7 天清理、取消和并发。连接事件覆盖旧记录迁移、跨日筛选/分页及 CSV 导出。未执行 Go race 检查(当前未配置所需 C 工具链)。 +- Edge 六项流程通过,覆盖拖拽连接、审计筛选/导出、共享目标、可选只读、SSH/SOCKS5 出口、三种新增服务配置、主题及移动布局。最后一项首次因 Edge 进程退出失败,单独重跑通过。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1f5987c --- /dev/null +++ b/go.mod @@ -0,0 +1,92 @@ +module agentdb + +go 1.25.0 + +require ( + github.com/go-mysql-org/go-mysql v1.13.0 + github.com/jackc/pgx/v5 v5.7.6 + github.com/pkg/sftp v1.13.9 + github.com/rabbitmq/amqp091-go v1.10.0 + github.com/xdg-go/scram v1.2.0 + go.mongodb.org/mongo-driver/v2 v2.5.0 + golang.org/x/crypto v0.48.0 + golang.org/x/sys v0.41.0 + golang.org/x/term v0.40.0 + k8s.io/api v0.34.1 + k8s.io/apimachinery v0.34.1 + k8s.io/client-go v0.34.1 + modernc.org/sqlite v1.44.3 + vitess.io/vitess v0.22.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/glog v1.2.4 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/kr/fs v0.1.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/spdystream v0.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pingcap/errors v0.11.5-0.20250318082626-8f80e5cb09ec // indirect + github.com/pingcap/log v1.1.1-0.20241212030209-7e3ff8601a2a // indirect + github.com/pingcap/tidb/pkg/parser v0.0.0-20250421232622-526b2c79173d // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20241121165744-79df5c4772f2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/shopspring/decimal v1.2.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.11.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect + google.golang.org/grpc v1.71.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4e09d3a --- /dev/null +++ b/go.sum @@ -0,0 +1,355 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-mysql-org/go-mysql v1.13.0 h1:Hlsa5x1bX/wBFtMbdIOmb6YzyaVNBWnwrb8gSIEPMDc= +github.com/go-mysql-org/go-mysql v1.13.0/go.mod h1:FQxw17uRbFvMZFK+dPtIPufbU46nBdrGaxOw0ac9MFs= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= +github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc= +github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pingcap/errors v0.11.5-0.20250318082626-8f80e5cb09ec h1:3EiGmeJWoNixU+EwllIn26x6s4njiWRXewdx2zlYa84= +github.com/pingcap/errors v0.11.5-0.20250318082626-8f80e5cb09ec/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg= +github.com/pingcap/log v1.1.1-0.20241212030209-7e3ff8601a2a h1:WIhmJBlNGmnCWH6TLMdZfNEDaiU8cFpZe3iaqDbQ0M8= +github.com/pingcap/log v1.1.1-0.20241212030209-7e3ff8601a2a/go.mod h1:ORfBOFp1eteu2odzsyaxI+b8TzJwgjwyQcGhI+9SfEA= +github.com/pingcap/tidb/pkg/parser v0.0.0-20250421232622-526b2c79173d h1:3Ej6eTuLZp25p3aH/EXdReRHY12hjZYs3RrGp7iLdag= +github.com/pingcap/tidb/pkg/parser v0.0.0-20250421232622-526b2c79173d/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= +github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= +github.com/planetscale/vtprotobuf v0.6.1-0.20241121165744-79df5c4772f2 h1:1sLMdKq4gNANTj0dUibycTLzpIEKVnLnbaEkxws78nw= +github.com/planetscale/vtprotobuf v0.6.1-0.20241121165744-79df5c4772f2/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= +modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +vitess.io/vitess v0.22.1 h1:nCA0v6tt3YVPf8qWMQJktzxZlsLYwdPxplo2TbSEm9A= +vitess.io/vitess v0.22.1/go.mod h1:JF7nQQ+XUP7og7ZNzOw0p+zKvUTwYd6TTKfFyzyfE9o= diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 0000000..0da1f32 --- /dev/null +++ b/internal/api/auth.go @@ -0,0 +1,99 @@ +package api + +import ( + "agentdb/internal/model" + "agentdb/internal/security" + "net/http" + "time" +) + +func (s *Server) limited(key string, max int) bool { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + for k, a := range s.attempts { + if now.After(a.Until) { + delete(s.attempts, k) + } + } + a := s.attempts[key] + if a.Until.IsZero() { + a.Until = now.Add(5 * time.Minute) + } + a.Count++ + s.attempts[key] = a + return a.Count > max +} + +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + if s.limited("login:"+source(r), 10) { + fail(w, 429, "too many login attempts; retry in 5 minutes") + return + } + var b struct { + Username string `json:"username"` + Password string `json:"password"` + } + if !decode(w, r, &b) { + return + } + rev, ok := s.store.Verify(b.Username, b.Password) + if !ok { + fail(w, 401, "invalid credentials") + return + } + token := security.Token() + sess := session{b.Username, security.Token(), rev, time.Now().Add(12 * time.Hour)} + s.mu.Lock() + for k, v := range s.sessions { + if time.Now().After(v.Expires) { + delete(s.sessions, k) + } + } + s.sessions[token] = sess + s.mu.Unlock() + http.SetCookie(w, &http.Cookie{Name: "agentdb_session", Value: token, Path: "/", HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteStrictMode, MaxAge: 43200}) + respond(w, 200, map[string]string{"username": sess.User, "csrfToken": sess.CSRF}) +} + +func (s *Server) reveal(w http.ResponseWriter, r *http.Request, p model.Proxy, sess session, token string, allowedFields ...string) { + a := s.limited("reveal:ip:"+source(r), 5) + b := s.limited("reveal:session:"+token, 5) + if a || b { + s.store.Event(p.ID, source(r), "secret_view_denied", "rate_limit") + fail(w, 429, "too many attempts; retry in 5 minutes") + return + } + var body struct { + Field string `json:"field"` + Password string `json:"password"` + } + if !decode(w, r, &body) { + return + } + if len(allowedFields) > 0 { + allowed := false + for _, field := range allowedFields { + if field == body.Field { + allowed = true + } + } + if !allowed { + fail(w, 400, "unsupported secret field") + return + } + } + secrets := map[string]string{"targetPassword": p.TargetPassword, "gatewayPassword": p.GatewayPassword, "socksPassword": p.Outbound.Password, "sshPassphrase": p.SSH.Passphrase} + value, exists := secrets[body.Field] + if !exists { + fail(w, 400, "unsupported secret field") + return + } + if _, ok := s.store.Verify(sess.User, body.Password); s.authEnabled && !ok { + s.store.Event(p.ID, source(r), "secret_view_denied", body.Field) + fail(w, 401, "administrator password incorrect") + return + } + s.store.Event(p.ID, source(r), "secret_viewed", body.Field) + respond(w, 200, map[string]string{"value": value}) +} diff --git a/internal/api/commands.go b/internal/api/commands.go new file mode 100644 index 0000000..604145a --- /dev/null +++ b/internal/api/commands.go @@ -0,0 +1,103 @@ +package api + +import ( + "agentdb/internal/store" + "encoding/csv" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +func (s *Server) commands(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + before := int64(0) + if q.Get("before") != "" { + var err error + before, err = strconv.ParseInt(q.Get("before"), 10, 64) + if err != nil || before < 0 { + fail(w, 400, "invalid cursor") + return + } + } + f := store.CommandFilter{Gateway: q.Get("gateway"), Protocol: q.Get("protocol"), Target: q.Get("target"), Source: q.Get("source"), Decision: q.Get("decision"), Kind: q.Get("kind"), Keyword: q.Get("keyword"), From: q.Get("from"), To: q.Get("to")} + if len(f.Keyword) > 512 { + fail(w, 400, "keyword too long") + return + } + var from, to time.Time + for key, value := range map[string]string{"from": f.From, "to": f.To} { + if value != "" { + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + fail(w, 400, "invalid time range") + return + } + if key == "from" { + from = parsed + } else { + to = parsed + } + } + } + if !from.IsZero() && !to.IsZero() && from.After(to) { + fail(w, 400, "invalid time range") + return + } + if q.Get("export") == "csv" { + s.exportCommands(w, r, f) + return + } + records, err := s.store.FilterCommands(r.Context(), f, before) + if err != nil { + fail(w, 500, "cannot load command audit") + return + } + next := int64(0) + if len(records) == 200 { + next = records[len(records)-1].RowID + } + respond(w, 200, map[string]any{"items": records, "nextBefore": next}) +} + +// Neutralize spreadsheet formulas while preserving multiline CSV fields. +func csvCell(value string) string { + trimmed := strings.TrimLeft(value, " \t\r\n") + if len(trimmed) > 0 && strings.ContainsRune("=+-@", rune(trimmed[0])) { + return "'" + value + } + return value +} +func (s *Server) exportCommands(w http.ResponseWriter, r *http.Request, f store.CommandFilter) { + file, err := os.CreateTemp("", "agentdb-export-*.csv") + if err != nil { + fail(w, 500, "cannot create export") + return + } + defer func() { file.Close(); os.Remove(file.Name()) }() + if _, err = file.WriteString("\xef\xbb\xbf"); err != nil { + fail(w, 500, "cannot create export") + return + } + writer := csv.NewWriter(file) + err = writer.Write([]string{"时间", "网关ID", "目标ID", "来源IP", "协议", "操作", "处理", "命令内容", "已截断"}) + if err == nil { + err = s.store.WalkCommands(r.Context(), f, 0, 0, func(rec store.CommandRecord) error { + values := []string{rec.At, rec.ProxyID, rec.TargetID, rec.Source, rec.Protocol, rec.Kind, rec.Decision, rec.Text, strconv.FormatBool(rec.Truncated)} + for i := range values { + values[i] = csvCell(values[i]) + } + return writer.Write(values) + }) + } + writer.Flush() + if err != nil || writer.Error() != nil { + fail(w, 500, "cannot export command audit") + return + } + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", "attachment; filename=agentdb-commands.csv") + w.Header().Set("Cache-Control", "no-store") + http.ServeContent(w, r, "agentdb-commands.csv", time.Time{}, file) +} diff --git a/internal/api/commands_test.go b/internal/api/commands_test.go new file mode 100644 index 0000000..a02925d --- /dev/null +++ b/internal/api/commands_test.go @@ -0,0 +1,56 @@ +package api + +import ( + "agentdb/internal/model" + "encoding/csv" + "encoding/json" + "strings" + "testing" +) + +func TestCommandFiltersAndCompleteCSVExport(t *testing.T) { + f := newFixture(t) + requireStatus(t, f.request("GET", "/api/v1/commands?export=csv", nil, false), 401) + f.login() + p := model.Proxy{ID: "gateway", TargetID: "target", Type: "mysql"} + for i := 0; i < 205; i++ { + if err := f.store.RecordCommand(p, "127.0.0.1", model.Command{Kind: "query", Decision: "allowed", Text: "SELECT '中文,\nquoted'"}); err != nil { + t.Fatal(err) + } + } + if err := f.store.RecordCommand(p, "192.0.2.1", model.Command{Kind: "exec", Decision: "blocked", Text: "=spreadsheet()"}); err != nil { + t.Fatal(err) + } + path := "/api/v1/commands?target=target&source=127.0.0.1&decision=allowed&kind=query&keyword=select&from=2000-01-01T00:00:00Z&to=2100-01-01T00:00:00Z" + w := f.request("GET", path, nil, false) + requireStatus(t, w, 200) + var page struct { + Items []any `json:"items"` + Next int64 `json:"nextBefore"` + } + if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil || len(page.Items) != 200 || page.Next == 0 { + t.Fatal("filtered page", err) + } + w = f.request("GET", path+"&export=csv", nil, false) + requireStatus(t, w, 200) + records, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(w.Body.String(), "\xef\xbb\xbf"))).ReadAll() + if err != nil || len(records) != 206 { + t.Fatalf("complete export rows=%d err=%v", len(records), err) + } + if records[1][7] != "SELECT '中文,\nquoted'" { + t.Fatal("CSV quoting or Unicode lost") + } + w = f.request("GET", "/api/v1/commands?decision=blocked&export=csv", nil, false) + records, err = csv.NewReader(strings.NewReader(strings.TrimPrefix(w.Body.String(), "\xef\xbb\xbf"))).ReadAll() + if err != nil || len(records) != 2 || records[1][7] != "'=spreadsheet()" { + t.Fatal("formula neutralization", err) + } + for _, query := range []string{"from=invalid", "from=2100-01-01T00:00:00Z&to=2000-01-01T00:00:00Z", "before=-1"} { + requireStatus(t, f.request("GET", "/api/v1/commands?"+query, nil, false), 400) + } + w = f.request("GET", "/api/v1/commands?keyword=missing", nil, false) + requireStatus(t, w, 200) + if err = json.Unmarshal(w.Body.Bytes(), &page); err != nil || len(page.Items) != 0 { + t.Fatal("keyword mismatch", err) + } +} diff --git a/internal/api/events.go b/internal/api/events.go new file mode 100644 index 0000000..c9624ac --- /dev/null +++ b/internal/api/events.go @@ -0,0 +1,94 @@ +package api + +import ( + "agentdb/internal/store" + "encoding/csv" + "net/http" + "os" + "strconv" + "time" +) + +func (s *Server) events(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + f := store.EventFilter{Gateway: q.Get("gateway"), Source: q.Get("source"), Category: q.Get("category"), Keyword: q.Get("keyword"), From: q.Get("from"), To: q.Get("to")} + var from, to time.Time + for key, value := range map[string]string{"from": f.From, "to": f.To} { + if value != "" { + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + fail(w, 400, "invalid time range") + return + } + if key == "from" { + from = parsed + } else { + to = parsed + } + } + } + if !from.IsZero() && !to.IsZero() && from.After(to) { + fail(w, 400, "invalid time range") + return + } + if len(f.Keyword) > 512 { + fail(w, 400, "keyword too long") + return + } + before := int64(0) + if q.Get("before") != "" { + var err error + before, err = strconv.ParseInt(q.Get("before"), 10, 64) + if err != nil || before < 0 { + fail(w, 400, "invalid cursor") + return + } + } + if q.Get("export") == "csv" { + file, err := os.CreateTemp("", "agentdb-events-*.csv") + if err != nil { + fail(w, 500, "cannot create export") + return + } + defer func() { file.Close(); os.Remove(file.Name()) }() + if _, err = file.WriteString("\xef\xbb\xbf"); err != nil { + fail(w, 500, "cannot create export") + return + } + writer := csv.NewWriter(file) + err = writer.Write([]string{"时间", "服务ID", "来源IP", "事件类型", "详情"}) + if err == nil { + err = s.store.WalkEvents(r.Context(), f, 0, 0, func(e store.Event) error { + cells := []string{e.At, e.ProxyID, e.Source, e.Category, e.Detail} + for i := range cells { + cells[i] = csvCell(cells[i]) + } + return writer.Write(cells) + }) + } + writer.Flush() + if err != nil || writer.Error() != nil { + fail(w, 500, "cannot export events") + return + } + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", "attachment; filename=agentdb-events.csv") + w.Header().Set("Cache-Control", "no-store") + http.ServeContent(w, r, "agentdb-events.csv", time.Time{}, file) + return + } + records, err := s.store.FilterEvents(r.Context(), f, before) + if err != nil { + fail(w, 500, "cannot load events") + return + } + if q.Get("page") == "1" { + next := int64(0) + if len(records) == 300 { + next = records[len(records)-1].ID + } + respond(w, 200, map[string]any{"items": records, "nextBefore": next}) + } else { + respond(w, 200, records) + } +} diff --git a/internal/api/events_test.go b/internal/api/events_test.go new file mode 100644 index 0000000..0bdb653 --- /dev/null +++ b/internal/api/events_test.go @@ -0,0 +1,34 @@ +package api + +import ( + "encoding/csv" + "encoding/json" + "strings" + "testing" +) + +func TestEventFiltersAndExport(t *testing.T) { + f := newFixture(t) + requireStatus(t, f.request("GET", "/api/v1/events?export=csv", nil, false), 401) + f.login() + for i := 0; i < 305; i++ { + f.store.Event("test-gateway", "127.0.0.1", "connected", "detail,\n中文") + } + path := "/api/v1/events?gateway=test-gateway&source=127.0.0.1&category=connected&keyword=detail" + w := f.request("GET", path+"&page=1", nil, false) + requireStatus(t, w, 200) + var page struct { + Items []any `json:"items"` + Next int64 `json:"nextBefore"` + } + if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil || len(page.Items) != 300 || page.Next == 0 { + t.Fatal("pagination", err) + } + w = f.request("GET", path+"&export=csv", nil, false) + requireStatus(t, w, 200) + rows, err := csv.NewReader(strings.NewReader(strings.TrimPrefix(w.Body.String(), "\xef\xbb\xbf"))).ReadAll() + if err != nil || len(rows) != 306 || rows[1][4] != "detail,\n中文" { + t.Fatal("CSV export", err) + } + requireStatus(t, f.request("GET", "/api/v1/events?from=invalid", nil, false), 400) +} diff --git a/internal/api/http_helpers.go b/internal/api/http_helpers.go new file mode 100644 index 0000000..5554950 --- /dev/null +++ b/internal/api/http_helpers.go @@ -0,0 +1,42 @@ +package api + +import ( + "encoding/json" + "io" + "net" + "net/http" +) + +func source(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func decode(w http.ResponseWriter, r *http.Request, v any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + d := json.NewDecoder(r.Body) + d.DisallowUnknownFields() + if err := d.Decode(v); err != nil { + fail(w, 400, "invalid JSON body") + return false + } + var extra any + if err := d.Decode(&extra); err != io.EOF { + fail(w, 400, "expected one JSON object") + return false + } + return true +} + +func respond(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func fail(w http.ResponseWriter, status int, message string) { + respond(w, status, map[string]string{"error": message}) +} diff --git a/internal/api/outbounds.go b/internal/api/outbounds.go new file mode 100644 index 0000000..88e3ca1 --- /dev/null +++ b/internal/api/outbounds.go @@ -0,0 +1,128 @@ +package api + +import ( + "agentdb/internal/model" + "agentdb/internal/security" + "net/http" + "strings" +) + +func (s *Server) outboundView(p model.OutboundProfile) any { + refs, _ := s.store.OutboundReferences(p.ID) + flags := map[string]bool{"password": p.Outbound.Password != ""} + p.Outbound.Password = "" + return map[string]any{"config": p, "targetIds": refs, "secretStatus": flags} +} +func (s *Server) outbounds(w http.ResponseWriter, r *http.Request, path string, sess session, token string) { + parts := strings.Split(path, "/") + if len(parts) == 1 { + switch r.Method { + case "GET": + profiles, err := s.store.Outbounds() + if err != nil { + fail(w, 500, "cannot load outbounds") + return + } + out := []any{} + for _, p := range profiles { + out = append(out, s.outboundView(p)) + } + respond(w, 200, out) + case "POST": + s.saveOutbound(w, r, model.OutboundProfile{}) + default: + fail(w, 405, "method not allowed") + } + return + } + p, err := s.store.GetOutbound(parts[1]) + if err != nil { + fail(w, 404, "outbound not found") + return + } + if len(parts) == 3 && parts[2] == "reveal-password" && r.Method == "POST" { + s.reveal(w, r, model.Proxy{ID: p.ID, Outbound: p.Outbound}, sess, token, "socksPassword") + return + } + if len(parts) != 2 { + fail(w, 404, "not found") + return + } + switch r.Method { + case "GET": + respond(w, 200, s.outboundView(p)) + case "PUT": + refs, e := s.store.OutboundReferences(p.ID) + if e != nil { + fail(w, 500, "cannot load references") + return + } + for _, id := range refs { + if !s.targetStopped(w, id) { + return + } + } + s.saveOutbound(w, r, p) + case "DELETE": + refs, e := s.store.OutboundReferences(p.ID) + if e != nil { + fail(w, 500, "cannot load references") + return + } + if len(refs) > 0 { + fail(w, 409, "outbound is referenced by targets; rebind those targets first") + return + } + if e = s.store.DeleteOutbound(p.ID); e != nil { + fail(w, 500, "delete failed") + return + } + s.store.Event(p.ID, source(r), "outbound_deleted", "") + respond(w, 200, map[string]bool{"ok": true}) + default: + fail(w, 405, "method not allowed") + } +} +func (s *Server) saveOutbound(w http.ResponseWriter, r *http.Request, current model.OutboundProfile) { + var patch map[string]any + if !decode(w, r, &patch) { + return + } + if err := editable(patch); err != nil { + fail(w, 400, err.Error()) + return + } + p, err := model.MergeOutbound(current, patch) + if err != nil { + fail(w, 400, "invalid outbound fields") + return + } + creating := p.ID == "" + if creating { + p.ID = security.Token()[:16] + } + if err = p.Validate(); err != nil { + fail(w, 400, err.Error()) + return + } + if err = s.store.SaveOutbound(p); err != nil { + fail(w, 500, "save failed") + return + } + refs, _ := s.store.OutboundReferences(p.ID) + for _, id := range refs { + gids, _ := s.store.TargetReferences(id) + s.mu.Lock() + delete(s.probes, "target:"+id) + for _, gid := range gids { + delete(s.probes, gid) + } + s.mu.Unlock() + } + status, event := 200, "outbound_updated" + if creating { + status, event = 201, "outbound_created" + } + s.store.Event(p.ID, source(r), event, p.Outbound.Mode) + respond(w, status, s.outboundView(p)) +} diff --git a/internal/api/outbounds_test.go b/internal/api/outbounds_test.go new file mode 100644 index 0000000..ccd93e4 --- /dev/null +++ b/internal/api/outbounds_test.go @@ -0,0 +1,84 @@ +package api + +import ( + "agentdb/internal/model" + "encoding/json" + "strings" + "testing" +) + +func TestSharedOutboundLifecycle(t *testing.T) { + f := newFixture(t) + f.login() + w := f.request("POST", "/api/v1/outbounds", map[string]any{"name": "shared SOCKS", "outbound": map[string]any{"mode": "socks5", "host": "proxy.internal", "port": 1080, "auth": "password", "username": "u", "password": "outbound-secret-unique"}}, true) + requireStatus(t, w, 201) + if strings.Contains(w.Body.String(), "outbound-secret-unique") { + t.Fatal("outbound secret leaked") + } + var ov struct { + Config model.OutboundProfile `json:"config"` + SecretStatus map[string]bool `json:"secretStatus"` + } + if err := json.Unmarshal(w.Body.Bytes(), &ov); err != nil { + t.Fatal(err) + } + if !ov.SecretStatus["password"] { + t.Fatal("missing secret status") + } + opath := "/api/v1/outbounds/" + ov.Config.ID + for _, field := range []string{"", "targetPassword"} { + requireStatus(t, f.request("POST", opath+"/reveal-password", map[string]string{"field": field, "password": adminPassword}, true), 400) + } + requireStatus(t, f.request("POST", opath+"/reveal-password", map[string]string{"field": "socksPassword", "password": "wrong"}, true), 401) + w = f.request("POST", opath+"/reveal-password", map[string]string{"field": "socksPassword", "password": adminPassword}, true) + requireStatus(t, w, 200) + if !strings.Contains(w.Body.String(), "outbound-secret-unique") { + t.Fatal("outbound reveal failed") + } + tids := []string{} + for _, name := range []string{"one", "two"} { + w = f.request("POST", "/api/v1/targets", map[string]any{"name": name, "type": "mysql", "targetHost": "db.internal", "targetPort": 3306, "targetUser": "db", "outboundId": ov.Config.ID}, true) + requireStatus(t, w, 201) + var tv struct { + Config model.Target `json:"config"` + } + if err := json.Unmarshal(w.Body.Bytes(), &tv); err != nil { + t.Fatal(err) + } + if tv.Config.OutboundID != ov.Config.ID || tv.Config.Outbound.Host != "proxy.internal" || tv.Config.Outbound.Password != "" { + t.Fatal("target reference resolution/redaction failed") + } + tids = append(tids, tv.Config.ID) + } + requireStatus(t, f.request("DELETE", opath, nil, true), 409) + w = f.request("POST", "/api/v1/proxies", map[string]any{"name": "gateway", "type": "mysql", "targetId": tids[0], "listenHost": "127.0.0.1", "listenPort": freePort(t), "gatewayUser": "u", "gatewayPassword": "p"}, true) + requireStatus(t, w, 201) + gid := readView(t, w).Config.ID + requireStatus(t, f.request("POST", "/api/v1/proxies/"+gid+"/start", nil, true), 200) + requireStatus(t, f.request("PUT", opath, map[string]any{"outbound": map[string]any{"host": "changed.internal"}}, true), 409) + requireStatus(t, f.request("POST", "/api/v1/proxies/"+gid+"/stop", nil, true), 200) + requireStatus(t, f.request("PUT", opath, map[string]any{"outbound": map[string]any{"host": "changed.internal"}}, true), 200) + for _, id := range tids { + target, err := f.store.GetTarget(id) + if err != nil || target.Outbound.Host != "changed.internal" || target.Outbound.Password != "outbound-secret-unique" { + t.Fatal("shared update failed") + } + } + requireStatus(t, f.request("PUT", "/api/v1/targets/"+tids[0], map[string]any{"outboundId": "missing"}, true), 400) + requireStatus(t, f.request("PUT", "/api/v1/targets/"+tids[0], map[string]any{"outboundId": "", "outbound": map[string]any{"mode": "direct"}}, true), 400) + // Inline compatibility forks the route rather than modifying other references. + requireStatus(t, f.request("PUT", "/api/v1/targets/"+tids[0], map[string]any{"outbound": map[string]any{"host": "fork.internal"}}, true), 200) + first, _ := f.store.GetTarget(tids[0]) + second, _ := f.store.GetTarget(tids[1]) + if first.OutboundID == second.OutboundID || second.Outbound.Host != "changed.internal" { + t.Fatal("legacy inline update modified shared profile") + } + for _, id := range tids { + requireStatus(t, f.request("PUT", "/api/v1/targets/"+id, map[string]any{"outboundId": ""}, true), 200) + } + requireStatus(t, f.request("DELETE", opath, nil, true), 200) + p, err := f.store.Get(gid) + if err != nil || p.OutboundID != "" || p.Outbound.Mode != "direct" { + t.Fatal("direct selection not resolved") + } +} diff --git a/internal/api/proxies.go b/internal/api/proxies.go new file mode 100644 index 0000000..9b716e6 --- /dev/null +++ b/internal/api/proxies.go @@ -0,0 +1,154 @@ +package api + +import ( + "agentdb/internal/model" + "agentdb/internal/protocol/sshproxy" + "agentdb/internal/security" + "golang.org/x/crypto/ssh" + "net/http" +) + +func (s *Server) view(p model.Proxy) any { + redacted, flags := p.Redacted() + hostFingerprint := "" + if p.Type == "ssh" && p.SSH.HostKey != "" { + if key, e := ssh.ParsePrivateKey([]byte(p.SSH.HostKey)); e == nil { + hostFingerprint = ssh.FingerprintSHA256(key.PublicKey()) + } + } + return map[string]any{"config": redacted, "secretStatus": flags, "runtime": s.manager.Status(p.ID), "gatewayFingerprint": hostFingerprint} +} + +func (s *Server) create(w http.ResponseWriter, r *http.Request) { + var patch map[string]any + if !decode(w, r, &patch) { + return + } + if err := editable(patch); err != nil { + fail(w, 400, err.Error()) + return + } + base := model.Defaults() + switch patch["type"] { + case "pgsql": + base.AuthSource = "postgres" + case "rabbitmq": + base.AuthSource = "/" + base.TargetTLS = false + case "redis": + base.TargetUser = "default" + base.TargetTLS = false + } + if patch["type"] == "ssh" { + base.AccessMode = "" + } + p, err := model.Merge(base, patch) + if err != nil { + fail(w, 400, "invalid configuration fields") + return + } + p.ID = security.Token()[:16] + p.Enabled = false + if p.TargetID == "" && p.OutboundID != "" { + if _, inline := patch["outbound"]; inline { + fail(w, 400, "choose outboundId or inline outbound") + return + } + profile, e := s.store.GetOutbound(p.OutboundID) + if e != nil { + fail(w, 400, "outbound not found") + return + } + p.Outbound = profile.Outbound + } + if p.TargetID != "" { + if err = gatewayFields(patch); err != nil { + fail(w, 400, err.Error()) + return + } + p, err = s.resolveTarget(p) + if err != nil { + fail(w, 400, "target not found") + return + } + t, _ := s.store.GetTarget(p.TargetID) + if t.Type != p.Type { + fail(w, 400, "gateway and target protocols must match") + return + } + } + if err = p.Validate(); err != nil { + fail(w, 400, err.Error()) + return + } + if err = validateKeys(p); err != nil { + fail(w, 400, err.Error()) + return + } + if p.Type == "ssh" { + p.SSH.HostKey, err = sshproxy.GenerateHostKey() + if err != nil { + fail(w, 500, "host key generation failed") + return + } + } + if err = s.store.Save(p); err != nil { + fail(w, 500, "save failed") + return + } + s.store.Event(p.ID, source(r), "proxy_created", p.Type) + p, _ = s.store.Get(p.ID) + respond(w, 201, s.view(p)) +} + +func (s *Server) update(w http.ResponseWriter, r *http.Request, p model.Proxy) { + if s.manager.Status(p.ID).Running { + fail(w, 409, "stop proxy before editing") + return + } + var patch map[string]any + if !decode(w, r, &patch) { + return + } + if err := editable(patch); err != nil { + fail(w, 400, err.Error()) + return + } + if err := gatewayFields(patch); err != nil { + fail(w, 400, err.Error()) + return + } + next, err := model.Merge(p, patch) + if err != nil { + fail(w, 400, "invalid configuration fields") + return + } + if next.Type != p.Type { + fail(w, 400, "proxy type cannot change") + return + } + next, err = s.resolveTarget(next) + if err != nil { + fail(w, 400, "target not found") + return + } + target, _ := s.store.GetTarget(next.TargetID) + if target.Type != next.Type { + fail(w, 400, "gateway and target protocols must match") + return + } + if err = next.Validate(); err != nil { + fail(w, 400, err.Error()) + return + } + if err = validateKeys(next); err != nil { + fail(w, 400, err.Error()) + return + } + if err = s.store.Save(next); err != nil { + fail(w, 500, "save failed") + return + } + s.store.Event(p.ID, source(r), "proxy_updated", next.AccessMode) + respond(w, 200, s.view(next)) +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..3bb4150 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,304 @@ +package api + +import ( + "agentdb/internal/buildinfo" + "agentdb/internal/gateway" + "agentdb/internal/outbound" + "agentdb/internal/protocol/sshproxy" + "agentdb/internal/security" + "agentdb/internal/store" + "context" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +type session struct { + User, CSRF, Revision string + Expires time.Time +} +type attempt struct { + Count int + Until time.Time +} +type probe struct { + Key, Fingerprint, Host string + Port int + Until time.Time + Session string +} +type Server struct { + store *store.Store + manager *gateway.Manager + mu sync.Mutex + ops sync.Mutex + sessions map[string]session + attempts map[string]attempt + probes map[string]probe + secure bool + authEnabled bool + devCSRF string +} + +func New(s *store.Store, m *gateway.Manager, secure bool, authentication ...bool) *Server { + enabled := true + if len(authentication) > 0 { + enabled = authentication[0] + } + return &Server{store: s, manager: m, sessions: map[string]session{}, attempts: map[string]attempt{}, probes: map[string]probe{}, secure: secure, authEnabled: enabled, devCSRF: security.Token()} +} +func (s *Server) Handler(static http.Handler) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { respond(w, 200, map[string]string{"status": "ok"}) }) + mux.HandleFunc("/api/v1/", s.serve) + mux.Handle("/", static) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'") + mux.ServeHTTP(w, r) + }) +} +func (s *Server) serve(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/v1/"), "/") + if r.Method != "GET" { + if origin := r.Header.Get("Origin"); origin != "" { + u, e := url.Parse(origin) + if e != nil || u.Host != r.Host || (u.Scheme != "http" && u.Scheme != "https") { + fail(w, 403, "origin rejected") + return + } + } + } + if path == "login" && r.Method == "POST" { + if !s.authEnabled { + fail(w, 400, "login is disabled in development mode") + return + } + s.login(w, r) + return + } + var sess session + cookie := &http.Cookie{Value: "development"} + if !s.authEnabled { + ip := net.ParseIP(source(r)) + host := r.Host + if h, _, e := net.SplitHostPort(host); e == nil { + host = h + } + hostIP := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() || (host != "localhost" && (hostIP == nil || !hostIP.IsLoopback())) { + fail(w, 403, "development mode is local only") + return + } + sess = session{User: "开发者", CSRF: s.devCSRF} + } else { + var err error + cookie, err = r.Cookie("agentdb_session") + if err != nil { + fail(w, 401, "login required") + return + } + s.mu.Lock() + var ok bool + sess, ok = s.sessions[cookie.Value] + if time.Now().After(sess.Expires) { + delete(s.sessions, cookie.Value) + ok = false + } + s.mu.Unlock() + rev, err := s.store.Revision() + if !ok || err != nil || rev != sess.Revision { + fail(w, 401, "session expired") + return + } + } + if r.Method != "GET" && r.Header.Get("X-CSRF-Token") != sess.CSRF { + fail(w, 403, "CSRF token required") + return + } + if path == "session" && r.Method == "GET" { + respond(w, 200, map[string]any{"username": sess.User, "csrfToken": sess.CSRF, "authEnabled": s.authEnabled}) + return + } + if path == "version" { + if r.Method != "GET" { + w.Header().Set("Allow", "GET") + fail(w, 405, "method not allowed") + return + } + respond(w, 200, buildinfo.Current()) + return + } + if path == "logout" && r.Method == "POST" { + s.mu.Lock() + delete(s.sessions, cookie.Value) + s.mu.Unlock() + http.SetCookie(w, &http.Cookie{Name: "agentdb_session", Path: "/", MaxAge: -1, HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteStrictMode}) + respond(w, 200, map[string]bool{"ok": true}) + return + } + if path == "events" && r.Method == "GET" { + s.events(w, r) + return + } + if path == "commands" && r.Method == "GET" { + s.commands(w, r) + return + } + if path == "proxies" && r.Method == "GET" { + ps, e := s.store.List() + if e != nil { + fail(w, 500, "cannot decrypt configurations; verify master key") + return + } + items := make([]any, 0, len(ps)) + for _, p := range ps { + items = append(items, s.view(p)) + } + respond(w, 200, items) + return + } + s.ops.Lock() + defer s.ops.Unlock() + if path == "outbounds" || strings.HasPrefix(path, "outbounds/") { + s.outbounds(w, r, path, sess, cookie.Value) + return + } + if path == "targets" || strings.HasPrefix(path, "targets/") { + s.targets(w, r, path, sess, cookie.Value) + return + } + if path == "proxies" && r.Method == "POST" { + s.create(w, r) + return + } + parts := strings.Split(path, "/") + if len(parts) < 2 || parts[0] != "proxies" { + fail(w, 404, "not found") + return + } + p, e := s.store.Get(parts[1]) + if e != nil { + fail(w, 404, "proxy not found") + return + } + if len(parts) == 2 { + switch r.Method { + case "GET": + respond(w, 200, s.view(p)) + case "PUT": + s.update(w, r, p) + case "DELETE": + if s.manager.Status(p.ID).Running { + fail(w, 409, "stop proxy before deleting") + return + } + if e = s.store.Delete(p.ID); e != nil { + fail(w, 500, "delete failed") + return + } + s.store.Event(p.ID, source(r), "proxy_deleted", "") + respond(w, 200, map[string]bool{"ok": true}) + default: + fail(w, 405, "method not allowed") + } + return + } + if len(parts) != 3 || r.Method != "POST" { + fail(w, 405, "method not allowed") + return + } + switch parts[2] { + case "start": + if e = s.manager.Start(p); e != nil { + fail(w, 409, e.Error()) + return + } + p.Enabled = true + if e = s.store.Save(p); e != nil { + s.manager.Stop(p.ID) + fail(w, 500, "cannot persist enabled state") + return + } + s.store.Event(p.ID, source(r), "proxy_started", "") + respond(w, 200, s.view(p)) + case "stop": + s.manager.Stop(p.ID) + p.Enabled = false + if e = s.store.Save(p); e != nil { + fail(w, 500, "cannot persist stopped state") + return + } + s.store.Event(p.ID, source(r), "proxy_stopped", "") + respond(w, 200, s.view(p)) + case "test": + ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) + defer cancel() + result, e := s.manager.Test(ctx, p) + if e != nil { + stage := gateway.FailureCategory(e) + s.store.Event(p.ID, source(r), "test_failed", stage) + respond(w, 422, map[string]string{"error": gateway.FailureMessage(stage), "stage": stage}) + return + } + respond(w, 200, result) + case "probe": + if p.Type != "ssh" { + fail(w, 400, "SSH only") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + result, e := sshproxy.Probe(ctx, p, outbound.Dialer(p)) + if e != nil { + fail(w, 422, "SSH host key probe failed") + return + } + s.mu.Lock() + s.probes[p.ID] = probe{result.HostKey, result.Fingerprint, p.TargetHost, p.TargetPort, time.Now().Add(5 * time.Minute), cookie.Value} + s.mu.Unlock() + respond(w, 200, result) + case "confirm-host-key": + if p.Type != "ssh" || s.manager.Status(p.ID).Running { + fail(w, 409, "stop SSH proxy first") + return + } + var body struct { + Fingerprint string `json:"fingerprint"` + } + if !decode(w, r, &body) { + return + } + s.mu.Lock() + pr, ok := s.probes[p.ID] + s.mu.Unlock() + if !ok || pr.Session != cookie.Value || time.Now().After(pr.Until) || pr.Host != p.TargetHost || pr.Port != p.TargetPort || pr.Fingerprint != body.Fingerprint { + fail(w, 409, "probe again and confirm the exact fingerprint") + return + } + target, err := s.store.GetTarget(p.TargetID) + if err != nil || !s.targetStopped(w, p.TargetID) { + return + } + target.SSH.TargetHostKey = pr.Key + if e = s.store.SaveTarget(target); e != nil { + fail(w, 500, "save failed") + return + } + s.mu.Lock() + delete(s.probes, p.ID) + s.mu.Unlock() + s.store.Event(p.ID, source(r), "ssh_host_key_confirmed", pr.Fingerprint) + respond(w, 200, s.view(p)) + case "reveal-password": + s.reveal(w, r, p, sess, cookie.Value) + default: + fail(w, 404, "not found") + } +} diff --git a/internal/api/server_test.go b/internal/api/server_test.go new file mode 100644 index 0000000..c338983 --- /dev/null +++ b/internal/api/server_test.go @@ -0,0 +1,386 @@ +package api + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "agentdb/internal/gateway" + "agentdb/internal/model" + "agentdb/internal/protocol/sshproxy" + "agentdb/internal/security" + "agentdb/internal/store" + "golang.org/x/crypto/ssh" +) + +const adminPassword = "integration-admin-password" + +type failingProtocol struct{ inertProtocol } + +func (failingProtocol) Test(context.Context, model.Proxy, model.DialFunc) (model.TestResult, error) { + return model.TestResult{}, errors.New("SOCKS5 authentication failed: private-upstream-diagnostic") +} + +func TestConnectionErrorsExposeStageWithoutDiagnostic(t *testing.T) { + f := newFixture(t) + f.login() + v := f.create("mysql") + manager := gateway.New(f.store, nil, map[string]model.Handler{"mysql": failingProtocol{}}) + f.server.manager = manager + t.Cleanup(manager.Shutdown) + w := f.request("POST", "/api/v1/proxies/"+v.Config.ID+"/test", map[string]any{}, true) + requireStatus(t, w, 422) + var result map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result["stage"] != "socks5_authentication" { + t.Fatal("wrong connection failure stage") + } + if strings.Contains(w.Body.String(), "private-upstream-diagnostic") { + t.Fatal("diagnostic leaked to API") + } + logs, err := f.store.Events() + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(logs) + if bytes.Contains(raw, []byte("private-upstream-diagnostic")) { + t.Fatal("diagnostic leaked to audit") + } +} + +type fixture struct { + t *testing.T + server *Server + store *store.Store + handler http.Handler + cookie *http.Cookie + csrf string +} +type inertProtocol struct{} + +func (inertProtocol) Serve(ctx context.Context, c net.Conn, _ model.Proxy, _ model.DialFunc, _ *tls.Config, _ model.EventFunc) error { + defer c.Close() + <-ctx.Done() + return ctx.Err() +} +func (inertProtocol) Test(context.Context, model.Proxy, model.DialFunc) (model.TestResult, error) { + return model.TestResult{Version: "test"}, nil +} +func newFixture(t *testing.T) *fixture { + t.Helper() + v, err := security.NewVault(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))) + if err != nil { + t.Fatal(err) + } + s, err := store.Open(filepath.Join(t.TempDir(), "test.db"), v) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + if err = s.InitAdmin("admin", adminPassword); err != nil { + t.Fatal(err) + } + m := gateway.New(s, &tls.Config{MinVersion: tls.VersionTLS12}, map[string]model.Handler{"mysql": inertProtocol{}, "mongodb": inertProtocol{}, "ssh": inertProtocol{}}) + t.Cleanup(m.Shutdown) + api := New(s, m, true) + return &fixture{t: t, server: api, store: s, handler: api.Handler(http.NotFoundHandler())} +} +func (f *fixture) request(method, path string, body any, csrf bool) *httptest.ResponseRecorder { + f.t.Helper() + var data []byte + var err error + if body != nil { + data, err = json.Marshal(body) + if err != nil { + f.t.Fatal(err) + } + } + req := httptest.NewRequest(method, "https://agentdb.test"+path, bytes.NewReader(data)) + req.RemoteAddr = "127.0.0.1:12345" + req.Header.Set("Content-Type", "application/json") + if f.cookie != nil { + req.AddCookie(f.cookie) + } + if csrf { + req.Header.Set("X-CSRF-Token", f.csrf) + } + w := httptest.NewRecorder() + f.handler.ServeHTTP(w, req) + return w +} +func requireStatus(t *testing.T, w *httptest.ResponseRecorder, want int) { + t.Helper() + if w.Code != want { + t.Fatalf("status %d, want %d: %s", w.Code, want, w.Body.String()) + } +} +func (f *fixture) login() { + f.t.Helper() + w := f.request("POST", "/api/v1/login", map[string]string{"username": "admin", "password": adminPassword}, false) + requireStatus(f.t, w, 200) + cookies := w.Result().Cookies() + if len(cookies) != 1 { + f.t.Fatal("missing session cookie") + } + f.cookie = cookies[0] + var body map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + f.t.Fatal(err) + } + f.csrf = body["csrfToken"] + if f.csrf == "" { + f.t.Fatal("missing CSRF") + } +} +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := l.Addr().(*net.TCPAddr).Port + l.Close() + return port +} +func config(t *testing.T, kind string) map[string]any { + return map[string]any{"name": "test proxy", "type": kind, "listenHost": "127.0.0.1", "listenPort": freePort(t), "targetHost": "db.example", "targetPort": 3306, "allowCIDRs": []string{"127.0.0.1/32"}, "targetUser": "upstream", "targetPassword": "target-secret-unique", "gatewayUser": "gateway", "gatewayPassword": "gateway-secret-unique", "outbound": map[string]any{"mode": "socks5", "host": "socks.example", "port": 1080, "auth": "password", "username": "socks-user", "password": "socks-secret-unique"}} +} + +type proxyView struct { + Config model.Proxy `json:"config"` + Flags map[string]bool `json:"secretStatus"` + Runtime gateway.Status `json:"runtime"` +} + +func readView(t *testing.T, w *httptest.ResponseRecorder) proxyView { + t.Helper() + var v proxyView + if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil { + t.Fatal(err) + } + return v +} +func (f *fixture) create(kind string) proxyView { + f.t.Helper() + w := f.request("POST", "/api/v1/proxies", config(f.t, kind), true) + requireStatus(f.t, w, 201) + return readView(f.t, w) +} + +func TestSessionsCSRFAndPasswordReset(t *testing.T) { + f := newFixture(t) + requireStatus(t, f.request("GET", "/api/v1/proxies", nil, false), 401) + requireStatus(t, f.request("POST", "/api/v1/login", map[string]string{"username": "admin", "password": "wrong"}, false), 401) + f.login() + if !f.cookie.HttpOnly || !f.cookie.Secure || f.cookie.SameSite != http.SameSiteStrictMode { + t.Fatal("session cookie protections missing") + } + requireStatus(t, f.request("GET", "/api/v1/session", nil, false), 200) + requireStatus(t, f.request("POST", "/api/v1/proxies", config(t, "mysql"), false), 403) + req := httptest.NewRequest("POST", "https://agentdb.test/api/v1/logout", nil) + req.RemoteAddr = "127.0.0.1:12345" + req.AddCookie(f.cookie) + req.Header.Set("X-CSRF-Token", f.csrf) + req.Header.Set("Origin", "https://evil.test") + w := httptest.NewRecorder() + f.handler.ServeHTTP(w, req) + requireStatus(t, w, 403) + if err := f.store.ResetPassword("replacement-password"); err != nil { + t.Fatal(err) + } + requireStatus(t, f.request("GET", "/api/v1/session", nil, false), 401) +} + +func TestDefaultReadwriteRedactionAndSecretPreservingUpdate(t *testing.T) { + f := newFixture(t) + f.login() + for _, kind := range []string{"mysql", "mongodb"} { + v := f.create(kind) + if v.Config.AccessMode != "readwrite" { + t.Fatalf("%s does not default readwrite", kind) + } + if !v.Flags["targetPassword"] || !v.Flags["gatewayPassword"] || !v.Flags["socksPassword"] { + t.Fatal("secret presence flags missing") + } + if v.Config.TargetPassword != "" || v.Config.GatewayPassword != "" || v.Config.Outbound.Password != "" { + t.Fatal("create leaked secrets") + } + path := "/api/v1/proxies/" + v.Config.ID + w := f.request("PUT", path, map[string]any{"name": "renamed", "accessMode": "readonly"}, true) + requireStatus(t, w, 200) + requireStatus(t, f.request("PUT", "/api/v1/targets/"+v.Config.TargetID, map[string]any{"outbound": map[string]any{"host": "new-socks.example"}}, true), 200) + saved, err := f.store.Get(v.Config.ID) + if err != nil { + t.Fatal(err) + } + if saved.TargetPassword != "target-secret-unique" || saved.GatewayPassword != "gateway-secret-unique" || saved.Outbound.Password != "socks-secret-unique" || saved.AccessMode != "readonly" { + t.Fatal("partial update lost secret or mode") + } + } + for _, path := range []string{"/api/v1/proxies", "/api/v1/events"} { + w := f.request("GET", path, nil, false) + requireStatus(t, w, 200) + for _, secret := range []string{"target-secret-unique", "gateway-secret-unique", "socks-secret-unique", adminPassword} { + if strings.Contains(w.Body.String(), secret) { + t.Fatal("secret leaked in " + path) + } + } + } +} + +func TestRevealRequiresReauthenticationAndLimitsBothSessionAndIP(t *testing.T) { + f := newFixture(t) + f.login() + v := f.create("mysql") + path := "/api/v1/proxies/" + v.Config.ID + "/reveal-password" + body := map[string]string{"field": "targetPassword", "password": adminPassword} + requireStatus(t, f.request("POST", path, body, false), 403) + requireStatus(t, f.request("POST", path, map[string]string{"field": "targetPassword", "password": "wrong"}, true), 401) + w := f.request("POST", path, body, true) + requireStatus(t, w, 200) + if w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("secret response cacheable") + } + if !strings.Contains(w.Body.String(), "target-secret-unique") || strings.Contains(w.Body.String(), "gateway-secret-unique") { + t.Fatal("wrong secret disclosed") + } + requireStatus(t, f.request("POST", path, map[string]string{"field": "sshPrivateKey", "password": adminPassword}, true), 400) + requireStatus(t, f.request("POST", path, body, true), 200) + requireStatus(t, f.request("POST", path, body, true), 200) + requireStatus(t, f.request("POST", path, body, true), 429) + encoded, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "https://agentdb.test"+path, bytes.NewReader(encoded)) + req.RemoteAddr = "127.0.0.2:12345" + req.AddCookie(f.cookie) + req.Header.Set("X-CSRF-Token", f.csrf) + otherIP := httptest.NewRecorder() + f.handler.ServeHTTP(otherIP, req) + requireStatus(t, otherIP, 429) + // New session at the same address cannot bypass the independent IP limit. + f.login() + requireStatus(t, f.request("POST", path, body, true), 429) + events, err := f.store.Events() + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(events) + if bytes.Contains(raw, []byte("target-secret-unique")) || bytes.Contains(raw, []byte(adminPassword)) { + t.Fatal("audit leaked credentials") + } +} + +func TestStopRequiredForEditingDeletingAndModeSwitch(t *testing.T) { + f := newFixture(t) + f.login() + v := f.create("mysql") + path := "/api/v1/proxies/" + v.Config.ID + requireStatus(t, f.request("POST", path+"/start", nil, true), 200) + requireStatus(t, f.request("PUT", path, map[string]any{"accessMode": "readonly"}, true), 409) + requireStatus(t, f.request("DELETE", path, nil, true), 409) + requireStatus(t, f.request("POST", path+"/stop", nil, true), 200) + requireStatus(t, f.request("PUT", path, map[string]any{"accessMode": "readonly"}, true), 200) + requireStatus(t, f.request("PUT", path, map[string]any{"type": "mongodb"}, true), 400) + requireStatus(t, f.request("DELETE", path, nil, true), 200) + requireStatus(t, f.request("GET", path, nil, false), 404) +} + +func TestSSHHostKeyCannotBeInjectedAndPrivateKeyIsRedacted(t *testing.T) { + f := newFixture(t) + f.login() + key, err := sshproxy.GenerateHostKey() + if err != nil { + t.Fatal(err) + } + signer, err := ssh.ParsePrivateKey([]byte(key)) + if err != nil { + t.Fatal(err) + } + public := string(ssh.MarshalAuthorizedKey(signer.PublicKey())) + for _, field := range []string{"targetHostKey", "TargetHostKey", "TARGETHOSTKEY", "hostKey", "HostKey"} { + c := config(t, "ssh") + c["ssh"] = map[string]any{field: public} + w := f.request("POST", "/api/v1/proxies", c, true) + requireStatus(t, w, 400) + } + c := config(t, "ssh") + c["ssh"] = map[string]any{"authMethod": "privatekey", "privateKey": key} + w := f.request("POST", "/api/v1/proxies", c, true) + requireStatus(t, w, 201) + if strings.Contains(w.Body.String(), "PRIVATE KEY") { + t.Fatal("SSH key leaked") + } + v := readView(t, w) + if v.Config.AccessMode != "" || !v.Flags["sshPrivateKey"] { + t.Fatal("invalid SSH view") + } + path := "/api/v1/proxies/" + v.Config.ID + requireStatus(t, f.request("POST", path+"/start", nil, true), 409) + requireStatus(t, f.request("POST", path+"/confirm-host-key", map[string]string{"fingerprint": ssh.FingerprintSHA256(signer.PublicKey())}, true), 409) + requireStatus(t, f.request("PUT", path, map[string]any{"ssh": map[string]any{"TargetHostKey": public}}, true), 400) + saved, err := f.store.Get(v.Config.ID) + if err != nil { + t.Fatal(err) + } + if saved.SSH.TargetHostKey != "" || saved.SSH.HostKey == "" { + t.Fatal("host pin injected or gateway key missing") + } +} + +func TestLogoutInvalidatesSessionAndMalformedJSONRejected(t *testing.T) { + f := newFixture(t) + f.login() + req := httptest.NewRequest("POST", "https://agentdb.test/api/v1/proxies", io.NopCloser(strings.NewReader(`{} {}`))) + req.AddCookie(f.cookie) + req.Header.Set("X-CSRF-Token", f.csrf) + w := httptest.NewRecorder() + f.handler.ServeHTTP(w, req) + requireStatus(t, w, 400) + requireStatus(t, f.request("POST", "/api/v1/logout", nil, true), 200) + requireStatus(t, f.request("GET", "/api/v1/proxies", nil, false), 401) +} + +func TestExpiredSessionCannotRevealPassword(t *testing.T) { + f := newFixture(t) + f.login() + v := f.create("mysql") + f.server.mu.Lock() + s := f.server.sessions[f.cookie.Value] + s.Expires = time.Now().Add(-time.Second) + f.server.sessions[f.cookie.Value] = s + f.server.mu.Unlock() + requireStatus(t, f.request("POST", "/api/v1/proxies/"+v.Config.ID+"/reveal-password", map[string]string{"field": "targetPassword", "password": adminPassword}, true), 401) +} + +func TestManagedFieldsAndInvalidSSHKeysRejected(t *testing.T) { + f := newFixture(t) + f.login() + for _, field := range []string{"id", "ID", "enabled", "Enabled", "Type", "unknown"} { + c := config(t, "mysql") + c[field] = "injected" + requireStatus(t, f.request("POST", "/api/v1/proxies", c, true), 400) + } + for _, sshConfig := range []map[string]any{ + {"authMethod": "privatekey", "privateKey": "invalid"}, + {"authorizedKeys": []string{"not a key"}}, + } { + c := config(t, "ssh") + c["ssh"] = sshConfig + requireStatus(t, f.request("POST", "/api/v1/proxies", c, true), 400) + } + c := config(t, "ssh") + c["accessMode"] = "readonly" + requireStatus(t, f.request("POST", "/api/v1/proxies", c, true), 400) +} diff --git a/internal/api/targets.go b/internal/api/targets.go new file mode 100644 index 0000000..97b8fa2 --- /dev/null +++ b/internal/api/targets.go @@ -0,0 +1,269 @@ +package api + +import ( + "agentdb/internal/gateway" + "agentdb/internal/model" + "agentdb/internal/outbound" + "agentdb/internal/protocol/sshproxy" + "agentdb/internal/security" + "context" + "errors" + "net/http" + "strings" + "time" +) + +func (s *Server) targetView(t model.Target) any { + p, flags := t.Redacted() + ids, _ := s.store.TargetReferences(t.ID) + return map[string]any{"config": p, "secretStatus": flags, "gatewayIds": ids} +} +func (s *Server) targetStopped(w http.ResponseWriter, id string) bool { + ids, err := s.store.TargetReferences(id) + if err != nil { + fail(w, 500, "cannot load target references") + return false + } + for _, gid := range ids { + if s.manager.Status(gid).Running { + fail(w, 409, "stop all gateways referencing this target first") + return false + } + } + return true +} +func (s *Server) targets(w http.ResponseWriter, r *http.Request, path string, sess session, token string) { + parts := strings.Split(path, "/") + if len(parts) == 1 { + switch r.Method { + case "GET": + ts, err := s.store.Targets() + if err != nil { + fail(w, 500, "cannot load targets") + return + } + out := []any{} + for _, t := range ts { + out = append(out, s.targetView(t)) + } + respond(w, 200, out) + case "POST": + s.saveTarget(w, r, model.Target{}) + default: + fail(w, 405, "method not allowed") + } + return + } + t, err := s.store.GetTarget(parts[1]) + if err != nil { + fail(w, 404, "target not found") + return + } + if len(parts) == 2 { + switch r.Method { + case "GET": + respond(w, 200, s.targetView(t)) + case "PUT": + if s.targetStopped(w, t.ID) { + s.saveTarget(w, r, t) + } + case "DELETE": + ids, e := s.store.TargetReferences(t.ID) + if e != nil { + fail(w, 500, "cannot load references") + return + } + if len(ids) > 0 { + fail(w, 409, "target is referenced by gateways; rebind or delete those gateways first") + return + } + if e = s.store.DeleteTarget(t.ID); e != nil { + fail(w, 500, "delete failed") + return + } + s.store.Event(t.ID, source(r), "target_deleted", "") + respond(w, 200, map[string]bool{"ok": true}) + default: + fail(w, 405, "method not allowed") + } + return + } + if len(parts) != 3 || r.Method != "POST" { + fail(w, 405, "method not allowed") + return + } + p := t.Runtime() + switch parts[2] { + case "test": + ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) + defer cancel() + result, e := s.manager.Test(ctx, p) + if e != nil { + stage := gateway.FailureCategory(e) + respond(w, 422, map[string]string{"error": gateway.FailureMessage(stage), "stage": stage}) + return + } + respond(w, 200, result) + case "reveal-password": + s.reveal(w, r, p, sess, token) + case "probe": + if t.Type != "ssh" { + fail(w, 400, "SSH only") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + result, e := sshproxy.Probe(ctx, p, outbound.Dialer(p)) + if e != nil { + fail(w, 422, "SSH host key probe failed") + return + } + s.mu.Lock() + s.probes["target:"+t.ID] = probe{result.HostKey, result.Fingerprint, t.TargetHost, t.TargetPort, time.Now().Add(5 * time.Minute), token} + s.mu.Unlock() + respond(w, 200, result) + case "confirm-host-key": + if t.Type != "ssh" { + fail(w, 400, "SSH only") + return + } + if !s.targetStopped(w, t.ID) { + return + } + var body struct { + Fingerprint string `json:"fingerprint"` + } + if !decode(w, r, &body) { + return + } + s.mu.Lock() + pr, ok := s.probes["target:"+t.ID] + s.mu.Unlock() + if !ok || pr.Session != token || time.Now().After(pr.Until) || pr.Host != t.TargetHost || pr.Port != t.TargetPort || pr.Fingerprint != body.Fingerprint { + fail(w, 409, "probe again and confirm the exact fingerprint") + return + } + t.SSH.TargetHostKey = pr.Key + if err = s.store.SaveTarget(t); err != nil { + fail(w, 500, "save failed") + return + } + s.mu.Lock() + delete(s.probes, "target:"+t.ID) + s.mu.Unlock() + s.store.Event(t.ID, source(r), "ssh_host_key_confirmed", pr.Fingerprint) + respond(w, 200, s.targetView(t)) + default: + fail(w, 404, "not found") + } +} +func (s *Server) saveTarget(w http.ResponseWriter, r *http.Request, current model.Target) { + var patch map[string]any + if !decode(w, r, &patch) { + return + } + if err := editable(patch); err != nil { + fail(w, 400, err.Error()) + return + } + creating := current.ID == "" + base := current + if creating { + base = model.TargetDefaults() + switch patch["type"] { + case "pgsql": + base.AuthSource = "postgres" + case "rabbitmq": + base.AuthSource = "/" + base.TargetTLS = false + case "redis": + base.TargetUser = "default" + base.TargetTLS = false + } + } + t, err := model.MergeTarget(base, patch) + if err != nil { + fail(w, 400, "invalid target fields") + return + } + if creating { + t.ID = security.Token()[:16] + } else if t.Type != current.Type { + fail(w, 400, "target protocol cannot change") + return + } + if _, selected := patch["outboundId"]; selected { + if _, inline := patch["outbound"]; inline { + fail(w, 400, "choose outboundId or inline outbound") + return + } + if t.OutboundID == "" { + t.Outbound = model.Outbound{Mode: "direct", Auth: "none"} + } else { + profile, e := s.store.GetOutbound(t.OutboundID) + if e != nil { + fail(w, 400, "outbound not found") + return + } + t.Outbound = profile.Outbound + } + } else if _, inline := patch["outbound"]; inline { + t.OutboundID = "" + } + if t.TargetHost != current.TargetHost || t.TargetPort != current.TargetPort || t.Outbound != current.Outbound || t.OutboundID != current.OutboundID { + t.SSH.TargetHostKey = "" + } + if err = t.Validate(); err != nil { + fail(w, 400, err.Error()) + return + } + if err = validateKeys(t.Runtime()); err != nil { + fail(w, 400, err.Error()) + return + } + if err = s.store.SaveTarget(t); err != nil { + fail(w, 500, "save failed") + return + } + t, err = s.store.GetTarget(t.ID) + if err != nil { + fail(w, 500, "cannot load saved target") + return + } + s.mu.Lock() + delete(s.probes, "target:"+t.ID) + ids, _ := s.store.TargetReferences(t.ID) + for _, id := range ids { + delete(s.probes, id) + } + s.mu.Unlock() + status, event := 200, "target_updated" + if creating { + status, event = 201, "target_created" + } + s.store.Event(t.ID, source(r), event, t.Type) + respond(w, status, s.targetView(t)) +} + +func (s *Server) resolveTarget(p model.Proxy) (model.Proxy, error) { + t, err := s.store.GetTarget(p.TargetID) + if err != nil { + return p, err + } + return t.Apply(p), nil +} +func gatewayFields(patch map[string]any) error { + for _, k := range []string{"group", "targetHost", "targetPort", "targetUser", "targetPassword", "targetTLS", "targetCA", "targetCompression", "authSource", "outbound", "outboundId"} { + if _, ok := patch[k]; ok { + return errors.New("edit upstream connection settings through target services") + } + } + if ssh, ok := patch["ssh"].(map[string]any); ok { + for k := range ssh { + if k != "authorizedKeys" { + return errors.New("gateway SSH settings only accept authorizedKeys") + } + } + } + return nil +} diff --git a/internal/api/targets_test.go b/internal/api/targets_test.go new file mode 100644 index 0000000..df508df --- /dev/null +++ b/internal/api/targets_test.go @@ -0,0 +1,95 @@ +package api + +import ( + "agentdb/internal/model" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSharedTargetLifecycle(t *testing.T) { + f := newFixture(t) + f.login() + target := map[string]any{"name": "shared database", "type": "mysql", "targetHost": "db.internal", "targetPort": 3306, "targetUser": "upstream", "targetPassword": "shared-secret"} + w := f.request("POST", "/api/v1/targets", target, true) + requireStatus(t, w, 201) + var tv struct { + Config model.Target `json:"config"` + } + json.Unmarshal(w.Body.Bytes(), &tv) + if tv.Config.TargetPassword != "" { + t.Fatal("target password leaked") + } + tid := tv.Config.ID + var gateways []string + for i := 0; i < 2; i++ { + config := map[string]any{"name": "gateway", "type": "mysql", "targetId": tid, "listenHost": "127.0.0.1", "listenPort": freePort(t), "allowCIDRs": []string{"127.0.0.1"}, "gatewayUser": "client", "gatewayPassword": "gateway-secret"} + w = f.request("POST", "/api/v1/proxies", config, true) + requireStatus(t, w, 201) + v := readView(t, w) + gateways = append(gateways, v.Config.ID) + } + path := "/api/v1/targets/" + tid + requireStatus(t, f.request("DELETE", path, nil, true), 409) + requireStatus(t, f.request("POST", "/api/v1/proxies/"+gateways[0]+"/start", nil, true), 200) + requireStatus(t, f.request("PUT", path, map[string]any{"targetHost": "new.internal"}, true), 409) + requireStatus(t, f.request("POST", "/api/v1/proxies/"+gateways[0]+"/stop", nil, true), 200) + requireStatus(t, f.request("PUT", path, map[string]any{"targetHost": "new.internal"}, true), 200) + for _, id := range gateways { + p, err := f.store.Get(id) + if err != nil || p.TargetHost != "new.internal" || p.TargetPassword != "shared-secret" { + t.Fatal("shared target not resolved") + } + } + requireStatus(t, f.request("PUT", "/api/v1/proxies/"+gateways[0], map[string]any{"targetPassword": "injected"}, true), 400) + requireStatus(t, f.request("PUT", "/api/v1/proxies/"+gateways[0], map[string]any{"targetId": "missing"}, true), 400) + requireStatus(t, f.request("PUT", path, map[string]any{"ssh": map[string]any{"TargetHostKey": "injected"}}, true), 400) + requireStatus(t, f.request("POST", path+"/test", nil, true), 200) + for _, id := range gateways { + requireStatus(t, f.request("DELETE", "/api/v1/proxies/"+id, nil, true), 200) + } + requireStatus(t, f.request("GET", path, nil, true), 200) + requireStatus(t, f.request("DELETE", path, nil, true), 200) +} + +func TestCommandAuditRequiresSession(t *testing.T) { + f := newFixture(t) + p := model.Proxy{ID: "g", TargetID: "t", Type: "mysql"} + if err := f.store.RecordCommand(p, "127.0.0.1", model.Command{Kind: "query", Text: "select 42", Decision: "allowed"}); err != nil { + t.Fatal(err) + } + requireStatus(t, f.request("GET", "/api/v1/commands", nil, false), 401) + f.login() + w := f.request("GET", "/api/v1/commands?gateway=g&protocol=mysql", nil, false) + requireStatus(t, w, 200) + if !strings.Contains(w.Body.String(), "select 42") || w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("audit result/cache protection missing") + } + requireStatus(t, f.request("GET", "/api/v1/commands?before=-1", nil, false), 400) +} + +func TestDevelopmentSessionRequiresLocalHostAndCSRF(t *testing.T) { + f := newFixture(t) + server := New(f.store, f.server.manager, false, false) + handler := server.Handler(http.NotFoundHandler()) + call := func(method, host, remote, csrf, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, "http://"+host+"/api/v1/session", strings.NewReader(body)) + r.RemoteAddr = remote + r.Header.Set("X-CSRF-Token", csrf) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w + } + w := call("GET", "127.0.0.1:8080", "127.0.0.1:1234", "", "") + requireStatus(t, w, 200) + var session map[string]any + json.Unmarshal(w.Body.Bytes(), &session) + if session["authEnabled"] != false || session["csrfToken"] == "" { + t.Fatal("invalid development session") + } + requireStatus(t, call("GET", "evil.test", "127.0.0.1:1234", "", ""), 403) + requireStatus(t, call("GET", "127.0.0.1:8080", "192.168.1.2:1234", "", ""), 403) + requireStatus(t, call("POST", "127.0.0.1:8080", "127.0.0.1:1234", "", "{}"), 403) +} diff --git a/internal/api/validation.go b/internal/api/validation.go new file mode 100644 index 0000000..76f77f0 --- /dev/null +++ b/internal/api/validation.go @@ -0,0 +1,53 @@ +package api + +import ( + "agentdb/internal/model" + "errors" + "golang.org/x/crypto/ssh" + "strings" +) + +func editable(p map[string]any) error { + for _, k := range []string{"id", "enabled"} { + if _, ok := p[k]; ok { + return errors.New(k + " is server managed") + } + } + if v, ok := p["ssh"].(map[string]any); ok { + for _, k := range []string{"hostKey", "targetHostKey"} { + if _, ok := v[k]; ok { + return errors.New("SSH host keys must use the confirmation flow") + } + } + } + return nil +} + +func validateKeys(p model.Proxy) error { + if p.Type != "ssh" { + return nil + } + for _, key := range p.SSH.AuthorizedKeys { + pub, _, opts, rest, e := ssh.ParseAuthorizedKey([]byte(key)) + if e != nil || len(opts) > 0 || len(strings.TrimSpace(string(rest))) > 0 || !supportedKey(pub) { + return errors.New("each authorized key must be a single valid SSH public key") + } + } + if p.SSH.AuthMethod == "privatekey" { + var signer ssh.Signer + var err error + if p.SSH.Passphrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(p.SSH.PrivateKey), []byte(p.SSH.Passphrase)) + } else { + signer, err = ssh.ParsePrivateKey([]byte(p.SSH.PrivateKey)) + } + if err != nil || !supportedKey(signer.PublicKey()) { + return errors.New("invalid or unsupported SSH private key or passphrase") + } + } + return nil +} + +func supportedKey(k ssh.PublicKey) bool { + return k != nil && (k.Type() == ssh.KeyAlgoED25519 || k.Type() == ssh.KeyAlgoRSA) +} diff --git a/internal/api/version_test.go b/internal/api/version_test.go new file mode 100644 index 0000000..e53e27f --- /dev/null +++ b/internal/api/version_test.go @@ -0,0 +1,23 @@ +package api + +import ( + "agentdb/internal/buildinfo" + "encoding/json" + "testing" +) + +func TestVersion(t *testing.T) { + f := newFixture(t) + requireStatus(t, f.request("GET", "/api/v1/version", nil, false), 401) + f.login() + w := f.request("GET", "/api/v1/version", nil, false) + requireStatus(t, w, 200) + var got buildinfo.Info + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got != buildinfo.Current() { + t.Fatalf("version response: %+v", got) + } + requireStatus(t, f.request("POST", "/api/v1/version", nil, true), 405) +} diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go new file mode 100644 index 0000000..81f9259 --- /dev/null +++ b/internal/buildinfo/version.go @@ -0,0 +1,32 @@ +package buildinfo + +import ( + "agentdb" + "fmt" + "runtime" +) + +// These values are set by the build scripts using linker flags. +var Version string +var Commit = "unknown" +var BuildTime = "unknown" + +type Info struct { + Version string `json:"version"` + Commit string `json:"commit"` + BuildTime string `json:"buildTime"` + GoVersion string `json:"goVersion"` + Platform string `json:"platform"` +} + +func Current() Info { + v := Version + if v == "" { + v = agentdb.Version() + } + return Info{v, Commit, BuildTime, runtime.Version(), runtime.GOOS + "/" + runtime.GOARCH} +} + +func (i Info) String() string { + return fmt.Sprintf("AgentDB %s (commit %s, built %s, %s, %s)", i.Version, i.Commit, i.BuildTime, i.GoVersion, i.Platform) +} diff --git a/internal/buildinfo/version_test.go b/internal/buildinfo/version_test.go new file mode 100644 index 0000000..d898ffb --- /dev/null +++ b/internal/buildinfo/version_test.go @@ -0,0 +1,24 @@ +package buildinfo + +import ( + "agentdb" + "strings" + "testing" +) + +func TestCurrent(t *testing.T) { + oldVersion, oldCommit, oldTime := Version, Commit, BuildTime + t.Cleanup(func() { Version, Commit, BuildTime = oldVersion, oldCommit, oldTime }) + Version = "" + if Current().Version != agentdb.Version() { + t.Fatal("default must use embedded VERSION") + } + Version, Commit, BuildTime = "1.2.3-rc.1", "abc123-dirty", "2026-09-09T00:00:00Z" + i := Current() + if i.Version != Version || i.Commit != Commit || i.BuildTime != BuildTime || i.Platform == "" || i.GoVersion == "" { + t.Fatalf("metadata mismatch: %+v", i) + } + if !strings.Contains(i.String(), "AgentDB 1.2.3-rc.1") { + t.Fatal(i.String()) + } +} diff --git a/internal/desktop/desktop.go b/internal/desktop/desktop.go new file mode 100644 index 0000000..6505e69 --- /dev/null +++ b/internal/desktop/desktop.go @@ -0,0 +1,96 @@ +// Package desktop provides local, single-user startup without environment secrets. +package desktop + +import ( + "agentdb/internal/security" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" +) + +func Enabled() bool { return runtime.GOOS == "windows" && os.Getenv("AGENTDB_MASTER_KEY") == "" } + +type Config struct { + Key string + Data string + Dir string + Close func() +} + +func Prepare() (*Config, error) { + dir, err := os.UserConfigDir() + if err != nil { + return nil, err + } + dir = filepath.Join(dir, "AgentDB") + data := os.Getenv("AGENTDB_DATA") + if data != "" { + data, err = filepath.Abs(data) + if err != nil { + return nil, err + } + dir = filepath.Dir(data) + } else { + data = filepath.Join(dir, "agentdb.sqlite") + } + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + release, err := lock(filepath.Join(dir, "desktop.lock")) + if err != nil { + return nil, fmt.Errorf("AgentDB may already be running; close its existing window before restarting: %w", err) + } + key, err := loadKey(filepath.Join(dir, "master-key.dpapi"), data) + if err != nil { + release() + return nil, err + } + return &Config{Key: key, Data: data, Dir: dir, Close: release}, nil +} + +func loadKey(path, data string) (string, error) { + blob, err := os.ReadFile(path) + if err == nil { + plain, e := unprotect(blob) + if e != nil { + return "", fmt.Errorf("cannot unlock local key with this Windows account: %w", e) + } + key := string(plain) + if _, e = security.NewVault(key); e != nil { + return "", e + } + return key, nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + if _, err = os.Stat(data); !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("database already exists but local key is missing; restore master-key.dpapi or set the original AGENTDB_MASTER_KEY") + } + raw := make([]byte, 32) + if _, err = rand.Read(raw); err != nil { + return "", err + } + key := base64.StdEncoding.EncodeToString(raw) + blob, err = protect([]byte(key)) + if err != nil { + return "", err + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return "", err + } + _, err = f.Write(blob) + if err == nil { + err = f.Sync() + } + closeErr := f.Close() + if err != nil { + return "", err + } + return key, closeErr +} diff --git a/internal/desktop/platform_other.go b/internal/desktop/platform_other.go new file mode 100644 index 0000000..c92de37 --- /dev/null +++ b/internal/desktop/platform_other.go @@ -0,0 +1,10 @@ +//go:build !windows + +package desktop + +import "fmt" + +func protect([]byte) ([]byte, error) { return nil, fmt.Errorf("desktop mode requires Windows") } +func unprotect([]byte) ([]byte, error) { return nil, fmt.Errorf("desktop mode requires Windows") } +func lock(string) (func(), error) { return nil, fmt.Errorf("desktop mode requires Windows") } +func OpenBrowser(string) error { return fmt.Errorf("desktop mode requires Windows") } diff --git a/internal/desktop/platform_windows.go b/internal/desktop/platform_windows.go new file mode 100644 index 0000000..7ca41b1 --- /dev/null +++ b/internal/desktop/platform_windows.go @@ -0,0 +1,50 @@ +package desktop + +import ( + "fmt" + "golang.org/x/sys/windows" + "os/exec" + "unsafe" +) + +func protect(raw []byte) ([]byte, error) { return crypt(raw, false) } +func unprotect(raw []byte) ([]byte, error) { return crypt(raw, true) } +func crypt(raw []byte, decrypt bool) ([]byte, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("empty local key") + } + in := windows.DataBlob{Size: uint32(len(raw)), Data: &raw[0]} + var out windows.DataBlob + var err error + if decrypt { + err = windows.CryptUnprotectData(&in, nil, nil, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out) + } else { + err = windows.CryptProtectData(&in, nil, nil, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out) + } + if err != nil { + return nil, err + } + defer windows.LocalFree(windows.Handle(unsafe.Pointer(out.Data))) + return append([]byte(nil), unsafe.Slice(out.Data, out.Size)...), nil +} + +func lock(path string) (func(), error) { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + h, err := windows.CreateFile(p, windows.GENERIC_READ|windows.GENERIC_WRITE, 0, nil, windows.OPEN_ALWAYS, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + return nil, err + } + return func() { windows.CloseHandle(h) }, nil +} + +func OpenBrowser(url string) error { + cmd := exec.Command("rundll32.exe", "url.dll,FileProtocolHandler", url) + if err := cmd.Start(); err != nil { + return err + } + go cmd.Wait() + return nil +} diff --git a/internal/desktop/platform_windows_test.go b/internal/desktop/platform_windows_test.go new file mode 100644 index 0000000..75fc0da --- /dev/null +++ b/internal/desktop/platform_windows_test.go @@ -0,0 +1,48 @@ +package desktop + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestLocalKeyPersistenceAndLock(t *testing.T) { + dir := t.TempDir() + t.Setenv("AGENTDB_DATA", filepath.Join(dir, "test.sqlite")) + c, err := Prepare() + if err != nil { + t.Fatal(err) + } + key := c.Key + if other, err := Prepare(); err == nil { + other.Close() + t.Fatal("second instance obtained lock") + } + c.Close() + blob, err := os.ReadFile(filepath.Join(dir, "master-key.dpapi")) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(blob, []byte(key)) { + t.Fatal("plaintext key saved") + } + c, err = Prepare() + if err != nil { + t.Fatal(err) + } + if c.Key != key { + t.Fatal("restart changed key") + } + c.Close() + if err = os.WriteFile(filepath.Join(dir, "test.sqlite"), []byte("existing database"), 0600); err != nil { + t.Fatal(err) + } + if err = os.Remove(filepath.Join(dir, "master-key.dpapi")); err != nil { + t.Fatal(err) + } + if c, err = Prepare(); err == nil { + c.Close() + t.Fatal("replaced missing key for existing database") + } +} diff --git a/internal/desktop/setup.go b/internal/desktop/setup.go new file mode 100644 index 0000000..0ddf294 --- /dev/null +++ b/internal/desktop/setup.go @@ -0,0 +1,91 @@ +package desktop + +import ( + "agentdb/internal/security" + "agentdb/internal/store" + "crypto/subtle" + "html/template" + "net/http" + "strings" + "sync" +) + +// Setup gates every application route until the local administrator is created. +// The one-time capability is delivered only through the locally opened browser. +func Setup(s *store.Store, next http.Handler, host string) (http.Handler, string, error) { + ready, err := s.HasAdmin() + if err != nil { + return nil, "", err + } + if ready { + return next, "", nil + } + token := security.Token() + var mu sync.Mutex + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + if ready { + mu.Unlock() + next.ServeHTTP(w, r) + return + } + defer mu.Unlock() + w.Header().Set("Cache-Control", "no-store") + // Keep the same-origin form POST's Origin header; never send the setup + // capability as a referrer to a different origin. + w.Header().Set("Referrer-Policy", "same-origin") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'") + if r.Host != host { + http.Error(w, "invalid host", http.StatusForbidden) + return + } + if r.URL.Path != "/setup" { + http.Error(w, "请在程序自动打开的页面中完成管理员设置。", http.StatusServiceUnavailable) + return + } + supplied := r.URL.Query().Get("token") + if subtle.ConstantTimeCompare([]byte(supplied), []byte(token)) != 1 { + http.Error(w, "invalid setup token", http.StatusForbidden) + return + } + message := "" + switch r.Method { + case http.MethodGet: + case http.MethodPost: + if origin := r.Header.Get("Origin"); origin != "http://"+host { + http.Error(w, "invalid origin", http.StatusForbidden) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 4096) + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + user, password := strings.TrimSpace(r.PostForm.Get("username")), r.PostForm.Get("password") + if user == "" || len(user) > 128 || len(password) < 12 || len(password) > 72 || password != r.PostForm.Get("confirm") { + message = "请输入用户名,以及两次相同的 12–72 字节密码。" + } else if err := s.InitAdmin(user, password); err != nil { + message = "保存失败,请检查数据目录权限后重试。" + } else { + ready = true + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + default: + w.Header().Set("Allow", "GET, POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + setupPage.Execute(w, message) + }) + return handler, "/setup?token=" + token, nil +} + +var setupPage = template.Must(template.New("setup").Parse(` +AgentDB · 首次设置 + +

欢迎使用 AgentDB

设置管理员账号后即可管理 MySQL、MongoDB 和 SSH 代理。下次启动直接登录。

+{{if .}}{{end}} +

密码须为 12–72 字节(建议使用英文、数字和符号)。

`)) diff --git a/internal/desktop/setup_test.go b/internal/desktop/setup_test.go new file mode 100644 index 0000000..a2c2e0b --- /dev/null +++ b/internal/desktop/setup_test.go @@ -0,0 +1,60 @@ +package desktop + +import ( + "agentdb/internal/security" + "agentdb/internal/store" + "encoding/base64" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" +) + +func TestSetupCapabilityAndOneTimeInitialization(t *testing.T) { + v, _ := security.NewVault(base64.StdEncoding.EncodeToString(make([]byte, 32))) + s, err := store.Open(filepath.Join(t.TempDir(), "test.sqlite"), v) + if err != nil { + t.Fatal(err) + } + defer s.Close() + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }) + h, path, err := Setup(s, next, "127.0.0.1:8080") + if err != nil { + t.Fatal(err) + } + request := func(method, path, origin, body string) int { + r := httptest.NewRequest(method, "http://127.0.0.1:8080"+path, strings.NewReader(body)) + r.Header.Set("Origin", origin) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w.Code + } + password := "valid-initial-password" + form := url.Values{"username": {"admin"}, "password": {password}, "confirm": {password}}.Encode() + for _, tc := range []struct { + method, path, origin, body string + status int + }{ + {"GET", "/api/v1/proxies", "", "", 503}, + {"GET", "/setup?token=wrong", "", "", 403}, + {"GET", path, "", "", 200}, + {"POST", path, "http://attacker.invalid", form, 403}, + {"POST", path, "http://127.0.0.1:8080", "username=admin&password=short&confirm=short", 200}, + {"POST", path, "http://127.0.0.1:8080", form, 303}, + {"POST", path, "http://127.0.0.1:8080", form, 204}, + } { + if got := request(tc.method, tc.path, tc.origin, tc.body); got != tc.status { + t.Fatalf("%s %s: got %d, want %d", tc.method, tc.path, got, tc.status) + } + } + if _, ok := s.Verify("admin", password); !ok { + t.Fatal("administrator was not saved") + } + _, path, err = Setup(s, next, "127.0.0.1:8080") + if err != nil || path != "" { + t.Fatalf("setup was re-enabled: %q %v", path, err) + } +} diff --git a/internal/gateway/failure.go b/internal/gateway/failure.go new file mode 100644 index 0000000..e139c85 --- /dev/null +++ b/internal/gateway/failure.go @@ -0,0 +1,67 @@ +package gateway + +import ( + "context" + "errors" + "strings" +) + +// FailureCategory intentionally discards arbitrary protocol error text: upstream +// diagnostics can contain usernames, addresses or SQL. Only stable categories +// may be exposed by the management API or stored in the audit log. +func FailureCategory(err error) string { + if err == nil { + return "closed" + } + message := strings.ToLower(err.Error()) + switch { + case strings.Contains(message, "kubernetes"): + return "k8s_port_forward" + case strings.Contains(message, "unsupported client capabilities"): + return "mysql_client_capabilities" + case strings.Contains(message, "ssh forwarding") && strings.Contains(message, "fingerprint"): + return "ssh_forward_identity" + case strings.Contains(message, "ssh forwarding") && strings.Contains(message, "authenticate"): + return "ssh_forward_authentication" + case strings.Contains(message, "ssh forwarding"): + return "ssh_forward_connection" + case strings.Contains(message, "socks5") && strings.Contains(message, "authentication"): + return "socks5_authentication" + case strings.Contains(message, "socks5") && strings.Contains(message, "target connection"): + return "socks5_target_connection" + case strings.Contains(message, "socks5"): + return "socks5_connection" + case strings.Contains(message, "host key") || strings.Contains(message, "hostkey") || strings.Contains(message, "fingerprint"): + return "ssh_host_identity" + case strings.Contains(message, "x509") || strings.Contains(message, "certificate") || strings.Contains(message, "tls"): + return "target_tls" + case errors.Is(err, context.DeadlineExceeded) || strings.Contains(message, "timeout"): + return "connection_timeout" + case strings.Contains(message, "auth") || strings.Contains(message, "access denied") || strings.Contains(message, "password"): + return "target_authentication" + default: + return "target_connection_or_protocol" + } +} + +func FailureMessage(category string) string { + messages := map[string]string{ + "k8s_port_forward": "Kubernetes port-forward 失败,请检查 kubeconfig、context、命名空间、资源、端口及 pods/portforward 权限", + "mysql_client_capabilities": "MySQL 客户端请求了网关不支持的协议能力,请检查客户端压缩及协议设置", + "ssh_forward_identity": "SSH 转发主机指纹不匹配,请核对跳板机身份", + "ssh_forward_authentication": "SSH 转发认证失败,请检查跳板机账号和密码", + "ssh_forward_connection": "SSH 转发连接失败,请检查跳板机地址、TCP 转发权限及目标连通性", + "socks5_authentication": "SOCKS5 认证失败,请检查代理用户名和密码", + "socks5_target_connection": "SOCKS5 无法访问目标服务,请检查目标地址及代理网络", + "socks5_connection": "无法建立 SOCKS5 连接,请检查出口地址、端口和协议", + "ssh_host_identity": "SSH 主机身份校验失败,请重新核对目标指纹", + "target_tls": "目标 TLS 校验失败,请检查 CA 证书、主机名及 TLS 配置", + "connection_timeout": "连接超时,请检查网络和目标服务状态", + "target_authentication": "目标服务认证失败,请检查目标账号和凭据", + "target_connection_or_protocol": "目标连接或协议协商失败,请检查目标地址、端口及服务类型", + } + if message, ok := messages[category]; ok { + return message + } + return "连接失败" +} diff --git a/internal/gateway/manager.go b/internal/gateway/manager.go new file mode 100644 index 0000000..984aa56 --- /dev/null +++ b/internal/gateway/manager.go @@ -0,0 +1,202 @@ +package gateway + +import ( + "agentdb/internal/model" + "agentdb/internal/outbound" + "agentdb/internal/store" + "context" + "crypto/tls" + "errors" + "net" + "net/netip" + "strconv" + "sync" + "time" +) + +type Status struct { + Running bool `json:"running"` + Connections int `json:"connections"` + Rejected int64 `json:"rejected"` + Error string `json:"error,omitempty"` +} +type instance struct { + listener net.Listener + cancel context.CancelFunc + connections map[net.Conn]struct{} + rejected int64 + stopping bool + wg sync.WaitGroup +} +type Manager struct { + mu sync.Mutex + instances map[string]*instance + failures map[string]string + handlers map[string]model.Handler + tls *tls.Config + store *store.Store +} + +func New(s *store.Store, t *tls.Config, h map[string]model.Handler) *Manager { + return &Manager{instances: map[string]*instance{}, failures: map[string]string{}, handlers: h, tls: t, store: s} +} +func (m *Manager) Start(p model.Proxy) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.instances[p.ID]; ok { + return errors.New("proxy already running or stopping") + } + h := m.handlers[p.Type] + if h == nil { + return errors.New("unsupported protocol") + } + if p.Type == "ssh" && p.SSH.TargetHostKey == "" { + return errors.New("confirm SSH target host key first") + } + l, err := net.Listen("tcp", net.JoinHostPort(p.ListenHost, strconv.Itoa(p.ListenPort))) + if err != nil { + m.failures[p.ID] = "listen failed (address unavailable or in use)" + return errors.New(m.failures[p.ID]) + } + ctx, cancel := context.WithCancel(context.Background()) + inst := &instance{listener: l, cancel: cancel, connections: map[net.Conn]struct{}{}} + m.instances[p.ID] = inst + delete(m.failures, p.ID) + inst.wg.Add(1) + go m.accept(ctx, p, inst, h) + return nil +} +func (m *Manager) accept(ctx context.Context, p model.Proxy, i *instance, h model.Handler) { + defer i.wg.Done() + for { + c, err := i.listener.Accept() + if err != nil { + return + } + source, _, _ := net.SplitHostPort(c.RemoteAddr().String()) + m.mu.Lock() + allowed := !i.stopping && Allowed(p.AllowCIDRs, source) && len(i.connections) < p.MaxConnections + if allowed { + i.connections[c] = struct{}{} + i.wg.Add(1) + } + m.mu.Unlock() + if !allowed { + c.Close() + m.store.Event(p.ID, source, "connection_rejected", "source_or_limit") + continue + } + go func() { + defer i.wg.Done() + defer c.Close() + defer func() { m.mu.Lock(); delete(i.connections, c); m.mu.Unlock() }() + defer func() { + if recover() != nil { + m.store.Event(p.ID, source, "disconnected", "protocol_panic") + } + }() + m.store.Event(p.ID, source, "connected", "") + commandCtx := model.WithCommandAudit(ctx, func(command model.Command) error { return m.store.RecordCommand(p, source, command) }) + err := h.Serve(commandCtx, c, p, outbound.Dialer(p), m.tls, func(category, rule string) { + if category == model.EventReadOnlyRejected { + m.mu.Lock() + i.rejected++ + m.mu.Unlock() + } + m.store.Event(p.ID, source, category, rule) + }) + detail := "closed" + if err != nil { + detail = FailureCategory(err) + } + m.store.Event(p.ID, source, "disconnected", detail) + }() + } +} +func (m *Manager) Stop(id string) { + m.mu.Lock() + i := m.instances[id] + if i == nil { + delete(m.failures, id) + m.mu.Unlock() + return + } + i.stopping = true + i.cancel() + i.listener.Close() + for c := range i.connections { + c.Close() + } + m.mu.Unlock() + i.wg.Wait() + m.mu.Lock() + if m.instances[id] == i { + delete(m.instances, id) + } + delete(m.failures, id) + m.mu.Unlock() +} +func (m *Manager) Shutdown() { + m.shutdown(30 * time.Second) +} +func (m *Manager) shutdown(grace time.Duration) { + m.mu.Lock() + ids := make([]string, 0, len(m.instances)) + instances := make([]*instance, 0, len(m.instances)) + for id, inst := range m.instances { + ids = append(ids, id) + instances = append(instances, inst) + inst.stopping = true + inst.listener.Close() + } + m.mu.Unlock() + var wg sync.WaitGroup + for _, inst := range instances { + wg.Add(1) + go func(inst *instance) { defer wg.Done(); inst.wg.Wait() }(inst) + } + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(grace): + } + for _, id := range ids { + m.Stop(id) + } +} +func (m *Manager) Status(id string) Status { + m.mu.Lock() + defer m.mu.Unlock() + if i := m.instances[id]; i != nil { + return Status{Running: true, Connections: len(i.connections), Rejected: i.rejected} + } + return Status{Error: m.failures[id]} +} +func (m *Manager) Test(ctx context.Context, p model.Proxy) (model.TestResult, error) { + h := m.handlers[p.Type] + if h == nil { + return model.TestResult{}, errors.New("unsupported protocol") + } + return h.Test(ctx, p, outbound.Dialer(p)) +} +func Allowed(cidrs []string, source string) bool { + ip, err := netip.ParseAddr(source) + if err != nil { + return false + } + ip = ip.Unmap() + if len(cidrs) == 0 { + return true + } + for _, s := range cidrs { + if prefix, err := netip.ParsePrefix(s); err == nil { + if prefix.Contains(ip) { + return true + } + } else if addr, err := netip.ParseAddr(s); err == nil && addr.Unmap() == ip { + return true + } + } + return false +} diff --git a/internal/gateway/manager_test.go b/internal/gateway/manager_test.go new file mode 100644 index 0000000..b26d09a --- /dev/null +++ b/internal/gateway/manager_test.go @@ -0,0 +1,191 @@ +package gateway + +import ( + "agentdb/internal/model" + "agentdb/internal/security" + "agentdb/internal/store" + "bytes" + "context" + "crypto/tls" + "encoding/base64" + "io" + "net" + "path/filepath" + "testing" + "time" +) + +func TestAllowed(t *testing.T) { + cases := []struct { + rules []string + ip string + want bool + }{ + {[]string{"127.0.0.0/8"}, "127.0.0.1", true}, + {[]string{"127.0.0.1"}, "::ffff:127.0.0.1", true}, + {[]string{"2001:db8::/32"}, "2001:db8::4", true}, + {[]string{"2001:db8::1"}, "2001:db8::2", false}, + {[]string{"invalid", "10.0.0.0/8"}, "127.0.0.1", false}, + {nil, "127.0.0.1", true}, {[]string{}, "2001:db8::4", true}, + {nil, "invalid", false}, {[]string{"0.0.0.0/0"}, "invalid", false}, + } + for _, tc := range cases { + if got := Allowed(tc.rules, tc.ip); got != tc.want { + t.Errorf("Allowed(%v,%q)=%v", tc.rules, tc.ip, got) + } + } +} + +type heldHandler struct{ entered chan struct{} } + +func (h heldHandler) Serve(ctx context.Context, c net.Conn, _ model.Proxy, _ model.DialFunc, _ *tls.Config, event model.EventFunc) error { + event(model.EventReadOnlyRejected, "test") + h.entered <- struct{}{} + <-ctx.Done() + return ctx.Err() +} +func (heldHandler) Test(context.Context, model.Proxy, model.DialFunc) (model.TestResult, error) { + return model.TestResult{}, nil +} +func testManager(t *testing.T) (*Manager, model.Proxy, chan struct{}) { + t.Helper() + v, err := security.NewVault(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32))) + if err != nil { + t.Fatal(err) + } + s, err := store.Open(filepath.Join(t.TempDir(), "test.db"), v) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close() }) + entered := make(chan struct{}, 10) + m := New(s, &tls.Config{MinVersion: tls.VersionTLS12}, map[string]model.Handler{"mysql": heldHandler{entered}}) + t.Cleanup(m.Shutdown) + return m, model.Proxy{ID: "test", Type: "mysql", ListenHost: "127.0.0.1", ListenPort: 0, AllowCIDRs: []string{"127.0.0.1/32"}, MaxConnections: 1}, entered +} +func address(m *Manager, id string) string { + m.mu.Lock() + defer m.mu.Unlock() + return m.instances[id].listener.Addr().String() +} +func TestManagerLimitStopAndRestart(t *testing.T) { + m, p, entered := testManager(t) + if err := m.Start(p); err != nil { + t.Fatal(err) + } + addr := address(m, p.ID) + if err := m.Start(p); err == nil { + t.Fatal("duplicate start accepted") + } + c, err := net.Dial("tcp", addr) + if err != nil { + t.Fatal(err) + } + defer c.Close() + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("handler not started") + } + status := m.Status(p.ID) + if !status.Running || status.Connections != 1 || status.Rejected != 1 { + t.Fatalf("status %+v", status) + } + second, err := net.Dial("tcp", addr) + if err != nil { + t.Fatal(err) + } + defer second.Close() + second.SetReadDeadline(time.Now().Add(time.Second)) + var b [1]byte + if _, err = second.Read(b[:]); err != io.EOF { + t.Fatalf("limited connection not closed: %v", err) + } + m.Stop(p.ID) + if m.Status(p.ID).Running { + t.Fatal("still running after stop") + } + c.SetReadDeadline(time.Now().Add(time.Second)) + if _, err = c.Read(b[:]); err != io.EOF { + t.Fatalf("active connection not closed: %v", err) + } + if err = m.Start(p); err != nil { + t.Fatal(err) + } + if m.Status(p.ID).Connections != 0 { + t.Fatal("old connections survived restart") + } +} +func TestManagerRejectsSourceWithoutTLS(t *testing.T) { + m, p, entered := testManager(t) + m.tls = nil + p.AllowCIDRs = []string{"192.0.2.0/24"} + if err := m.Start(p); err != nil { + t.Fatal(err) + } + c, err := net.Dial("tcp", address(m, p.ID)) + if err != nil { + t.Fatal(err) + } + defer c.Close() + c.SetReadDeadline(time.Now().Add(time.Second)) + var b [1]byte + if _, err = c.Read(b[:]); err != io.EOF { + t.Fatalf("source not closed: %v", err) + } + select { + case <-entered: + t.Fatal("denied source reached protocol") + default: + } +} +func TestManagerPortConflictDoesNotStopOtherProxy(t *testing.T) { + m, p, _ := testManager(t) + if err := m.Start(p); err != nil { + t.Fatal(err) + } + m.mu.Lock() + port := m.instances[p.ID].listener.Addr().(*net.TCPAddr).Port + m.mu.Unlock() + other := p + other.ID = "other" + other.ListenPort = port + if err := m.Start(other); err == nil { + t.Fatal("port conflict accepted") + } + if !m.Status(p.ID).Running || m.Status(other.ID).Running || m.Status(other.ID).Error == "" { + t.Fatal("bad independent failure status") + } +} + +func TestShutdownClosesAfterGracePeriod(t *testing.T) { + m, p, entered := testManager(t) + if err := m.Start(p); err != nil { + t.Fatal(err) + } + c, err := net.Dial("tcp", address(m, p.ID)) + if err != nil { + t.Fatal(err) + } + defer c.Close() + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("handler not started") + } + done := make(chan struct{}) + go func() { m.shutdown(150 * time.Millisecond); close(done) }() + select { + case <-done: + t.Fatal("shutdown did not allow a grace period") + case <-time.After(30 * time.Millisecond): + } + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("shutdown did not close stalled connection") + } + if m.Status(p.ID).Running { + t.Fatal("proxy remains running") + } +} diff --git a/internal/localcert/cert.go b/internal/localcert/cert.go new file mode 100644 index 0000000..59a1f74 --- /dev/null +++ b/internal/localcert/cert.go @@ -0,0 +1,48 @@ +package localcert + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "time" +) + +func Generate(dir string, validity time.Duration) error { + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + for _, name := range []string{"cert.pem", "key.pem"} { + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + return fmt.Errorf("refusing to replace existing %s", name) + } + } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return err + } + cert := &x509.Certificate{SerialNumber: serial, Subject: pkix.Name{CommonName: "AgentDB localhost development"}, DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(validity), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, IsCA: true} + der, err := x509.CreateCertificate(rand.Reader, cert, cert, &key.PublicKey, key) + if err != nil { + return err + } + raw, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return err + } + if err = os.WriteFile(filepath.Join(dir, "key.pem"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: raw}), 0600); err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "cert.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0644) +} diff --git a/internal/model/audit.go b/internal/model/audit.go new file mode 100644 index 0000000..7a55309 --- /dev/null +++ b/internal/model/audit.go @@ -0,0 +1,37 @@ +package model + +import ( + "context" + "strings" + "unicode/utf8" +) + +const MaxCommandBytes = 16384 + +type Command struct { + Kind string `json:"kind"` + Text string `json:"text"` + Decision string `json:"decision"` + Truncated bool `json:"truncated"` +} +type commandAuditKey struct{} +type CommandAudit func(Command) error + +func WithCommandAudit(ctx context.Context, audit CommandAudit) context.Context { + return context.WithValue(ctx, commandAuditKey{}, audit) +} +func AuditCommand(ctx context.Context, kind, text, decision string) error { + audit, _ := ctx.Value(commandAuditKey{}).(CommandAudit) + if audit == nil { + return nil + } + text = strings.ToValidUTF8(text, "�") + truncated := len(text) > MaxCommandBytes + if truncated { + text = text[:MaxCommandBytes] + for !utf8.ValidString(text) && len(text) > 0 { + text = text[:len(text)-1] + } + } + return audit(Command{Kind: kind, Text: text, Decision: decision, Truncated: truncated}) +} diff --git a/internal/model/kubernetes.go b/internal/model/kubernetes.go new file mode 100644 index 0000000..b31e5e9 --- /dev/null +++ b/internal/model/kubernetes.go @@ -0,0 +1,24 @@ +package model + +import ( + "errors" + "regexp" + "strings" +) + +var kubeName = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$`) +var kubeNamespace = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`) + +func (o Outbound) ValidateKubernetes() error { + kind, name, ok := strings.Cut(o.Resource, "/") + if !ok || (kind != "pod" && kind != "service" && kind != "deployment") || len(name) > 253 || !kubeName.MatchString(name) || strings.Contains(name, "..") { + return errors.New("Kubernetes resource must be pod/name, service/name or deployment/name") + } + if len(o.Namespace) > 63 || (o.Namespace != "" && !kubeNamespace.MatchString(o.Namespace)) { + return errors.New("invalid Kubernetes namespace") + } + if len(o.KubeConfig) > 4096 || len(o.KubeContext) > 253 || strings.ContainsAny(o.KubeConfig+o.KubeContext, "\x00\r\n") { + return errors.New("invalid Kubernetes config path or context") + } + return nil +} diff --git a/internal/model/kubernetes_test.go b/internal/model/kubernetes_test.go new file mode 100644 index 0000000..e02b022 --- /dev/null +++ b/internal/model/kubernetes_test.go @@ -0,0 +1,19 @@ +package model + +import "testing" + +func TestValidateKubernetes(t *testing.T) { + for _, resource := range []string{"pod/mysql-0", "service/mysql", "deployment/postgres"} { + if err := (Outbound{Resource: resource, Namespace: "my-app", KubeConfig: `C:\Users\test\.kube\config`}).ValidateKubernetes(); err != nil { + t.Fatal(err) + } + } + for _, o := range []Outbound{ + {Resource: "pod/../config"}, {Resource: "pod/foo/bar"}, {Resource: "statefulset/db"}, {Resource: "pod/"}, + {Resource: "pod/db", Namespace: "-bad"}, {Resource: "pod/db", KubeContext: "bad\ncontext"}, {Resource: "pod/db", KubeConfig: "bad\x00path"}, + } { + if err := o.ValidateKubernetes(); err == nil { + t.Errorf("accepted invalid Kubernetes config: %+v", o) + } + } +} diff --git a/internal/model/outbound_profile.go b/internal/model/outbound_profile.go new file mode 100644 index 0000000..a9fa249 --- /dev/null +++ b/internal/model/outbound_profile.go @@ -0,0 +1,45 @@ +package model + +import ( + "encoding/json" + "errors" + "reflect" + "strings" +) + +type OutboundProfile struct { + ID string `json:"id"` + Name string `json:"name"` + Outbound Outbound `json:"outbound"` +} + +func (p *OutboundProfile) Validate() error { + if strings.TrimSpace(p.Name) == "" || len(p.Name) > 100 { + return errors.New("name must contain 1-100 bytes") + } + if p.Outbound.Mode == "direct" || p.Outbound.Mode == "" { + return errors.New("shared outbound must be socks5, ssh or k8s") + } + probe := Defaults() + probe.Name, probe.Type, probe.TargetHost, probe.TargetUser, probe.GatewayUser, probe.GatewayPassword = "validation", "mysql", "localhost", "validation", "validation", "validation" + probe.ListenPort, probe.TargetPort = 1, 1 + probe.Outbound = p.Outbound + if err := probe.Validate(); err != nil { + return err + } + p.Outbound = probe.Outbound + return nil +} +func MergeOutbound(current OutboundProfile, patch map[string]any) (OutboundProfile, error) { + if err := exactFields(patch, reflect.TypeOf(OutboundProfile{})); err != nil { + return OutboundProfile{}, err + } + raw, _ := json.Marshal(current) + var base map[string]any + _ = json.Unmarshal(raw, &base) + merge(base, patch) + raw, _ = json.Marshal(base) + var out OutboundProfile + err := json.Unmarshal(raw, &out) + return out, err +} diff --git a/internal/model/proxy.go b/internal/model/proxy.go new file mode 100644 index 0000000..c1eb3ae --- /dev/null +++ b/internal/model/proxy.go @@ -0,0 +1,80 @@ +package model + +import ( + "context" + "crypto/tls" + "net" +) + +// Proxy is a resolved runtime snapshot of a gateway and its referenced target. +// Only GatewayOnly is persisted in the gateway table. HTTP responses must use +// Redacted rather than marshal a snapshot containing decrypted credentials. +type Proxy struct { + ID string `json:"id"` + TargetID string `json:"targetId,omitempty"` + OutboundID string `json:"outboundId,omitempty"` + TargetCompression string `json:"targetCompression,omitempty"` + Name string `json:"name"` + Group string `json:"group,omitempty"` + Type string `json:"type"` + ListenHost string `json:"listenHost"` + ListenPort int `json:"listenPort"` + AllowCIDRs []string `json:"allowCIDRs"` + TargetHost string `json:"targetHost"` + TargetPort int `json:"targetPort"` + TargetUser string `json:"targetUser"` + TargetPassword string `json:"targetPassword,omitempty"` + GatewayUser string `json:"gatewayUser"` + GatewayPassword string `json:"gatewayPassword,omitempty"` + AccessMode string `json:"accessMode,omitempty"` + MaxConnections int `json:"maxConnections"` + Enabled bool `json:"enabled"` + TargetTLS bool `json:"targetTLS"` + TargetCA string `json:"targetCA,omitempty"` + AuthSource string `json:"authSource,omitempty"` + Outbound Outbound `json:"outbound"` + SSH SSHConfig `json:"ssh"` +} +type Outbound struct { + KubeConfig string `json:"kubeConfig,omitempty"` + KubeContext string `json:"kubeContext,omitempty"` + Namespace string `json:"namespace,omitempty"` + Resource string `json:"resource,omitempty"` + HostFingerprint string `json:"hostFingerprint,omitempty"` + Mode string `json:"mode"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + Auth string `json:"auth,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` +} +type SSHConfig struct { + AuthMethod string `json:"authMethod,omitempty"` + PrivateKey string `json:"privateKey,omitempty"` + Passphrase string `json:"passphrase,omitempty"` + AuthorizedKeys []string `json:"authorizedKeys,omitempty"` + HostKey string `json:"hostKey,omitempty"` + TargetHostKey string `json:"targetHostKey,omitempty"` +} + +type DialFunc func(context.Context, string, string) (net.Conn, error) +type EventFunc func(category, rule string) + +const EventReadOnlyRejected = "readonly_rejected" +const EventProtocolRejected = "protocol_rejected" + +type TestResult struct { + Compression string `json:"compression,omitempty"` + Version string `json:"version,omitempty"` + TLS bool `json:"tls"` + Role string `json:"role,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + HostKey string `json:"hostKey,omitempty"` +} + +// Handler owns protocol negotiation. Manager enforces IP limits and closes +// inbound connections on stop. Implementations must close upstream on cancel. +type Handler interface { + Serve(context.Context, net.Conn, Proxy, DialFunc, *tls.Config, EventFunc) error + Test(context.Context, Proxy, DialFunc) (TestResult, error) +} diff --git a/internal/model/target.go b/internal/model/target.go new file mode 100644 index 0000000..fa34077 --- /dev/null +++ b/internal/model/target.go @@ -0,0 +1,96 @@ +package model + +import ( + "encoding/json" + "reflect" +) + +// Target owns upstream identity and routing. It can be shared by many gateways. +type Target struct { + TargetCompression string `json:"targetCompression,omitempty"` + ID string `json:"id"` + OutboundID string `json:"outboundId,omitempty"` + Name string `json:"name"` + Group string `json:"group,omitempty"` + Type string `json:"type"` + TargetHost string `json:"targetHost"` + TargetPort int `json:"targetPort"` + TargetUser string `json:"targetUser"` + TargetPassword string `json:"targetPassword,omitempty"` + TargetTLS bool `json:"targetTLS"` + TargetCA string `json:"targetCA,omitempty"` + AuthSource string `json:"authSource,omitempty"` + Outbound Outbound `json:"outbound"` + SSH TargetSSH `json:"ssh"` +} +type TargetSSH struct { + AuthMethod string `json:"authMethod,omitempty"` + PrivateKey string `json:"privateKey,omitempty"` + Passphrase string `json:"passphrase,omitempty"` + TargetHostKey string `json:"targetHostKey,omitempty"` +} + +func TargetFromProxy(p Proxy) Target { + return Target{OutboundID: p.OutboundID, TargetCompression: p.TargetCompression, ID: p.TargetID, Name: p.Name, Group: p.Group, Type: p.Type, TargetHost: p.TargetHost, TargetPort: p.TargetPort, TargetUser: p.TargetUser, TargetPassword: p.TargetPassword, TargetTLS: p.TargetTLS, TargetCA: p.TargetCA, AuthSource: p.AuthSource, Outbound: p.Outbound, SSH: TargetSSH{p.SSH.AuthMethod, p.SSH.PrivateKey, p.SSH.Passphrase, p.SSH.TargetHostKey}} +} +func (t Target) Apply(p Proxy) Proxy { + p.TargetCompression = t.TargetCompression + p.TargetID = t.ID + p.Group = t.Group + p.OutboundID = t.OutboundID + p.TargetHost, p.TargetPort, p.TargetUser, p.TargetPassword = t.TargetHost, t.TargetPort, t.TargetUser, t.TargetPassword + p.TargetTLS, p.TargetCA, p.AuthSource, p.Outbound = t.TargetTLS, t.TargetCA, t.AuthSource, t.Outbound + p.SSH.AuthMethod, p.SSH.PrivateKey, p.SSH.Passphrase, p.SSH.TargetHostKey = t.SSH.AuthMethod, t.SSH.PrivateKey, t.SSH.Passphrase, t.SSH.TargetHostKey + return p +} + +// GatewayOnly removes upstream material before persisting a gateway record. +func (p Proxy) GatewayOnly() Proxy { + id := p.TargetID + p = (Target{}).Apply(p) + p.TargetID = id + return p +} +func TargetDefaults() Target { return TargetFromProxy(Defaults()) } +func (t *Target) Validate() error { + p := t.Apply(Defaults()) + p.Name, p.Type = t.Name, t.Type + p.ListenPort = 1 + p.AllowCIDRs = []string{"127.0.0.1"} + p.GatewayUser = "validation" + p.GatewayPassword = "validation" + if p.Type == "ssh" { + p.AccessMode = "" + } + if err := p.Validate(); err != nil { + return err + } + *t = TargetFromProxy(p) + return nil +} +func (t Target) Runtime() Proxy { + p := t.Apply(Defaults()) + p.ID, p.Name, p.Type = t.ID, t.Name, t.Type + if p.Type == "ssh" { + p.AccessMode = "" + } + return p +} +func (t Target) Redacted() (Target, map[string]bool) { + p, flags := t.Runtime().Redacted() + delete(flags, "gatewayPassword") + return TargetFromProxy(p), flags +} +func MergeTarget(current Target, patch map[string]any) (Target, error) { + if err := exactFields(patch, reflect.TypeOf(Target{})); err != nil { + return Target{}, err + } + raw, _ := json.Marshal(current) + var base map[string]any + _ = json.Unmarshal(raw, &base) + merge(base, patch) + raw, _ = json.Marshal(base) + var out Target + err := json.Unmarshal(raw, &out) + return out, err +} diff --git a/internal/model/validation.go b/internal/model/validation.go new file mode 100644 index 0000000..a524e16 --- /dev/null +++ b/internal/model/validation.go @@ -0,0 +1,200 @@ +package model + +import ( + "encoding/base64" + "encoding/json" + "errors" + "net" + "net/netip" + "reflect" + "strings" +) + +func Defaults() Proxy { + return Proxy{ListenHost: "0.0.0.0", MaxConnections: 100, TargetTLS: true, AccessMode: "readwrite", AuthSource: "admin", Outbound: Outbound{Mode: "direct", Auth: "none"}, SSH: SSHConfig{AuthMethod: "password"}} +} +func (p *Proxy) Validate() error { + if p.TargetCompression == "" { + p.TargetCompression = "none" + } + if p.TargetCompression != "none" && p.TargetCompression != "zlib" { + return errors.New("targetCompression must be none or zlib") + } + if p.Type != "mysql" && p.Type != "mongodb" && p.TargetCompression != "none" { + return errors.New("target compression is supported only for MySQL and MongoDB") + } + if strings.TrimSpace(p.Name) == "" || len(p.Name) > 100 { + return errors.New("name must contain 1-100 bytes") + } + if len(p.Group) > 100 || strings.ContainsAny(p.Group, "\r\n\x00") { + return errors.New("group must contain at most 100 bytes") + } + if p.Type != "mysql" && p.Type != "mongodb" && p.Type != "ssh" && p.Type != "redis" && p.Type != "rabbitmq" && p.Type != "pgsql" { + return errors.New("unsupported proxy type") + } + if net.ParseIP(p.ListenHost) == nil { + return errors.New("listenHost must be an IP address") + } + if !port(p.ListenPort) || !port(p.TargetPort) { + return errors.New("port must be 1-65535") + } + if !host(p.TargetHost) { + return errors.New("invalid target host") + } + for _, s := range p.AllowCIDRs { + if _, err := netip.ParsePrefix(s); err != nil { + if _, err = netip.ParseAddr(s); err != nil { + return errors.New("invalid source IP/CIDR") + } + } + } + if p.MaxConnections < 1 || p.MaxConnections > 10000 { + return errors.New("maxConnections must be 1-10000") + } + if p.Type == "redis" && p.TargetUser == "" { + p.TargetUser = "default" + } + if p.TargetUser == "" || p.GatewayUser == "" { + return errors.New("target and gateway usernames are required") + } + if p.Type == "ssh" { + if p.AccessMode != "" { + return errors.New("SSH has no accessMode") + } + if p.GatewayPassword == "" && len(p.SSH.AuthorizedKeys) == 0 { + return errors.New("SSH requires a gateway password or authorized key") + } + if p.SSH.AuthMethod != "password" && p.SSH.AuthMethod != "privatekey" { + return errors.New("SSH authMethod must be password or privatekey") + } + if p.SSH.AuthMethod == "privatekey" && p.SSH.PrivateKey == "" { + return errors.New("SSH private key is required") + } + } else { + if p.AccessMode == "" { + p.AccessMode = "readwrite" + } + if p.AccessMode != "readwrite" && p.AccessMode != "readonly" { + return errors.New("invalid accessMode") + } + if p.Type != "mysql" && p.Type != "mongodb" && p.AccessMode == "readonly" { + return errors.New("readonly is currently supported only for MySQL and MongoDB") + } + if p.GatewayPassword == "" { + return errors.New("gateway password is required") + } + if p.Type == "mongodb" && p.AuthSource == "" { + p.AuthSource = "admin" + } + } + if p.Outbound.Mode == "" { + p.Outbound.Mode = "direct" + } + if p.Outbound.Mode != "direct" && p.Outbound.Mode != "socks5" && p.Outbound.Mode != "ssh" && p.Outbound.Mode != "k8s" { + return errors.New("invalid outbound mode") + } + if p.Outbound.Mode == "k8s" { + if err := p.Outbound.ValidateKubernetes(); err != nil { + return err + } + } + if p.Outbound.Mode == "socks5" { + o := p.Outbound + if !host(o.Host) || !port(o.Port) { + return errors.New("invalid SOCKS5 address") + } + if o.Auth != "none" && o.Auth != "password" { + return errors.New("invalid SOCKS5 auth") + } + if o.Auth == "password" && (len(o.Username) < 1 || len(o.Username) > 255 || len(o.Password) < 1 || len(o.Password) > 255) { + return errors.New("SOCKS5 credentials must be 1-255 UTF-8 bytes") + } + } + if p.Outbound.Mode == "ssh" { + o := p.Outbound + if !host(o.Host) || !port(o.Port) { + return errors.New("invalid SSH forwarding address") + } + if o.Auth != "password" || o.Username == "" || o.Password == "" { + return errors.New("SSH forwarding requires username and password") + } + fingerprint, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(o.HostFingerprint, "SHA256:")) + if !strings.HasPrefix(o.HostFingerprint, "SHA256:") || err != nil || len(fingerprint) != 32 { + return errors.New("SSH forwarding requires a valid SHA256 host fingerprint") + } + } + return nil +} +func port(n int) bool { return n >= 1 && n <= 65535 } + +func host(s string) bool { + return len(s) > 0 && len(s) <= 253 && !strings.ContainsAny(s, " /\\\t\r\n\x00") +} +func (p Proxy) Redacted() (Proxy, map[string]bool) { + flags := map[string]bool{"targetPassword": p.TargetPassword != "", "gatewayPassword": p.GatewayPassword != "", "socksPassword": p.Outbound.Password != "", "sshPrivateKey": p.SSH.PrivateKey != "", "sshPassphrase": p.SSH.Passphrase != ""} + p.TargetPassword = "" + p.GatewayPassword = "" + p.Outbound.Password = "" + p.SSH.PrivateKey = "" + p.SSH.Passphrase = "" + p.SSH.HostKey = "" + return p, flags +} +func Merge(current Proxy, patch map[string]any) (Proxy, error) { + if err := exactFields(patch, reflect.TypeOf(Proxy{})); err != nil { + return Proxy{}, err + } + raw, _ := json.Marshal(current) + var base map[string]any + _ = json.Unmarshal(raw, &base) + merge(base, patch) + raw, _ = json.Marshal(base) + var out Proxy + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + err := dec.Decode(&out) + return out, err +} + +// encoding/json otherwise accepts case-insensitive aliases, which could bypass +// server-managed field guards. Configuration patches use exact JSON names only. +func exactFields(patch map[string]any, t reflect.Type) error { + fields := map[string]reflect.Type{} + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + fields[strings.Split(f.Tag.Get("json"), ",")[0]] = f.Type + } + for k, v := range patch { + ft, ok := fields[k] + if !ok { + return errors.New("unknown configuration field: " + k) + } + if v == nil { + return errors.New("configuration fields cannot be null") + } + if ft.Kind() == reflect.Struct { + sub, ok := v.(map[string]any) + if !ok { + return errors.New("configuration object expected") + } + if err := exactFields(sub, ft); err != nil { + return err + } + } + } + return nil +} +func merge(a, b map[string]any) { + for k, v := range b { + if sub, ok := v.(map[string]any); ok { + dst, ok := a[k].(map[string]any) + if !ok { + dst = map[string]any{} + } + merge(dst, sub) + a[k] = dst + } else { + a[k] = v + } + } +} diff --git a/internal/outbound/dial.go b/internal/outbound/dial.go new file mode 100644 index 0000000..59fce41 --- /dev/null +++ b/internal/outbound/dial.go @@ -0,0 +1,159 @@ +package outbound + +import ( + "agentdb/internal/model" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "strconv" + "time" +) + +// Dialer preserves target hostnames through SOCKS5 and never falls back to direct. +func Dialer(p model.Proxy) model.DialFunc { + return func(ctx context.Context, network, address string) (result net.Conn, resultErr error) { + if p.Outbound.Mode == "k8s" { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + return dialKubernetes(ctx, network, address, p.Outbound) + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if p.Outbound.Mode == "ssh" { + return dialSSH(ctx, network, address, p.Outbound) + } + d := net.Dialer{} + if p.Outbound.Mode == "" || p.Outbound.Mode == "direct" { + return d.DialContext(ctx, network, address) + } + if p.Outbound.Mode != "socks5" || network != "tcp" { + return nil, errors.New("unsupported outbound mode") + } + defer func() { + if resultErr != nil { + resultErr = fmt.Errorf("SOCKS5 negotiation failed: %w", resultErr) + } + }() + o := p.Outbound + c, err := d.DialContext(ctx, "tcp", net.JoinHostPort(o.Host, strconv.Itoa(o.Port))) + if err != nil { + return nil, errors.New("SOCKS5 connection failed") + } + good := false + defer func() { + if !good { + c.Close() + } + }() + stop := context.AfterFunc(ctx, func() { c.Close() }) + defer stop() + deadline, _ := ctx.Deadline() + _ = c.SetDeadline(deadline) + method := byte(0) + if o.Auth == "password" { + method = 2 + if len(o.Username) < 1 || len(o.Username) > 255 || len(o.Password) < 1 || len(o.Password) > 255 { + return nil, errors.New("invalid SOCKS5 credentials") + } + } + if err = write(c, []byte{5, 1, method}); err != nil { + return nil, err + } + reply := make([]byte, 2) + if _, err = io.ReadFull(c, reply); err != nil { + return nil, err + } + if reply[0] != 5 || reply[1] != method { + return nil, errors.New("SOCKS5 authentication method rejected") + } + if method == 2 { + req := append([]byte{1, byte(len(o.Username))}, []byte(o.Username)...) + req = append(req, byte(len(o.Password))) + req = append(req, []byte(o.Password)...) + if err = write(c, req); err != nil { + return nil, err + } + if _, err = io.ReadFull(c, reply); err != nil { + return nil, err + } + if reply[0] != 1 || reply[1] != 0 { + return nil, errors.New("SOCKS5 authentication failed") + } + } + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return nil, errors.New("invalid target port") + } + req := []byte{5, 1, 0} + if ip := net.ParseIP(host); ip != nil { + if v4 := ip.To4(); v4 != nil { + req = append(req, 1) + req = append(req, v4...) + } else { + req = append(req, 4) + req = append(req, ip.To16()...) + } + } else { + if len(host) < 1 || len(host) > 255 { + return nil, errors.New("invalid target hostname") + } + req = append(req, 3, byte(len(host))) + req = append(req, host...) + } + req = binary.BigEndian.AppendUint16(req, uint16(n)) + if err = write(c, req); err != nil { + return nil, err + } + header := make([]byte, 4) + if _, err = io.ReadFull(c, header); err != nil { + return nil, err + } + if header[0] != 5 || header[1] != 0 || header[2] != 0 { + return nil, fmt.Errorf("SOCKS5 target connection rejected (code %d)", header[1]) + } + size := 0 + switch header[3] { + case 1: + size = 4 + case 4: + size = 16 + case 3: + var b [1]byte + if _, err = io.ReadFull(c, b[:]); err != nil { + return nil, err + } + size = int(b[0]) + default: + return nil, errors.New("invalid SOCKS5 address") + } + if _, err = io.CopyN(io.Discard, c, int64(size+2)); err != nil { + return nil, err + } + if !stop() || ctx.Err() != nil { + return nil, context.Canceled + } + _ = c.SetDeadline(time.Time{}) + good = true + return c, nil + } +} +func write(w io.Writer, b []byte) error { + for len(b) > 0 { + n, err := w.Write(b) + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + b = b[n:] + } + return nil +} diff --git a/internal/outbound/dial_test.go b/internal/outbound/dial_test.go new file mode 100644 index 0000000..7da56f6 --- /dev/null +++ b/internal/outbound/dial_test.go @@ -0,0 +1,266 @@ +package outbound + +import ( + "agentdb/internal/model" + "bytes" + "context" + "encoding/binary" + "fmt" + "io" + "net" + "testing" + "time" +) + +func mockSOCKS(t *testing.T, handle func(net.Conn) error) (model.Proxy, <-chan error) { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { l.Close() }) + done := make(chan error, 1) + go func() { + c, err := l.Accept() + if err != nil { + done <- err + return + } + defer c.Close() + c.SetDeadline(time.Now().Add(3 * time.Second)) + done <- handle(c) + }() + return model.Proxy{Outbound: model.Outbound{Mode: "socks5", Host: "127.0.0.1", Port: l.Addr().(*net.TCPAddr).Port, Auth: "none"}}, done +} +func expectBytes(c net.Conn, want []byte) error { + got := make([]byte, len(want)) + if _, err := io.ReadFull(c, got); err != nil { + return err + } + if !bytes.Equal(got, want) { + return fmt.Errorf("got %v want %v", got, want) + } + return nil +} +func awaitSOCKS(t *testing.T, done <-chan error) { + t.Helper() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(4 * time.Second): + t.Fatal("SOCKS server did not finish") + } +} + +func TestSOCKSAuthenticationAddressEncodingAndTunnel(t *testing.T) { + for _, host := range []string{"only-remote-dns.invalid", "192.0.2.1", "2001:db8::1"} { + for _, auth := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/password=%v", host, auth), func(t *testing.T) { + method := byte(0) + if auth { + method = 2 + } + p, done := mockSOCKS(t, func(c net.Conn) error { + if err := expectBytes(c, []byte{5, 1, method}); err != nil { + return err + } + if _, err := c.Write([]byte{5, method}); err != nil { + return err + } + if auth { + if err := expectBytes(c, []byte{1, 4, 'u', 's', 'e', 'r', 4, 'p', 'a', 's', 's'}); err != nil { + return err + } + if _, err := c.Write([]byte{1, 0}); err != nil { + return err + } + } + want := []byte{5, 1, 0} + if ip := net.ParseIP(host); ip != nil { + if v4 := ip.To4(); v4 != nil { + want = append(want, 1) + want = append(want, v4...) + } else { + want = append(want, 4) + want = append(want, ip.To16()...) + } + } else { + want = append(want, 3, byte(len(host))) + want = append(want, host...) + } + want = binary.BigEndian.AppendUint16(want, 3306) + if err := expectBytes(c, want); err != nil { + return err + } + if _, err := c.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 1}); err != nil { + return err + } + if err := expectBytes(c, []byte("ping")); err != nil { + return err + } + _, err := c.Write([]byte("pong")) + return err + }) + if auth { + p.Outbound.Auth = "password" + p.Outbound.Username = "user" + p.Outbound.Password = "pass" + } + c, err := Dialer(p)(context.Background(), "tcp", net.JoinHostPort(host, "3306")) + if err != nil { + t.Fatal(err) + } + defer c.Close() + c.SetDeadline(time.Now().Add(time.Second)) + if _, err = c.Write([]byte("ping")); err != nil { + t.Fatal(err) + } + if err = expectBytes(c, []byte("pong")); err != nil { + t.Fatal(err) + } + awaitSOCKS(t, done) + }) + } + } +} + +func TestSOCKSRejectsDowngradeAndAuthenticationFailure(t *testing.T) { + for _, downgrade := range []bool{false, true} { + t.Run(fmt.Sprint(downgrade), func(t *testing.T) { + p, done := mockSOCKS(t, func(c net.Conn) error { + if err := expectBytes(c, []byte{5, 1, 2}); err != nil { + return err + } + if downgrade { + _, err := c.Write([]byte{5, 0}) + return err + } + if _, err := c.Write([]byte{5, 2}); err != nil { + return err + } + if err := expectBytes(c, []byte{1, 1, 'u', 1, 'p'}); err != nil { + return err + } + _, err := c.Write([]byte{1, 1}) + return err + }) + p.Outbound.Auth = "password" + p.Outbound.Username = "u" + p.Outbound.Password = "p" + c, err := Dialer(p)(context.Background(), "tcp", "127.0.0.1:1") + if c != nil { + c.Close() + } + if err == nil { + t.Fatal("accepted rejected authentication") + } + awaitSOCKS(t, done) + }) + } +} + +func TestSOCKSFailureNeverFallsBackToDirect(t *testing.T) { + target, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer target.Close() + p, done := mockSOCKS(t, func(c net.Conn) error { + if err := expectBytes(c, []byte{5, 1, 0}); err != nil { + return err + } + if _, err := c.Write([]byte{5, 0}); err != nil { + return err + } + request := make([]byte, 10) + if _, err := io.ReadFull(c, request); err != nil { + return err + } + _, err := c.Write([]byte{5, 5, 0, 1, 0, 0, 0, 0, 0, 0}) + return err + }) + c, err := Dialer(p)(context.Background(), "tcp", target.Addr().String()) + if c != nil { + c.Close() + } + if err == nil { + t.Fatal("accepted CONNECT rejection") + } + awaitSOCKS(t, done) + target.(*net.TCPListener).SetDeadline(time.Now().Add(100 * time.Millisecond)) + c, err = target.Accept() + if err == nil { + c.Close() + t.Fatal("fell back to direct target") + } +} + +func TestSOCKSCancelClosesHandshake(t *testing.T) { + started := make(chan struct{}) + p, done := mockSOCKS(t, func(c net.Conn) error { + if err := expectBytes(c, []byte{5, 1, 0}); err != nil { + return err + } + close(started) + var b [1]byte + _, err := c.Read(b[:]) + if err != io.EOF { + return fmt.Errorf("expected peer close, got %v", err) + } + return nil + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + result := make(chan error, 1) + go func() { + c, err := Dialer(p)(ctx, "tcp", "remote.invalid:22") + if c != nil { + c.Close() + } + result <- err + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("handshake did not start") + } + cancel() + select { + case err := <-result: + if err == nil { + t.Fatal("canceled dial succeeded") + } + case <-time.After(time.Second): + t.Fatal("canceled dial stuck") + } + awaitSOCKS(t, done) +} + +func TestDirectModeAndCanceledContext(t *testing.T) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + for _, mode := range []string{"", "direct"} { + c, err := Dialer(model.Proxy{Outbound: model.Outbound{Mode: mode}})(context.Background(), "tcp", l.Addr().String()) + if err != nil { + t.Fatal(err) + } + peer, err := l.Accept() + if err != nil { + c.Close() + t.Fatal(err) + } + peer.Close() + c.Close() + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if c, err := Dialer(model.Proxy{})(ctx, "tcp", l.Addr().String()); err == nil { + c.Close() + t.Fatal("canceled direct dial succeeded") + } +} diff --git a/internal/outbound/kubernetes.go b/internal/outbound/kubernetes.go new file mode 100644 index 0000000..c38b45b --- /dev/null +++ b/internal/outbound/kubernetes.go @@ -0,0 +1,309 @@ +package outbound + +import ( + "agentdb/internal/model" + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + streamspdy "k8s.io/apimachinery/pkg/util/httpstream/spdy" + "k8s.io/apimachinery/pkg/util/intstr" + utilnet "k8s.io/apimachinery/pkg/util/net" + appclient "k8s.io/client-go/kubernetes/typed/apps/v1" + coreclient "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/portforward" + "k8s.io/client-go/transport/spdy" +) + +type kubernetesConn struct { + net.Conn + stop func() + done <-chan struct{} + once sync.Once +} + +func (c *kubernetesConn) Close() error { + err := c.Conn.Close() + c.once.Do(func() { c.stop(); <-c.done }) + return err +} + +// The tunnel owns its lifetime. A caller's dial deadline only bounds setup. +func dialKubernetes(ctx context.Context, network, address string, o model.Outbound) (net.Conn, error) { + if network != "tcp" { + return nil, errors.New("Kubernetes port-forward requires TCP") + } + if err := o.ValidateKubernetes(); err != nil { + return nil, err + } + _, rawPort, err := net.SplitHostPort(address) + port, parseErr := strconv.Atoi(rawPort) + if err != nil || parseErr != nil || port < 1 || port > 65535 { + return nil, errors.New("Kubernetes port-forward invalid target port") + } + if err := ctx.Err(); err != nil { + return nil, err + } + rules := clientcmd.NewDefaultClientConfigLoadingRules() + rules.ExplicitPath = o.KubeConfig + overrides := &clientcmd.ConfigOverrides{CurrentContext: o.KubeContext} + loader := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, overrides) + config, err := loader.ClientConfig() + if err != nil { + return nil, errors.New("Kubernetes config could not be loaded") + } + namespace := o.Namespace + if namespace == "" { + namespace, _, err = loader.Namespace() + if err != nil { + return nil, errors.New("Kubernetes namespace could not be loaded") + } + } + return dialKubernetesConfig(ctx, config, namespace, o.Resource, port) +} + +func dialKubernetesConfig(ctx context.Context, config *rest.Config, namespace, resource string, port int) (net.Conn, error) { + apiURL, err := url.Parse(config.Host) + if err != nil { + return nil, errors.New("Kubernetes API address is invalid") + } + proxy := config.Proxy + if proxy == nil { + proxy = http.ProxyFromEnvironment + } + proxyURL, err := proxy(&http.Request{URL: apiURL}) + if err != nil || proxyURL != nil { + return nil, errors.New("Kubernetes port-forward requires direct API access; kubeconfig proxy-url and HTTP proxy are not supported") + } + core, err := coreclient.NewForConfig(config) + if err != nil { + return nil, errors.New("Kubernetes client configuration failed") + } + apps, err := appclient.NewForConfig(config) + if err != nil { + return nil, errors.New("Kubernetes client configuration failed") + } + pod, remotePort, err := resolveKubernetesTarget(ctx, core, apps, namespace, resource, port) + if err != nil { + return nil, err + } + // Do not apply a request-wide timeout to the upgraded streaming connection. + streamConfig := rest.CopyConfig(config) + streamConfig.Timeout = 0 + lifetime, cancel := context.WithCancel(context.Background()) + baseDial := streamConfig.Dial + if baseDial == nil { + baseDial = (&net.Dialer{}).DialContext + } + streamConfig.Dial = func(ctx context.Context, network, address string) (net.Conn, error) { + conn, err := baseDial(ctx, network, address) + if err != nil { + return nil, err + } + return &kubernetesSocket{Conn: conn, stop: context.AfterFunc(lifetime, func() { conn.Close() })}, nil + } + upgradeTransport, err := rest.TransportFor(streamConfig) + if err != nil { + cancel() + return nil, errors.New("Kubernetes port-forward transport configuration failed") + } + tlsConfig, err := utilnet.TLSClientConfig(upgradeTransport) + if err != nil { + cancel() + return nil, errors.New("Kubernetes port-forward TLS configuration failed") + } + if tlsConfig == nil { + tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + // DialURL treats a nil TLS configuration as insecure when using custom dialers. + // Supply an explicit verified default while retaining client certificate callbacks. + upgradeTransport = &http.Transport{TLSClientConfig: tlsConfig, DialContext: streamConfig.Dial} + upgrader, err := streamspdy.NewRoundTripperWithConfig(streamspdy.RoundTripperConfig{UpgradeTransport: upgradeTransport, PingPeriod: 5 * time.Second}) + if err != nil { + cancel() + return nil, errors.New("Kubernetes port-forward transport configuration failed") + } + transport, err := rest.HTTPWrappersForConfig(streamConfig, upgrader) + if err != nil { + cancel() + return nil, errors.New("Kubernetes port-forward authentication configuration failed") + } + stopCh, ready := make(chan struct{}), make(chan struct{}) + var once sync.Once + stop := func() { once.Do(func() { cancel(); close(stopCh) }) } + url := core.RESTClient().Post().Resource("pods").Namespace(namespace).Name(pod).SubResource("portforward").URL() + dialer := spdy.NewDialer(upgrader, &http.Client{Transport: contextTransport{transport, lifetime}}, http.MethodPost, url) + forwarder, err := portforward.NewOnAddresses(dialer, []string{"127.0.0.1"}, []string{"0:" + strconv.Itoa(remotePort)}, stopCh, ready, io.Discard, io.Discard) + if err != nil { + stop() + return nil, errors.New("Kubernetes port-forward initialization failed") + } + done := make(chan struct{}) + go func() { defer close(done); _ = forwarder.ForwardPorts() }() + owned := true + defer func() { + if owned { + stop() + <-done + } + }() + select { + case <-ctx.Done(): + return nil, fmt.Errorf("Kubernetes port-forward setup: %w", ctx.Err()) + case <-done: + return nil, errors.New("Kubernetes port-forward startup failed; check API access and pods/portforward permission") + case <-ready: + } + ports, err := forwarder.GetPorts() + if err != nil || len(ports) != 1 { + return nil, errors.New("Kubernetes port-forward listener unavailable") + } + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(int(ports[0].Local)))) + if err != nil { + return nil, fmt.Errorf("Kubernetes port-forward local connection: %w", err) + } + if err := ctx.Err(); err != nil { + conn.Close() + return nil, err + } + select { + case <-done: + conn.Close() + return nil, errors.New("Kubernetes port-forward stopped") + default: + } + owned = false + return &kubernetesConn{Conn: conn, stop: stop, done: done}, nil +} + +type contextTransport struct { + base http.RoundTripper + ctx context.Context +} + +// SPDY reads upgrade responses directly, so request cancellation must also +// close the underlying socket when an API server never sends its response. +type kubernetesSocket struct { + net.Conn + stop func() bool +} + +func (c *kubernetesSocket) Close() error { c.stop(); return c.Conn.Close() } + +func (t contextTransport) RoundTrip(r *http.Request) (*http.Response, error) { + return t.base.RoundTrip(r.Clone(t.ctx)) +} + +func resolveKubernetesTarget(ctx context.Context, core coreclient.CoreV1Interface, apps appclient.AppsV1Interface, namespace, resource string, port int) (string, int, error) { + kind, name, _ := strings.Cut(resource, "/") + var selector string + var servicePort *corev1.ServicePort + switch kind { + case "pod": + pod, err := core.Pods(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", 0, errors.New("Kubernetes Pod lookup failed; check name and read permission") + } + if pod.Status.Phase != corev1.PodRunning || pod.DeletionTimestamp != nil { + return "", 0, errors.New("Kubernetes Pod is not running") + } + return pod.Name, port, nil + case "service": + svc, err := core.Services(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", 0, errors.New("Kubernetes Service lookup failed; check name and read permission") + } + if len(svc.Spec.Selector) == 0 { + return "", 0, errors.New("Kubernetes Service requires a Pod selector") + } + selector = labels.SelectorFromSet(svc.Spec.Selector).String() + for i := range svc.Spec.Ports { + p := &svc.Spec.Ports[i] + if int(p.Port) == port && (p.Protocol == "" || p.Protocol == corev1.ProtocolTCP) { + servicePort = p + break + } + } + if servicePort == nil { + return "", 0, errors.New("Kubernetes Service has no matching TCP port") + } + case "deployment": + dep, err := apps.Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", 0, errors.New("Kubernetes Deployment lookup failed; check name and read permission") + } + if dep.Spec.Selector == nil { + return "", 0, errors.New("Kubernetes Deployment has no Pod selector") + } + s, err := metav1.LabelSelectorAsSelector(dep.Spec.Selector) + if err != nil || s.Empty() { + return "", 0, errors.New("Kubernetes Deployment has invalid Pod selector") + } + selector = s.String() + default: + return "", 0, errors.New("unsupported Kubernetes resource") + } + pods, err := core.Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: selector}) + if err != nil { + return "", 0, errors.New("Kubernetes Pod list failed; check read permission") + } + sort.SliceStable(pods.Items, func(i, j int) bool { return podReady(pods.Items[i]) && !podReady(pods.Items[j]) }) + for _, pod := range pods.Items { + if pod.Status.Phase != corev1.PodRunning || pod.DeletionTimestamp != nil { + continue + } + if servicePort == nil { + return pod.Name, port, nil + } + n, err := serviceTargetPort(pod, *servicePort) + if err == nil { + return pod.Name, n, nil + } + } + return "", 0, errors.New("Kubernetes has no running Pod with the requested TCP target port") +} + +func podReady(p corev1.Pod) bool { + for _, c := range p.Status.Conditions { + if c.Type == corev1.PodReady && c.Status == corev1.ConditionTrue { + return true + } + } + return false +} + +func serviceTargetPort(pod corev1.Pod, port corev1.ServicePort) (int, error) { + if port.TargetPort.Type == intstr.Int { + p := int(port.TargetPort.IntVal) + if p == 0 { + p = int(port.Port) + } + if p > 0 && p <= 65535 { + return p, nil + } + } else { + for _, container := range pod.Spec.Containers { + for _, p := range container.Ports { + if p.Name == port.TargetPort.StrVal && (p.Protocol == "" || p.Protocol == corev1.ProtocolTCP) && p.ContainerPort > 0 && p.ContainerPort <= 65535 { + return int(p.ContainerPort), nil + } + } + } + } + return 0, errors.New("Kubernetes Service targetPort was not found on Pod") +} diff --git a/internal/outbound/kubernetes_test.go b/internal/outbound/kubernetes_test.go new file mode 100644 index 0000000..a4cf0b6 --- /dev/null +++ b/internal/outbound/kubernetes_test.go @@ -0,0 +1,181 @@ +package outbound + +import ( + "context" + "encoding/json" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/httpstream" + streamspdy "k8s.io/apimachinery/pkg/util/httpstream/spdy" + "k8s.io/apimachinery/pkg/util/intstr" + appclient "k8s.io/client-go/kubernetes/typed/apps/v1" + coreclient "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/portforward" +) + +func TestKubernetesNativeForwardLifetime(t *testing.T) { + closed := make(chan struct{}) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" { + http.Error(w, "unauthorized", 401) + return + } + switch r.URL.Path { + case "/api/v1/namespaces/testing/pods/db": + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "db"}, Status: corev1.PodStatus{Phase: corev1.PodRunning}}) + case "/api/v1/namespaces/testing/pods/db/portforward": + if _, err := httpstream.Handshake(r, w, []string{portforward.PortForwardProtocolV1Name}); err != nil { + return + } + conn := streamspdy.NewResponseUpgrader().UpgradeResponse(w, r, func(s httpstream.Stream, reply <-chan struct{}) error { + go func() { + <-reply + defer s.Close() + if s.Headers().Get(corev1.PortHeader) != "3306" { + t.Errorf("wrong remote port: %v", s.Headers()) + } + if s.Headers().Get(corev1.StreamType) == corev1.StreamTypeData { + io.Copy(s, s) + } + }() + return nil + }) + if conn != nil { + <-conn.CloseChan() + close(closed) + } + default: + http.NotFound(w, r) + } + })) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + conn, err := dialKubernetesConfig(ctx, &rest.Config{Host: server.URL, BearerToken: "test-token", TLSClientConfig: rest.TLSClientConfig{CAData: ca}}, "testing", "pod/db", 3306) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + cancel() + conn.SetDeadline(time.Now().Add(3 * time.Second)) + if _, err := conn.Write([]byte("native-forward")); err != nil { + t.Fatal(err) + } + buf := make([]byte, len("native-forward")) + if _, err := io.ReadFull(conn, buf); err != nil { + t.Fatal(err) + } + if string(buf) != "native-forward" { + t.Fatalf("unexpected response: %q", buf) + } + conn.Close() + select { + case <-closed: + case <-time.After(3 * time.Second): + t.Fatal("forward connection leaked after Close") + } +} + +func TestKubernetesCanceledSetupClosesRequest(t *testing.T) { + entered, finished := make(chan struct{}), make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/namespaces/testing/pods/db" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "db"}, Status: corev1.PodStatus{Phase: corev1.PodRunning}}) + return + } + close(entered) + <-r.Context().Done() + close(finished) + })) + defer server.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + result := make(chan error, 1) + go func() { + _, err := dialKubernetesConfig(ctx, &rest.Config{Host: server.URL}, "testing", "pod/db", 3306) + result <- err + }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("upgrade not started") + } + cancel() + select { + case err := <-result: + if err == nil { + t.Fatal("expected cancellation") + } + case <-time.After(3 * time.Second): + t.Fatal("canceled dial hung") + } + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("HTTP upgrade leaked") + } +} + +func TestKubernetesServiceNamedPort(t *testing.T) { + pod := corev1.Pod{Spec: corev1.PodSpec{Containers: []corev1.Container{{Ports: []corev1.ContainerPort{{Name: "database", ContainerPort: 3306, Protocol: corev1.ProtocolTCP}}}}}} + for _, tc := range []struct { + target intstr.IntOrString + want int + fail bool + }{ + {intstr.FromString("database"), 3306, false}, {intstr.FromInt32(5432), 5432, false}, {intstr.FromInt32(0), 3307, false}, {intstr.FromString("missing"), 0, true}, + } { + n, err := serviceTargetPort(pod, corev1.ServicePort{Port: 3307, TargetPort: tc.target}) + if (err != nil) != tc.fail || n != tc.want { + t.Fatalf("target %v: %d, %v", tc.target, n, err) + } + } +} + +func TestKubernetesServiceResolution(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/namespaces/testing/services/db": + json.NewEncoder(w).Encode(&corev1.Service{Spec: corev1.ServiceSpec{Selector: map[string]string{"app": "db"}, Ports: []corev1.ServicePort{{Port: 3307, TargetPort: intstr.FromString("sql")}}}}) + case "/api/v1/namespaces/testing/pods": + if r.URL.Query().Get("labelSelector") != "app=db" { + t.Errorf("wrong selector: %s", r.URL.RawQuery) + } + json.NewEncoder(w).Encode(&corev1.PodList{Items: []corev1.Pod{ + {ObjectMeta: metav1.ObjectMeta{Name: "pending"}, Status: corev1.PodStatus{Phase: corev1.PodPending}}, + {ObjectMeta: metav1.ObjectMeta{Name: "running"}, Status: corev1.PodStatus{Phase: corev1.PodRunning}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Ports: []corev1.ContainerPort{{Name: "sql", ContainerPort: 3306}}}}}}, + }}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + config := &rest.Config{Host: server.URL} + core, err := coreclient.NewForConfig(config) + if err != nil { + t.Fatal(err) + } + apps, err := appclient.NewForConfig(config) + if err != nil { + t.Fatal(err) + } + pod, port, err := resolveKubernetesTarget(context.Background(), core, apps, "testing", "service/db", 3307) + if err != nil || pod != "running" || port != 3306 { + t.Fatalf("resolution: %s:%d %v", pod, port, err) + } + if _, _, err := resolveKubernetesTarget(context.Background(), core, apps, "testing", "service/db", 9999); err == nil { + t.Fatal("accepted unknown service port") + } +} diff --git a/internal/outbound/ssh.go b/internal/outbound/ssh.go new file mode 100644 index 0000000..2f18ea2 --- /dev/null +++ b/internal/outbound/ssh.go @@ -0,0 +1,72 @@ +package outbound + +import ( + "agentdb/internal/model" + "context" + "errors" + "fmt" + "golang.org/x/crypto/ssh" + "net" + "strconv" + "time" +) + +// Each forwarded connection owns its SSH transport, including deadlines and cleanup. +type sshForwardConn struct { + net.Conn + raw net.Conn + client *ssh.Client +} + +func (c *sshForwardConn) Close() error { err := c.Conn.Close(); c.client.Close(); return err } +func (c *sshForwardConn) SetDeadline(t time.Time) error { return c.raw.SetDeadline(t) } +func (c *sshForwardConn) SetReadDeadline(t time.Time) error { return c.raw.SetReadDeadline(t) } +func (c *sshForwardConn) SetWriteDeadline(t time.Time) error { return c.raw.SetWriteDeadline(t) } + +func dialSSH(ctx context.Context, network, address string, o model.Outbound) (net.Conn, error) { + if network != "tcp" { + return nil, errors.New("SSH forwarding requires TCP") + } + if o.Auth != "password" || o.HostFingerprint == "" { + return nil, errors.New("SSH forwarding authentication or host fingerprint missing") + } + endpoint := net.JoinHostPort(o.Host, strconv.Itoa(o.Port)) + raw, err := (&net.Dialer{}).DialContext(ctx, "tcp", endpoint) + if err != nil { + return nil, fmt.Errorf("SSH forwarding connection: %w", err) + } + good := false + defer func() { + if !good { + raw.Close() + } + }() + stop := context.AfterFunc(ctx, func() { raw.Close() }) + defer stop() + deadline, _ := ctx.Deadline() + raw.SetDeadline(deadline) + config := &ssh.ClientConfig{User: o.Username, Auth: []ssh.AuthMethod{ssh.Password(o.Password)}, HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error { + if ssh.FingerprintSHA256(key) != o.HostFingerprint { + return errors.New("SSH forwarding host fingerprint mismatch") + } + return nil + }} + conn, channels, requests, err := ssh.NewClientConn(raw, endpoint, config) + if err != nil { + return nil, fmt.Errorf("SSH forwarding handshake: %w", err) + } + client := ssh.NewClient(conn, channels, requests) + forwarded, err := client.DialContext(ctx, "tcp", address) + if err != nil { + client.Close() + return nil, fmt.Errorf("SSH forwarding target connection: %w", err) + } + if !stop() || ctx.Err() != nil { + forwarded.Close() + client.Close() + return nil, context.Canceled + } + raw.SetDeadline(time.Time{}) + good = true + return &sshForwardConn{Conn: forwarded, raw: raw, client: client}, nil +} diff --git a/internal/outbound/ssh_test.go b/internal/outbound/ssh_test.go new file mode 100644 index 0000000..452017e --- /dev/null +++ b/internal/outbound/ssh_test.go @@ -0,0 +1,126 @@ +package outbound + +import ( + "agentdb/internal/model" + "context" + "crypto/ed25519" + "crypto/rand" + "errors" + "golang.org/x/crypto/ssh" + "io" + "net" + "strconv" + "testing" + "time" +) + +func TestSSHForwarding(t *testing.T) { + _, private, _ := ed25519.GenerateKey(rand.Reader) + signer, _ := ssh.NewSignerFromKey(private) + cfg := &ssh.ServerConfig{PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { + if c.User() != "jump" || string(pass) != "fixture-password" { + return nil, errors.New("bad credentials") + } + return nil, nil + }} + cfg.AddHostKey(signer) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + destinations := make(chan string, 10) + closed := make(chan struct{}, 10) + go func() { + for { + raw, err := listener.Accept() + if err != nil { + return + } + go func() { + defer raw.Close() + conn, channels, requests, err := ssh.NewServerConn(raw, cfg) + if err != nil { + return + } + defer conn.Close() + defer func() { closed <- struct{}{} }() + go ssh.DiscardRequests(requests) + for channel := range channels { + if channel.ChannelType() != "direct-tcpip" { + channel.Reject(ssh.UnknownChannelType, "unsupported") + continue + } + var destination struct { + Host string + Port uint32 + Origin string + OriginPort uint32 + } + if ssh.Unmarshal(channel.ExtraData(), &destination) != nil { + channel.Reject(ssh.ConnectionFailed, "invalid") + continue + } + destinations <- net.JoinHostPort(destination.Host, strconv.Itoa(int(destination.Port))) + if destination.Host == "denied.internal" { + channel.Reject(ssh.Prohibited, "forwarding denied") + continue + } + c, r, err := channel.Accept() + if err != nil { + continue + } + go ssh.DiscardRequests(r) + go func() { defer c.Close(); io.Copy(c, c) }() + } + }() + } + }() + host, port, _ := net.SplitHostPort(listener.Addr().String()) + pn, _ := strconv.Atoi(port) + p := model.Proxy{Outbound: model.Outbound{Mode: "ssh", Host: host, Port: pn, Auth: "password", Username: "jump", Password: "fixture-password", HostFingerprint: ssh.FingerprintSHA256(signer.PublicKey())}} + c, err := Dialer(p)(context.Background(), "tcp", "database.internal:3306") + if err != nil { + t.Fatal(err) + } + c.SetDeadline(time.Now().Add(time.Second)) + if _, err = c.Write([]byte("mysql-through-ssh")); err != nil { + t.Fatal(err) + } + data := make([]byte, len("mysql-through-ssh")) + if _, err = io.ReadFull(c, data); err != nil || string(data) != "mysql-through-ssh" { + t.Fatalf("echo: %s %v", data, err) + } + if got := <-destinations; got != "database.internal:3306" { + t.Fatal(got) + } + c.Close() + select { + case <-closed: + case <-time.After(time.Second): + t.Fatal("SSH transport leaked after close") + } + for _, mode := range []string{"password", "fingerprint", "denied", "canceled"} { + t.Run(mode, func(t *testing.T) { + bad := p + destination := "database.internal:3306" + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + switch mode { + case "password": + bad.Outbound.Password = "wrong" + case "fingerprint": + bad.Outbound.HostFingerprint = "SHA256:wrong" + case "denied": + destination = "denied.internal:3306" + case "canceled": + cancel() + } + c, err := Dialer(bad)(ctx, "tcp", destination) + if err == nil { + c.Close() + t.Fatal("expected rejection") + } + }) + } +} diff --git a/internal/protocol/mongoproxy/compression.go b/internal/protocol/mongoproxy/compression.go new file mode 100644 index 0000000..323253f --- /dev/null +++ b/internal/protocol/mongoproxy/compression.go @@ -0,0 +1,126 @@ +package mongoproxy + +import ( + "bytes" + "compress/zlib" + "encoding/binary" + "errors" + "io" + "net" + "strings" +) + +// compressedConn is used only for upstream MongoDB wire messages. Its writers +// always supply a complete frame; readers may request any number of bytes. +type compressedConn struct { + net.Conn + pending *bytes.Reader +} + +func (c *compressedConn) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + if c.pending != nil && c.pending.Len() > 0 { + return c.pending.Read(p) + } + var header [16]byte + if _, err := io.ReadFull(c.Conn, header[:]); err != nil { + return 0, err + } + size := int(binary.LittleEndian.Uint32(header[:])) + if size < 21 || size > maxMessage { + return 0, errors.New("invalid compressed MongoDB frame size") + } + raw := make([]byte, size) + copy(raw, header[:]) + if _, err := io.ReadFull(c.Conn, raw[16:]); err != nil { + return 0, err + } + if binary.LittleEndian.Uint32(raw[12:]) == 2012 { + var err error + raw, err = decompressFrame(raw) + if err != nil { + return 0, err + } + } + c.pending = bytes.NewReader(raw) + return c.pending.Read(p) +} +func (c *compressedConn) Write(raw []byte) (int, error) { + encoded, err := compressFrame(raw) + if err != nil { + return 0, err + } + if err = writeAll(c.Conn, encoded); err != nil { + return 0, err + } + return len(raw), nil +} +func compressFrame(raw []byte) ([]byte, error) { + if len(raw) < 21 || len(raw) > maxMessage || int(binary.LittleEndian.Uint32(raw)) != len(raw) { + return nil, errors.New("invalid MongoDB frame for compression") + } + m, err := readMessage(bytes.NewReader(raw)) + if err != nil { + return nil, err + } + // Match the MongoDB command-compression exclusions for authentication and + // credential management. Also avoid compressing small or incompressible data. + switch strings.ToLower(command(m.doc)) { + case "hello", "ismaster", "saslstart", "saslcontinue", "authenticate", "getnonce", "createuser", "updateuser", "copydb", "copydbsaslstart", "copydbgetnonce": + return raw, nil + } + if len(raw) < 256 || m.opcode != 2013 { + return raw, nil + } + var b bytes.Buffer + w, err := zlib.NewWriterLevel(&b, zlib.DefaultCompression) + if err != nil { + return nil, err + } + if _, err = w.Write(raw[16:]); err != nil { + w.Close() + return nil, err + } + if err = w.Close(); err != nil { + return nil, err + } + if b.Len()+25 >= len(raw) { + return raw, nil + } + out := make([]byte, 25+b.Len()) + copy(out, raw[:16]) + binary.LittleEndian.PutUint32(out, uint32(len(out))) + binary.LittleEndian.PutUint32(out[12:], 2012) + binary.LittleEndian.PutUint32(out[16:], uint32(m.opcode)) + binary.LittleEndian.PutUint32(out[20:], uint32(len(raw)-16)) + out[24] = 2 + copy(out[25:], b.Bytes()) + return out, nil +} +func decompressFrame(raw []byte) ([]byte, error) { + if len(raw) < 25 || len(raw) > maxMessage || int(binary.LittleEndian.Uint32(raw)) != len(raw) || binary.LittleEndian.Uint32(raw[12:]) != 2012 || raw[24] != 2 || binary.LittleEndian.Uint32(raw[16:]) != 2013 { + return nil, errors.New("unsupported MongoDB compressed envelope") + } + size := int(binary.LittleEndian.Uint32(raw[20:])) + if size < 5 || size > maxMessage-16 { + return nil, errors.New("invalid MongoDB uncompressed size") + } + input := bytes.NewReader(raw[25:]) + r, err := zlib.NewReader(input) + if err != nil { + return nil, errors.New("invalid MongoDB zlib stream") + } + body, err := io.ReadAll(io.LimitReader(r, int64(size)+1)) + closeErr := r.Close() + if err != nil || closeErr != nil || len(body) != size || input.Len() != 0 { + return nil, errors.New("MongoDB decompression size or checksum mismatch") + } + out := make([]byte, size+16) + copy(out, raw[:16]) + binary.LittleEndian.PutUint32(out, uint32(len(out))) + binary.LittleEndian.PutUint32(out[12:], 2013) + copy(out[16:], body) + return out, nil +} diff --git a/internal/protocol/mongoproxy/compression_test.go b/internal/protocol/mongoproxy/compression_test.go new file mode 100644 index 0000000..b438a16 --- /dev/null +++ b/internal/protocol/mongoproxy/compression_test.go @@ -0,0 +1,100 @@ +package mongoproxy + +import ( + "agentdb/internal/model" + "bytes" + "context" + "encoding/binary" + "go.mongodb.org/mongo-driver/v2/bson" + "net" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestCompressionRoundtripBoundsAndAuthExclusion(t *testing.T) { + var buf bytes.Buffer + if err := writeDocument(&buf, 41, 0, false, bson.D{{Key: "insert", Value: "items"}, {Key: "padding", Value: strings.Repeat("compressible-data", 10000)}, {Key: "$db", Value: "test"}}); err != nil { + t.Fatal(err) + } + original := buf.Bytes() + encoded, err := compressFrame(original) + if err != nil { + t.Fatal(err) + } + if len(encoded) >= len(original)/4 { + t.Fatalf("compression did not save traffic: %d -> %d", len(original), len(encoded)) + } + decoded, err := decompressFrame(encoded) + if err != nil || !bytes.Equal(original, decoded) { + t.Fatal("compressed payload changed") + } + for _, mutate := range []func([]byte){ + func(b []byte) { b[24] = 1 }, + func(b []byte) { binary.LittleEndian.PutUint32(b[16:], 2012) }, + func(b []byte) { binary.LittleEndian.PutUint32(b[20:], maxMessage) }, + func(b []byte) { binary.LittleEndian.PutUint32(b[20:], 5) }, + func(b []byte) { b[len(b)-1] ^= 255 }, + } { + bad := bytes.Clone(encoded) + mutate(bad) + if _, err = decompressFrame(bad); err == nil { + t.Fatal("malformed compressed envelope accepted") + } + } + for _, command := range []string{"hello", "saslStart", "saslContinue", "createUser", "updateUser"} { + buf.Reset() + writeDocument(&buf, 1, 0, false, bson.D{{Key: command, Value: int32(1)}, {Key: "payload", Value: strings.Repeat("secret", 1000)}}) + raw := bytes.Clone(buf.Bytes()) + out, err := compressFrame(raw) + if err != nil || !bytes.Equal(raw, out) { + t.Fatal("authentication/credential command compressed") + } + } +} + +func TestNegotiatedCompressionAndFallback(t *testing.T) { + for _, supported := range []bool{false, true} { + t.Run(map[bool]string{false: "fallback", true: "zlib"}[supported], func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var calls atomic.Int32 + dial := func(context.Context, string, string) (net.Conn, error) { + a, b := net.Pipe() + go fakeMongo(ctx, b, &calls, supported) + return a, nil + } + p := model.Proxy{TargetHost: "target.invalid", TargetPort: 27017, TargetUser: "target", TargetPassword: "target-secret", TargetCompression: "zlib"} + u, err := connect(ctx, p, dial) + if err != nil { + t.Fatal(err) + } + defer u.Close() + want := "none" + if supported { + want = "zlib" + } + if u.compression != want { + t.Fatalf("negotiated %s, want %s", u.compression, want) + } + for i := 0; i < 3; i++ { + d, err := exchange(u, bson.D{{Key: "insert", Value: "items"}, {Key: "padding", Value: strings.Repeat("sample-data", 10000)}, {Key: "$db", Value: "test"}}) + if err != nil || !ok(d) { + t.Fatalf("compressed exchange: %v", err) + } + } + if calls.Load() != 3 { + t.Fatal("requests not forwarded") + } + }) + } +} +func FuzzCompressedFrame(f *testing.F) { + f.Add([]byte{1, 2, 3}) + var b bytes.Buffer + writeDocument(&b, 1, 0, false, bson.D{{Key: "find", Value: "items"}, {Key: "padding", Value: strings.Repeat("x", 4000)}}) + seed, _ := compressFrame(b.Bytes()) + f.Add(seed) + f.Fuzz(func(t *testing.T, raw []byte) { _, _ = decompressFrame(raw) }) +} diff --git a/internal/protocol/mongoproxy/driver_test.go b/internal/protocol/mongoproxy/driver_test.go new file mode 100644 index 0000000..30d6f6b --- /dev/null +++ b/internal/protocol/mongoproxy/driver_test.go @@ -0,0 +1,78 @@ +package mongoproxy + +import ( + "agentdb/internal/model" + "context" + "crypto/tls" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + "net" + "sync" + "sync/atomic" + "testing" + "time" +) + +// The official driver exercises its own speculative SCRAM, pooling, legacy initial +// handshake, OP_MSG document sequences and cursor handling against the gateway. +// The upstream fixture is deterministic; real database version tests are separate. +func TestOfficialDriver(t *testing.T) { + for _, mode := range []string{"readwrite", "readonly"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + serverTLS, roots := testTLS(t) + var calls atomic.Int32 + dial := func(_ context.Context, network, address string) (net.Conn, error) { + a, b := net.Pipe() + go fakeMongo(ctx, b, &calls) + return a, nil + } + p := model.Proxy{TargetHost: "target.invalid", TargetPort: 27017, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: mode} + listener, e := net.Listen("tcp", "127.0.0.1:0") + if e != nil { + t.Fatal(e) + } + defer listener.Close() + var wg sync.WaitGroup + accepted := make(chan struct{}) + go func() { + defer close(accepted) + for { + c, e := listener.Accept() + if e != nil { + return + } + wg.Add(1) + go func() { defer wg.Done(); _ = New().Serve(ctx, c, p, dial, serverTLS, nil) }() + } + }() + defer func() { cancel(); listener.Close(); <-accepted; wg.Wait() }() + client, e := mongo.Connect(options.Client().SetHosts([]string{listener.Addr().String()}).SetDirect(true).SetTLSConfig(&tls.Config{RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12}).SetAuth(options.Credential{AuthMechanism: "SCRAM-SHA-256", AuthSource: "admin", Username: "gateway", Password: "gateway-secret"}).SetServerSelectionTimeout(5 * time.Second)) + if e != nil { + t.Fatal(e) + } + defer client.Disconnect(context.Background()) + if e := client.Ping(ctx, nil); e != nil { + t.Fatal(e) + } + collection := client.Database("test").Collection("items") + _, e = collection.InsertOne(ctx, bson.D{{Key: "x", Value: int32(2)}}) + if (e == nil) != (mode == "readwrite") { + t.Fatalf("insert mode=%s error=%v", mode, e) + } + cursor, e := collection.Find(ctx, bson.D{{Key: "x", Value: int32(1)}}) + if e != nil { + t.Fatal(e) + } + var result []bson.D + if e = cursor.All(ctx, &result); e != nil { + t.Fatal(e) + } + if len(result) != 1 || number(lookup(result[0], "x")) != 1 { + t.Fatal(result) + } + }) + } +} diff --git a/internal/protocol/mongoproxy/gateway.go b/internal/protocol/mongoproxy/gateway.go new file mode 100644 index 0000000..b1c2bea --- /dev/null +++ b/internal/protocol/mongoproxy/gateway.go @@ -0,0 +1,430 @@ +package mongoproxy + +import ( + "bufio" + "context" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/binary" + "errors" + "fmt" + "net" + "strconv" + "sync/atomic" + "time" + + "agentdb/internal/model" + "github.com/xdg-go/scram" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type Handler struct{} + +func New() *Handler { return &Handler{} } + +var _ model.Handler = (*Handler)(nil) +var requestID atomic.Int32 + +type upstream struct { + net.Conn + hello bson.D + compression string +} + +func exchange(c net.Conn, d bson.D) (bson.D, error) { + id := requestID.Add(1) + if e := writeDocument(c, id, 0, false, d); e != nil { + return nil, e + } + m, e := readMessage(c) + if e != nil { + return nil, e + } + if m.response != id { + return nil, errors.New("upstream response correlation failed") + } + return decode(m.doc) +} +func connect(ctx context.Context, p model.Proxy, dial model.DialFunc) (*upstream, error) { + dc, cancel := context.WithTimeout(ctx, 5*time.Second) + c, e := dial(dc, "tcp", net.JoinHostPort(p.TargetHost, strconv.Itoa(p.TargetPort))) + cancel() + if e != nil { + return nil, e + } + good := false + defer func() { + if !good { + c.Close() + } + }() + baseConn := c + stop := context.AfterFunc(ctx, func() { baseConn.Close() }) + defer stop() + c.SetDeadline(time.Now().Add(15 * time.Second)) + if p.TargetTLS { + cfg := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: p.TargetHost} + if p.TargetCA != "" { + pool, e := x509.SystemCertPool() + if e != nil { + pool = x509.NewCertPool() + } + if !pool.AppendCertsFromPEM([]byte(p.TargetCA)) { + return nil, errors.New("invalid target CA") + } + cfg.RootCAs = pool + } + t := tls.Client(c, cfg) + if e := t.HandshakeContext(ctx); e != nil { + return nil, e + } + c = t + } + helloRequest := bson.D{{Key: "hello", Value: int32(1)}, {Key: "$db", Value: "admin"}} + if p.TargetCompression == "zlib" { + helloRequest = append(helloRequest, bson.E{Key: "compression", Value: bson.A{"zlib"}}) + } + hello, e := exchange(c, helloRequest) + if e != nil { + return nil, e + } + if !ok(hello) { + return nil, errors.New("target hello failed") + } + compression := "none" + if p.TargetCompression == "zlib" { + if supported, ok := lookup(hello, "compression").(bson.A); ok { + for _, value := range supported { + if str(value) == "zlib" { + compression = "zlib" + break + } + } + } + if compression == "zlib" { + c = &compressedConn{Conn: c} + } + } + client, e := scram.SHA256.NewClient(p.TargetUser, p.TargetPassword, "") + if e != nil { + return nil, errors.New("invalid target SCRAM credentials") + } + conv := client.NewConversation() + payload, e := conv.Step("") + if e != nil { + return nil, e + } + db := p.AuthSource + if db == "" { + db = "admin" + } + resp, e := exchange(c, bson.D{{Key: "saslStart", Value: int32(1)}, {Key: "mechanism", Value: "SCRAM-SHA-256"}, {Key: "payload", Value: bson.Binary{Data: []byte(payload)}}, {Key: "autoAuthorize", Value: int32(1)}, {Key: "options", Value: bson.D{{Key: "skipEmptyExchange", Value: true}}}, {Key: "$db", Value: db}}) + for steps := 0; steps < 4; steps++ { + if e != nil { + return nil, e + } + if !ok(resp) { + return nil, errors.New("target SCRAM authentication failed") + } + b, yes := lookup(resp, "payload").(bson.Binary) + if !yes { + return nil, errors.New("invalid target SCRAM payload") + } + payload, e = conv.Step(string(b.Data)) + if e != nil { + return nil, errors.New("target SCRAM verification failed") + } + if done, _ := lookup(resp, "done").(bool); done { + if !conv.Done() || !conv.Valid() { + return nil, errors.New("incomplete target SCRAM authentication") + } + good = true + c.SetDeadline(time.Time{}) + return &upstream{Conn: c, hello: hello, compression: compression}, nil + } + resp, e = exchange(c, bson.D{{Key: "saslContinue", Value: int32(1)}, {Key: "conversationId", Value: lookup(resp, "conversationId")}, {Key: "payload", Value: bson.Binary{Data: []byte(payload)}}, {Key: "$db", Value: db}}) + } + return nil, errors.New("target SCRAM exchange limit exceeded") +} + +func (h *Handler) Test(ctx context.Context, p model.Proxy, dial model.DialFunc) (model.TestResult, error) { + u, e := connect(ctx, p, dial) + if e != nil { + return model.TestResult{}, e + } + defer u.Close() + stop := context.AfterFunc(ctx, func() { u.Close() }) + defer stop() + u.SetDeadline(time.Now().Add(15 * time.Second)) + d, e := exchange(u, bson.D{{Key: "buildInfo", Value: int32(1)}, {Key: "$db", Value: "admin"}}) + if e != nil { + return model.TestResult{}, e + } + if !ok(d) { + return model.TestResult{}, errors.New("target buildInfo failed") + } + role := "standalone" + if lookup(u.hello, "setName") != nil { + role = "secondary" + if b, _ := lookup(u.hello, "isWritablePrimary").(bool); b { + role = "primary" + } + } + return model.TestResult{Version: str(lookup(d, "version")), TLS: p.TargetTLS, Role: role, Compression: u.compression}, nil +} + +type authState struct { + server *scram.Server + conv *scram.ServerConversation + authenticated bool +} + +func newAuth(user, password string) (*authState, error) { + salt := make([]byte, 24) + if _, e := rand.Read(salt); e != nil { + return nil, e + } + client, e := scram.SHA256.NewClient(user, password, "") + if e != nil { + return nil, e + } + stored := client.GetStoredCredentials(scram.KeyFactors{Salt: string(salt), Iters: 15000}) + server, e := scram.SHA256.NewServer(func(candidate string) (scram.StoredCredentials, error) { + if candidate != user { + return scram.StoredCredentials{}, errors.New("authentication failed") + } + return stored, nil + }) + if e != nil { + return nil, e + } + return &authState{server: server}, nil +} +func (a *authState) step(d bson.D, speculative bool) bson.D { + if len(d) == 0 { + return failure(18, "authentication failed") + } + cmd := d[0].Key + db := str(lookup(d, "$db")) + if speculative { + db = str(lookup(d, "db")) + } + if db != "admin" { + return failure(18, "authentication failed") + } + if cmd == "saslStart" { + if str(lookup(d, "mechanism")) != "SCRAM-SHA-256" { + return failure(18, "only SCRAM-SHA-256 is supported") + } + a.authenticated = false + a.conv = a.server.NewConversation() + } else if cmd != "saslContinue" || number(lookup(d, "conversationId")) != 1 { + return failure(18, "authentication failed") + } + b, yes := lookup(d, "payload").(bson.Binary) + if !yes || len(b.Data) > 16384 || a.conv == nil { + return failure(18, "authentication failed") + } + // Older clients finish with an empty SASL exchange after the verified proof. + if a.conv.Done() { + if a.conv.Valid() && len(b.Data) == 0 { + return bson.D{{Key: "ok", Value: float64(1)}, {Key: "conversationId", Value: int32(1)}, {Key: "done", Value: true}, {Key: "payload", Value: bson.Binary{Data: []byte{}}}} + } + return failure(18, "authentication failed") + } + out, e := a.conv.Step(string(b.Data)) + if e != nil { + a.conv = nil + a.authenticated = false + return failure(18, "authentication failed") + } + a.authenticated = a.conv.Done() && a.conv.Valid() + return bson.D{{Key: "ok", Value: float64(1)}, {Key: "conversationId", Value: int32(1)}, {Key: "done", Value: a.authenticated}, {Key: "payload", Value: bson.Binary{Data: []byte(out)}}} +} +func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial model.DialFunc, serverTLS *tls.Config, event model.EventFunc) error { + defer raw.Close() + stop := context.AfterFunc(ctx, func() { raw.Close() }) + defer stop() + raw.SetDeadline(time.Now().Add(15 * time.Second)) + reader := bufio.NewReader(raw) + header, err := reader.Peek(16) + if err != nil { + return err + } + var c net.Conn = &bufferedConn{Conn: raw, reader: reader} + opcode := binary.LittleEndian.Uint32(header[12:16]) + if opcode != 2004 && opcode != 2013 && header[0] == 22 && header[1] == 3 { + if serverTLS == nil { + return errors.New("TLS requested but gateway certificate is not configured") + } + cfg := serverTLS.Clone() + cfg.MinVersion = tls.VersionTLS12 + secure := tls.Server(c, cfg) + if err := secure.HandshakeContext(ctx); err != nil { + return err + } + c = secure + } + u, e := connect(ctx, p, dial) + if e != nil { + return e + } + defer u.Close() + stopUp := context.AfterFunc(ctx, func() { u.Close() }) + defer stopUp() + a, e := newAuth(p.GatewayUser, p.GatewayPassword) + if e != nil { + return e + } + cursors := map[int64]string{} + for { + m, e := readMessage(c) + if e != nil { + return e + } + if m.response != 0 || (m.opcode != 2013 && m.opcode != 2004) { + return errors.New("invalid client request header") + } + d, e := decode(m.doc) + if e != nil { + return e + } + if len(d) == 0 || !unique(d, 0) { + return errors.New("invalid command BSON") + } + cmd := d[0].Key + audit := func(decision string) error { + raw, err := bson.MarshalExtJSON(d, false, false) + if err != nil { + return err + } + text := string(raw) + if m.sequences { + text += fmt.Sprintf("\n[document sequences: %d total wire bytes; batch bodies omitted]", len(m.raw)) + } + return model.AuditCommand(ctx, cmd, text, decision) + } + reply := func(d bson.D) error { return writeDocument(c, requestID.Add(1), m.id, m.opcode == 2004, d) } + if m.opcode == 2004 && cmd != "hello" && cmd != "isMaster" && cmd != "ismaster" { + if e := audit("blocked"); e != nil { + return errors.New("command audit unavailable") + } + if e := reply(failure(115, "legacy commands are not supported")); e != nil { + return e + } + continue + } + if cmd == "hello" || cmd == "isMaster" || cmd == "ismaster" { + // Refresh node role and availability. Never forward client authentication + // metadata or an awaitable/exhaust request to the upstream connection. + u.SetDeadline(time.Now().Add(15 * time.Second)) + fresh, err := exchange(u, bson.D{{Key: "hello", Value: int32(1)}, {Key: "$db", Value: "admin"}}) + u.SetDeadline(time.Time{}) + if err != nil { + return err + } + if !ok(fresh) { + return errors.New("target hello failed") + } + u.hello = fresh + out := bson.D{} + for _, v := range u.hello { + switch v.Key { + case "compression", "speculativeAuthenticate", "saslSupportedMechs", "topologyVersion", "hosts", "passives", "arbiters", "primary", "me": + continue + } + out = append(out, v) + } + out = append(out, bson.E{Key: "saslSupportedMechs", Value: bson.A{"SCRAM-SHA-256"}}) + if spec, yes := lookup(d, "speculativeAuthenticate").(bson.D); yes { + out = append(out, bson.E{Key: "speculativeAuthenticate", Value: a.step(spec, true)}) + } + if e := reply(out); e != nil { + return e + } + if !a.authenticated { + c.SetDeadline(time.Now().Add(15 * time.Second)) + } + continue + } + if cmd == "saslStart" || cmd == "saslContinue" { + out := a.step(d, false) + if e := reply(out); e != nil { + return e + } + if !ok(out) { + return errors.New("gateway authentication failed") + } + if a.authenticated { + c.SetDeadline(time.Time{}) + } + continue + } + if !a.authenticated { + if e := audit("blocked"); e != nil { + return errors.New("command audit unavailable") + } + if e := reply(failure(13, "authentication required")); e != nil { + return e + } + continue + } + if cmd == "authenticate" || cmd == "logout" || cmd == "getnonce" { + if e := reply(failure(115, "authentication mechanism is not supported")); e != nil { + return e + } + continue + } + if p.AccessMode == "readonly" { + if e := checkReadonly(d, cursors); e != nil || m.sequences { + if e := audit("blocked"); e != nil { + return errors.New("command audit unavailable") + } + if event != nil { + event(model.EventReadOnlyRejected, "mongo_readonly:"+auditCommand(cmd)) + } + if m.flags&2 != 0 { + return errReadonly + } + if e := reply(failure(13, errReadonly.Error())); e != nil { + return e + } + continue + } + } + if e := audit("allowed"); e != nil { + return errors.New("command audit unavailable") + } + if e := writeAll(u, m.raw); e != nil { + return e + } + if m.flags&2 != 0 { + continue + } + response, e := readMessage(u) + if e != nil { + return e + } + if response.response != m.id { + return fmt.Errorf("upstream response correlation failed") + } + if p.AccessMode == "readonly" { + rd, e := decode(response.doc) + if e != nil { + return e + } + trackCursors(d, rd, cursors) + } + if e := writeAll(c, response.raw); e != nil { + return e + } + } +} + +type bufferedConn struct { + net.Conn + reader *bufio.Reader +} + +func (c *bufferedConn) Read(p []byte) (int, error) { return c.reader.Read(p) } diff --git a/internal/protocol/mongoproxy/gateway_test.go b/internal/protocol/mongoproxy/gateway_test.go new file mode 100644 index 0000000..d994dfa --- /dev/null +++ b/internal/protocol/mongoproxy/gateway_test.go @@ -0,0 +1,264 @@ +package mongoproxy + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "math/big" + "net" + "strings" + "sync/atomic" + "testing" + "time" + + "agentdb/internal/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func testTLS(t *testing.T) (*tls.Config, *x509.CertPool) { + t.Helper() + key, e := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if e != nil { + t.Fatal(e) + } + template := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"}, DNSNames: []string{"localhost"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}} + der, e := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if e != nil { + t.Fatal(e) + } + cert, e := x509.ParseCertificate(der) + if e != nil { + t.Fatal(e) + } + roots := x509.NewCertPool() + roots.AddCert(cert) + return &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, MinVersion: tls.VersionTLS12}, roots +} + +func fakeMongo(ctx context.Context, c net.Conn, calls *atomic.Int32, compression ...bool) { + defer c.Close() + stop := context.AfterFunc(ctx, func() { c.Close() }) + defer stop() + auth, _ := newAuth("target", "target-secret") + for { + m, e := readMessage(c) + if e != nil { + return + } + d, e := decode(m.doc) + if e != nil || len(d) == 0 { + return + } + var out bson.D + negotiate := false + switch d[0].Key { + case "hello", "isMaster", "ismaster": + out = doc(`{"ok":1,"isWritablePrimary":true,"helloOk":true,"minWireVersion":0,"maxWireVersion":25,"maxBsonObjectSize":16777216,"maxMessageSizeBytes":48000000,"maxWriteBatchSize":100000,"logicalSessionTimeoutMinutes":30,"hosts":["private.invalid:27017"],"me":"private.invalid:27017","primary":"private.invalid:27017"}`) + if len(compression) > 0 && compression[0] && lookup(d, "compression") != nil { + out = append(out, bson.E{Key: "compression", Value: bson.A{"zlib"}}) + negotiate = true + } + case "saslStart", "saslContinue": + out = auth.step(d, false) + case "buildInfo": + out = doc(`{"ok":1,"version":"8.0.0"}`) + default: + if !auth.authenticated { + out = failure(13, "not authenticated") + } else { + calls.Add(1) + if d[0].Key == "find" { + out = doc(`{"ok":1,"cursor":{"id":{"$numberLong":"17"},"ns":"test.items","firstBatch":[{"x":1}]}}`) + } else if d[0].Key == "getMore" { + out = doc(`{"ok":1,"cursor":{"id":{"$numberLong":"0"},"ns":"test.items","nextBatch":[]}}`) + } else { + out = doc(`{"ok":1,"n":1}`) + } + } + } + if m.flags&2 != 0 { + continue + } + if writeDocument(c, requestID.Add(1), m.id, false, out) != nil { + return + } + if negotiate { + c = &compressedConn{Conn: c} + } + } +} +func TestGatewayTLSAuthenticationForwardingAndReadonly(t *testing.T) { + for _, scenario := range []string{"readwrite", "readonly", "plaintext", "no-certificate"} { + mode := scenario + if scenario == "plaintext" || scenario == "no-certificate" { + mode = "readwrite" + } + t.Run(scenario, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + serverTLS, roots := testTLS(t) + if scenario == "no-certificate" { + serverTLS = nil + } + var calls atomic.Int32 + commands := make(chan model.Command, 16) + auditCtx := model.WithCommandAudit(ctx, func(command model.Command) error { commands <- command; return nil }) + targetDial := func(ctx context.Context, network, address string) (net.Conn, error) { + if address != "target.invalid:27017" { + t.Errorf("dial address=%s", address) + } + a, b := net.Pipe() + go fakeMongo(ctx, b, &calls, true) + return a, nil + } + // A dial context only governs establishment. Keep fake target alive for the session. + targetDial = func(_ context.Context, network, address string) (net.Conn, error) { + if address != "target.invalid:27017" { + t.Errorf("dial address=%s", address) + } + a, b := net.Pipe() + go fakeMongo(ctx, b, &calls, true) + return a, nil + } + p := model.Proxy{TargetHost: "target.invalid", TargetPort: 27017, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: mode} + p.TargetCompression = "zlib" + server, client := net.Pipe() + done := make(chan error, 1) + var rejected atomic.Int32 + go func() { + done <- New().Serve(auditCtx, server, p, targetDial, serverTLS, func(category, rule string) { rejected.Add(1) }) + }() + dialGateway := func(_ context.Context, network, address string) (net.Conn, error) { return client, nil } + clientConfig := model.Proxy{TargetHost: "localhost", TargetPort: 27017, TargetUser: "gateway", TargetPassword: "gateway-secret", TargetTLS: true} + // Connect with a real TLS handshake, then reuse the client-side SCRAM implementation. + // Export this test CA as PEM for target validation. + if serverTLS != nil { + clientConfig.TargetCA = certPoolPEM(serverTLS) + } + clientConfig.TargetTLS = scenario != "plaintext" && scenario != "no-certificate" + _ = roots + u, e := connect(ctx, clientConfig, dialGateway) + if e != nil { + t.Fatal(e) + } + for _, key := range []string{"hosts", "me", "primary", "topologyVersion"} { + if lookup(u.hello, key) != nil { + t.Fatalf("gateway exposed upstream topology field %s", key) + } + } + response, e := exchange(u, doc(`{"insert":"items","documents":[{"x":2}],"$db":"test"}`)) + if e != nil { + t.Fatal(e) + } + if mode == "readonly" { + if ok(response) || calls.Load() != 0 || rejected.Load() != 1 { + t.Fatalf("write escaped policy: response=%v calls=%d", response, calls.Load()) + } + } else if !ok(response) || calls.Load() != 1 { + t.Fatal(response, calls.Load()) + } + response, e = exchange(u, doc(`{"find":"items","filter":{"x":1},"$db":"test"}`)) + if e != nil || !ok(response) { + t.Fatal(response, e) + } + response, e = exchange(u, doc(`{"getMore":{"$numberLong":"17"},"collection":"items","$db":"test"}`)) + if e != nil || !ok(response) { + t.Fatal(response, e) + } + if mode == "readonly" { + response, e = exchange(u, doc(`{"getMore":{"$numberLong":"17"},"collection":"items","$db":"test"}`)) + if e != nil || ok(response) { + t.Fatal("stale cursor accepted", response, e) + } + } + u.Close() + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("gateway did not terminate") + } + close(commands) + foundInsert, foundFind := false, false + for command := range commands { + if command.Kind == "insert" { + foundInsert = true + want := "allowed" + if mode == "readonly" { + want = "blocked" + } + if command.Decision != want { + t.Fatal("wrong insert decision") + } + } + if command.Kind == "find" { + foundFind = true + } + if strings.Contains(command.Text, "sasl") || strings.Contains(command.Text, "target-secret") { + t.Fatal("authentication entered command audit") + } + } + if !foundInsert || !foundFind { + t.Fatal("MongoDB command audit missing") + } + }) + } +} +func certPoolPEM(c *tls.Config) string { + return "-----BEGIN CERTIFICATE-----\n" + base64.StdEncoding.EncodeToString(c.Certificates[0].Certificate[0]) + "\n-----END CERTIFICATE-----\n" +} +func TestUpstreamTLSAndSCRAMValidation(t *testing.T) { + for _, scenario := range []string{"valid", "wrong-host", "wrong-ca", "wrong-password"} { + t.Run(scenario, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + serverTLS, _ := testTLS(t) + var calls atomic.Int32 + done := make(chan struct{}) + dial := func(_ context.Context, network, address string) (net.Conn, error) { + a, b := net.Pipe() + go func() { + defer close(done) + fakeMongo(ctx, tls.Server(b, serverTLS), &calls) + }() + return a, nil + } + p := model.Proxy{TargetHost: "localhost", TargetPort: 27017, TargetTLS: true, TargetCA: certPoolPEM(serverTLS), TargetUser: "target", TargetPassword: "target-secret"} + switch scenario { + case "wrong-host": + p.TargetHost = "different.invalid" + case "wrong-ca": + other, _ := testTLS(t) + p.TargetCA = certPoolPEM(other) + case "wrong-password": + p.TargetPassword = "incorrect-target-secret" + } + u, err := connect(ctx, p, dial) + if scenario == "valid" { + if err != nil { + t.Fatal(err) + } + u.Close() + } else { + if err == nil { + u.Close() + t.Fatal("invalid target identity or password accepted") + } + if strings.Contains(err.Error(), p.TargetPassword) { + t.Fatal("error exposed target password") + } + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("target connection was not closed") + } + }) + } +} diff --git a/internal/protocol/mongoproxy/integration_test.go b/internal/protocol/mongoproxy/integration_test.go new file mode 100644 index 0000000..ac11dd8 --- /dev/null +++ b/internal/protocol/mongoproxy/integration_test.go @@ -0,0 +1,140 @@ +package mongoproxy + +import ( + "context" + "crypto/tls" + "net" + "os" + "strconv" + "sync" + "testing" + "time" + + "agentdb/internal/model" + "agentdb/internal/testutil" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// Run against each version fixture with AGENTDB_MONGO_TEST_ADDR=host:port, +// AGENTDB_MONGO_TEST_USER and AGENTDB_MONGO_TEST_PASSWORD. Tests create and drop +// an isolated database. TLS is enabled when AGENTDB_MONGO_TEST_CA contains PEM. +func TestRealMongo(t *testing.T) { + address := os.Getenv("AGENTDB_MONGO_TEST_ADDR") + if address == "" { + t.Skip("set AGENTDB_MONGO_TEST_ADDR for real MongoDB version verification") + } + host, portText, e := net.SplitHostPort(address) + if e != nil { + t.Fatal(e) + } + port, e := strconv.Atoi(portText) + if e != nil { + t.Fatal(e) + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + serverTLS, roots := testTLS(t) + p := model.Proxy{TargetHost: host, TargetPort: port, TargetUser: os.Getenv("AGENTDB_MONGO_TEST_USER"), TargetPassword: os.Getenv("AGENTDB_MONGO_TEST_PASSWORD"), AuthSource: "admin", GatewayUser: "gateway", GatewayPassword: "gateway-test-secret", TargetCA: os.Getenv("AGENTDB_MONGO_TEST_CA")} + dial := testutil.FixtureDial(t, &p) + p.TargetTLS = p.TargetCA != "" + info, e := New().Test(ctx, p, dial) + if e != nil { + t.Fatal(e) + } + t.Logf("target MongoDB %s role=%s TLS=%v", info.Version, info.Role, info.TLS) + clients := map[string]*mongo.Client{} + var wg sync.WaitGroup + var listeners []net.Listener + var accepted []chan struct{} + defer func() { + for _, c := range clients { + c.Disconnect(context.Background()) + } + cancel() + for _, l := range listeners { + l.Close() + } + for _, done := range accepted { + <-done + } + wg.Wait() + }() + for _, mode := range []string{"readwrite", "readonly"} { + config := p + config.AccessMode = mode + listener, e := net.Listen("tcp", "127.0.0.1:0") + if e != nil { + t.Fatal(e) + } + listeners = append(listeners, listener) + done := make(chan struct{}) + accepted = append(accepted, done) + go func() { + defer close(done) + for { + c, e := listener.Accept() + if e != nil { + return + } + wg.Add(1) + go func() { defer wg.Done(); _ = New().Serve(ctx, c, config, dial, serverTLS, nil) }() + } + }() + client, e := mongo.Connect(options.Client().SetHosts([]string{listener.Addr().String()}).SetDirect(true).SetMaxPoolSize(1).SetTLSConfig(&tls.Config{RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12}).SetAuth(options.Credential{AuthMechanism: "SCRAM-SHA-256", AuthSource: "admin", Username: p.GatewayUser, Password: p.GatewayPassword})) + if e != nil { + t.Fatal(e) + } + clients[mode] = client + if e = client.Ping(ctx, nil); e != nil { + t.Fatal(e) + } + } + dbName := "agentdb_gateway_test_" + strconv.FormatInt(time.Now().UnixNano(), 10) + rw := clients["readwrite"].Database(dbName) + ro := clients["readonly"].Database(dbName) + defer rw.Drop(context.Background()) + collection := rw.Collection("items") + _, e = collection.InsertMany(ctx, []any{bson.D{{Key: "x", Value: int32(1)}, {Key: "text", Value: "中文😀"}}, bson.D{{Key: "x", Value: int32(2)}}}) + if e != nil { + t.Fatal(e) + } + cursor, e := ro.Collection("items").Find(ctx, bson.D{}, options.Find().SetBatchSize(1)) + if e != nil { + t.Fatal(e) + } + var rows []bson.D + if e = cursor.All(ctx, &rows); e != nil { + t.Fatal(e) + } + if len(rows) != 2 { + t.Fatal(rows) + } + if _, e = ro.Collection("items").InsertOne(ctx, bson.D{{Key: "x", Value: 3}}); e == nil { + t.Fatal("readonly insert accepted") + } + if _, e = ro.Collection("items").Aggregate(ctx, mongo.Pipeline{bson.D{{Key: "$out", Value: "forbidden"}}}); e == nil { + t.Fatal("readonly $out accepted") + } + if _, e = ro.Collection("items").UpdateMany(ctx, bson.D{}, bson.D{{Key: "$set", Value: bson.D{{Key: "x", Value: 9}}}}); e == nil { + t.Fatal("readonly update accepted") + } + n, e := collection.CountDocuments(ctx, bson.D{}) + if e != nil || n != 2 { + t.Fatal("readonly changed target", n, e) + } + if info.Role == "primary" { + session, e := clients["readwrite"].StartSession() + if e != nil { + t.Fatal(e) + } + defer session.EndSession(context.Background()) + _, e = session.WithTransaction(ctx, func(tx context.Context) (any, error) { + return collection.InsertOne(tx, bson.D{{Key: "transaction", Value: true}}) + }) + if e != nil { + t.Fatal("transaction forwarding", e) + } + } +} diff --git a/internal/protocol/mongoproxy/policy.go b/internal/protocol/mongoproxy/policy.go new file mode 100644 index 0000000..7198800 --- /dev/null +++ b/internal/protocol/mongoproxy/policy.go @@ -0,0 +1,279 @@ +package mongoproxy + +import ( + "errors" + "go.mongodb.org/mongo-driver/v2/bson" + "strings" +) + +var errReadonly = errors.New("request rejected by AgentDB read-only policy") + +func set(s string) map[string]bool { + m := map[string]bool{} + for _, s := range strings.Fields(s) { + m[s] = true + } + return m +} + +var expressions = set("$literal $eq $ne $gt $gte $lt $lte $cmp $and $or $not $add $subtract $multiply $divide $mod $abs $ceil $floor $round $cond $ifNull $switch $convert $toString $toInt $toLong $toDouble $toDecimal $toDate $toBool $concat $toLower $toUpper $substrBytes $substrCP $strLenBytes $strLenCP $trim $sum $avg $min $max $first $last $push $addToSet") +var queries = set("$eq $ne $gt $gte $lt $lte $in $nin $and $or $nor $not $exists $type $regex $options $all $elemMatch $size $expr") +var stages = set("$match $project $addFields $set $unset $group $sort $limit $skip $unwind $count $sortByCount $replaceRoot $replaceWith $lookup $facet $unionWith") +var common = set("$db $clusterTime $readPreference lsid maxTimeMS comment readConcern apiVersion apiStrict apiDeprecationErrors") +var commandFields = map[string]map[string]bool{ + "ping": set(""), "buildInfo": set(""), + "listDatabases": set("filter nameOnly authorizedDatabases"), + "listCollections": set("filter nameOnly authorizedCollections cursor"), + "listIndexes": set("cursor"), + "find": set("filter sort projection hint skip limit batchSize singleBatch tailable awaitData noCursorTimeout allowPartialResults collation returnKey showRecordId let allowDiskUse"), + "count": set("query limit skip hint collation"), + "distinct": set("key query collation"), + "aggregate": set("pipeline cursor allowDiskUse hint collation let"), + "getMore": set("collection batchSize"), "killCursors": set("cursors"), "endSessions": set(""), + "explain": set("verbosity"), +} + +// unique validates documents recursively before any lookup, preventing duplicate-key +// differences between the gateway and server. A finite depth also bounds policy work. +func unique(v any, depth int) bool { + if depth > 64 { + return false + } + switch x := v.(type) { + case bson.D: + seen := map[string]bool{} + for _, e := range x { + if seen[e.Key] { + return false + } + seen[e.Key] = true + if !unique(e.Value, depth+1) { + return false + } + } + case bson.A: + for _, e := range x { + if !unique(e, depth+1) { + return false + } + } + } + return true +} +func inspect(v any, expr bool, depth int) bool { + if depth > 64 { + return false + } + switch x := v.(type) { + case bson.D: + for _, e := range x { + if expr && e.Key == "$literal" { + continue + } + if strings.HasPrefix(e.Key, "$") { + if expr { + if !expressions[e.Key] { + return false + } + } else if !queries[e.Key] { + return false + } + } + if !inspect(e.Value, expr || e.Key == "$expr", depth+1) { + return false + } + } + case bson.A: + for _, e := range x { + if !inspect(e, expr, depth+1) { + return false + } + } + } + return true +} +func pipeline(v any, depth int) bool { + a, valid := v.(bson.A) + if !valid || depth > 64 { + return false + } + for _, v := range a { + d, valid := v.(bson.D) + if !valid || len(d) != 1 || !stages[d[0].Key] { + return false + } + e := d[0] + switch e.Key { + case "$match": + if !inspect(e.Value, false, depth+1) { + return false + } + case "$lookup": + args, yes := e.Value.(bson.D) + if !yes { + return false + } + for _, arg := range args { + switch arg.Key { + case "from", "localField", "foreignField", "as": + if _, yes := arg.Value.(string); !yes { + return false + } + case "let": + if !inspect(arg.Value, true, depth+1) { + return false + } + case "pipeline": + if !pipeline(arg.Value, depth+1) { + return false + } + default: + return false + } + } + case "$facet": + args, yes := e.Value.(bson.D) + if !yes { + return false + } + for _, arg := range args { + if !pipeline(arg.Value, depth+1) { + return false + } + } + case "$unionWith": + if _, yes := e.Value.(string); yes { + continue + } + args, yes := e.Value.(bson.D) + if !yes { + return false + } + for _, arg := range args { + switch arg.Key { + case "coll": + if _, yes := arg.Value.(string); !yes { + return false + } + case "pipeline": + if !pipeline(arg.Value, depth+1) { + return false + } + default: + return false + } + } + default: + if !inspect(e.Value, true, depth+1) { + return false + } + } + } + return true +} +func checkReadonly(d bson.D, cursors map[int64]string) error { + if len(d) == 0 || !unique(d, 0) { + return errReadonly + } + cmd := d[0].Key + allowed, yes := commandFields[cmd] + if !yes { + return errReadonly + } + for _, e := range d[1:] { + if !allowed[e.Key] && !common[e.Key] { + return errReadonly + } + switch e.Key { + case "filter", "query": + if !inspect(e.Value, false, 0) { + return errReadonly + } + case "projection", "sort", "let": + if !inspect(e.Value, true, 0) { + return errReadonly + } + case "pipeline": + if !pipeline(e.Value, 0) { + return errReadonly + } + case "$readPreference", "$clusterTime", "lsid", "readConcern", "collation", "cursor", "hint": // Metadata is not an expression context. + default: + if !inspect(e.Value, true, 0) { + return errReadonly + } + } + } + if cmd == "aggregate" && lookup(d, "pipeline") == nil { + return errReadonly + } + if cmd == "explain" { + nested, yes := d[0].Value.(bson.D) + if !yes || len(nested) == 0 || nested[0].Key == "explain" { + return errReadonly + } + switch nested[0].Key { + case "find", "count", "distinct", "aggregate": + default: + return errReadonly + } + if v := lookup(d, "verbosity"); v != nil && v != "queryPlanner" { + return errReadonly + } + if checkReadonly(nested, cursors) != nil { + return errReadonly + } + } + ns := str(lookup(d, "$db")) + "." + str(lookup(d, "collection")) + if cmd == "getMore" { + id := number(d[0].Value) + if id == 0 || cursors[id] != ns { + return errReadonly + } + } + if cmd == "killCursors" { + ns = str(lookup(d, "$db")) + "." + str(d[0].Value) + a, yes := lookup(d, "cursors").(bson.A) + if !yes { + return errReadonly + } + for _, v := range a { + if id := number(v); id == 0 || cursors[id] != ns { + return errReadonly + } + } + } + return nil +} +func trackCursors(request, response bson.D, cursors map[int64]string) { + if len(request) == 0 || !ok(response) { + return + } + if request[0].Key == "getMore" { + delete(cursors, number(request[0].Value)) + } + if d, yes := lookup(response, "cursor").(bson.D); yes { + if id := number(lookup(d, "id")); id != 0 { + cursors[id] = str(lookup(d, "ns")) + } + } + if request[0].Key == "killCursors" && ok(response) { + if a, yes := lookup(request, "cursors").(bson.A); yes { + for _, v := range a { + delete(cursors, number(v)) + } + } + } +} + +// Unknown command names are client-controlled and must not become audit content. +func auditCommand(cmd string) string { + if _, ok := commandFields[cmd]; ok { + return cmd + } + switch cmd { + case "insert", "update", "delete", "findAndModify", "create", "drop", "dropDatabase", "createIndexes", "dropIndexes", "renameCollection", "mapReduce", "commitTransaction", "abortTransaction": + return cmd + } + return "unknown" +} diff --git a/internal/protocol/mongoproxy/policy_test.go b/internal/protocol/mongoproxy/policy_test.go new file mode 100644 index 0000000..3030d5d --- /dev/null +++ b/internal/protocol/mongoproxy/policy_test.go @@ -0,0 +1,224 @@ +package mongoproxy + +import ( + "bytes" + "encoding/binary" + "github.com/xdg-go/scram" + "go.mongodb.org/mongo-driver/v2/bson" + "testing" +) + +func doc(s string) bson.D { + var d bson.D + if e := bson.UnmarshalExtJSON([]byte(s), false, &d); e != nil { + panic(e) + } + return d +} +func TestReadonly(t *testing.T) { + cases := []struct { + json string + allow bool + }{ + {`{"find":"items","filter":{"x":{"$gt":2}},"$db":"test"}`, true}, + {`{"insert":"items","documents":[{"x":1}],"$db":"test"}`, false}, + {`{"find":"items","filter":{"$where":"danger()"}}`, false}, + {`{"find":"items","filter":{"x":{"$unknown":1}}}`, false}, + {`{"find":"items","filter":{},"autocommit":false}`, false}, + {`{"aggregate":"items","pipeline":[{"$match":{"x":1}},{"$group":{"_id":"$x","n":{"$sum":1}}}],"cursor":{}}`, true}, + {`{"aggregate":"items","pipeline":[{"$facet":{"x":[{"$out":"evil"}]}}],"cursor":{}}`, false}, + {`{"aggregate":"items","pipeline":[{"$lookup":{"from":"x","pipeline":[{"$merge":"evil"}],"as":"x"}}],"cursor":{}}`, false}, + {`{"aggregate":"items","pipeline":[{"$unionWith":{"coll":"x","pipeline":[{"$project":{"x":{"$function":{"body":"evil"}}}}]}}],"cursor":{}}`, false}, + {`{"aggregate":"items","pipeline":[{"$project":{"x":{"$literal":{"$function":"data"}}}}],"cursor":{}}`, true}, + {`{"explain":{"find":"items"},"verbosity":"queryPlanner"}`, true}, + {`{"explain":{"find":"items"},"verbosity":"executionStats"}`, false}, + {`{"explain":{"insert":"items"},"verbosity":"queryPlanner"}`, false}, + {`{"getMore":12,"collection":"items","$db":"test"}`, true}, + {`{"getMore":13,"collection":"items","$db":"test"}`, false}, + {`{"getMore":12,"collection":"other","$db":"test"}`, false}, + {`{"killCursors":"items","cursors":[12],"$db":"test"}`, true}, + {`{"killCursors":"items","cursors":[12,13],"$db":"test"}`, false}, + } + for _, c := range cases { + t.Run(c.json, func(t *testing.T) { + if got := checkReadonly(doc(c.json), map[int64]string{12: "test.items"}) == nil; got != c.allow { + t.Fatalf("allow=%v want %v", got, c.allow) + } + }) + } + duplicate := bson.D{{Key: "find", Value: "items"}, {Key: "filter", Value: bson.D{{Key: "x", Value: 1}, {Key: "x", Value: 2}}}} + if checkReadonly(duplicate, nil) == nil { + t.Fatal("duplicate field accepted") + } + deep := bson.D{} + for i := 0; i < 70; i++ { + deep = bson.D{{Key: "x", Value: deep}} + } + if checkReadonly(bson.D{{Key: "find", Value: "items"}, {Key: "filter", Value: deep}}, nil) == nil { + t.Fatal("excessive depth accepted") + } +} +func TestSCRAM(t *testing.T) { + for _, password := range []string{"correct", "wrong"} { + t.Run(password, func(t *testing.T) { + a, e := newAuth("gateway", "correct") + if e != nil { + t.Fatal(e) + } + c, e := scram.SHA256.NewClient("gateway", password, "") + if e != nil { + t.Fatal(e) + } + conv := c.NewConversation() + first, e := conv.Step("") + if e != nil { + t.Fatal(e) + } + resp := a.step(bson.D{{Key: "saslStart", Value: int32(1)}, {Key: "mechanism", Value: "SCRAM-SHA-256"}, {Key: "payload", Value: bson.Binary{Data: []byte(first)}}, {Key: "db", Value: "admin"}}, true) + if !ok(resp) { + t.Fatal(resp) + } + proof, e := conv.Step(string(lookup(resp, "payload").(bson.Binary).Data)) + if e != nil { + t.Fatal(e) + } + resp = a.step(bson.D{{Key: "saslContinue", Value: int32(1)}, {Key: "conversationId", Value: int32(1)}, {Key: "payload", Value: bson.Binary{Data: []byte(proof)}}, {Key: "$db", Value: "admin"}}, false) + if password == "wrong" { + if ok(resp) || a.authenticated { + t.Fatal("wrong password accepted") + } + return + } + if !ok(resp) || !a.authenticated { + t.Fatal(resp) + } + if _, e := conv.Step(string(lookup(resp, "payload").(bson.Binary).Data)); e != nil || !conv.Valid() { + t.Fatal("server proof not verified", e) + } + }) + } +} +func TestWireRoundtrip(t *testing.T) { + for _, legacy := range []bool{false, true} { + var b bytes.Buffer + if e := writeDocument(&b, 2, 1, legacy, doc(`{"ok":1,"cursor":{"id":{"$numberLong":"42"},"ns":"test.items","firstBatch":[]}}`)); e != nil { + t.Fatal(e) + } + m, e := readMessage(&b) + if e != nil { + t.Fatal(e) + } + if m.id != 2 || m.response != 1 { + t.Fatal(m) + } + d, e := decode(m.doc) + if e != nil { + t.Fatal(e) + } + cursors := map[int64]string{} + trackCursors(doc(`{"find":"items"}`), d, cursors) + if cursors[42] != "test.items" { + t.Fatal(cursors) + } + } +} +func TestWireRejectsMalformed(t *testing.T) { + var b bytes.Buffer + writeDocument(&b, 1, 0, false, doc(`{"ping":1}`)) + original := b.Bytes() + for _, mutate := range []func([]byte){func(b []byte) { binary.LittleEndian.PutUint32(b, maxMessage+1) }, func(b []byte) { binary.LittleEndian.PutUint32(b[16:], 65536) }, func(b []byte) { b[20] = 3 }, func(b []byte) { binary.LittleEndian.PutUint32(b[21:], 999) }} { + bad := append([]byte(nil), original...) + mutate(bad) + if _, e := readMessage(bytes.NewReader(bad)); e == nil { + t.Fatal("malformed wire accepted") + } + } +} +func FuzzReadonly(f *testing.F) { + b, _ := bson.Marshal(doc(`{"find":"items","filter":{"x":1}}`)) + f.Add(b) + f.Fuzz(func(t *testing.T, b []byte) { + if len(b) > 1024*1024 { + return + } + d, err := decode(b) + if err == nil { + _ = checkReadonly(d, map[int64]string{}) + } + }) +} +func FuzzWire(f *testing.F) { + var b bytes.Buffer + writeDocument(&b, 1, 0, false, doc(`{"ping":1}`)) + f.Add(b.Bytes()) + f.Fuzz(func(t *testing.T, b []byte) { + if len(b) > 1024*1024 { + return + } + _, _ = readMessage(bytes.NewReader(b)) + }) +} + +func TestCursorErrorKeepsApprovedCursor(t *testing.T) { + cursors := map[int64]string{17: "test.items"} + request := doc(`{"getMore":17,"collection":"items","$db":"test"}`) + trackCursors(request, failure(50, "time limit exceeded"), cursors) + if checkReadonly(request, cursors) != nil { + t.Fatal("recoverable server error lost cursor authorization") + } + trackCursors(request, doc(`{"ok":1,"cursor":{"id":0,"ns":"test.items","nextBatch":[]}}`), cursors) + if checkReadonly(request, cursors) == nil { + t.Fatal("exhausted cursor remained authorized") + } +} + +func TestWireSequenceValidation(t *testing.T) { + var body bytes.Buffer + if err := writeDocument(&body, 1, 0, false, doc(`{"insert":"items","$db":"test"}`)); err != nil { + t.Fatal(err) + } + document, _ := bson.Marshal(doc(`{"x":1}`)) + sequence := func(identifier string, docs []byte) []byte { + section := make([]byte, 5) + section[0] = 1 + section = append(section, []byte(identifier)...) + section = append(section, 0) + section = append(section, docs...) + binary.LittleEndian.PutUint32(section[1:], uint32(len(section)-1)) + return section + } + valid := sequence("documents", document) + for _, tc := range []struct { + name string + sections []byte + allow bool + }{ + {"valid", valid, true}, + {"empty-identifier", sequence("", document), false}, + {"truncated-document", sequence("documents", document[:len(document)-1]), false}, + {"duplicate-identifier", append(append([]byte{}, valid...), valid...), false}, + } { + t.Run(tc.name, func(t *testing.T) { + raw := append(append([]byte{}, body.Bytes()...), tc.sections...) + binary.LittleEndian.PutUint32(raw, uint32(len(raw))) + _, err := readMessage(bytes.NewReader(raw)) + if (err == nil) != tc.allow { + t.Fatalf("allow=%v error=%v", tc.allow, err) + } + }) + } +} + +func TestWireRejectsDeepBodyBeforeDecode(t *testing.T) { + deep := bson.D{{Key: "x", Value: 1}} + for i := 0; i < 70; i++ { + deep = bson.D{{Key: "nested", Value: deep}} + } + var b bytes.Buffer + if err := writeDocument(&b, 1, 0, false, deep); err != nil { + t.Fatal(err) + } + if _, err := readMessage(&b); err == nil { + t.Fatal("deep body accepted before decoder depth validation") + } +} diff --git a/internal/protocol/mongoproxy/wire.go b/internal/protocol/mongoproxy/wire.go new file mode 100644 index 0000000..ce99f31 --- /dev/null +++ b/internal/protocol/mongoproxy/wire.go @@ -0,0 +1,232 @@ +package mongoproxy + +import ( + "bytes" + "encoding/binary" + "errors" + "go.mongodb.org/mongo-driver/v2/bson" + "io" +) + +const maxMessage = 48 * 1024 * 1024 + +type message struct { + id, response, opcode int32 + flags uint32 + sequences bool + doc bson.Raw + raw []byte +} + +func readMessage(r io.Reader) (message, error) { + var m message + h := make([]byte, 16) + if _, e := io.ReadFull(r, h); e != nil { + return m, e + } + n := int(binary.LittleEndian.Uint32(h)) + if n < 21 || n > maxMessage { + return m, errors.New("invalid MongoDB message size") + } + m.raw = make([]byte, n) + copy(m.raw, h) + if _, e := io.ReadFull(r, m.raw[16:]); e != nil { + return m, e + } + m.id = int32(binary.LittleEndian.Uint32(h[4:])) + m.response = int32(binary.LittleEndian.Uint32(h[8:])) + m.opcode = int32(binary.LittleEndian.Uint32(h[12:])) + b := m.raw[16:] + switch m.opcode { + case 2013: + m.flags = binary.LittleEndian.Uint32(b) + if m.flags & ^uint32(2) != 0 { + return m, errors.New("unsupported OP_MSG flags (checksum/exhaust)") + } + b = b[4:] + identifiers := map[string]bool{} + for len(b) > 0 { + kind := b[0] + b = b[1:] + if len(b) < 4 { + return m, errors.New("invalid section") + } + size := int(binary.LittleEndian.Uint32(b)) + if size < 5 || size > len(b) { + return m, errors.New("invalid section size") + } + if kind == 0 { + if m.doc != nil { + return m, errors.New("duplicate body") + } + m.doc = bson.Raw(b[:size]) + if e := validateRaw(m.doc, 0); e != nil { + return m, e + } + } else if kind == 1 { + m.sequences = true + section := b[4:size] + end := bytes.IndexByte(section, 0) + if end <= 0 || identifiers[string(section[:end])] { + return m, errors.New("invalid or duplicate sequence identifier") + } + identifiers[string(section[:end])] = true + section = section[end+1:] + for len(section) > 0 { + if len(section) < 5 { + return m, errors.New("invalid sequence document") + } + length := int(binary.LittleEndian.Uint32(section)) + if length < 5 || length > len(section) { + return m, errors.New("invalid sequence document size") + } + if err := validateRaw(bson.Raw(section[:length]), 0); err != nil { + return m, err + } + section = section[length:] + } + } else { + return m, errors.New("unknown section") + } + b = b[size:] + } + case 2004: + if len(b) < 13 { + return m, errors.New("invalid OP_QUERY") + } + i := 4 + for i < len(b) && b[i] != 0 { + i++ + } + if i+9 >= len(b) { + return m, errors.New("invalid OP_QUERY namespace") + } + m.doc = bson.Raw(b[i+9:]) + if e := validateRaw(m.doc, 0); e != nil { + return m, e + } + case 1: + if len(b) < 20 || binary.LittleEndian.Uint32(b[16:]) != 1 { + return m, errors.New("invalid OP_REPLY") + } + m.doc = bson.Raw(b[20:]) + if e := validateRaw(m.doc, 0); e != nil { + return m, e + } + default: + return m, errors.New("unsupported MongoDB opcode") + } + if m.doc == nil { + return m, errors.New("missing body") + } + return m, nil +} +func writeAll(w io.Writer, b []byte) error { + for len(b) > 0 { + n, e := w.Write(b) + if e != nil { + return e + } + if n == 0 { + return io.ErrShortWrite + } + b = b[n:] + } + return nil +} +func writeDocument(w io.Writer, id, response int32, legacy bool, d bson.D) error { + b, e := bson.Marshal(d) + if e != nil { + return e + } + offset := 21 + opcode := uint32(2013) + if legacy { + offset = 36 + opcode = 1 + } + out := make([]byte, offset+len(b)) + binary.LittleEndian.PutUint32(out, uint32(len(out))) + binary.LittleEndian.PutUint32(out[4:], uint32(id)) + binary.LittleEndian.PutUint32(out[8:], uint32(response)) + binary.LittleEndian.PutUint32(out[12:], opcode) + if legacy { + binary.LittleEndian.PutUint32(out[32:], 1) + } + copy(out[offset:], b) + return writeAll(w, out) +} +func decode(raw bson.Raw) (bson.D, error) { + if err := validateRaw(raw, 0); err != nil { + return nil, err + } + var d bson.D + e := bson.Unmarshal(raw, &d) + return d, e +} + +// Bound nesting before reflection-based BSON decoding allocates recursive values. +func validateRaw(raw bson.Raw, depth int) error { + if depth > 64 || len(raw) < 5 || int(binary.LittleEndian.Uint32(raw)) != len(raw) || raw[len(raw)-1] != 0 { + return errors.New("invalid or excessively nested BSON") + } + es, err := raw.Elements() + if err != nil { + return err + } + for _, e := range es { + v := e.Value() + switch v.Type { + case bson.TypeEmbeddedDocument: + if err := validateRaw(v.Document(), depth+1); err != nil { + return err + } + case bson.TypeArray: + if err := validateRaw(bson.Raw(v.Array()), depth+1); err != nil { + return err + } + case bson.TypeCodeWithScope: + _, scope, valid := v.CodeWithScopeOK() + if !valid { + return errors.New("invalid BSON scope") + } + if err := validateRaw(scope, depth+1); err != nil { + return err + } + } + } + return nil +} +func command(raw bson.Raw) string { + es, e := raw.Elements() + if e != nil || len(es) == 0 { + return "" + } + return es[0].Key() +} +func lookup(d bson.D, k string) any { + for _, e := range d { + if e.Key == k { + return e.Value + } + } + return nil +} +func number(v any) int64 { + switch n := v.(type) { + case int32: + return int64(n) + case int64: + return n + case int: + return int64(n) + case float64: + return int64(n) + } + return 0 +} +func str(v any) string { s, _ := v.(string); return s } +func ok(d bson.D) bool { return number(lookup(d, "ok")) == 1 } +func failure(code int32, msg string) bson.D { + return bson.D{{Key: "ok", Value: float64(0)}, {Key: "code", Value: code}, {Key: "errmsg", Value: msg}} +} diff --git a/internal/protocol/mysqlproxy/audit.go b/internal/protocol/mysqlproxy/audit.go new file mode 100644 index 0000000..796af66 --- /dev/null +++ b/internal/protocol/mysqlproxy/audit.go @@ -0,0 +1,36 @@ +package mysqlproxy + +import ( + "agentdb/internal/model" + "context" + "encoding/binary" + "fmt" + "github.com/go-mysql-org/go-mysql/mysql" +) + +func auditCommand(ctx context.Context, data []byte, statements map[uint32]string, decision string) error { + kind, text := "protocol", fmt.Sprintf("command=%d bytes=%d", data[0], len(data)) + switch data[0] { + case mysql.COM_QUERY: + kind, text = "query", string(data[1:]) + case mysql.COM_STMT_PREPARE: + kind, text = "prepare", string(data[1:]) + case mysql.COM_INIT_DB: + kind, text = "use_database", "USE "+string(data[1:]) + case mysql.COM_PING: + kind, text = "ping", "PING" + case mysql.COM_QUIT: + kind, text = "quit", "QUIT" + case mysql.COM_RESET_CONNECTION: + kind, text = "reset", "RESET CONNECTION" + case mysql.COM_FIELD_LIST: + kind, text = "field_list", fmt.Sprintf("FIELD LIST %q", data[1:]) + case mysql.COM_STMT_EXECUTE, mysql.COM_STMT_SEND_LONG_DATA, mysql.COM_STMT_CLOSE, mysql.COM_STMT_RESET: + kind = "prepared" + if len(data) >= 5 { + id := binary.LittleEndian.Uint32(data[1:]) + text = fmt.Sprintf("command=%d statement_id=%d payload_bytes=%d\n%s", data[0], id, len(data)-5, statements[id]) + } + } + return model.AuditCommand(ctx, kind, text, decision) +} diff --git a/internal/protocol/mysqlproxy/audit_test.go b/internal/protocol/mysqlproxy/audit_test.go new file mode 100644 index 0000000..d5c61c6 --- /dev/null +++ b/internal/protocol/mysqlproxy/audit_test.go @@ -0,0 +1,53 @@ +package mysqlproxy + +import ( + "agentdb/internal/model" + "errors" + "github.com/go-mysql-org/go-mysql/client" + "strings" + "testing" +) + +func TestGatewayCommandAuditAndFailClosed(t *testing.T) { + records := make(chan model.Command, 16) + addr, tls, _, _ := testGateway(t, "readonly", func(command model.Command) error { records <- command; return nil }) + c, err := client.Connect(addr, "gateway", "gateway-secret", "", func(c *client.Conn) error { c.SetTLSConfig(tls); return nil }) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if _, err = c.Execute("select 42"); err != nil { + t.Fatal(err) + } + if _, err = c.Execute("delete from items"); err == nil { + t.Fatal("write allowed") + } + query, blocked := <-records, <-records + if query.Text != "select 42" || query.Decision != "allowed" || blocked.Text != "delete from items" || blocked.Decision != "blocked" { + t.Fatal("incorrect command audit") + } + statement, err := c.Prepare("select ?") + if err != nil { + t.Fatal(err) + } + defer statement.Close() + if _, err = statement.Execute(42); err != nil { + t.Fatal(err) + } + prepare, execute := <-records, <-records + if prepare.Kind != "prepare" || execute.Kind != "prepared" || !strings.Contains(execute.Text, "select ?") { + t.Fatal("prepared statement template not audited") + } + addr, tls, queries, _ := testGateway(t, "readwrite", func(model.Command) error { return errors.New("disk unavailable") }) + broken, err := client.Connect(addr, "gateway", "gateway-secret", "", func(c *client.Conn) error { c.SetTLSConfig(tls); return nil }) + if err != nil { + t.Fatal(err) + } + defer broken.Close() + if _, err = broken.Execute("select 42"); err == nil { + t.Fatal("audit failure allowed command") + } + if queries.Load() != 0 { + t.Fatal("unaudited query reached target") + } +} diff --git a/internal/protocol/mysqlproxy/compression.go b/internal/protocol/mysqlproxy/compression.go new file mode 100644 index 0000000..b9b3dd6 --- /dev/null +++ b/internal/protocol/mysqlproxy/compression.go @@ -0,0 +1,52 @@ +package mysqlproxy + +import ( + "bytes" + "encoding/binary" + "errors" + "github.com/go-mysql-org/go-mysql/mysql" + "io" + "net" +) + +type greetingConn struct { + net.Conn + pending *bytes.Reader +} + +func (c *greetingConn) Read(p []byte) (int, error) { + if c.pending.Len() > 0 { + return c.pending.Read(p) + } + return c.Conn.Read(p) +} + +// The client library enables compression from requested flags without checking +// server support. Inspect and replay the greeting before configuring the client. +func peekCompression(c net.Conn) (net.Conn, bool, error) { + var header [4]byte + if _, err := io.ReadFull(c, header[:]); err != nil { + return nil, false, err + } + size := int(header[0]) | int(header[1])<<8 | int(header[2])<<16 + if size < 1 || size > 65536 || header[3] != 0 { + return nil, false, errors.New("invalid MySQL greeting") + } + body := make([]byte, size) + if _, err := io.ReadFull(c, body); err != nil { + return nil, false, err + } + compress := false + if body[0] == 10 { + end := bytes.IndexByte(body[1:], 0) + if end < 0 { + return nil, false, errors.New("invalid MySQL server version") + } + capOffset := 1 + end + 1 + 4 + 8 + 1 + if capOffset+2 > len(body) { + return nil, false, errors.New("truncated MySQL capabilities") + } + compress = uint32(binary.LittleEndian.Uint16(body[capOffset:]))&mysql.CLIENT_COMPRESS != 0 + } + return &greetingConn{c, bytes.NewReader(append(header[:], body...))}, compress, nil +} diff --git a/internal/protocol/mysqlproxy/compression_test.go b/internal/protocol/mysqlproxy/compression_test.go new file mode 100644 index 0000000..b517fd6 --- /dev/null +++ b/internal/protocol/mysqlproxy/compression_test.go @@ -0,0 +1,171 @@ +package mysqlproxy + +import ( + "agentdb/internal/model" + "bytes" + "compress/zlib" + "context" + "encoding/binary" + "github.com/go-mysql-org/go-mysql/mysql" + "github.com/go-mysql-org/go-mysql/server" + "io" + "net" + "strings" + "sync/atomic" + "testing" + "time" +) + +// A test server transport implementing the compressed framing independently of +// go-mysql's client compressor (the server library itself has no compression). +type compressedBackendConn struct { + net.Conn + active, greeted bool + pending *bytes.Reader + sequence byte +} + +func (c *compressedBackendConn) Read(p []byte) (int, error) { + if !c.active { + return c.Conn.Read(p) + } + if c.pending != nil && c.pending.Len() > 0 { + return c.pending.Read(p) + } + var h [7]byte + if _, err := io.ReadFull(c.Conn, h[:]); err != nil { + return 0, err + } + n := int(h[0]) | int(h[1])<<8 | int(h[2])<<16 + raw := make([]byte, n) + if _, err := io.ReadFull(c.Conn, raw); err != nil { + return 0, err + } + c.sequence = h[3] + 1 + if h[4] != 0 || h[5] != 0 || h[6] != 0 { + r, err := zlib.NewReader(bytes.NewReader(raw)) + if err != nil { + return 0, err + } + raw, err = io.ReadAll(r) + r.Close() + if err != nil { + return 0, err + } + } + c.pending = bytes.NewReader(raw) + return c.pending.Read(p) +} +func (c *compressedBackendConn) Write(p []byte) (int, error) { + if !c.greeted { + c.greeted = true + p = bytes.Clone(p) + end := bytes.IndexByte(p[5:], 0) + offset := 5 + end + 1 + 4 + 8 + 1 + caps := binary.LittleEndian.Uint16(p[offset:]) + binary.LittleEndian.PutUint16(p[offset:], caps|uint16(mysql.CLIENT_COMPRESS)) + } + if !c.active { + return c.Conn.Write(p) + } + var out bytes.Buffer + w := zlib.NewWriter(&out) + w.Write(p) + w.Close() + compressed := out.Bytes() + h := []byte{byte(len(compressed)), byte(len(compressed) >> 8), byte(len(compressed) >> 16), c.sequence, byte(len(p)), byte(len(p) >> 8), byte(len(p) >> 16)} + c.sequence++ + if _, err := c.Conn.Write(append(h, compressed...)); err != nil { + return 0, err + } + return len(p), nil +} + +type trafficConn struct { + net.Conn + written *atomic.Int64 +} + +func (c *trafficConn) Write(p []byte) (int, error) { + n, e := c.Conn.Write(p) + c.written.Add(int64(n)) + return n, e +} + +func TestMySQLCompressionNegotiationTrafficAndFallback(t *testing.T) { + for _, supported := range []bool{false, true} { + t.Run(map[bool]string{false: "fallback", true: "zlib"}[supported], func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var queries atomic.Int32 + var written atomic.Int64 + dial := func(context.Context, string, string) (net.Conn, error) { + a, b := net.Pipe() + go func() { + defer b.Close() + stop := context.AfterFunc(ctx, func() { b.Close() }) + defer stop() + var raw net.Conn = b + var compressed *compressedBackendConn + if supported { + compressed = &compressedBackendConn{Conn: b} + raw = compressed + } + srv := server.NewServer("5.7.44-test", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil) + c, err := srv.NewConn(raw, "target", "secret", &fakeBackend{queries: &queries}) + if err != nil { + return + } + if supported { + compressed.active = true + } + for !c.Closed() { + if c.HandleCommand() != nil { + return + } + } + }() + return &trafficConn{a, &written}, nil + } + p := model.Proxy{TargetHost: "db.invalid", TargetPort: 3306, TargetUser: "target", TargetPassword: "secret", TargetCompression: "zlib"} + c, err := open(ctx, p, dial, "") + if err != nil { + t.Fatal(err) + } + defer c.Close() + expected := uint8(mysql.MYSQL_COMPRESS_NONE) + if supported { + expected = mysql.MYSQL_COMPRESS_ZLIB + } + if c.Compression != expected { + t.Fatalf("negotiated %d, want %d", c.Compression, expected) + } + query := "SELECT 42 /*" + strings.Repeat("compressible payload ", 4000) + "*/" + before := written.Load() + for i := 0; i < 3; i++ { + result, err := c.Execute(query) + if err != nil { + t.Fatal(err) + } + v, err := result.GetInt(0, 0) + if err != nil || v != 42 { + t.Fatal("response changed") + } + result.Close() + } + if supported && written.Load()-before >= int64(len(query)) { + t.Fatal("compression did not reduce upstream bytes") + } + statement, err := c.Prepare("SELECT ?") + if err != nil { + t.Fatal(err) + } + result, err := statement.Execute(42) + if err != nil { + t.Fatal(err) + } + result.Close() + statement.Close() + }) + } +} diff --git a/internal/protocol/mysqlproxy/gateway.go b/internal/protocol/mysqlproxy/gateway.go new file mode 100644 index 0000000..5366d67 --- /dev/null +++ b/internal/protocol/mysqlproxy/gateway.go @@ -0,0 +1,451 @@ +// Package mysqlproxy terminates authentication and relays protocol packets. +package mysqlproxy + +import ( + "agentdb/internal/model" + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/binary" + "errors" + "fmt" + "net" + "strconv" + "time" + + "github.com/go-mysql-org/go-mysql/client" + "github.com/go-mysql-org/go-mysql/mysql" + "github.com/go-mysql-org/go-mysql/server" + "vitess.io/vitess/go/vt/sqlparser" +) + +type Handler struct{} + +func New() *Handler { return &Handler{} } + +var _ model.Handler = (*Handler)(nil) + +type handshakeHandler struct { + server.EmptyHandler + database string +} + +func (h *handshakeHandler) UseDB(s string) error { h.database = s; return nil } + +func open(ctx context.Context, p model.Proxy, dial model.DialFunc, db string) (*client.Conn, error) { + var config *tls.Config + if p.TargetTLS { + config = &tls.Config{MinVersion: tls.VersionTLS12, ServerName: p.TargetHost} + if p.TargetCA != "" { + roots, e := x509.SystemCertPool() + if e != nil { + roots = x509.NewCertPool() + } + if !roots.AppendCertsFromPEM([]byte(p.TargetCA)) { + return nil, errors.New("invalid target CA") + } + config.RootCAs = roots + } + } + var raw net.Conn + compress := false + var stop func() bool + wrapped := func(ctx context.Context, network, address string) (net.Conn, error) { + c, e := dial(ctx, network, address) + if e != nil { + return nil, e + } + raw = c + _ = c.SetDeadline(time.Now().Add(15 * time.Second)) + stop = context.AfterFunc(ctx, func() { _ = c.Close() }) + if p.TargetCompression == "zlib" { + var err error + c, compress, err = peekCompression(c) + if err != nil { + _ = raw.Close() + return nil, err + } + } + return c, nil + } + c, e := client.ConnectWithDialer(ctx, "tcp", net.JoinHostPort(p.TargetHost, strconv.Itoa(p.TargetPort)), p.TargetUser, p.TargetPassword, db, wrapped, func(c *client.Conn) error { + c.SetTLSConfig(config) + c.UnsetCapability(mysql.CLIENT_MULTI_STATEMENTS | mysql.CLIENT_MULTI_RESULTS | mysql.CLIENT_PS_MULTI_RESULTS | mysql.CLIENT_LOCAL_FILES | mysql.CLIENT_COMPRESS | mysql.CLIENT_DEPRECATE_EOF | mysql.CLIENT_QUERY_ATTRIBUTES) + if compress { + c.SetCapability(mysql.CLIENT_COMPRESS) + } + return c.SetCollation("utf8mb4_general_ci") + }) + if stop != nil { + defer stop() + } + if e != nil { + if raw != nil { + _ = raw.Close() + } + return nil, e + } + if ctx.Err() != nil { + c.Close() + return nil, ctx.Err() + } + if p.AccessMode == "readonly" { + if e = setReadOnlyMode(c); e != nil { + c.Close() + return nil, e + } + } + _ = raw.SetDeadline(time.Time{}) + return c, nil +} + +func (h *Handler) Test(ctx context.Context, p model.Proxy, d model.DialFunc) (model.TestResult, error) { + c, e := open(ctx, p, d, "") + if e != nil { + return model.TestResult{}, e + } + defer c.Close() + compression := "none" + if c.Compression == mysql.MYSQL_COMPRESS_ZLIB { + compression = "zlib" + } + return model.TestResult{Version: c.GetServerVersion(), TLS: p.TargetTLS, Compression: compression}, nil +} + +func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial model.DialFunc, tc *tls.Config, event model.EventFunc) (err error) { + defer raw.Close() + defer func() { + if recover() != nil { + err = errors.New("malformed MySQL protocol packet") + } + }() + if tc != nil { + tc = tc.Clone() + tc.MinVersion = tls.VersionTLS12 + } + stop := context.AfterFunc(ctx, func() { _ = raw.Close() }) + defer stop() + _ = raw.SetDeadline(time.Now().Add(15 * time.Second)) + hh := &handshakeHandler{} + s := server.NewServer("8.0.11-AgentDB", 45, mysql.AUTH_NATIVE_PASSWORD, nil, tc) + downstream, e := s.NewConn(&clientGreetingConn{Conn: raw}, p.GatewayUser, p.GatewayPassword, hh) + if e != nil { + return e + } + // Client capability flags describe what the client supports, not what was + // negotiated. NewServer does not advertise these optional extensions; native + // clients can still declare them and use the common (legacy) wire format. + downstream.UnsetCapability(mysql.CLIENT_PS_MULTI_RESULTS | mysql.CLIENT_COMPRESS | mysql.CLIENT_DEPRECATE_EOF | mysql.CLIENT_QUERY_ATTRIBUTES | mysql.CLIENT_LOCAL_FILES | mysql.CLIENT_ZSTD_COMPRESSION_ALGORITHM) + up, e := open(ctx, p, dial, hh.database) + if e != nil { + // The protocol library has already acknowledged gateway authentication. + // Consume the first command so its error uses the correct response sequence. + if _, readErr := downstream.ReadPacket(); readErr == nil { + _ = downstream.WriteValue(mysql.NewError(mysql.ER_ACCESS_DENIED_ERROR, "target connection failed")) + } + return errors.New("target connection failed") + } + defer up.Close() + stopUp := context.AfterFunc(ctx, func() { _ = up.Close() }) + defer stopUp() + _ = raw.SetDeadline(time.Time{}) + statements := map[uint32]bool{} + statementSQL := map[uint32]string{} + deny := func(rule string) error { + if event != nil { + category := model.EventProtocolRejected + if rule == "readonly_statement" { + category = model.EventReadOnlyRejected + } + event(category, "mysql:"+rule) + } + return downstream.WriteValue(mysql.NewError(mysql.ER_OPTION_PREVENTS_STATEMENT, "AgentDB rejected request: "+rule)) + } + for { + data, e := downstream.ReadPacket() + if e != nil { + return e + } + if len(data) == 0 { + return errors.New("empty MySQL command") + } + cmd := data[0] + valid := true + var batch []string + rule := "unsupported_command" + switch cmd { + case mysql.COM_QUIT: + return auditCommand(ctx, data, statementSQL, "allowed") + case mysql.COM_QUERY, mysql.COM_STMT_PREPARE: + if cmd == mysql.COM_QUERY { + pieces, splitErr := sqlparser.NewTestParser().SplitStatementToPieces(string(data[1:])) + if splitErr == nil && len(pieces) > 1 { + batch = pieces + if CheckReadOnly(string(data[1:])) != nil || !downstream.HasCapability(mysql.CLIENT_MULTI_RESULTS) { + valid = false + rule = "unsupported_batch" + } + } + } + if p.AccessMode == "readonly" && CheckReadOnly(string(data[1:])) != nil { + valid = false + rule = "readonly_statement" + } + case mysql.COM_PING, mysql.COM_INIT_DB, mysql.COM_RESET_CONNECTION: + case mysql.COM_FIELD_LIST: + valid = bytes.IndexByte(data[1:], 0) >= 0 + case mysql.COM_STMT_EXECUTE, mysql.COM_STMT_SEND_LONG_DATA, mysql.COM_STMT_CLOSE, mysql.COM_STMT_RESET: + valid = len(data) >= 5 && statements[binary.LittleEndian.Uint32(data[1:])] + if valid && cmd == mysql.COM_STMT_EXECUTE { + // Reject server cursors. go-mysql sets PARAMETER_COUNT_AVAILABLE + // even without query attributes; servers ignore it in that case. + valid = len(data) >= 10 && data[5] & ^byte(mysql.PARAMETER_COUNT_AVAILABLE) == 0 + } + if valid && cmd == mysql.COM_STMT_SEND_LONG_DATA { + valid = len(data) >= 7 + } + rule = "invalid_statement" + default: + valid = false + } + if !valid { + if e = auditCommand(ctx, data, statementSQL, "blocked"); e != nil { + return errors.New("command audit unavailable") + } + if cmd == mysql.COM_STMT_CLOSE || cmd == mysql.COM_STMT_SEND_LONG_DATA { + return errors.New("invalid no-response statement command") + } + if e = deny(rule); e != nil { + return e + } + downstream.ResetSequence() + continue + } + if e = auditCommand(ctx, data, statementSQL, "allowed"); e != nil { + return errors.New("command audit unavailable") + } + if len(batch) > 1 { + for i, query := range batch { + up.ResetSequence() + if e = up.WritePacket(append([]byte{0, 0, 0, 0, mysql.COM_QUERY}, []byte(query)...)); e != nil { + return e + } + first, e := relayResponseMore(downstream, up, cmd, statements, i < len(batch)-1) + if e != nil { + return e + } + if len(first) > 0 && first[0] == mysql.ERR_HEADER { + break + } + } + downstream.ResetSequence() + continue + } + up.ResetSequence() + if e = up.WritePacket(append(make([]byte, 4), data...)); e != nil { + return e + } + switch cmd { + case mysql.COM_STMT_CLOSE: + delete(statements, binary.LittleEndian.Uint32(data[1:])) + delete(statementSQL, binary.LittleEndian.Uint32(data[1:])) + case mysql.COM_STMT_SEND_LONG_DATA: + default: + var first []byte + first, e = relayResponse(downstream, up, cmd, statements) + if e != nil { + return e + } + if cmd == mysql.COM_STMT_PREPARE && len(first) >= 12 && first[0] == 0 { + statementSQL[binary.LittleEndian.Uint32(first[1:])] = string(data[1:]) + } + if cmd == mysql.COM_RESET_CONNECTION && len(first) > 0 && first[0] == 0 { + clear(statements) + clear(statementSQL) + if p.AccessMode == "readonly" { + if e = setReadOnlyMode(up); e != nil { + return errors.New("failed to restore readonly parser mode") + } + } + } + } + downstream.ResetSequence() + } +} + +func relayResponse(d *server.Conn, u *client.Conn, cmd byte, statements map[uint32]bool) ([]byte, error) { + return relayResponseMore(d, u, cmd, statements, false) +} +func relayResponseMore(d *server.Conn, u *client.Conn, cmd byte, statements map[uint32]bool, additional bool) ([]byte, error) { + for { + initial := true + read := func(terminal bool) ([]byte, error) { + b, e := u.ReadPacket() + if e != nil { + return nil, e + } + if len(b) == 0 { + return nil, errors.New("empty upstream packet") + } + // Never expose an unexpected file request to the client: some drivers + // automatically open the requested local file before sending its bytes. + if initial && b[0] == mysql.LocalInFile_HEADER { + _ = d.WriteValue(mysql.NewError(mysql.ER_OPTION_PREVENTS_STATEMENT, "LOCAL INFILE is unsupported")) + return nil, errors.New("LOCAL INFILE is unsupported") + } + out := b + if additional && (initial && b[0] == mysql.OK_HEADER || terminal && isEOF(b)) { + out = append([]byte(nil), b...) + offset := 3 + if out[0] == mysql.OK_HEADER { + offset = 1 + for i := 0; i < 2; i++ { + _, _, n := mysql.LengthEncodedInt(out[offset:]) + offset += n + } + } + if offset+2 > len(out) { + return nil, errors.New("invalid result status") + } + binary.LittleEndian.PutUint16(out[offset:offset+2], binary.LittleEndian.Uint16(out[offset:offset+2])|mysql.SERVER_MORE_RESULTS_EXISTS) + } + initial = false + if e = d.WritePacket(append(make([]byte, 4), out...)); e != nil { + return nil, e + } + return b, nil + } + first, e := read(false) + if e != nil { + return nil, e + } + if first[0] == mysql.ERR_HEADER { + return first, nil + } + if cmd == mysql.COM_STMT_PREPARE { + if len(first) < 12 || first[0] != 0 { + return nil, errors.New("invalid prepare response") + } + id := binary.LittleEndian.Uint32(first[1:]) + cols := int(binary.LittleEndian.Uint16(first[5:])) + params := int(binary.LittleEndian.Uint16(first[7:])) + n := cols + params + if cols > 0 { + n++ + } + if params > 0 { + n++ + } + for i := 0; i < n; i++ { + if _, e = read(false); e != nil { + return nil, e + } + } + statements[id] = true + return first, nil + } + if first[0] == mysql.OK_HEADER { + if moreResults(first) { + continue + } + return first, nil + } + if cmd == mysql.COM_FIELD_LIST { + for !isEOF(first) { + first, e = read(false) + if e != nil { + return nil, e + } + if first[0] == mysql.ERR_HEADER { + break + } + } + return first, nil + } + n, _, used := mysql.LengthEncodedInt(first) + if used != len(first) || n > 65535 { + return nil, fmt.Errorf("invalid result column count") + } + for i := uint64(0); i < n; i++ { + if _, e = read(false); e != nil { + return nil, e + } + } + b, e := read(false) + if e != nil { + return nil, e + } + if !isEOF(b) { + return nil, errors.New("expected result metadata EOF") + } + for { + b, e = read(true) + if e != nil { + return nil, e + } + if b[0] == mysql.ERR_HEADER || isEOF(b) { + if moreResults(b) { + break + } + return first, nil + } + } + } +} +func isEOF(b []byte) bool { return len(b) > 0 && len(b) < 9 && b[0] == mysql.EOF_HEADER } + +// Use the negotiated wire shape: go-mysql Execute consults server capability +// bits even when CLIENT_QUERY_ATTRIBUTES was explicitly disabled at handshake. +func setReadOnlyMode(c *client.Conn) error { + c.ResetSequence() + b := append([]byte{0, 0, 0, 0, mysql.COM_QUERY}, []byte("SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'")...) + if e := c.WritePacket(b); e != nil { + return e + } + _, e := c.ReadOKPacket() + return e +} + +func moreResults(packet []byte) bool { + if isEOF(packet) && len(packet) >= 5 { + return binary.LittleEndian.Uint16(packet[3:5])&mysql.SERVER_MORE_RESULTS_EXISTS != 0 + } + if len(packet) > 0 && packet[0] == mysql.OK_HEADER { + offset := 1 + for i := 0; i < 2; i++ { + if offset >= len(packet) { + return false + } + _, _, n := mysql.LengthEncodedInt(packet[offset:]) + if n == 0 { + return false + } + offset += n + } + return offset+2 <= len(packet) && binary.LittleEndian.Uint16(packet[offset:offset+2])&mysql.SERVER_MORE_RESULTS_EXISTS != 0 + } + return false +} + +type clientGreetingConn struct { + net.Conn + sent bool +} + +func (c *clientGreetingConn) Write(p []byte) (int, error) { + if !c.sent && len(p) > 5 { + c.sent = true + end := bytes.IndexByte(p[5:], 0) + if end >= 0 { + low := 5 + end + 1 + 4 + 8 + 1 + if low+7 <= len(p) { + p = append([]byte(nil), p...) + upper := binary.LittleEndian.Uint16(p[low+5 : low+7]) + upper |= uint16((mysql.CLIENT_MULTI_STATEMENTS | mysql.CLIENT_MULTI_RESULTS) >> 16) + binary.LittleEndian.PutUint16(p[low+5:low+7], upper) + } + } + } + return c.Conn.Write(p) +} diff --git a/internal/protocol/mysqlproxy/gateway_test.go b/internal/protocol/mysqlproxy/gateway_test.go new file mode 100644 index 0000000..edb8ad5 --- /dev/null +++ b/internal/protocol/mysqlproxy/gateway_test.go @@ -0,0 +1,316 @@ +package mysqlproxy + +import ( + "agentdb/internal/model" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/binary" + "github.com/go-mysql-org/go-mysql/client" + "github.com/go-mysql-org/go-mysql/mysql" + "github.com/go-mysql-org/go-mysql/packet" + "github.com/go-mysql-org/go-mysql/server" + "math/big" + "net" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" +) + +type fakeBackend struct { + server.EmptyHandler + queries *atomic.Int32 +} + +func (f *fakeBackend) UseDB(string) error { return nil } +func (f *fakeBackend) HandleQuery(q string) (*mysql.Result, error) { + f.queries.Add(1) + if strings.HasPrefix(strings.ToLower(q), "select") { + r, e := mysql.BuildSimpleResultset([]string{"n"}, [][]interface{}{{int64(42)}}, false) + return mysql.NewResult(r), e + } + return &mysql.Result{AffectedRows: 1, Status: mysql.SERVER_STATUS_AUTOCOMMIT}, nil +} +func (f *fakeBackend) HandleStmtPrepare(q string) (int, int, interface{}, error) { + f.queries.Add(1) + return strings.Count(q, "?"), 1, q, nil +} +func (f *fakeBackend) HandleStmtExecute(_ interface{}, _ string, args []interface{}) (*mysql.Result, error) { + r, e := mysql.BuildSimpleResultset([]string{"n"}, [][]interface{}{{int64(42)}}, true) + return mysql.NewResult(r), e +} +func (f *fakeBackend) HandleStmtClose(interface{}) error { return nil } +func (f *fakeBackend) HandleOtherCommand(cmd byte, _ []byte) error { + if cmd == mysql.COM_RESET_CONNECTION { + return nil + } + return mysql.NewError(mysql.ER_UNKNOWN_ERROR, "unsupported") +} + +func testTLS(t *testing.T) (*tls.Config, *tls.Config) { + t.Helper() + key, e := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if e != nil { + t.Fatal(e) + } + template := &x509.Certificate{SerialNumber: big.NewInt(1), DNSNames: []string{"localhost"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}} + der, e := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if e != nil { + t.Fatal(e) + } + cert, e := x509.ParseCertificate(der) + if e != nil { + t.Fatal(e) + } + roots := x509.NewCertPool() + roots.AddCert(cert) + return &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, MinVersion: tls.VersionTLS12}, &tls.Config{RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12} +} +func testGateway(t *testing.T, mode string, audit ...model.CommandAudit) (string, *tls.Config, *atomic.Int32, *atomic.Int32) { + return testGatewayTLS(t, mode, true, audit...) +} +func testGatewayTLS(t *testing.T, mode string, certificate bool, audit ...model.CommandAudit) (string, *tls.Config, *atomic.Int32, *atomic.Int32) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + if len(audit) > 0 { + ctx = model.WithCommandAudit(ctx, audit[0]) + } + t.Cleanup(cancel) + backend, e := net.Listen("tcp", "127.0.0.1:0") + if e != nil { + t.Fatal(e) + } + t.Cleanup(func() { backend.Close() }) + queries := &atomic.Int32{} + rejects := &atomic.Int32{} + go func() { + for { + r, e := backend.Accept() + if e != nil { + return + } + go func() { + defer r.Close() + s := server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil) + c, e := s.NewConn(r, "target", "secret", &fakeBackend{queries: queries}) + if e != nil { + return + } + for !c.Closed() { + if c.HandleCommand() != nil { + return + } + } + }() + } + }() + gateway, e := net.Listen("tcp", "127.0.0.1:0") + if e != nil { + t.Fatal(e) + } + t.Cleanup(func() { gateway.Close() }) + host, port, _ := net.SplitHostPort(backend.Addr().String()) + pn, _ := strconv.Atoi(port) + p := model.Proxy{TargetHost: host, TargetPort: pn, TargetUser: "target", TargetPassword: "secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: mode} + tc, cc := testTLS(t) + if !certificate { + tc = nil + } + go func() { + for { + r, e := gateway.Accept() + if e != nil { + return + } + go func() { + _ = New().Serve(ctx, r, p, (&net.Dialer{}).DialContext, tc, func(category, rule string) { + if category == model.EventReadOnlyRejected { + rejects.Add(1) + } + }) + }() + } + }() + return gateway.Addr().String(), cc, queries, rejects +} +func TestGatewayReadOnlyAndPrepared(t *testing.T) { + addr, tc, queries, rejects := testGateway(t, "readonly") + c, e := client.Connect(addr, "gateway", "gateway-secret", "", func(c *client.Conn) error { c.SetTLSConfig(tc); return nil }) + if e != nil { + t.Fatal(e) + } + defer c.Close() + r, e := c.Execute("select 42") + if e != nil { + t.Fatal(e) + } + if n, _ := r.GetInt(0, 0); n != 42 { + t.Fatalf("result: %d", n) + } + before := queries.Load() + if _, e = c.Execute("delete from t"); e == nil { + t.Fatal("write allowed") + } + if queries.Load() != before { + t.Fatal("rejected SQL reached backend") + } + if _, e = c.Prepare("insert into t values(?)"); e == nil { + t.Fatal("write prepare allowed") + } + stmt, e := c.Prepare("select ?") + if e != nil { + t.Fatal(e) + } + if _, e = stmt.Execute(42); e != nil { + t.Fatal(e) + } + if _, e = c.Execute("select 1"); e != nil { + t.Fatalf("connection unusable after reject: %v", e) + } + c.ResetSequence() + if e = c.WritePacket([]byte{0, 0, 0, 0, mysql.COM_RESET_CONNECTION}); e != nil { + t.Fatal(e) + } + if _, e = c.ReadOKPacket(); e != nil { + t.Fatal(e) + } + if _, e = stmt.Execute(1); e == nil { + t.Fatal("old statement valid after reset") + } + if rejects.Load() != 2 { + t.Fatalf("expected two readonly events; invalid statement IDs are protocol rejections: %d", rejects.Load()) + } +} +func TestGatewayReadWriteDefault(t *testing.T) { + addr, tc, _, _ := testGateway(t, "") + c, e := client.Connect(addr, "gateway", "gateway-secret", "", func(c *client.Conn) error { c.SetTLSConfig(tc); return nil }) + if e != nil { + t.Fatal(e) + } + defer c.Close() + if _, e = c.Execute("insert into t values(1)"); e != nil { + t.Fatal(e) + } +} +func TestGatewayAcceptsPlaintext(t *testing.T) { + for _, certificate := range []bool{true, false} { + addr, _, _, _ := testGatewayTLS(t, "readwrite", certificate) + c, err := client.Connect(addr, "gateway", "gateway-secret", "") + if err != nil { + t.Fatal(err) + } + defer c.Close() + if _, err = c.Execute("SELECT 1"); err != nil { + t.Fatal(err) + } + } +} + +func TestClientMultiStatementCapabilityDoesNotDisconnect(t *testing.T) { + for _, mode := range []string{"readwrite", "readonly"} { + t.Run(mode, func(t *testing.T) { + addr, _, _, _ := testGateway(t, mode) + c, err := client.Connect(addr, "gateway", "gateway-secret", "", func(c *client.Conn) error { + c.SetCapability(mysql.CLIENT_MULTI_STATEMENTS | mysql.CLIENT_MULTI_RESULTS) + return nil + }) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if _, err = c.Execute("SELECT 1"); err != nil { + t.Fatal(err) + } + if mode == "readonly" { + if _, err = c.Execute("SELECT 1; DELETE FROM items"); err == nil { + t.Fatal("readonly multi statement accepted") + } + } + if err = c.Ping(); err != nil { + t.Fatal(err) + } + }) + } +} + +// Native clients send all supported capability bits, while selecting the wire +// format from the intersection with the server greeting. +type declaredCapabilitiesConn struct { + net.Conn + first bool +} + +func (c *declaredCapabilitiesConn) Write(p []byte) (int, error) { + if !c.first && len(p) >= 8 { + c.first = true + p = append([]byte(nil), p...) + flags := binary.LittleEndian.Uint32(p[4:8]) + binary.LittleEndian.PutUint32(p[4:8], flags|mysql.CLIENT_DEPRECATE_EOF|mysql.CLIENT_QUERY_ATTRIBUTES|mysql.CLIENT_COMPRESS) + } + return c.Conn.Write(p) +} +func TestUnadvertisedCapabilitiesUseLegacyWireFormat(t *testing.T) { + addr, _, _, _ := testGateway(t, "readwrite") + dial := func(ctx context.Context, network, address string) (net.Conn, error) { + c, err := (&net.Dialer{}).DialContext(ctx, network, address) + if err != nil { + return nil, err + } + return &declaredCapabilitiesConn{Conn: c}, nil + } + c, err := client.ConnectWithDialer(context.Background(), "tcp", addr, "gateway", "gateway-secret", "", dial) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if _, err = c.Execute("SELECT 1"); err != nil { + t.Fatal(err) + } + stmt, err := c.Prepare("SELECT ?") + if err != nil { + t.Fatal(err) + } + defer stmt.Close() + if _, err = stmt.Execute(1); err != nil { + t.Fatal(err) + } + if err = c.Ping(); err != nil { + t.Fatal(err) + } +} + +func TestRelayRejectsLocalFileBeforeForwarding(t *testing.T) { + upstream, backend := net.Pipe() + downstream, frontend := net.Pipe() + defer upstream.Close() + defer backend.Close() + defer downstream.Close() + defer frontend.Close() + for _, c := range []net.Conn{upstream, backend, downstream, frontend} { + _ = c.SetDeadline(time.Now().Add(3 * time.Second)) + } + done := make(chan error, 1) + go func() { + _, e := relayResponse(&server.Conn{Conn: packet.NewConn(downstream)}, &client.Conn{Conn: packet.NewConn(upstream)}, mysql.COM_QUERY, map[uint32]bool{}) + done <- e + }() + b := append([]byte{0, 0, 0, 0, mysql.LocalInFile_HEADER}, []byte("private-file")...) + if e := packet.NewConn(backend).WritePacket(b); e != nil { + t.Fatal(e) + } + response, e := packet.NewConn(frontend).ReadPacket() + if e != nil { + t.Fatal(e) + } + if len(response) == 0 || response[0] != mysql.ERR_HEADER { + t.Fatalf("file request reached frontend: %x", response) + } + if e = <-done; e == nil { + t.Fatal("unexpected LOCAL INFILE did not terminate relay") + } +} diff --git a/internal/protocol/mysqlproxy/integration_test.go b/internal/protocol/mysqlproxy/integration_test.go new file mode 100644 index 0000000..7f3fa56 --- /dev/null +++ b/internal/protocol/mysqlproxy/integration_test.go @@ -0,0 +1,234 @@ +package mysqlproxy + +import ( + "agentdb/internal/model" + "agentdb/internal/testutil" + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "os" + "strconv" + "sync/atomic" + "testing" + "time" + + "github.com/go-mysql-org/go-mysql/client" + "github.com/go-mysql-org/go-mysql/mysql" +) + +// TestRealMySQL is opt-in and runs unchanged against the 5.7, 8.0 and 8.4 +// fixtures. The fixture account must be able to create a temporary database. +func TestRealMySQL(t *testing.T) { + address := os.Getenv("AGENTDB_MYSQL_TEST_ADDR") + if address == "" { + t.Skip("set AGENTDB_MYSQL_TEST_ADDR, USER, PASSWORD and optional CA") + } + host, port, e := net.SplitHostPort(address) + if e != nil { + t.Fatal(e) + } + pn, e := strconv.Atoi(port) + if e != nil { + t.Fatal(e) + } + p := model.Proxy{TargetHost: host, TargetPort: pn, TargetUser: os.Getenv("AGENTDB_MYSQL_TEST_USER"), TargetPassword: os.Getenv("AGENTDB_MYSQL_TEST_PASSWORD"), GatewayUser: "integration", GatewayPassword: "integration-password"} + p.TargetCompression = os.Getenv("AGENTDB_MYSQL_TEST_COMPRESSION") + var targetTLS *tls.Config + if path := os.Getenv("AGENTDB_MYSQL_TEST_CA"); path != "" { + pem, e := os.ReadFile(path) + if e != nil { + t.Fatal(e) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + t.Fatal("invalid fixture CA") + } + p.TargetTLS, p.TargetCA = true, string(pem) + targetTLS = &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: pool, ServerName: host} + } + dial := testutil.FixtureDial(t, &p) + control, e := client.ConnectWithDialer(context.Background(), "tcp", address, p.TargetUser, p.TargetPassword, "", client.Dialer(dial), func(c *client.Conn) error { c.SetTLSConfig(targetTLS); return c.SetCollation("utf8mb4_general_ci") }) + if e != nil { + t.Fatal(e) + } + defer control.Close() + verification, e := New().Test(context.Background(), p, dial) + if e != nil { + t.Fatal(e) + } + t.Logf("target version=%s TLS=%t negotiated compression=%s", verification.Version, verification.TLS, verification.Compression) + badTarget := p + badTarget.TargetPassword = "invalid-integration-password" + if wrong, err := open(context.Background(), badTarget, dial, ""); err == nil { + wrong.Close() + t.Fatal("invalid upstream password accepted") + } + database := fmt.Sprintf("agentdb_test_%d", time.Now().UnixNano()) + if _, e = control.Execute("CREATE DATABASE " + database + " CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"); e != nil { + t.Fatal(e) + } + defer func() { + if _, e := control.Execute("DROP DATABASE " + database); e != nil { + t.Errorf("fixture cleanup: %v", e) + } + }() + if _, e = control.Execute("CREATE TABLE " + database + ".items (id INT PRIMARY KEY, value VARCHAR(64)) ENGINE=InnoDB"); e != nil { + t.Fatal(e) + } + + for _, mode := range []string{"readwrite", "readonly"} { + t.Run(mode, func(t *testing.T) { + cfg := p + cfg.AccessMode = mode + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + listener, e := net.Listen("tcp", "127.0.0.1:0") + if e != nil { + t.Fatal(e) + } + defer listener.Close() + serverTLS, frontTLS := testTLS(t) + var rejected atomic.Int32 + var audited, blocked atomic.Int32 + ctx = model.WithCommandAudit(ctx, func(command model.Command) error { + audited.Add(1) + if command.Decision == "blocked" { + blocked.Add(1) + } + return nil + }) + go func() { + for { + raw, e := listener.Accept() + if e != nil { + return + } + go func() { + _ = New().Serve(ctx, raw, cfg, dial, serverTLS, func(string, string) { rejected.Add(1) }) + }() + } + }() + front, e := client.Connect(listener.Addr().String(), cfg.GatewayUser, cfg.GatewayPassword, database, func(c *client.Conn) error { c.SetTLSConfig(frontTLS); return c.SetCollation("utf8mb4_general_ci") }) + if e != nil { + t.Fatal(e) + } + defer front.Close() + if wrong, err := client.Connect(listener.Addr().String(), cfg.GatewayUser, "wrong-password", database, func(c *client.Conn) error { c.SetTLSConfig(frontTLS); return nil }); err == nil { + wrong.Close() + t.Fatal("invalid gateway password accepted") + } + plaintext, err := client.Connect(listener.Addr().String(), cfg.GatewayUser, cfg.GatewayPassword, database, func(c *client.Conn) error { + c.SetCapability(mysql.CLIENT_MULTI_STATEMENTS | mysql.CLIENT_MULTI_RESULTS) + return nil + }) + if err != nil { + t.Fatal(err) + } + if _, err = plaintext.Execute("SELECT 1"); err != nil { + t.Fatal(err) + } + results := 0 + _, err = plaintext.ExecuteMultiple("SHOW VARIABLES LIKE 'lower_case_%'; SHOW VARIABLES LIKE 'sql_mode'; SELECT COUNT(*) AS support_ndb FROM information_schema.ENGINES WHERE Engine = 'ndbcluster'", func(result *mysql.Result, e error) { + if e != nil { + t.Error(e) + } + results++ + }) + if err != nil || results != 3 { + t.Fatalf("batch results=%d error=%v", results, err) + } + mixed := 0 + _, err = plaintext.ExecuteMultiple("SET NAMES 'utf8mb4'; SELECT 1", func(result *mysql.Result, e error) { + if e != nil { + t.Error(e) + } + mixed++ + }) + if err != nil || mixed != 2 { + t.Fatalf("mixed batch results=%d error=%v", mixed, err) + } + for _, query := range []string{"SHOW TABLE STATUS", "SHOW FULL PROCESSLIST"} { + if _, err = plaintext.Execute(query); err != nil { + t.Fatal(err) + } + } + if err = plaintext.Ping(); err != nil { + t.Fatal(err) + } + plaintext.Close() + if mode == "readwrite" { + if _, e = front.Execute("BEGIN"); e != nil { + t.Fatal(e) + } + stmt, e := front.Prepare("INSERT INTO items(id,value) VALUES (?,?)") + if e != nil { + t.Fatal(e) + } + if _, e = stmt.Execute(1, "中文😀"); e != nil { + t.Fatal(e) + } + if _, e = front.Execute("ROLLBACK"); e != nil { + t.Fatal(e) + } + if _, e = front.Execute("BEGIN"); e != nil { + t.Fatal(e) + } + if _, e = stmt.Execute(1, "中文😀"); e != nil { + t.Fatal(e) + } + if _, e = front.Execute("COMMIT"); e != nil { + t.Fatal(e) + } + _ = stmt.Close() + for _, query := range []string{"UPDATE items SET value='temporary' WHERE id=1", "UPDATE items SET value='中文😀' WHERE id=1", "INSERT INTO items VALUES(2,'delete-me')", "DELETE FROM items WHERE id=2"} { + result, err := front.Execute(query) + if err != nil { + t.Fatal(err) + } + if result.AffectedRows != 1 { + t.Fatalf("unexpected affected rows: %d", result.AffectedRows) + } + result.Close() + } + } else { + if _, e = front.Execute("DELETE FROM items"); e == nil { + t.Fatal("readonly write accepted") + } + if _, e = front.Prepare("UPDATE items SET value=?"); e == nil { + t.Fatal("readonly write prepare accepted") + } + if rejected.Load() != 2 { + t.Fatal("missing gateway rejection events") + } + } + stmt, e := front.Prepare("SELECT value FROM items WHERE id=?") + if e != nil { + t.Fatal(e) + } + defer stmt.Close() + result, e := stmt.Execute(1) + if e != nil { + t.Fatal(e) + } + value, e := result.GetString(0, 0) + if e != nil || value != "中文😀" { + t.Fatalf("result %q: %v", value, e) + } + if audited.Load() == 0 || (mode == "readonly" && blocked.Load() != 2) { + t.Fatalf("missing command audit: total=%d blocked=%d", audited.Load(), blocked.Load()) + } + t.Logf("verified %s: command audit total=%d blocked=%d", mode, audited.Load(), blocked.Load()) + }) + } + result, e := control.Execute("SELECT COUNT(*), MIN(value) FROM " + database + ".items") + if e != nil { + t.Fatal(e) + } + n, _ := result.GetInt(0, 0) + value, _ := result.GetString(0, 1) + if n != 1 || value != "中文😀" { + t.Fatal("readonly requests changed stored data") + } +} diff --git a/internal/protocol/mysqlproxy/policy.go b/internal/protocol/mysqlproxy/policy.go new file mode 100644 index 0000000..f174544 --- /dev/null +++ b/internal/protocol/mysqlproxy/policy.go @@ -0,0 +1,161 @@ +package mysqlproxy + +import ( + "errors" + "strings" + "vitess.io/vitess/go/vt/sqlparser" +) + +var errReadOnly = errors.New("request denied by read-only policy") +var functions = wordSet("count sum avg min max abs ceil ceiling floor round coalesce ifnull nullif if concat concat_ws length char_length lower upper substring trim replace cast convert date year month day now current_timestamp database schema version user current_user") + +func wordSet(s string) map[string]bool { + m := map[string]bool{} + for _, v := range strings.Fields(s) { + m[v] = true + } + return m +} + +// CheckReadOnly checks every statement before any part of a batch is forwarded. Parser +// diagnostics are deliberately not returned because they can contain SQL data. +func CheckReadOnly(query string) (err error) { + defer func() { + if recover() != nil { + err = errReadOnly + } + }() + if len(query) > 1<<20 || strings.Contains(query, "/*!") || strings.Contains(query, "/*+") { + return errReadOnly + } + pieces, e := sqlparser.NewTestParser().SplitStatementToPieces(query) + if e != nil || len(pieces) == 0 || len(pieces) > 100 { + return errReadOnly + } + for _, piece := range pieces { + if e = checkReadOnlyStatement(piece); e != nil { + return e + } + } + return nil +} +func checkReadOnlyStatement(query string) error { + normalized := strings.ToLower(strings.Join(strings.Fields(query), " ")) + if normalized == "show processlist" || normalized == "show full processlist" { + return nil + } + stmt, e := sqlparser.NewTestParser().ParseStrictDDL(query) + if e != nil { + return errReadOnly + } + if e = statementAllowed(stmt); e != nil { + return e + } + count := 0 + return sqlparser.Walk(func(node sqlparser.SQLNode) (bool, error) { + count++ + if count > 10000 { + return false, errReadOnly + } + if st, ok := node.(sqlparser.Statement); ok { + if e := statementAllowed(st); e != nil { + return false, e + } + } + switch n := node.(type) { + case *sqlparser.Select: + if n.Lock != sqlparser.NoLock || n.Into != nil { + return false, errReadOnly + } + case *sqlparser.Union: + if n.Lock != sqlparser.NoLock || n.Into != nil { + return false, errReadOnly + } + case *sqlparser.Variable: + if n.Scope == sqlparser.VariableScope { + return false, errReadOnly + } + case *sqlparser.FuncExpr: + if !n.Qualifier.IsEmpty() || !functions[strings.ToLower(n.Name.String())] { + return false, errReadOnly + } + case sqlparser.Expr: + switch n.(type) { + case *sqlparser.AndExpr, *sqlparser.OrExpr, *sqlparser.XorExpr, *sqlparser.NotExpr, *sqlparser.ComparisonExpr, *sqlparser.BetweenExpr, *sqlparser.IsExpr, *sqlparser.ExistsExpr, *sqlparser.Literal, *sqlparser.Argument, *sqlparser.NullVal, sqlparser.BoolVal, *sqlparser.ColName, sqlparser.ValTuple, *sqlparser.Subquery, sqlparser.ListArg, *sqlparser.BinaryExpr, *sqlparser.UnaryExpr, *sqlparser.IntroducerExpr, *sqlparser.CollateExpr, *sqlparser.CaseExpr, *sqlparser.CastExpr: + case *sqlparser.Count, *sqlparser.CountStar, *sqlparser.Sum, *sqlparser.Avg, *sqlparser.Min, *sqlparser.Max, *sqlparser.ConvertExpr, *sqlparser.ConvertUsingExpr, *sqlparser.SubstrExpr: + case *sqlparser.TrimFuncExpr, *sqlparser.CurTimeFuncExpr: + s := strings.ToLower(sqlparser.String(n)) + i := strings.IndexByte(s, '(') + if i < 0 || !functions[s[:i]] { + return false, errReadOnly + } + default: + return false, errReadOnly + } + } + return true, nil + }, stmt) +} + +func statementAllowed(stmt sqlparser.Statement) error { + switch n := stmt.(type) { + case *sqlparser.Select, *sqlparser.Union, *sqlparser.Commit, *sqlparser.Rollback, *sqlparser.ExplainTab: + return nil + case *sqlparser.Begin: + for _, m := range n.TxAccessModes { + if m == sqlparser.ReadWrite { + return errReadOnly + } + } + return nil + case *sqlparser.ExplainStmt: + if n.Type == sqlparser.AnalyzeType { + return errReadOnly + } + switch n.Statement.(type) { + case *sqlparser.Select, *sqlparser.Union: + return nil + } + return errReadOnly + case *sqlparser.Show: + switch s := n.Internal.(type) { + case *sqlparser.ShowBasic: + switch s.Command { + case sqlparser.Database, sqlparser.Table, sqlparser.TableStatus, sqlparser.Column, sqlparser.Index, sqlparser.VariableGlobal, sqlparser.VariableSession, sqlparser.StatusGlobal, sqlparser.StatusSession: + return nil + } + case *sqlparser.ShowCreate: + if s.Command == sqlparser.CreateTbl || s.Command == sqlparser.CreateV { + return nil + } + } + return errReadOnly + case *sqlparser.Set: + for _, s := range n.Exprs { + if s.Var.Scope != sqlparser.NoScope && s.Var.Scope != sqlparser.SessionScope { + return errReadOnly + } + name := strings.ToLower(s.Var.Name.String()) + switch name { + case "autocommit": + if sqlparser.String(s.Expr) != "0" && sqlparser.String(s.Expr) != "1" { + return errReadOnly + } + case "names": + switch v := s.Expr.(type) { + case *sqlparser.Literal: + if !wordSet("utf8mb4 utf8 utf8mb3 ascii")[strings.ToLower(v.Val)] { + return errReadOnly + } + default: + return errReadOnly + } + default: + return errReadOnly + } + } + return nil + default: + return errReadOnly + } +} diff --git a/internal/protocol/mysqlproxy/policy_test.go b/internal/protocol/mysqlproxy/policy_test.go new file mode 100644 index 0000000..12511cb --- /dev/null +++ b/internal/protocol/mysqlproxy/policy_test.go @@ -0,0 +1,39 @@ +package mysqlproxy + +import "testing" + +func TestReadOnlyPolicy(t *testing.T) { + for _, q := range []string{"SHOW FULL PROCESSLIST", "SHOW PROCESSLIST", "SHOW VARIABLES LIKE 'lower_case_%'; SHOW VARIABLES LIKE 'sql_mode'; SELECT COUNT(*) AS support_ndb FROM information_schema.ENGINES WHERE Engine = 'ndbcluster'", "SHOW TABLE STATUS", "SHOW TABLE STATUS FROM app", "SHOW TABLE STATUS IN `app` LIKE 'user%'", "SHOW TABLE STATUS WHERE Name = 'users'"} { + if err := CheckReadOnly(q); err != nil { + t.Errorf("unexpected deny %s: %v", q, err) + } + } + for _, q := range []string{"SHOW TABLE STATUS; DELETE FROM t", "SHOW TABLE STATUS WHERE sleep(1)=0"} { + if err := CheckReadOnly(q); err == nil { + t.Errorf("unexpected allow %s", q) + } + } + allowed := []string{"select 1", "select 'delete from x'", "select count(*), max(id) from t", "select * from a union select * from b", "with c as (select 1 as n) select n from c", "select @@version", "show databases", "show tables", "show columns from t", "show index from t", "show create table t", "describe t", "explain select 1", "begin", "start transaction read only", "commit", "rollback", "set autocommit=0", "set names 'utf8mb4'", "select cast(1 as char)", "select substring(name,1,2), now() from t"} + for _, q := range allowed { + t.Run(q, func(t *testing.T) { + if e := CheckReadOnly(q); e != nil { + t.Errorf("unexpected deny: %v", e) + } + }) + } + denied := []string{"insert into t values(1)", "update t set x=2", "delete from t", "create table t(id int)", "call proc()", "select 1; delete from t", "select * from t for update", "select * from t lock in share mode", "select 1 into outfile '/tmp/x'", "select @x := 1", "select @x", "select db.custom()", "select sleep(1)", "select get_lock('x',1)", "select load_file('/tmp/a')", "select last_insert_id(1)", "select 1 /*! into outfile '/tmp/x' */", "select /*+ SET_VAR(sort_buffer_size=1) */ 1", "explain analyze select 1", "start transaction read write", "set global autocommit=1", "set sql_mode='NO_BACKSLASH_ESCAPES'", "set names gbk", "set @x=1", "prepare x from 'select 1'", "with c as (select sleep(1)) select * from c", "select 1 from t where exists(select db.custom())", "select uuid()", "garbage"} + for _, q := range denied { + t.Run(q, func(t *testing.T) { + if e := CheckReadOnly(q); e == nil { + t.Error("unexpected allow") + } + }) + } +} + +func FuzzReadOnly(f *testing.F) { + for _, s := range []string{"select 1", "select @x:=1", "with c as (select 1) select * from c", "/*! select 1 */"} { + f.Add(s) + } + f.Fuzz(func(t *testing.T, s string) { _ = CheckReadOnly(s) }) +} diff --git a/internal/protocol/pgproxy/postgres.go b/internal/protocol/pgproxy/postgres.go new file mode 100644 index 0000000..361cb22 --- /dev/null +++ b/internal/protocol/pgproxy/postgres.go @@ -0,0 +1,209 @@ +package pgproxy + +import ( + "agentdb/internal/model" + "agentdb/internal/protocol/transport" + "context" + "crypto/subtle" + "crypto/tls" + "errors" + "fmt" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgproto3" + "net" + "strings" + "time" +) + +type Handler struct{} + +func New() *Handler { return &Handler{} } +func open(ctx context.Context, p model.Proxy, dial model.DialFunc, database string) (*pgconn.PgConn, error) { + cfg, err := pgconn.ParseConfig("postgres://localhost/postgres?sslmode=disable") + if err != nil { + return nil, err + } + cfg.Host, cfg.Port, cfg.User, cfg.Password = p.TargetHost, uint16(p.TargetPort), p.TargetUser, p.TargetPassword + cfg.Database = database + if cfg.Database == "" { + cfg.Database = p.AuthSource + } + if cfg.Database == "" { + cfg.Database = "postgres" + } + cfg.TLSConfig, err = transport.TLSConfig(p) + if err != nil { + return nil, err + } + cfg.Fallbacks = nil + cfg.ConnectTimeout = 15 * time.Second + cfg.DialFunc = pgconn.DialFunc(dial) + // Keep DNS resolution on the configured proxy/jump host. + cfg.LookupFunc = func(_ context.Context, host string) ([]string, error) { return []string{host}, nil } + cfg.RuntimeParams = map[string]string{"application_name": "AgentDB", "client_encoding": "UTF8"} + return pgconn.ConnectConfig(ctx, cfg) +} +func (h *Handler) Test(ctx context.Context, p model.Proxy, dial model.DialFunc) (model.TestResult, error) { + c, err := open(ctx, p, dial, "") + if err != nil { + return model.TestResult{}, err + } + defer c.Close(context.Background()) + results, err := c.Exec(ctx, "SELECT 1").ReadAll() + if err != nil || len(results) != 1 { + return model.TestResult{}, errors.New("PostgreSQL query test failed") + } + return model.TestResult{Version: c.ParameterStatus("server_version"), TLS: p.TargetTLS, Compression: "none"}, nil +} +func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial model.DialFunc, tc *tls.Config, event model.EventFunc) error { + defer raw.Close() + stop := context.AfterFunc(ctx, func() { raw.Close() }) + defer stop() + raw.SetDeadline(time.Now().Add(20 * time.Second)) + var c net.Conn = raw + backend := pgproto3.NewBackend(c, c) + backend.SetMaxBodyLen(32 << 20) + var startup *pgproto3.StartupMessage + for tries := 0; tries < 3; tries++ { + msg, err := backend.ReceiveStartupMessage() + if err != nil { + return err + } + switch m := msg.(type) { + case *pgproto3.SSLRequest: + if tc == nil { + if err = transport.Write(c, []byte("N")); err != nil { + return err + } + continue + } + if _, already := c.(*tls.Conn); already { + return errors.New("duplicate PostgreSQL SSL request") + } + if err = transport.Write(c, []byte("S")); err != nil { + return err + } + cfg := tc.Clone() + cfg.MinVersion = tls.VersionTLS12 + secure := tls.Server(c, cfg) + if err = secure.HandshakeContext(ctx); err != nil { + return err + } + c = secure + backend = pgproto3.NewBackend(c, c) + backend.SetMaxBodyLen(32 << 20) + case *pgproto3.GSSEncRequest: + if err = transport.Write(c, []byte("N")); err != nil { + return err + } + case *pgproto3.StartupMessage: + startup = m + default: + return errors.New("unsupported PostgreSQL startup request") + } + if startup != nil { + break + } + } + if startup == nil { + return errors.New("missing PostgreSQL startup") + } + fail := func(message string) error { + backend.Send(&pgproto3.ErrorResponse{Severity: "FATAL", Code: "28000", Message: message}) + backend.Flush() + return errors.New(message) + } + if startup.Parameters["user"] != p.GatewayUser { + return fail("gateway authentication failed") + } + backend.SetAuthType(pgproto3.AuthTypeCleartextPassword) + backend.Send(&pgproto3.AuthenticationCleartextPassword{}) + if err := backend.Flush(); err != nil { + return err + } + msg, err := backend.Receive() + if err != nil { + return err + } + password, ok := msg.(*pgproto3.PasswordMessage) + if !ok || subtle.ConstantTimeCompare([]byte(password.Password), []byte(p.GatewayPassword)) != 1 { + return fail("gateway authentication failed") + } + upstream, err := open(ctx, p, dial, startup.Parameters["database"]) + if err != nil { + return fail("target connection or authentication failed") + } + if err = upstream.SyncConn(ctx); err != nil { + upstream.Close(ctx) + return err + } + connection, err := upstream.Hijack() + if err != nil { + upstream.Close(ctx) + return err + } + defer connection.Conn.Close() + stopUp := context.AfterFunc(ctx, func() { connection.Conn.Close() }) + defer stopUp() + backend.Send(&pgproto3.AuthenticationOk{}) + for name, value := range connection.ParameterStatuses { + backend.Send(&pgproto3.ParameterStatus{Name: name, Value: value}) + } + backend.Send(&pgproto3.ReadyForQuery{TxStatus: connection.TxStatus}) + if err = backend.Flush(); err != nil { + return err + } + raw.SetDeadline(time.Time{}) + connection.Conn.SetDeadline(time.Time{}) + done := make(chan error, 1) + go func() { + for { + message, e := connection.Frontend.Receive() + if e != nil { + done <- e + raw.Close() + return + } + backend.Send(message) + if e = backend.Flush(); e != nil { + done <- e + raw.Close() + return + } + } + }() + for { + message, err := backend.Receive() + if err != nil { + return err + } + kind, text := "", "" + switch m := message.(type) { + case *pgproto3.Query: + kind, text = "query", m.String + case *pgproto3.Parse: + kind, text = "prepare", m.Query + case *pgproto3.Bind: + kind, text = "bind", fmt.Sprintf("statement=%s portal=%s parameters=%d", m.PreparedStatement, m.DestinationPortal, len(m.Parameters)) + case *pgproto3.Execute: + kind, text = "execute", fmt.Sprintf("portal=%s maxRows=%d", m.Portal, m.MaxRows) + case *pgproto3.Terminate: + kind, text = "quit", "terminate" + case *pgproto3.CopyData: + kind, text = "copy_data", fmt.Sprintf("bytes=%d", len(m.Data)) + default: + kind = strings.TrimPrefix(fmt.Sprintf("%T", message), "*pgproto3.") + text = kind + } + if err = model.AuditCommand(ctx, kind, text, "allowed"); err != nil { + return err + } + connection.Frontend.Send(message) + if err = connection.Frontend.Flush(); err != nil { + return err + } + if _, ok := message.(*pgproto3.Terminate); ok { + return nil + } + } +} diff --git a/internal/protocol/pgproxy/postgres_test.go b/internal/protocol/pgproxy/postgres_test.go new file mode 100644 index 0000000..8c85c3a --- /dev/null +++ b/internal/protocol/pgproxy/postgres_test.go @@ -0,0 +1,148 @@ +package pgproxy + +import ( + "agentdb/internal/model" + "context" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgproto3" + "net" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +func TestPostgresAuthenticationSimpleExtendedAndAudit(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + dial := func(_ context.Context, _, _ string) (net.Conn, error) { + a, b := net.Pipe() + go func() { + defer b.Close() + b.SetDeadline(time.Now().Add(5 * time.Second)) + backend := pgproto3.NewBackend(b, b) + msg, err := backend.ReceiveStartupMessage() + if err != nil { + return + } + startup, ok := msg.(*pgproto3.StartupMessage) + if !ok || startup.Parameters["user"] != "target" { + return + } + backend.SetAuthType(pgproto3.AuthTypeCleartextPassword) + backend.Send(&pgproto3.AuthenticationCleartextPassword{}) + backend.Flush() + m, e := backend.Receive() + if e != nil { + return + } + pass, ok := m.(*pgproto3.PasswordMessage) + if !ok || pass.Password != "target-secret" { + return + } + backend.Send(&pgproto3.AuthenticationOk{}) + backend.Send(&pgproto3.ParameterStatus{Name: "server_version", Value: "16.0"}) + backend.Send(&pgproto3.ParameterStatus{Name: "client_encoding", Value: "UTF8"}) + backend.Send(&pgproto3.ReadyForQuery{TxStatus: 'I'}) + backend.Flush() + row := func() { + backend.Send(&pgproto3.RowDescription{Fields: []pgproto3.FieldDescription{{Name: []byte("n"), DataTypeOID: 23, DataTypeSize: 4, TypeModifier: -1}}}) + } + for { + m, e = backend.Receive() + if e != nil { + return + } + switch m.(type) { + case *pgproto3.Query: + row() + backend.Send(&pgproto3.DataRow{Values: [][]byte{[]byte("42")}}) + backend.Send(&pgproto3.CommandComplete{CommandTag: []byte("SELECT 1")}) + backend.Send(&pgproto3.ReadyForQuery{TxStatus: 'I'}) + case *pgproto3.Parse: + backend.Send(&pgproto3.ParseComplete{}) + case *pgproto3.Bind: + backend.Send(&pgproto3.BindComplete{}) + case *pgproto3.Describe: + row() + case *pgproto3.Execute: + backend.Send(&pgproto3.DataRow{Values: [][]byte{[]byte("42")}}) + backend.Send(&pgproto3.CommandComplete{CommandTag: []byte("SELECT 1")}) + case *pgproto3.Sync: + backend.Send(&pgproto3.ReadyForQuery{TxStatus: 'I'}) + case *pgproto3.Terminate: + return + } + if backend.Flush() != nil { + return + } + } + }() + return a, nil + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + p := model.Proxy{TargetHost: "pg.internal", TargetPort: 5432, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AuthSource: "postgres"} + var mu sync.Mutex + var records []model.Command + auditCtx := model.WithCommandAudit(ctx, func(c model.Command) error { mu.Lock(); records = append(records, c); mu.Unlock(); return nil }) + go func() { + for { + c, e := listener.Accept() + if e != nil { + return + } + go New().Serve(auditCtx, c, p, dial, nil, nil) + } + }() + connect := func(password string) (*pgconn.PgConn, error) { + cfg, _ := pgconn.ParseConfig("postgres://gateway@localhost/postgres?sslmode=disable") + host, port, _ := net.SplitHostPort(listener.Addr().String()) + n, _ := strconv.Atoi(port) + cfg.Host, cfg.Port, cfg.Password = host, uint16(n), password + return pgconn.ConnectConfig(ctx, cfg) + } + c, err := connect("gateway-secret") + if err != nil { + t.Fatal(err) + } + defer c.Close(ctx) + results, err := c.Exec(ctx, "SELECT 42").ReadAll() + if err != nil || len(results) != 1 || string(results[0].Rows[0][0]) != "42" { + t.Fatal("simple query", err) + } + extended := c.ExecParams(ctx, "SELECT $1", [][]byte{[]byte("private-parameter")}, nil, nil, nil).Read() + if extended.Err != nil { + t.Fatal(extended.Err) + } + bad, e := connect("wrong") + if e == nil { + bad.Close(ctx) + t.Fatal("wrong gateway password accepted") + } + testResult, err := New().Test(ctx, p, dial) + if err != nil || testResult.Version != "16.0" { + t.Fatal(testResult, err) + } + mu.Lock() + defer mu.Unlock() + query, prepare := false, false + for _, r := range records { + if r.Kind == "query" { + query = true + } + if r.Kind == "prepare" { + prepare = true + } + if strings.Contains(r.Text, "secret") || strings.Contains(r.Text, "private-parameter") { + t.Fatal("secret in audit") + } + } + if !query || !prepare { + t.Fatal("missing SQL audit") + } +} diff --git a/internal/protocol/rabbitproxy/rabbit.go b/internal/protocol/rabbitproxy/rabbit.go new file mode 100644 index 0000000..137564b --- /dev/null +++ b/internal/protocol/rabbitproxy/rabbit.go @@ -0,0 +1,315 @@ +package rabbitproxy + +import ( + "agentdb/internal/model" + "agentdb/internal/protocol/transport" + "context" + "crypto/subtle" + "crypto/tls" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "strings" + "time" +) + +type Handler struct{} + +func New() *Handler { return &Handler{} } + +var protocolHeader = []byte{'A', 'M', 'Q', 'P', 0, 0, 9, 1} + +type frame struct { + kind byte + channel uint16 + body []byte +} + +func read(c net.Conn) (frame, error) { + var h [7]byte + if _, err := io.ReadFull(c, h[:]); err != nil { + return frame{}, err + } + n := binary.BigEndian.Uint32(h[3:]) + if n > 16<<20 { + return frame{}, errors.New("AMQP frame too large") + } + b := make([]byte, int(n)+1) + if _, err := io.ReadFull(c, b); err != nil { + return frame{}, err + } + if b[n] != 206 { + return frame{}, errors.New("invalid AMQP frame ending") + } + if h[0] != 1 && h[0] != 2 && h[0] != 3 && h[0] != 8 { + return frame{}, errors.New("invalid AMQP frame type") + } + return frame{h[0], binary.BigEndian.Uint16(h[1:3]), b[:n]}, nil +} +func write(c net.Conn, f frame) error { + b := []byte{f.kind, byte(f.channel >> 8), byte(f.channel)} + b = binary.BigEndian.AppendUint32(b, uint32(len(f.body))) + b = append(b, f.body...) + b = append(b, 206) + return transport.Write(c, b) +} +func method(f frame, class, method uint16) bool { + return f.kind == 1 && len(f.body) >= 4 && binary.BigEndian.Uint16(f.body) == class && binary.BigEndian.Uint16(f.body[2:]) == method +} +func long(b []byte) ([]byte, []byte, error) { + if len(b) < 4 { + return nil, nil, io.ErrUnexpectedEOF + } + n := int(binary.BigEndian.Uint32(b)) + if n > len(b)-4 { + return nil, nil, io.ErrUnexpectedEOF + } + return b[4 : 4+n], b[4+n:], nil +} +func short(b []byte) (string, []byte, error) { + if len(b) < 1 || int(b[0]) > len(b)-1 { + return "", nil, io.ErrUnexpectedEOF + } + return string(b[1 : 1+int(b[0])]), b[1+int(b[0]):], nil +} +func addLong(b, data []byte) []byte { + b = binary.BigEndian.AppendUint32(b, uint32(len(data))) + return append(b, data...) +} +func startOK(user, pass string) frame { + b := []byte{0, 10, 0, 11, 0, 0, 0, 0, 5, 'P', 'L', 'A', 'I', 'N'} + b = addLong(b, []byte("\x00"+user+"\x00"+pass)) + b = append(b, 5, 'e', 'n', '_', 'U', 'S') + return frame{1, 0, b} +} +func start(c net.Conn) (frame, error) { + if err := transport.Write(c, protocolHeader); err != nil { + return frame{}, err + } + f, err := read(c) + if err != nil { + return f, err + } + if !method(f, 10, 10) || len(f.body) < 6 { + return f, errors.New("AMQP connection.start expected") + } + _, rest, err := long(f.body[6:]) + if err != nil { + return f, err + } + mechanisms, _, err := long(rest) + if err != nil { + return f, err + } + if !strings.Contains(" "+string(mechanisms)+" ", " PLAIN ") { + return f, errors.New("AMQP target requires unsupported authentication") + } + return f, nil +} +func credentials(f frame) (string, string, error) { + if !method(f, 10, 11) { + return "", "", errors.New("AMQP start-ok expected") + } + _, rest, err := long(f.body[4:]) + if err != nil { + return "", "", err + } + mechanism, rest, err := short(rest) + if err != nil || mechanism != "PLAIN" { + return "", "", errors.New("AMQP PLAIN required") + } + response, rest, err := long(rest) + if err != nil { + return "", "", err + } + _, rest, err = short(rest) + if err != nil || len(rest) != 0 { + return "", "", errors.New("invalid AMQP locale") + } + parts := strings.Split(string(response), "\x00") + if len(parts) != 3 || parts[0] != "" { + return "", "", errors.New("invalid AMQP credentials") + } + return parts[1], parts[2], nil +} +func target(ctx context.Context, p model.Proxy, dial model.DialFunc) (net.Conn, frame, error) { + c, err := transport.Dial(ctx, p, dial) + if err != nil { + return nil, frame{}, err + } + c.SetDeadline(time.Now().Add(15 * time.Second)) + f, err := start(c) + if err != nil { + c.Close() + return nil, f, err + } + return c, f, nil +} +func (h *Handler) Test(ctx context.Context, p model.Proxy, dial model.DialFunc) (model.TestResult, error) { + c, _, err := target(ctx, p, dial) + if err != nil { + return model.TestResult{}, err + } + defer c.Close() + stop := context.AfterFunc(ctx, func() { c.Close() }) + defer stop() + if err = write(c, startOK(p.TargetUser, p.TargetPassword)); err != nil { + return model.TestResult{}, err + } + tune, err := read(c) + if err != nil || !method(tune, 10, 30) { + return model.TestResult{}, errors.New("AMQP target authentication failed") + } + tune.body[3] = 31 + if err = write(c, tune); err != nil { + return model.TestResult{}, err + } + vhost := p.AuthSource + if vhost == "" { + vhost = "/" + } + if len(vhost) > 255 { + return model.TestResult{}, errors.New("AMQP virtual host too long") + } + body := append([]byte{0, 10, 0, 40, byte(len(vhost))}, vhost...) + body = append(body, 0, 0) + write(c, frame{1, 0, body}) + opened, err := read(c) + if err != nil || !method(opened, 10, 41) { + return model.TestResult{}, errors.New("AMQP virtual host unavailable") + } + return model.TestResult{Version: "AMQP 0-9-1", TLS: p.TargetTLS, Compression: "none", Role: vhost}, nil +} +func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial model.DialFunc, tc *tls.Config, event model.EventFunc) error { + defer raw.Close() + stop := context.AfterFunc(ctx, func() { raw.Close() }) + defer stop() + c, err := transport.Accept(ctx, raw, tc) + if err != nil { + return err + } + header := make([]byte, 8) + if _, err = io.ReadFull(c, header); err != nil { + return err + } + if string(header) != string(protocolHeader) { + return errors.New("AMQP 0-9-1 required") + } + up, greeting, err := target(ctx, p, dial) + if err != nil { + return err + } + defer up.Close() + stopUp := context.AfterFunc(ctx, func() { up.Close() }) + defer stopUp() + // Restrict the downstream SASL choice to the authentication method we terminate. + properties, rest, err := long(greeting.body[6:]) + if err != nil { + return err + } + _, locales, err := long(rest) + if err != nil { + return err + } + greeting.body = addLong([]byte{0, 10, 0, 10, 0, 9}, properties) + greeting.body = addLong(greeting.body, []byte("PLAIN")) + greeting.body = append(greeting.body, locales...) + if err = write(c, greeting); err != nil { + return err + } + auth, err := read(c) + if err != nil { + return err + } + user, pass, err := credentials(auth) + if err != nil || subtle.ConstantTimeCompare([]byte(user), []byte(p.GatewayUser)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(p.GatewayPassword)) != 1 { + return errors.New("AMQP gateway authentication failed") + } + if err = write(up, startOK(p.TargetUser, p.TargetPassword)); err != nil { + return err + } + tune, err := read(up) + if err != nil { + return err + } + if !method(tune, 10, 30) { + return errors.New("AMQP target authentication failed") + } + if err = write(c, tune); err != nil { + return err + } + accepted, err := read(c) + if err != nil || !method(accepted, 10, 31) { + return errors.New("AMQP tune-ok expected") + } + if err = write(up, accepted); err != nil { + return err + } + opened, err := read(c) + if err != nil || !method(opened, 10, 40) { + return errors.New("AMQP open expected") + } + vhost, _, err := short(opened.body[4:]) + if err != nil { + return err + } + if err = model.AuditCommand(ctx, "connection.open", vhost, "allowed"); err != nil { + return err + } + if err = write(up, opened); err != nil { + return err + } + response, err := read(up) + if err != nil { + return err + } + if err = write(c, response); err != nil { + return err + } + if !method(response, 10, 41) { + return errors.New("AMQP virtual host unavailable") + } + raw.SetDeadline(time.Time{}) + up.SetDeadline(time.Time{}) + go func() { + defer raw.Close() + for { + f, e := read(up) + if e != nil { + return + } + if write(c, f) != nil { + return + } + } + }() + for { + f, err := read(c) + if err != nil { + return err + } + if f.kind == 1 { + if len(f.body) < 4 { + return errors.New("invalid AMQP method") + } + class, methodID := binary.BigEndian.Uint16(f.body), binary.BigEndian.Uint16(f.body[2:]) + kind := fmt.Sprintf("method.%d.%d", class, methodID) + names := map[string]string{"method.50.10": "queue.declare", "method.50.20": "queue.bind", "method.50.40": "queue.delete", "method.40.10": "exchange.declare", "method.60.40": "basic.publish", "method.60.20": "basic.consume", "method.60.70": "basic.get", "method.60.80": "basic.ack", "method.60.90": "basic.reject", "method.60.120": "basic.nack", "method.85.10": "confirm.select", "method.90.10": "tx.select", "method.90.20": "tx.commit", "method.90.30": "tx.rollback"} + if name := names[kind]; name != "" { + kind = name + } + if class == 10 && (methodID == 11 || methodID == 70) { + return errors.New("AMQP authentication changes are unsupported") + } + detail := fmt.Sprintf("channel=%d method=%d.%d bytes=%d", f.channel, class, methodID, len(f.body)) + if err = model.AuditCommand(ctx, kind, detail, "allowed"); err != nil { + return err + } + } + if err = write(up, f); err != nil { + return err + } + } +} diff --git a/internal/protocol/rabbitproxy/rabbit_test.go b/internal/protocol/rabbitproxy/rabbit_test.go new file mode 100644 index 0000000..856c159 --- /dev/null +++ b/internal/protocol/rabbitproxy/rabbit_test.go @@ -0,0 +1,162 @@ +package rabbitproxy + +import ( + "agentdb/internal/model" + "context" + "encoding/binary" + amqp "github.com/rabbitmq/amqp091-go" + "io" + "net" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +func TestAMQPClientAuthenticationPublishAndAudit(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + received := make(chan string, 1) + auths := make(chan string, 5) + dial := func(_ context.Context, _, _ string) (net.Conn, error) { + a, b := net.Pipe() + go func() { + defer b.Close() + b.SetDeadline(time.Now().Add(5 * time.Second)) + header := make([]byte, 8) + io.ReadFull(b, header) + greeting := addLong([]byte{0, 10, 0, 10, 0, 9}, nil) + greeting = addLong(greeting, []byte("PLAIN")) + greeting = addLong(greeting, []byte("en_US")) + write(b, frame{1, 0, greeting}) + f, e := read(b) + if e != nil { + return + } + u, p, e := credentials(f) + if e != nil { + return + } + auths <- u + ":" + p + tune := []byte{0, 10, 0, 30, 0, 0, 0, 2, 0, 0, 0, 0} + write(b, frame{1, 0, tune}) + if _, e = read(b); e != nil { + return + } + if _, e = read(b); e != nil { + return + } + write(b, frame{1, 0, []byte{0, 10, 0, 41, 0}}) + for { + f, e = read(b) + if e != nil { + return + } + if f.kind == 3 { + received <- string(f.body) + continue + } + if f.kind != 1 { + continue + } + switch { + case method(f, 20, 10): + write(b, frame{1, f.channel, []byte{0, 20, 0, 11, 0, 0, 0, 0}}) + case method(f, 50, 10): + body := append([]byte{0, 50, 0, 11, 5}, []byte("queue")...) + body = append(body, make([]byte, 8)...) + write(b, frame{1, f.channel, body}) + case method(f, 20, 40): + write(b, frame{1, f.channel, []byte{0, 20, 0, 41}}) + case method(f, 10, 50): + write(b, frame{1, 0, []byte{0, 10, 0, 51}}) + return + } + } + }() + return a, nil + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + p := model.Proxy{TargetHost: "broker.internal", TargetPort: 5672, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret"} + var mu sync.Mutex + var audits []model.Command + auditCtx := model.WithCommandAudit(ctx, func(c model.Command) error { mu.Lock(); defer mu.Unlock(); audits = append(audits, c); return nil }) + go func() { + for { + c, e := listener.Accept() + if e != nil { + return + } + go New().Serve(auditCtx, c, p, dial, nil, nil) + } + }() + conn, err := amqp.DialConfig("amqp://gateway:gateway-secret@"+listener.Addr().String()+"/", amqp.Config{Heartbeat: time.Second, Locale: "en_US"}) + if err != nil { + t.Fatal(err) + } + ch, err := conn.Channel() + if err != nil { + t.Fatal(err) + } + if _, err = ch.QueueDeclare("queue", false, false, false, false, nil); err != nil { + t.Fatal(err) + } + if err = ch.PublishWithContext(ctx, "", "queue", false, false, amqp.Publishing{Body: []byte("private-message")}); err != nil { + t.Fatal(err) + } + select { + case body := <-received: + if body != "private-message" { + t.Fatal(body) + } + case <-time.After(3 * time.Second): + t.Fatal("message not forwarded") + } + if got := <-auths; got != "target:target-secret" { + t.Fatal("credentials were not mapped") + } + ch.Close() + conn.Close() + mu.Lock() + joined := "" + publish := false + for _, a := range audits { + joined += a.Text + if a.Kind == "basic.publish" { + publish = true + } + } + mu.Unlock() + if !publish || strings.Contains(joined, "secret") || strings.Contains(joined, "private-message") { + t.Fatal("invalid audit content") + } + bad, e := amqp.Dial("amqp://gateway:wrong@" + listener.Addr().String() + "/") + if e == nil { + bad.Close() + t.Fatal("bad credentials accepted") + } + p.AuthSource = "/" + if result, e := New().Test(ctx, p, dial); e != nil || result.Version != "AMQP 0-9-1" { + t.Fatal(result, e) + } +} +func TestFrameBounds(t *testing.T) { + a, b := net.Pipe() + defer a.Close() + defer b.Close() + go func() { + header := make([]byte, 7) + header[0] = 1 + binary.BigEndian.PutUint32(header[3:], 17<<20) + b.Write(header) + }() + if _, err := read(a); err == nil { + t.Fatal("oversize frame accepted") + } + _ = strconv.IntSize +} diff --git a/internal/protocol/redisproxy/redis.go b/internal/protocol/redisproxy/redis.go new file mode 100644 index 0000000..9c1f1e1 --- /dev/null +++ b/internal/protocol/redisproxy/redis.go @@ -0,0 +1,282 @@ +package redisproxy + +import ( + "agentdb/internal/model" + "agentdb/internal/protocol/transport" + "bufio" + "context" + "crypto/subtle" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "time" +) + +type Handler struct{} + +func New() *Handler { return &Handler{} } +func line(r *bufio.Reader) (string, error) { + var out []byte + for { + part, more, err := r.ReadLine() + if err != nil { + return "", err + } + out = append(out, part...) + if len(out) > 65536 { + return "", errors.New("Redis line too long") + } + if !more { + return string(out), nil + } + } +} +func request(r *bufio.Reader) ([]string, error) { + head, err := line(r) + if err != nil { + return nil, err + } + if len(head) == 0 { + return nil, errors.New("empty Redis request") + } + if head[0] != '*' { + return nil, errors.New("Redis requires RESP array commands") + } + count, err := strconv.Atoi(head[1:]) + if err != nil || count < 1 || count > 4096 { + return nil, errors.New("invalid Redis array") + } + args := make([]string, count) + total := 0 + for i := range args { + size, err := line(r) + if err != nil { + return nil, err + } + if len(size) < 2 || size[0] != '$' { + return nil, errors.New("Redis requires bulk arguments") + } + n, err := strconv.Atoi(size[1:]) + if err != nil || n < 0 || n > 16<<20 { + return nil, errors.New("invalid Redis argument length") + } + total += n + if total > 32<<20 { + return nil, errors.New("Redis command too large") + } + body := make([]byte, n+2) + if _, err = io.ReadFull(r, body); err != nil { + return nil, err + } + if string(body[n:]) != "\r\n" { + return nil, errors.New("invalid Redis argument ending") + } + args[i] = string(body[:n]) + } + return args, nil +} +func encode(args []string) []byte { + var b strings.Builder + fmt.Fprintf(&b, "*%d\r\n", len(args)) + for _, arg := range args { + fmt.Fprintf(&b, "$%d\r\n%s\r\n", len(arg), arg) + } + return []byte(b.String()) +} + +func targetAuth(p model.Proxy) bool { + return p.TargetPassword != "" || (p.TargetUser != "" && p.TargetUser != "default") +} + +func open(ctx context.Context, p model.Proxy, dial model.DialFunc) (net.Conn, error) { + c, err := transport.Dial(ctx, p, dial) + if err != nil { + return nil, err + } + c.SetDeadline(time.Now().Add(15 * time.Second)) + stop := context.AfterFunc(ctx, func() { c.Close() }) + defer stop() + if targetAuth(p) { + args := []string{"AUTH", p.TargetPassword} + if p.TargetUser != "" && p.TargetUser != "default" { + args = []string{"AUTH", p.TargetUser, p.TargetPassword} + } + if err = transport.Write(c, encode(args)); err != nil { + c.Close() + return nil, err + } + reader := bufio.NewReader(c) + reply, err := line(reader) + if err != nil || reply != "+OK" { + c.Close() + return nil, errors.New("Redis target authentication failed") + } + // Preserve any response bytes read ahead together with AUTH's reply. + return &transport.BufferedConn{Conn: c, Reader: reader}, nil + } + return c, nil +} +func (h *Handler) Test(ctx context.Context, p model.Proxy, dial model.DialFunc) (model.TestResult, error) { + c, err := open(ctx, p, dial) + if err != nil { + return model.TestResult{}, err + } + defer c.Close() + stop := context.AfterFunc(ctx, func() { c.Close() }) + defer stop() + transport.Write(c, encode([]string{"PING"})) + r := bufio.NewReader(c) + pong, err := line(r) + if err != nil || pong != "+PONG" { + return model.TestResult{}, errors.New("Redis PING failed") + } + result := model.TestResult{TLS: p.TargetTLS, Compression: "none"} + if transport.Write(c, encode([]string{"INFO", "server"})) == nil { + head, e := line(r) + if e == nil && strings.HasPrefix(head, "$") { + n, e := strconv.Atoi(head[1:]) + if e == nil && n >= 0 && n < 1<<20 { + data := make([]byte, n+2) + if _, e = io.ReadFull(r, data); e == nil { + for _, row := range strings.Split(string(data), "\r\n") { + if strings.HasPrefix(row, "redis_version:") { + result.Version = strings.TrimPrefix(row, "redis_version:") + } + } + } + } + } + } + return result, nil +} +func auth(args []string, p model.Proxy) (bool, bool) { + if len(args) == 0 { + return false, false + } + name := strings.ToUpper(args[0]) + user, pass := "", "" + found := false + if name == "AUTH" { + found = true + if len(args) == 2 { + user, pass = p.GatewayUser, args[1] + } else if len(args) == 3 { + user, pass = args[1], args[2] + } + } + if name == "HELLO" { + for i := 2; i < len(args); { + switch strings.ToUpper(args[i]) { + case "AUTH": + if found || i+2 >= len(args) { + return true, false + } + found = true + user, pass = args[i+1], args[i+2] + i += 3 + case "SETNAME": + if i+1 >= len(args) { + return true, false + } + i += 2 + default: + return true, false + } + } + } + return found, found && subtle.ConstantTimeCompare([]byte(user), []byte(p.GatewayUser)) == 1 && subtle.ConstantTimeCompare([]byte(pass), []byte(p.GatewayPassword)) == 1 +} +func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial model.DialFunc, tc *tls.Config, event model.EventFunc) error { + defer raw.Close() + stop := context.AfterFunc(ctx, func() { raw.Close() }) + defer stop() + c, err := transport.Accept(ctx, raw, tc) + if err != nil { + return err + } + reader := bufio.NewReader(c) + out := &transport.LockedWriter{Writer: c} + var upstream net.Conn + defer func() { + if upstream != nil { + upstream.Close() + } + }() + for { + args, err := request(reader) + if err != nil { + return err + } + name := strings.ToUpper(args[0]) + found, valid := auth(args, p) + if found && !valid { + transport.Write(out, []byte("-WRONGPASS invalid username-password pair\r\n")) + return errors.New("Redis gateway authentication failed") + } + if upstream == nil { + if !valid { + transport.Write(out, []byte("-NOAUTH Authentication required\r\n")) + continue + } + upstream, err = open(ctx, p, dial) + if err != nil { + transport.Write(out, []byte("-ERR target connection failed\r\n")) + return err + } + upstream.SetDeadline(time.Time{}) + raw.SetDeadline(time.Time{}) + go func() { io.Copy(out, upstream); raw.Close() }() + if name == "AUTH" { + if err = transport.Write(out, []byte("+OK\r\n")); err != nil { + return err + } + continue + } + } + if name == "AUTH" { + args = []string{"PING"} + if targetAuth(p) { + args = []string{"AUTH", p.TargetPassword} + if p.TargetUser != "" && p.TargetUser != "default" { + args = []string{"AUTH", p.TargetUser, p.TargetPassword} + } + } else { + args = []string{"ECHO", "OK"} + } + } + if name == "HELLO" && found { + for i := 2; i+2 < len(args); i += 2 { + if strings.EqualFold(args[i], "AUTH") { + if !targetAuth(p) { + args = append(args[:i], args[i+3:]...) + } else { + user := p.TargetUser + if user == "" { + user = "default" + } + args[i+1], args[i+2] = user, p.TargetPassword + } + break + } + } + } + if name != "AUTH" && name != "HELLO" { + text := name + if name != "ACL" && name != "CONFIG" && name != "MIGRATE" { + data, _ := json.Marshal(args) + text = string(data) + } + if err = model.AuditCommand(ctx, strings.ToLower(name), text, "allowed"); err != nil { + return err + } + } + if err = transport.Write(upstream, encode(args)); err != nil { + return err + } + } +} diff --git a/internal/protocol/redisproxy/redis_test.go b/internal/protocol/redisproxy/redis_test.go new file mode 100644 index 0000000..2f8a1d2 --- /dev/null +++ b/internal/protocol/redisproxy/redis_test.go @@ -0,0 +1,372 @@ +package redisproxy + +import ( + "agentdb/internal/model" + "agentdb/internal/protocol/transport" + "bufio" + "bytes" + "context" + "errors" + "io" + "net" + "reflect" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +var fixture = model.Proxy{GatewayUser: "gateway", GatewayPassword: "gateway-secret", TargetHost: "redis.example", TargetPort: 6379, TargetUser: "target", TargetPassword: "target-secret"} + +// Use real sockets for the gateway client: unlike net.Pipe, TCP permits the +// write-ahead behavior used by pipelining and streaming subscription clients. +func gateway(t *testing.T, ctx context.Context, p model.Proxy, dial model.DialFunc) (net.Conn, <-chan error) { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { l.Close() }) + done := make(chan error, 1) + go func() { + c, err := l.Accept() + if err != nil { + done <- err + return + } + done <- New().Serve(ctx, c, p, dial, nil, nil) + }() + c, err := net.Dial("tcp", l.Addr().String()) + if err != nil { + t.Fatal(err) + } + c.SetDeadline(time.Now().Add(5 * time.Second)) + t.Cleanup(func() { c.Close() }) + return c, done +} + +func writeCommand(t *testing.T, c net.Conn, args ...string) { + t.Helper() + if err := transport.Write(c, encode(args)); err != nil { + t.Fatal(err) + } +} +func readBytes(t *testing.T, c net.Conn, want string) { + t.Helper() + b := make([]byte, len(want)) + if _, err := io.ReadFull(c, b); err != nil { + t.Fatal(err) + } + if string(b) != want { + t.Fatalf("reply = %q, want %q", b, want) + } +} +func waitDone(t *testing.T, done <-chan error) error { + t.Helper() + select { + case err := <-done: + return err + case <-time.After(2 * time.Second): + t.Fatal("gateway did not close") + return nil + } +} + +func TestGatewayRejectsAuthenticationBeforeDial(t *testing.T) { + for _, args := range [][]string{ + {"AUTH", "wrong"}, {"AUTH", "another-user", fixture.GatewayPassword}, + {"HELLO", "3", "AUTH", fixture.GatewayUser, "wrong"}, + {"HELLO", "3", "AUTH", fixture.GatewayUser, fixture.GatewayPassword, "AUTH", "other", "secret"}, + {"HELLO", "3", "AUTH"}, + } { + t.Run(strings.Join(args[:1], ""), func(t *testing.T) { + var calls atomic.Int32 + c, done := gateway(t, context.Background(), fixture, func(context.Context, string, string) (net.Conn, error) { + calls.Add(1) + return nil, errors.New("must not dial") + }) + writeCommand(t, c, args...) + readBytes(t, c, "-WRONGPASS invalid username-password pair\r\n") + if waitDone(t, done) == nil { + t.Fatal("expected authentication failure") + } + if calls.Load() != 0 { + t.Fatal("invalid credentials reached upstream") + } + }) + } +} + +func TestCredentialMappingPipelineAndSubscriptions(t *testing.T) { + var mu sync.Mutex + var audits []model.Command + ctx := model.WithCommandAudit(context.Background(), func(c model.Command) error { + mu.Lock() + defer mu.Unlock() + audits = append(audits, c) + return nil + }) + observed := make(chan []string, 20) + upDone := make(chan struct{}) + dial := func(context.Context, string, string) (net.Conn, error) { + client, server := net.Pipe() + go func() { + defer close(upDone) + defer server.Close() + r := bufio.NewReader(server) + for { + args, err := request(r) + if err != nil { + return + } + observed <- args + response := "+OK\r\n" + switch strings.ToUpper(args[0]) { + case "PING": + response = "+PONG\r\n" + case "ECHO": + response = "$3\r\na\x00b\r\n" + case "HELLO": + response = "%1\r\n+proto\r\n:3\r\n" + case "SUBSCRIBE": + response = "*3\r\n$9\r\nsubscribe\r\n$4\r\nnews\r\n:1\r\n*3\r\n$7\r\nmessage\r\n$4\r\nnews\r\n$5\r\nhello\r\n" + } + if transport.Write(server, []byte(response)) != nil { + return + } + } + }() + return client, nil + } + c, done := gateway(t, ctx, fixture, dial) + writeCommand(t, c, "PING") + readBytes(t, c, "-NOAUTH Authentication required\r\n") + writeCommand(t, c, "AUTH", fixture.GatewayUser, fixture.GatewayPassword) + readBytes(t, c, "+OK\r\n") + // SETNAME's value happens to be AUTH; it must not be mistaken for an option. + writeCommand(t, c, "HELLO", "3", "SETNAME", "AUTH", "AUTH", fixture.GatewayUser, fixture.GatewayPassword) + readBytes(t, c, "%1\r\n+proto\r\n:3\r\n") + pipeline := append(encode([]string{"PING"}), encode([]string{"ECHO", "a\x00b"})...) + if err := transport.Write(c, pipeline); err != nil { + t.Fatal(err) + } + readBytes(t, c, "+PONG\r\n$3\r\na\x00b\r\n") + writeCommand(t, c, "SUBSCRIBE", "news") + readBytes(t, c, "*3\r\n$9\r\nsubscribe\r\n$4\r\nnews\r\n:1\r\n*3\r\n$7\r\nmessage\r\n$4\r\nnews\r\n$5\r\nhello\r\n") + c.Close() + waitDone(t, done) + select { + case <-upDone: + case <-time.After(time.Second): + t.Fatal("upstream not closed") + } + close(observed) + var got [][]string + for a := range observed { + got = append(got, a) + } + want := [][]string{{"AUTH", fixture.TargetUser, fixture.TargetPassword}, {"HELLO", "3", "SETNAME", "AUTH", "AUTH", fixture.TargetUser, fixture.TargetPassword}, {"PING"}, {"ECHO", "a\x00b"}, {"SUBSCRIBE", "news"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("upstream commands: %q", got) + } + mu.Lock() + defer mu.Unlock() + if len(audits) != 3 { + t.Fatalf("audits: %+v", audits) + } + for _, a := range audits { + if strings.Contains(a.Text, "secret") { + t.Fatal("authentication credentials leaked to audit") + } + } +} + +func TestAuditFailurePreventsForwarding(t *testing.T) { + observed := make(chan []string, 4) + ctx := model.WithCommandAudit(context.Background(), func(model.Command) error { return errors.New("audit disk unavailable") }) + upDone := make(chan struct{}) + dial := func(context.Context, string, string) (net.Conn, error) { + c, s := net.Pipe() + go func() { + defer close(upDone) + defer s.Close() + r := bufio.NewReader(s) + for { + a, err := request(r) + if err != nil { + return + } + observed <- a + if transport.Write(s, []byte("+OK\r\n")) != nil { + return + } + } + }() + return c, nil + } + c, done := gateway(t, ctx, fixture, dial) + writeCommand(t, c, "AUTH", fixture.GatewayPassword) + readBytes(t, c, "+OK\r\n") + writeCommand(t, c, "SET", "key", "value") + if err := waitDone(t, done); err == nil || !strings.Contains(err.Error(), "audit") { + t.Fatalf("error = %v", err) + } + select { + case <-upDone: + case <-time.After(time.Second): + t.Fatal("upstream not closed") + } + if len(observed) != 1 { + t.Fatal("SET reached target despite audit failure") + } +} + +func TestTargetAuthenticationCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + authReceived := make(chan struct{}) + dial := func(context.Context, string, string) (net.Conn, error) { + c, s := net.Pipe() + t.Cleanup(func() { s.Close() }) + go func() { request(bufio.NewReader(s)); close(authReceived) }() + return c, nil + } + done := make(chan error, 1) + go func() { + c, err := open(ctx, fixture, dial) + if c != nil { + c.Close() + } + done <- err + }() + select { + case <-authReceived: + case <-time.After(time.Second): + t.Fatal("no upstream AUTH") + } + cancel() + if waitDone(t, done) == nil { + t.Fatal("cancelled authentication succeeded") + } +} + +func TestRequestRejectsMalformedAndOversizedFrames(t *testing.T) { + for _, input := range []string{"PING\r\n", "*0\r\n", "*99999999\r\n", "*1\r\n$-1\r\n", "*1\r\n$16777217\r\n", "*1\r\n$1\r\nx!!", "*1\r\n$9\r\nshort"} { + if _, err := request(bufio.NewReader(strings.NewReader(input))); err == nil { + t.Fatalf("accepted %q", input) + } + } + args := []string{"SET", "key\r\n", string([]byte{0, 255, 1})} + got, err := request(bufio.NewReader(bytes.NewReader(encode(args)))) + if err != nil || !reflect.DeepEqual(got, args) { + t.Fatalf("binary round trip: %q, %v", got, err) + } +} + +func TestConnectionTestReportsVersionAndTargetAuthFailure(t *testing.T) { + for _, reject := range []bool{false, true} { + t.Run(map[bool]string{false: "success", true: "upstream_rejection"}[reject], func(t *testing.T) { + dial := func(context.Context, string, string) (net.Conn, error) { + c, s := net.Pipe() + go func() { + defer s.Close() + r := bufio.NewReader(s) + a, err := request(r) + if err != nil || !reflect.DeepEqual(a, []string{"AUTH", fixture.TargetUser, fixture.TargetPassword}) { + return + } + if reject { + transport.Write(s, []byte("-WRONGPASS upstream private diagnostic\r\n")) + return + } + transport.Write(s, []byte("+OK\r\n")) + a, err = request(r) + if err != nil || !reflect.DeepEqual(a, []string{"PING"}) { + return + } + transport.Write(s, []byte("+PONG\r\n")) + a, err = request(r) + if err != nil || !reflect.DeepEqual(a, []string{"INFO", "server"}) { + return + } + transport.Write(s, []byte("$21\r\nredis_version:7.2.4\r\n\r\n")) + }() + return c, nil + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + result, err := New().Test(ctx, fixture, dial) + if reject { + if err == nil || strings.Contains(err.Error(), "private") { + t.Fatalf("unexpected authentication error: %v", err) + } + } else if err != nil || result.Version != "7.2.4" || result.Compression != "none" { + t.Fatalf("result %+v, error %v", result, err) + } + }) + } +} + +func TestCancelClosesIdleClientAndUpstream(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + upDone := make(chan struct{}) + dial := func(context.Context, string, string) (net.Conn, error) { + c, s := net.Pipe() + go func() { + defer close(upDone) + defer s.Close() + if _, err := request(bufio.NewReader(s)); err != nil { + return + } + if err := transport.Write(s, []byte("+OK\r\n")); err != nil { + return + } + io.Copy(io.Discard, s) + }() + return c, nil + } + c, done := gateway(t, ctx, fixture, dial) + writeCommand(t, c, "AUTH", fixture.GatewayPassword) + readBytes(t, c, "+OK\r\n") + cancel() + waitDone(t, done) + select { + case <-upDone: + case <-time.After(time.Second): + t.Fatal("idle upstream not closed") + } +} + +func TestNamedNoPasswordTargetStillSelectsACLUser(t *testing.T) { + p := fixture + p.TargetPassword = "" + observed := make(chan []string, 1) + dial := func(context.Context, string, string) (net.Conn, error) { + c, s := net.Pipe() + go func() { + defer s.Close() + a, err := request(bufio.NewReader(s)) + if err != nil { + return + } + observed <- a + transport.Write(s, []byte("+OK\r\n")) + }() + return c, nil + } + c, err := open(context.Background(), p, dial) + if err != nil { + t.Fatal(err) + } + c.Close() + select { + case a := <-observed: + if !reflect.DeepEqual(a, []string{"AUTH", p.TargetUser, ""}) { + t.Fatalf("ACL user not selected: %q", a) + } + default: + t.Fatal("target ACL authentication was skipped") + } +} diff --git a/internal/protocol/sshproxy/audit.go b/internal/protocol/sshproxy/audit.go new file mode 100644 index 0000000..5e3fbdf --- /dev/null +++ b/internal/protocol/sshproxy/audit.go @@ -0,0 +1,191 @@ +package sshproxy + +import ( + "agentdb/internal/model" + "context" + "encoding/binary" + "fmt" + "golang.org/x/crypto/ssh" + "io" + "sync/atomic" +) + +func auditRequest(ctx context.Context, r *ssh.Request, decision string) error { + text := "" + switch r.Type { + case "exec", "subsystem": + var value struct{ Value string } + if ssh.Unmarshal(r.Payload, &value) != nil { + text = "[malformed request]" + } else { + text = value.Value + } + case "shell": + text = "interactive shell (subsequent records are submitted input, not verified executed commands)" + default: + if decision != "blocked" { + return nil + } + text = r.Type + } + return model.AuditCommand(ctx, r.Type, text, decision) +} + +type inputAudit struct { + ctx context.Context + reader io.Reader + mode atomic.Int32 // 1: interactive input, 2: SFTP protocol + line []byte + escape bool + lastCR bool + frame []byte + remaining int +} + +func newInputAudit(ctx context.Context, r io.Reader) *inputAudit { + return &inputAudit{ctx: ctx, reader: r} +} +func (a *inputAudit) Read(p []byte) (int, error) { + n, err := a.reader.Read(p) + if n == 0 { + return n, err + } + var auditErr error + switch a.mode.Load() { + case 1: + auditErr = a.shell(p[:n]) + case 2: + auditErr = a.sftp(p[:n]) + } + if auditErr != nil { + return 0, fmt.Errorf("command audit unavailable") + } + return n, err +} +func (a *inputAudit) shell(data []byte) error { + for _, b := range data { + if a.escape { + if b >= 0x40 && b <= 0x7e && b != '[' { + a.escape = false + } + continue + } + if b == 27 { + a.escape = true + continue + } + if b == '\r' || b == '\n' { + if b == '\n' && a.lastCR { + a.lastCR = false + continue + } + a.lastCR = b == '\r' + if len(a.line) > 0 { + if err := model.AuditCommand(a.ctx, "shell_input", string(a.line), "input"); err != nil { + return err + } + a.line = nil + } + continue + } + a.lastCR = false + switch b { + case 3, 21: + a.line = nil // Ctrl+C, Ctrl+U + case 8, 127: + if len(a.line) > 0 { + a.line = a.line[:len(a.line)-1] + } + default: + if (b >= 32 || b == '\t') && len(a.line) <= model.MaxCommandBytes { + a.line = append(a.line, b) + } + } + } + return nil +} +func (a *inputAudit) sftp(data []byte) error { + for len(data) > 0 { + if a.remaining > 0 { + n := min(a.remaining, len(data)) + a.remaining -= n + data = data[n:] + continue + } + need := 4 - len(a.frame) + if need > 0 { + n := min(need, len(data)) + a.frame = append(a.frame, data[:n]...) + data = data[n:] + if len(a.frame) < 4 { + continue + } + } + size := int(binary.BigEndian.Uint32(a.frame)) + if size < 1 || size > 1<<20 { + if err := model.AuditCommand(a.ctx, "sftp", "oversized/invalid packet; declared bytes="+fmt.Sprint(size), "allowed"); err != nil { + return err + } + a.frame = nil + a.remaining = size + continue + } + n := min(size+4-len(a.frame), len(data)) + a.frame = append(a.frame, data[:n]...) + data = data[n:] + if len(a.frame) < size+4 { + continue + } + if err := model.AuditCommand(a.ctx, "sftp", sftpDescription(a.frame[4:]), "allowed"); err != nil { + return err + } + a.frame = nil + } + return nil +} +func sftpDescription(packet []byte) string { + names := map[byte]string{1: "INIT", 3: "OPEN", 4: "CLOSE", 5: "READ", 6: "WRITE", 7: "LSTAT", 8: "FSTAT", 9: "SETSTAT", 10: "FSETSTAT", 11: "OPENDIR", 12: "READDIR", 13: "REMOVE", 14: "MKDIR", 15: "RMDIR", 16: "REALPATH", 17: "STAT", 18: "RENAME", 19: "READLINK", 20: "SYMLINK", 200: "EXTENDED"} + kind := packet[0] + name := names[kind] + if name == "" { + name = fmt.Sprintf("TYPE_%d", kind) + } + if len(packet) < 5 { + return name + " [short packet]" + } + if kind == 1 { + return fmt.Sprintf("INIT version=%d", binary.BigEndian.Uint32(packet[1:])) + } + text := fmt.Sprintf("%s request=%d", name, binary.BigEndian.Uint32(packet[1:])) + rest := packet[5:] + readString := func() ([]byte, bool) { + if len(rest) < 4 { + return nil, false + } + n := int(binary.BigEndian.Uint32(rest)) + rest = rest[4:] + if n > len(rest) { + return nil, false + } + value := rest[:n] + rest = rest[n:] + return value, true + } + if value, ok := readString(); ok { + switch kind { + case 4, 5, 6, 8, 10, 12: + text += fmt.Sprintf(" handle=%x", value[:min(len(value), 32)]) + default: + text += fmt.Sprintf(" path=%q", string(value[:min(len(value), model.MaxCommandBytes)])) + } + if kind == 18 || kind == 20 { + if value, ok := readString(); ok { + text += fmt.Sprintf(" destination=%q", string(value[:min(len(value), model.MaxCommandBytes)])) + } + } + if (kind == 5 || kind == 6) && len(rest) >= 12 { + text += fmt.Sprintf(" offset=%d bytes=%d", binary.BigEndian.Uint64(rest), binary.BigEndian.Uint32(rest[8:])) + } + } + return text +} diff --git a/internal/protocol/sshproxy/audit_test.go b/internal/protocol/sshproxy/audit_test.go new file mode 100644 index 0000000..1827f42 --- /dev/null +++ b/internal/protocol/sshproxy/audit_test.go @@ -0,0 +1,49 @@ +package sshproxy + +import ( + "agentdb/internal/model" + "bytes" + "context" + "encoding/binary" + "golang.org/x/crypto/ssh" + "io" + "strings" + "testing" +) + +func TestSSHExecShellAndSFTPAudit(t *testing.T) { + var records []model.Command + ctx := model.WithCommandAudit(context.Background(), func(c model.Command) error { records = append(records, c); return nil }) + command := "printf hello" + if err := auditRequest(ctx, &ssh.Request{Type: "exec", Payload: ssh.Marshal(struct{ Value string }{command})}, "allowed"); err != nil { + t.Fatal(err) + } + if len(records) != 1 || records[0].Text != command { + t.Fatal("exec command not recorded") + } + input := "ls -la\r\nprintf helx\blo\n" + r := newInputAudit(ctx, strings.NewReader(input)) + r.mode.Store(1) + raw, err := io.ReadAll(r) + if err != nil || string(raw) != input { + t.Fatal("audit changed terminal bytes") + } + if len(records) != 3 || records[1].Text != "ls -la" || records[2].Text != "printf hello" || records[2].Decision != "input" { + t.Fatal("input boundary audit failed", records) + } + payload := append([]byte{3, 0, 0, 0, 1}, ssh.Marshal(struct{ Path string }{"/tmp/example"})...) + packet := make([]byte, 4+len(payload)) + binary.BigEndian.PutUint32(packet, uint32(len(payload))) + copy(packet[4:], payload) + r = newInputAudit(ctx, bytes.NewReader(packet)) + r.mode.Store(2) + // Fragmented frames must be reconstructed without changing the SFTP stream. + for _, b := range packet { + if err = r.sftp([]byte{b}); err != nil { + t.Fatal(err) + } + } + if len(records) != 4 || !strings.Contains(records[3].Text, "OPEN") || !strings.Contains(records[3].Text, "/tmp/example") { + t.Fatal("SFTP request missing") + } +} diff --git a/internal/protocol/sshproxy/integration_test.go b/internal/protocol/sshproxy/integration_test.go new file mode 100644 index 0000000..0e58c17 --- /dev/null +++ b/internal/protocol/sshproxy/integration_test.go @@ -0,0 +1,122 @@ +package sshproxy + +import ( + "agentdb/internal/model" + "agentdb/internal/testutil" + "bytes" + "context" + "errors" + "fmt" + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" + "io" + "net" + "os" + "strconv" + "testing" + "time" +) + +// TestRealSSH writes only a uniquely named temporary file on the disposable +// OpenSSH fixture, then removes it. No production host should be used here. +func TestRealSSH(t *testing.T) { + address := os.Getenv("AGENTDB_SSH_TEST_ADDR") + if address == "" { + t.Skip("set AGENTDB_SSH_TEST_ADDR/USER/PASSWORD for OpenSSH fixture") + } + host, port, err := net.SplitHostPort(address) + if err != nil { + t.Fatal(err) + } + n, err := strconv.Atoi(port) + if err != nil { + t.Fatal(err) + } + hostKey, signer := testSigner(t) + p := model.Proxy{TargetHost: host, TargetPort: n, TargetUser: os.Getenv("AGENTDB_SSH_TEST_USER"), TargetPassword: os.Getenv("AGENTDB_SSH_TEST_PASSWORD"), GatewayUser: "integration", GatewayPassword: "disposable-gateway-password", SSH: model.SSHConfig{AuthMethod: "password", HostKey: hostKey}} + dial := testutil.FixtureDial(t, &p) + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + identity, err := Probe(ctx, p, dial) + if err != nil { + t.Fatal(err) + } + p.SSH.TargetHostKey = identity.HostKey + if _, err = New().Test(ctx, p, dial); err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + done := make(chan error, 1) + go func() { + raw, e := listener.Accept() + if e != nil { + done <- e + return + } + done <- New().Serve(ctx, raw, p, dial, nil, func(string, string) {}) + }() + cfg := &ssh.ClientConfig{User: p.GatewayUser, Auth: []ssh.AuthMethod{ssh.Password(p.GatewayPassword)}, HostKeyCallback: ssh.FixedHostKey(signer.PublicKey()), Timeout: 5 * time.Second} + client, err := ssh.Dial("tcp", listener.Addr().String(), cfg) + if err != nil { + t.Fatal(err) + } + defer func() { + client.Close() + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Error("SSH gateway connection leaked") + } + }() + session, err := client.NewSession() + if err != nil { + t.Fatal(err) + } + output, err := session.Output("printf agentdb-fixture; exit 7") + session.Close() + var exit *ssh.ExitError + if string(output) != "agentdb-fixture" || !errors.As(err, &exit) || exit.ExitStatus() != 7 { + t.Fatalf("exec output/exit mismatch: %q %v", output, err) + } + transfer, err := sftp.NewClient(client) + if err != nil { + t.Fatal(err) + } + defer transfer.Close() + remote := fmt.Sprintf("/tmp/agentdb-fixture-%d", time.Now().UnixNano()) + payload := []byte("AgentDB SFTP roundtrip 中文 😀\n") + f, err := transfer.OpenFile(remote, os.O_WRONLY|os.O_CREATE|os.O_EXCL) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := transfer.Remove(remote); err != nil { + t.Errorf("temporary file cleanup: %v", err) + } + }() + if _, err = f.Write(payload); err != nil { + f.Close() + t.Fatal(err) + } + if err = f.Close(); err != nil { + t.Fatal(err) + } + f, err = transfer.Open(remote) + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(f) + f.Close() + if err != nil || !bytes.Equal(got, payload) { + t.Fatalf("SFTP content mismatch: %v", err) + } + if c, err := client.Dial("tcp", "127.0.0.1:22"); err == nil { + c.Close() + t.Fatal("TCP forwarding unexpectedly accepted") + } +} diff --git a/internal/protocol/sshproxy/ssh.go b/internal/protocol/sshproxy/ssh.go new file mode 100644 index 0000000..01d87c9 --- /dev/null +++ b/internal/protocol/sshproxy/ssh.go @@ -0,0 +1,339 @@ +// Package sshproxy terminates SSH authentication at the gateway and forwards +// permitted session channels over an independently authenticated upstream. +package sshproxy + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + + "agentdb/internal/model" + "golang.org/x/crypto/ssh" +) + +type Handler struct{} + +func New() *Handler { return &Handler{} } + +var _ model.Handler = (*Handler)(nil) + +func GenerateHostKey() (string, error) { + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", err + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return "", err + } + return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})), nil +} + +func targetAddress(p model.Proxy) string { + return net.JoinHostPort(p.TargetHost, strconv.Itoa(p.TargetPort)) +} +func supportedKey(k ssh.PublicKey) bool { + return k.Type() == ssh.KeyAlgoED25519 || k.Type() == ssh.KeyAlgoRSA +} +func equalSecret(a, b string) bool { + x, y := sha256.Sum256([]byte(a)), sha256.Sum256([]byte(b)) + return subtle.ConstantTimeCompare(x[:], y[:]) == 1 +} +func algorithms() ssh.Config { + return ssh.Config{KeyExchanges: []string{"curve25519-sha256", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", "diffie-hellman-group14-sha256"}, Ciphers: []string{"chacha20-poly1305@openssh.com", "aes128-gcm@openssh.com", "aes256-gcm@openssh.com", "aes128-ctr", "aes192-ctr", "aes256-ctr"}, MACs: []string{"hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512"}} +} +func serverConfig(p model.Proxy) (*ssh.ServerConfig, error) { + signer, err := ssh.ParsePrivateKey([]byte(p.SSH.HostKey)) + if err != nil { + return nil, errors.New("invalid gateway host key") + } + if signer.PublicKey().Type() != ssh.KeyAlgoED25519 { + return nil, errors.New("gateway host key must be Ed25519") + } + cfg := &ssh.ServerConfig{Config: algorithms(), MaxAuthTries: 5, ServerVersion: "SSH-2.0-AgentDB", PublicKeyAuthAlgorithms: []string{ssh.KeyAlgoED25519, ssh.KeyAlgoRSASHA256, ssh.KeyAlgoRSASHA512}} + cfg.AddHostKey(signer) + if p.GatewayPassword != "" { + cfg.PasswordCallback = func(c ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + if equalSecret(c.User(), p.GatewayUser) && equalSecret(string(password), p.GatewayPassword) { + return nil, nil + } + return nil, errors.New("authentication failed") + } + } + keys := make([]ssh.PublicKey, 0, len(p.SSH.AuthorizedKeys)) + for _, entry := range p.SSH.AuthorizedKeys { + key, _, options, rest, e := ssh.ParseAuthorizedKey([]byte(entry)) + if e != nil || len(options) > 0 || len(bytes.TrimSpace(rest)) > 0 || !supportedKey(key) { + return nil, errors.New("invalid or unsupported authorized key") + } + keys = append(keys, key) + } + if len(keys) > 0 { + cfg.PublicKeyCallback = func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if c.User() == p.GatewayUser { + for _, known := range keys { + if bytes.Equal(known.Marshal(), key.Marshal()) { + return nil, nil + } + } + } + return nil, errors.New("authentication failed") + } + } + if cfg.PasswordCallback == nil && cfg.PublicKeyCallback == nil { + return nil, errors.New("gateway authentication is not configured") + } + return cfg, nil +} +func upstreamConfig(p model.Proxy) (*ssh.ClientConfig, error) { + key, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(p.SSH.TargetHostKey)) + if err != nil || len(bytes.TrimSpace(rest)) > 0 || !supportedKey(key) { + return nil, errors.New("target host key must be confirmed") + } + cfg := &ssh.ClientConfig{Config: algorithms(), User: p.TargetUser, HostKeyAlgorithms: []string{ssh.KeyAlgoED25519, ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSASHA256}, HostKeyCallback: ssh.FixedHostKey(key)} + switch p.SSH.AuthMethod { + case "password": + cfg.Auth = []ssh.AuthMethod{ssh.Password(p.TargetPassword)} + case "privatekey", "privateKey": + var signer ssh.Signer + if p.SSH.Passphrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(p.SSH.PrivateKey), []byte(p.SSH.Passphrase)) + } else { + signer, err = ssh.ParsePrivateKey([]byte(p.SSH.PrivateKey)) + } + if err != nil { + return nil, errors.New("invalid target private key or passphrase") + } + if !supportedKey(signer.PublicKey()) { + return nil, errors.New("unsupported target private key") + } + if signer.PublicKey().Type() == ssh.KeyAlgoRSA { + multi, ok := signer.(ssh.AlgorithmSigner) + if !ok { + return nil, errors.New("RSA SHA-2 signing unavailable") + } + signer, err = ssh.NewSignerWithAlgorithms(multi, []string{ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSASHA256}) + if err != nil { + return nil, err + } + } + cfg.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)} + default: + return nil, errors.New("invalid target authentication method") + } + return cfg, nil +} + +// connect leaves the connection owned by the caller; cancellation remains active +// throughout the session, not only while dialing. +func connect(ctx context.Context, p model.Proxy, dial model.DialFunc, cfg *ssh.ClientConfig) (*ssh.Client, func(), error) { + raw, err := dial(ctx, "tcp", targetAddress(p)) + if err != nil { + return nil, nil, fmt.Errorf("SSH target connection: %w", err) + } + stop := context.AfterFunc(ctx, func() { raw.Close() }) + cleanup := func() { stop(); raw.Close() } + raw.SetDeadline(time.Now().Add(15 * time.Second)) + conn, channels, requests, err := ssh.NewClientConn(raw, targetAddress(p), cfg) + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("SSH target handshake: %w", err) + } + raw.SetDeadline(time.Time{}) + return ssh.NewClient(conn, channels, requests), cleanup, nil +} + +func (h *Handler) Test(ctx context.Context, p model.Proxy, dial model.DialFunc) (model.TestResult, error) { + cfg, err := upstreamConfig(p) + if err != nil { + return model.TestResult{}, err + } + c, closeConn, err := connect(ctx, p, dial, cfg) + if err != nil { + return model.TestResult{}, err + } + defer closeConn() + defer c.Close() + key, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(p.SSH.TargetHostKey)) + return model.TestResult{Version: string(c.ServerVersion()), Fingerprint: ssh.FingerprintSHA256(key)}, nil +} + +// Probe deliberately aborts in HostKeyCallback, before user authentication. +func Probe(ctx context.Context, p model.Proxy, dial model.DialFunc) (model.TestResult, error) { + var result model.TestResult + captured := errors.New("host key captured") + cfg := &ssh.ClientConfig{Config: algorithms(), User: "agentdb-host-key-probe", HostKeyAlgorithms: []string{ssh.KeyAlgoED25519, ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSASHA256}, HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error { + result.Fingerprint = ssh.FingerprintSHA256(key) + result.HostKey = string(ssh.MarshalAuthorizedKey(key)) + return captured + }} + c, cleanup, err := connect(ctx, p, dial, cfg) + if cleanup != nil { + defer cleanup() + } + if c != nil { + defer c.Close() + } + if errors.Is(err, captured) { + return result, nil + } + return model.TestResult{}, err +} + +func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial model.DialFunc, _ *tls.Config, event model.EventFunc) error { + defer raw.Close() + stop := context.AfterFunc(ctx, func() { raw.Close() }) + defer stop() + cfg, err := serverConfig(p) + if err != nil { + return err + } + upstreamCfg, err := upstreamConfig(p) + if err != nil { + return err + } + raw.SetDeadline(time.Now().Add(15 * time.Second)) + down, channels, requests, err := ssh.NewServerConn(raw, cfg) + if err != nil { + return errors.New("SSH gateway authentication or handshake failed") + } + defer down.Close() + raw.SetDeadline(time.Time{}) + sessionCtx, cancel := context.WithCancel(ctx) + defer cancel() + upstream, cleanup, err := connect(sessionCtx, p, dial, upstreamCfg) + if err != nil { + return err + } + defer cleanup() + defer upstream.Close() + go func() { upstream.Wait(); down.Close() }() + go func() { + for req := range requests { + if req.WantReply { + req.Reply(false, nil) + } + } + }() + slots := make(chan struct{}, 10) + var sessions sync.WaitGroup + defer func() { upstream.Close(); down.Close(); sessions.Wait() }() + for incoming := range channels { + if incoming.ChannelType() != "session" { + incoming.Reject(ssh.Prohibited, "only session channels are supported") + if event != nil { + event(model.EventProtocolRejected, "ssh:channel_not_allowed") + } + continue + } + select { + case slots <- struct{}{}: + default: + incoming.Reject(ssh.ResourceShortage, "session limit reached") + continue + } + sessions.Add(1) + go func(ch ssh.NewChannel) { + defer sessions.Done() + defer func() { <-slots }() + bridgeSession(ctx, upstream, ch, event) + }(incoming) + } + return nil +} + +func allowedRequest(req *ssh.Request) bool { + switch req.Type { + case "pty-req", "window-change", "shell", "exec", "signal", "break": + return true + case "subsystem": + var v struct{ Name string } + return ssh.Unmarshal(req.Payload, &v) == nil && v.Name == "sftp" + case "env": + var v struct{ Name, Value string } + return ssh.Unmarshal(req.Payload, &v) == nil && (v.Name == "TERM" || v.Name == "LANG" || strings.HasPrefix(v.Name, "LC_")) + default: + return false + } +} +func bridgeSession(ctx context.Context, up *ssh.Client, incoming ssh.NewChannel, event model.EventFunc) { + target, targetRequests, err := up.OpenChannel("session", incoming.ExtraData()) + if err != nil { + incoming.Reject(ssh.ConnectionFailed, "target session unavailable") + return + } + defer target.Close() + client, clientRequests, err := incoming.Accept() + if err != nil { + return + } + defer client.Close() + input := newInputAudit(ctx, client) + var workers sync.WaitGroup + var output sync.WaitGroup + output.Add(2) + workers.Add(5) + go func() { + defer workers.Done() + defer target.Close() + for req := range clientRequests { + ok := false + allowed := allowedRequest(req) + decision := "blocked" + if allowed { + decision = "allowed" + } + if err := auditRequest(ctx, req, decision); err != nil { + client.Close() + return + } + if allowed { + if req.Type == "shell" { + input.mode.Store(1) + } + if req.Type == "subsystem" { + input.mode.Store(2) + } + ok, _ = target.SendRequest(req.Type, req.WantReply, req.Payload) + } else if event != nil { + event(model.EventProtocolRejected, "ssh:request_not_allowed") + } + if req.WantReply { + req.Reply(ok, nil) + } + } + }() + go func() { defer workers.Done(); io.Copy(target, input); target.CloseWrite() }() + go func() { defer workers.Done(); defer output.Done(); io.Copy(client.Stderr(), target.Stderr()) }() + go func() { defer workers.Done(); defer output.Done(); io.Copy(client, target) }() + go func() { defer workers.Done(); output.Wait(); client.CloseWrite() }() + // Keep request processing alive until upstream closes its channel so exit-status + // cannot be lost when stdout reaches EOF before the exit notification. + for req := range targetRequests { + ok := false + if req.Type == "exit-status" || req.Type == "exit-signal" || req.Type == "xon-xoff" { + ok, _ = client.SendRequest(req.Type, req.WantReply, req.Payload) + } + if req.WantReply { + req.Reply(ok, nil) + } + } + target.Close() + output.Wait() + client.Close() + workers.Wait() +} diff --git a/internal/protocol/sshproxy/ssh_test.go b/internal/protocol/sshproxy/ssh_test.go new file mode 100644 index 0000000..d304353 --- /dev/null +++ b/internal/protocol/sshproxy/ssh_test.go @@ -0,0 +1,346 @@ +package sshproxy + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "io" + "net" + "strconv" + "sync/atomic" + "testing" + "time" + + "agentdb/internal/model" + "golang.org/x/crypto/ssh" +) + +func testSigner(t *testing.T) (string, ssh.Signer) { + t.Helper() + key, err := GenerateHostKey() + if err != nil { + t.Fatal(err) + } + s, err := ssh.ParsePrivateKey([]byte(key)) + if err != nil { + t.Fatal(err) + } + return key, s +} +func testTarget(t *testing.T) (string, ssh.Signer, *atomic.Int32) { + t.Helper() + _, signer := testSigner(t) + calls := &atomic.Int32{} + cfg := &ssh.ServerConfig{PasswordCallback: func(c ssh.ConnMetadata, b []byte) (*ssh.Permissions, error) { + calls.Add(1) + if c.User() == "target" && string(b) == "secret" { + return nil, nil + } + return nil, errors.New("denied") + }} + cfg.AddHostKey(signer) + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { l.Close() }) + go func() { + for { + raw, e := l.Accept() + if e != nil { + return + } + go func() { + defer raw.Close() + c, chs, reqs, e := ssh.NewServerConn(raw, cfg) + if e != nil { + return + } + defer c.Close() + go ssh.DiscardRequests(reqs) + for nc := range chs { + ch, rs, e := nc.Accept() + if e != nil { + continue + } + go func() { + defer ch.Close() + for r := range rs { + if r.Type == "exec" { + r.Reply(true, nil) + io.WriteString(ch, "hello\n") + io.WriteString(ch.Stderr(), "warning\n") + ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Code uint32 }{7})) + return + } + if r.Type == "subsystem" { + r.Reply(true, nil) + io.Copy(ch, ch) + return + } + r.Reply(true, nil) + } + }() + } + }() + } + }() + return l.Addr().String(), signer, calls +} +func testProxy(t *testing.T, address string, signer ssh.Signer) model.Proxy { + key, _ := testSigner(t) + host, port, _ := net.SplitHostPort(address) + n, _ := strconv.Atoi(port) + return model.Proxy{GatewayUser: "gateway", GatewayPassword: "gateway-secret", TargetHost: host, TargetPort: n, TargetUser: "target", TargetPassword: "secret", SSH: model.SSHConfig{HostKey: key, TargetHostKey: string(ssh.MarshalAuthorizedKey(signer.PublicKey())), AuthMethod: "password"}} +} +func direct(ctx context.Context, n, a string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, n, a) +} + +func TestProbeDoesNotAuthenticateAndPinIsEnforced(t *testing.T) { + address, signer, calls := testTarget(t) + p := testProxy(t, address, signer) + result, err := Probe(context.Background(), p, direct) + if err != nil { + t.Fatal(err) + } + if calls.Load() != 0 { + t.Fatal("probe sent credentials") + } + if result.Fingerprint != ssh.FingerprintSHA256(signer.PublicKey()) || result.HostKey != p.SSH.TargetHostKey { + t.Fatal("wrong probe identity") + } + if _, err = New().Test(context.Background(), p, direct); err != nil { + t.Fatal(err) + } + _, other := testSigner(t) + p.SSH.TargetHostKey = string(ssh.MarshalAuthorizedKey(other.PublicKey())) + before := calls.Load() + if _, err = New().Test(context.Background(), p, direct); err == nil { + t.Fatal("host key mismatch accepted") + } + if calls.Load() != before { + t.Fatal("sent credentials to mismatched host") + } +} + +func startGateway(t *testing.T, p model.Proxy) (string, context.CancelFunc) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { cancel(); l.Close() }) + go func() { + for { + c, err := l.Accept() + if err != nil { + return + } + go New().Serve(ctx, c, p, direct, nil, nil) + } + }() + return l.Addr().String(), cancel +} +func TestGatewayExecSFTPAndRejectedForwarding(t *testing.T) { + address, signer, _ := testTarget(t) + p := testProxy(t, address, signer) + gateway, cancel := startGateway(t, p) + gatewayKey, err := ssh.ParsePrivateKey([]byte(p.SSH.HostKey)) + if err != nil { + t.Fatal(err) + } + c, err := ssh.Dial("tcp", gateway, &ssh.ClientConfig{User: p.GatewayUser, Auth: []ssh.AuthMethod{ssh.Password(p.GatewayPassword)}, HostKeyCallback: ssh.FixedHostKey(gatewayKey.PublicKey()), Timeout: 5 * time.Second}) + if err != nil { + t.Fatal(err) + } + defer c.Close() + session, err := c.NewSession() + if err != nil { + t.Fatal(err) + } + if err = session.Setenv("LD_PRELOAD", "bad"); err == nil { + t.Fatal("unsafe environment accepted") + } + if err = session.Setenv("LANG", "en_US.UTF-8"); err != nil { + t.Fatal(err) + } + if err = session.RequestPty("xterm", 24, 80, ssh.TerminalModes{}); err != nil { + t.Fatal(err) + } + if err = session.WindowChange(40, 120); err != nil { + t.Fatal(err) + } + out, err := session.CombinedOutput("ignored") + var exit *ssh.ExitError + if !errors.As(err, &exit) || exit.ExitStatus() != 7 { + t.Fatalf("exit status: %v", err) + } + if len(out) != 14 { + t.Fatalf("lost output: %q", out) + } + session.Close() + if ch, _, err := c.OpenChannel("direct-tcpip", nil); err == nil { + ch.Close() + t.Fatal("forwarding accepted") + } + if ok, _, err := c.SendRequest("tcpip-forward", true, nil); err != nil || ok { + t.Fatalf("global forwarding: %v %v", ok, err) + } + ch, reqs, err := c.OpenChannel("session", nil) + if err != nil { + t.Fatal(err) + } + go ssh.DiscardRequests(reqs) + ok, err := ch.SendRequest("subsystem", true, ssh.Marshal(struct{ Name string }{"sftp"})) + if err != nil || !ok { + t.Fatalf("sftp: %v", err) + } + payload := []byte("sftp bytes") + if _, err = ch.Write(payload); err != nil { + t.Fatal(err) + } + got := make([]byte, len(payload)) + if _, err = io.ReadFull(ch, got); err != nil { + t.Fatal(err) + } + if string(got) != string(payload) { + t.Fatal("subsystem payload changed") + } + ch.Close() + cancel() + done := make(chan error, 1) + go func() { done <- c.Wait() }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("cancel did not close gateway") + } +} + +func TestGatewayPublicKeyAndSessionLimit(t *testing.T) { + address, signer, _ := testTarget(t) + p := testProxy(t, address, signer) + _, clientKey := testSigner(t) + p.GatewayPassword = "" + p.SSH.AuthorizedKeys = []string{string(ssh.MarshalAuthorizedKey(clientKey.PublicKey()))} + gateway, _ := startGateway(t, p) + host, _ := ssh.ParsePrivateKey([]byte(p.SSH.HostKey)) + c, err := ssh.Dial("tcp", gateway, &ssh.ClientConfig{User: p.GatewayUser, Auth: []ssh.AuthMethod{ssh.PublicKeys(clientKey)}, HostKeyCallback: ssh.FixedHostKey(host.PublicKey()), Timeout: 5 * time.Second}) + if err != nil { + t.Fatal(err) + } + defer c.Close() + for i := 0; i < 10; i++ { + ch, reqs, err := c.OpenChannel("session", nil) + if err != nil { + t.Fatal(err) + } + defer ch.Close() + go ssh.DiscardRequests(reqs) + } + if ch, _, err := c.OpenChannel("session", nil); err == nil { + ch.Close() + t.Fatal("session limit bypassed") + } +} + +func TestRequestPolicy(t *testing.T) { + for _, name := range []string{"auth-agent-req@openssh.com", "x11-req", "unknown"} { + if allowedRequest(&ssh.Request{Type: name}) { + t.Fatal(name) + } + } + for _, name := range []string{"TERM", "LANG", "LC_ALL"} { + if !allowedRequest(&ssh.Request{Type: "env", Payload: ssh.Marshal(struct{ Name, Value string }{name, "value"})}) { + t.Fatal(name) + } + } + if allowedRequest(&ssh.Request{Type: "subsystem", Payload: ssh.Marshal(struct{ Name string }{"other"})}) { + t.Fatal("other subsystem accepted") + } + if allowedRequest(&ssh.Request{Type: "env", Payload: []byte{0xff}}) { + t.Fatal("malformed env accepted") + } +} + +func TestEncryptedPrivateKeyUpstream(t *testing.T) { + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer, err := ssh.NewSignerFromKey(key) + if err != nil { + t.Fatal(err) + } + block, err := ssh.MarshalPrivateKeyWithPassphrase(key, "test", []byte("passphrase")) + if err != nil { + t.Fatal(err) + } + _, host := testSigner(t) + cfg := &ssh.ServerConfig{PublicKeyCallback: func(c ssh.ConnMetadata, k ssh.PublicKey) (*ssh.Permissions, error) { + if c.User() == "target" && string(k.Marshal()) == string(signer.PublicKey().Marshal()) { + return nil, nil + } + return nil, errors.New("denied") + }} + cfg.AddHostKey(host) + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + go func() { + raw, e := l.Accept() + if e != nil { + return + } + defer raw.Close() + c, chs, reqs, e := ssh.NewServerConn(raw, cfg) + if e != nil { + return + } + defer c.Close() + go ssh.DiscardRequests(reqs) + for ch := range chs { + ch.Reject(ssh.Prohibited, "test") + } + }() + p := testProxy(t, l.Addr().String(), host) + p.SSH.AuthMethod = "privatekey" + p.SSH.PrivateKey = string(pem.EncodeToMemory(block)) + p.SSH.Passphrase = "passphrase" + if _, err := New().Test(context.Background(), p, direct); err != nil { + t.Fatal(err) + } + p.SSH.Passphrase = "wrong" + if _, err := upstreamConfig(p); err == nil { + t.Fatal("bad passphrase accepted") + } +} + +func TestCanceledHandshakeClosesConnection(t *testing.T) { + _, signer := testSigner(t) + p := testProxy(t, "127.0.0.1:22", signer) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + raw, peer := net.Pipe() + defer peer.Close() + done := make(chan error, 1) + go func() { + _, err := New().Test(ctx, p, func(context.Context, string, string) (net.Conn, error) { return raw, nil }) + done <- err + }() + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("canceled handshake succeeded") + } + case <-time.After(2 * time.Second): + t.Fatal("handshake cancellation leaked") + } +} diff --git a/internal/protocol/transport/transport.go b/internal/protocol/transport/transport.go new file mode 100644 index 0000000..305678f --- /dev/null +++ b/internal/protocol/transport/transport.go @@ -0,0 +1,110 @@ +package transport + +import ( + "agentdb/internal/model" + "bufio" + "context" + "crypto/tls" + "crypto/x509" + "errors" + "io" + "net" + "strconv" + "sync" + "time" +) + +func TLSConfig(p model.Proxy) (*tls.Config, error) { + if !p.TargetTLS { + return nil, nil + } + cfg := &tls.Config{ServerName: p.TargetHost, MinVersion: tls.VersionTLS12} + if p.TargetCA != "" { + pool, err := x509.SystemCertPool() + if err != nil { + pool = x509.NewCertPool() + } + if !pool.AppendCertsFromPEM([]byte(p.TargetCA)) { + return nil, errors.New("invalid target CA") + } + cfg.RootCAs = pool + } + return cfg, nil +} +func Dial(ctx context.Context, p model.Proxy, dial model.DialFunc) (net.Conn, error) { + c, err := dial(ctx, "tcp", net.JoinHostPort(p.TargetHost, strconv.Itoa(p.TargetPort))) + if err != nil { + return nil, err + } + cfg, err := TLSConfig(p) + if err != nil { + c.Close() + return nil, err + } + if cfg != nil { + secure := tls.Client(c, cfg) + if err = secure.HandshakeContext(ctx); err != nil { + c.Close() + return nil, err + } + c = secure + } + return c, nil +} + +type BufferedConn struct { + net.Conn + Reader *bufio.Reader +} + +func (c *BufferedConn) Read(p []byte) (int, error) { return c.Reader.Read(p) } +func Accept(ctx context.Context, raw net.Conn, cfg *tls.Config) (net.Conn, error) { + raw.SetDeadline(time.Now().Add(15 * time.Second)) + reader := bufio.NewReader(raw) + first, err := reader.Peek(1) + if err != nil { + return nil, err + } + var c net.Conn = &BufferedConn{Conn: raw, Reader: reader} + if first[0] == 22 { + if cfg == nil { + return nil, errors.New("gateway TLS certificate missing") + } + config := cfg.Clone() + config.MinVersion = tls.VersionTLS12 + secure := tls.Server(c, config) + if err = secure.HandshakeContext(ctx); err != nil { + return nil, err + } + c = secure + } + return c, nil +} +func Write(w io.Writer, b []byte) error { + for len(b) > 0 { + n, err := w.Write(b) + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + b = b[n:] + } + return nil +} + +type LockedWriter struct { + mu sync.Mutex + Writer io.Writer +} + +func (w *LockedWriter) Write(b []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + err := Write(w.Writer, b) + if err != nil { + return 0, err + } + return len(b), nil +} diff --git a/internal/security/vault.go b/internal/security/vault.go new file mode 100644 index 0000000..763b57d --- /dev/null +++ b/internal/security/vault.go @@ -0,0 +1,49 @@ +package security + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "io" +) + +type Vault struct{ aead cipher.AEAD } + +func NewVault(key string) (*Vault, error) { + b, err := base64.StdEncoding.DecodeString(key) + if err != nil || len(b) != 32 { + return nil, errors.New("AGENTDB_MASTER_KEY must be base64 encoding of 32 random bytes") + } + block, err := aes.NewCipher(b) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &Vault{aead}, nil +} +func (v *Vault) Seal(data []byte, purpose string) ([]byte, error) { + nonce := make([]byte, v.aead.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + return v.aead.Seal(nonce, nonce, data, []byte(purpose)), nil +} +func (v *Vault) Open(data []byte, purpose string) ([]byte, error) { + n := v.aead.NonceSize() + if len(data) < n { + return nil, errors.New("invalid encrypted data") + } + return v.aead.Open(nil, data[:n], data[n:], []byte(purpose)) +} +func Token() string { + b := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/internal/security/vault_test.go b/internal/security/vault_test.go new file mode 100644 index 0000000..bc1acbd --- /dev/null +++ b/internal/security/vault_test.go @@ -0,0 +1,58 @@ +package security + +import ( + "bytes" + "encoding/base64" + "testing" +) + +func TestVaultAuthenticatesCiphertextPurposeAndKey(t *testing.T) { + key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)) + v, err := NewVault(key) + if err != nil { + t.Fatal(err) + } + plaintext := []byte("database-password") + a, err := v.Seal(plaintext, "proxy:a") + if err != nil { + t.Fatal(err) + } + b, err := v.Seal(plaintext, "proxy:a") + if err != nil { + t.Fatal(err) + } + if bytes.Equal(a, b) || bytes.Contains(a, plaintext) { + t.Fatal("nonce reused or plaintext stored") + } + got, err := v.Open(a, "proxy:a") + if err != nil || !bytes.Equal(got, plaintext) { + t.Fatalf("round trip: %q %v", got, err) + } + if _, err = v.Open(a, "proxy:b"); err == nil { + t.Fatal("accepted incorrect associated data") + } + other, _ := NewVault(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32))) + if _, err = other.Open(a, "proxy:a"); err == nil { + t.Fatal("accepted wrong key") + } + for i := range a { + corrupt := bytes.Clone(a) + corrupt[i] ^= 1 + if _, err := v.Open(corrupt, "proxy:a"); err == nil { + t.Fatalf("accepted tamper at %d", i) + } + } + for i := 0; i < len(a); i++ { + if _, err := v.Open(a[:i], "proxy:a"); err == nil { + t.Fatalf("accepted truncation %d", i) + } + } +} + +func TestVaultRejectsInvalidMasterKeys(t *testing.T) { + for _, key := range []string{"", "invalid", base64.StdEncoding.EncodeToString(make([]byte, 16)), base64.StdEncoding.EncodeToString(make([]byte, 33))} { + if _, err := NewVault(key); err == nil { + t.Fatal("accepted invalid master key") + } + } +} diff --git a/internal/store/audit_shards.go b/internal/store/audit_shards.go new file mode 100644 index 0000000..a60017e --- /dev/null +++ b/internal/store/audit_shards.go @@ -0,0 +1,194 @@ +package store + +import ( + "database/sql" + "errors" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const shardStride int64 = 1 << 32 +const auditSchema = `PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; +CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY,proxy_id TEXT NOT NULL,target_id TEXT NOT NULL,source TEXT NOT NULL,protocol TEXT NOT NULL,kind TEXT NOT NULL,decision TEXT NOT NULL,content BLOB NOT NULL,at TEXT NOT NULL); +CREATE INDEX IF NOT EXISTS commands_at ON commands(at); +CREATE INDEX IF NOT EXISTS commands_proxy ON commands(proxy_id); +CREATE INDEX IF NOT EXISTS commands_target ON commands(target_id); +CREATE INDEX IF NOT EXISTS commands_filter ON commands(protocol,decision,kind); +CREATE TABLE IF NOT EXISTS events(id INTEGER PRIMARY KEY AUTOINCREMENT,proxy_id TEXT NOT NULL,source TEXT NOT NULL,category TEXT NOT NULL,detail TEXT NOT NULL,at TEXT NOT NULL); +CREATE INDEX IF NOT EXISTS events_at ON events(at); +CREATE INDEX IF NOT EXISTS events_proxy ON events(proxy_id);` + +func (s *Store) auditDB(day string) (*sql.DB, error) { + if _, err := time.Parse("2006-01-02", day); err != nil { + return nil, err + } + s.auditMu.Lock() + defer s.auditMu.Unlock() + if s.closed { + return nil, errors.New("store is closed") + } + if db := s.audits[day]; db != nil { + return db, nil + } + if err := os.MkdirAll(s.auditDir, 0700); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", filepath.Join(s.auditDir, day+".sqlite")) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + if _, err = db.Exec(auditSchema); err != nil { + db.Close() + return nil, err + } + s.audits[day] = db + return db, nil +} +func (s *Store) auditDays() ([]string, error) { + entries, err := os.ReadDir(s.auditDir) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + days := []string{} + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sqlite") { + continue + } + day := strings.TrimSuffix(entry.Name(), ".sqlite") + if _, err = time.Parse("2006-01-02", day); err == nil { + days = append(days, day) + } + } + sort.Sort(sort.Reverse(sort.StringSlice(days))) + return days, nil +} +func (s *Store) migrateCommands() error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + rows, err := tx.Query("SELECT id,proxy_id,target_id,source,protocol,kind,decision,content,at FROM commands ORDER BY rowid") + if err != nil { + return err + } + migrated := false + var shardTx *sql.Tx + var shardDay string + batchSize := 0 + defer func() { + if shardTx != nil { + shardTx.Rollback() + } + }() + for rows.Next() { + var id, proxy, target, source, protocol, kind, decision, at string + var content []byte + if err = rows.Scan(&id, &proxy, &target, &source, &protocol, &kind, &decision, &content, &at); err != nil { + rows.Close() + return err + } + date, e := time.Parse(time.RFC3339Nano, at) + if e != nil { + rows.Close() + return e + } + day := date.UTC().Format("2006-01-02") + if shardTx != nil && (day != shardDay || batchSize >= 1000) { + if e = shardTx.Commit(); e != nil { + rows.Close() + return e + } + shardTx = nil + } + if shardTx == nil { + db, e := s.auditDB(day) + if e != nil { + rows.Close() + return e + } + shardTx, e = db.Begin() + if e != nil { + rows.Close() + return e + } + shardDay, batchSize = day, 0 + } + if _, e = shardTx.Exec("INSERT OR IGNORE INTO commands(id,proxy_id,target_id,source,protocol,kind,decision,content,at) VALUES(?,?,?,?,?,?,?,?,?)", id, proxy, target, source, protocol, kind, decision, content, at); e != nil { + rows.Close() + return e + } + // A previous interrupted migration may already have copied this row. + // Only discard the legacy copy when all persisted values match exactly. + var matches int + e = shardTx.QueryRow("SELECT COUNT(*) FROM commands WHERE id=? AND proxy_id=? AND target_id=? AND source=? AND protocol=? AND kind=? AND decision=? AND content=? AND at=?", id, proxy, target, source, protocol, kind, decision, content, at).Scan(&matches) + if e != nil || matches != 1 { + rows.Close() + if e != nil { + return e + } + return errors.New("audit migration found conflicting record " + id) + } + migrated = true + batchSize++ + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + if migrated { + if err = shardTx.Commit(); err != nil { + return err + } + shardTx = nil + if _, err = tx.Exec("DELETE FROM commands"); err != nil { + return err + } + } + if err = tx.Commit(); err != nil { + return err + } + if migrated { + _, err = s.db.Exec("VACUUM") + } + return err +} +func (s *Store) pruneCommands(cutoff time.Time) { + s.auditOps.Lock() + defer s.auditOps.Unlock() + if s.closed { + return + } + cutoff = cutoff.UTC() + days, err := s.auditDays() + if err != nil { + return + } + for _, day := range days { + if day < cutoff.Format("2006-01-02") { + s.auditMu.Lock() + if db := s.audits[day]; db != nil { + db.Close() + delete(s.audits, day) + } + for _, suffix := range []string{".sqlite", ".sqlite-wal", ".sqlite-shm"} { + _ = os.Remove(filepath.Join(s.auditDir, day+suffix)) + } + s.auditMu.Unlock() + } else if day == cutoff.Format("2006-01-02") { + db, err := s.auditDB(day) + if err == nil { + db.Exec("DELETE FROM commands WHERE julianday(at)= cursor) { + t.Fatalf("duplicate or unordered record %+v", rec) + } + seen[rec.ID] = true + cursor = rec.RowID + } + } + if len(seen) != 411 { + t.Fatal("missing records", len(seen)) + } + f := CommandFilter{Gateway: "gateway", Target: "target", Source: "192.0.2.1", Protocol: "mysql", Kind: "query", Decision: "allowed", Keyword: "OLD-PAGE-MATCH"} + recs, err := s.FilterCommands(context.Background(), f, 0) + if err != nil || len(recs) != 3 { + t.Fatalf("older keyword matches: %d %v", len(recs), err) + } + f.From = now.AddDate(0, 0, -1).Format(time.RFC3339Nano) + recs, err = s.FilterCommands(context.Background(), f, 0) + if err != nil || len(recs) != 0 { + t.Fatalf("time filter: %d %v", len(recs), err) + } + exported := map[string]bool{} + err = s.WalkCommands(context.Background(), CommandFilter{}, 0, 0, func(rec CommandRecord) error { + if exported[rec.ID] { + t.Fatal("duplicate export row", rec.ID) + } + exported[rec.ID] = true + return nil + }) + if err != nil || len(exported) != 411 { + t.Fatalf("cross-day export got %d: %v", len(exported), err) + } + stop := errors.New("export disconnected") + if err = s.WalkCommands(context.Background(), CommandFilter{}, 0, 0, func(CommandRecord) error { return stop }); !errors.Is(err, stop) { + t.Fatal("visitor error lost", err) + } +} + +func TestAuditShardsMigrationRestartAndEncryption(t *testing.T) { + s, path, v := shardFixture(t) + for i := 0; i < 3; i++ { + id := fmt.Sprintf("legacy-%d", i) + at := time.Date(2026, 9, 9-i, 12, 0, 0, 0, time.UTC) + blob, err := s.seal(model.Command{Kind: "query", Decision: "allowed", Text: "SELECT 'legacy-secret'"}, "command:"+id) + if err != nil { + t.Fatal(err) + } + args := []any{id, "gateway", "target", "192.0.2.1", "mysql", "query", "allowed", blob, at.Format(time.RFC3339Nano)} + const insert = "INSERT INTO commands(id,proxy_id,target_id,source,protocol,kind,decision,content,at) VALUES(?,?,?,?,?,?,?,?,?)" + if _, err = s.db.Exec(insert, args...); err != nil { + t.Fatal(err) + } + if i == 0 { // Simulate interruption after copying one row, before legacy deletion. + db, e := s.auditDB(at.Format("2006-01-02")) + if e != nil { + t.Fatal(e) + } + if _, e = db.Exec(insert, args...); e != nil { + t.Fatal(e) + } + } + } + s.Close() + for attempt := 0; attempt < 2; attempt++ { + reopened, err := Open(path, v) + if err != nil { + t.Fatal(err) + } + recs, err := reopened.Commands("", "", 0) + if err != nil || len(recs) != 3 { + reopened.Close() + t.Fatalf("restart %d: %d %v", attempt, len(recs), err) + } + for _, rec := range recs { + if rec.Text != "SELECT 'legacy-secret'" { + t.Fatal("migration changed content") + } + } + var count int + if err = reopened.db.QueryRow("SELECT COUNT(*) FROM commands").Scan(&count); err != nil || count != 0 { + t.Fatal("legacy data not cleared", count, err) + } + reopened.Close() + } + files, err := filepath.Glob(filepath.Join(filepath.Dir(path), "audit", "*")) + if err != nil { + t.Fatal(err) + } + if len(files) != 3 { + t.Fatal("daily shard count", len(files)) + } + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(data, []byte("legacy-secret")) { + t.Fatal("plaintext persisted in", file) + } + } +} + +func TestAuditShardsMigrationConflictPreservesLegacy(t *testing.T) { + s, path, v := shardFixture(t) + at := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC) + blob, err := s.seal(model.Command{Text: "SELECT 'original'"}, "command:collision") + if err != nil { + t.Fatal(err) + } + args := []any{"collision", "gateway", "target", "source", "mysql", "query", "allowed", blob, at.Format(time.RFC3339Nano)} + const insert = "INSERT INTO commands(id,proxy_id,target_id,source,protocol,kind,decision,content,at) VALUES(?,?,?,?,?,?,?,?,?)" + if _, err = s.db.Exec(insert, args...); err != nil { + t.Fatal(err) + } + db, err := s.auditDB(at.Format("2006-01-02")) + if err != nil { + t.Fatal(err) + } + args[3] = "different-source" + if _, err = db.Exec(insert, args...); err != nil { + t.Fatal(err) + } + s.Close() + if reopened, err := Open(path, v); err == nil { + reopened.Close() + t.Fatal("conflicting migration accepted") + } + legacy, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer legacy.Close() + var count int + if err = legacy.QueryRow("SELECT COUNT(*) FROM commands").Scan(&count); err != nil || count != 1 { + t.Fatal("legacy row lost", count, err) + } +} + +func TestAuditShardsFailedMigrationCanResume(t *testing.T) { + s, path, v := shardFixture(t) + // Put the invalid timestamp after a committed batch to exercise partial progress. + for i := 0; i < 1002; i++ { + id := fmt.Sprintf("restart-%d", i) + blob, err := s.seal(model.Command{Text: "SELECT 1"}, "command:"+id) + if err != nil { + t.Fatal(err) + } + at := "2026-09-09T12:00:00Z" + if i == 1001 { + at = "invalid" + } + if _, err = s.db.Exec("INSERT INTO commands(id,proxy_id,target_id,source,protocol,kind,decision,content,at) VALUES(?,?,?,?,?,?,?,?,?)", id, "gateway", "target", "source", "mysql", "query", "allowed", blob, at); err != nil { + t.Fatal(err) + } + } + s.Close() + if reopened, err := Open(path, v); err == nil { + reopened.Close() + t.Fatal("invalid migration timestamp accepted") + } + legacy, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + var count int + if err = legacy.QueryRow("SELECT COUNT(*) FROM commands").Scan(&count); err != nil || count != 1002 { + t.Fatal("legacy rows lost after interruption", count, err) + } + if _, err = legacy.Exec("UPDATE commands SET at='2026-09-09T12:00:00Z' WHERE at='invalid'"); err != nil { + t.Fatal(err) + } + legacy.Close() + reopened, err := Open(path, v) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + seen := map[string]bool{} + err = reopened.WalkCommands(context.Background(), CommandFilter{}, 0, 0, func(rec CommandRecord) error { + if seen[rec.ID] { + t.Fatal("duplicate after migration retry") + } + seen[rec.ID] = true + return nil + }) + if err != nil || len(seen) != 1002 { + t.Fatal("incomplete migration retry", len(seen), err) + } +} + +func TestAuditShardsRetentionAndCancellation(t *testing.T) { + s, _, _ := shardFixture(t) + now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC) + p := model.Proxy{ID: "gateway", TargetID: "target", Type: "mysql"} + for _, at := range []time.Time{now.Add(-8 * 24 * time.Hour), now.Add(-7*24*time.Hour - time.Second), now.Add(-7 * 24 * time.Hour), now} { + if err := s.recordCommandAt(p, "source", model.Command{Text: "SELECT 1"}, at); err != nil { + t.Fatal(err) + } + } + for _, name := range []string{"keep.txt", "not-a-date.sqlite", "2026-09-01.sqlite.backup"} { + if err := os.WriteFile(filepath.Join(s.auditDir, name), []byte("preserve"), 0600); err != nil { + t.Fatal(err) + } + } + s.pruneCommands(now.Add(-7 * 24 * time.Hour).In(time.FixedZone("offset", -13*3600))) + recs, err := s.Commands("", "", 0) + if err != nil || len(recs) != 2 { + t.Fatal("retention boundary", len(recs), err) + } + if _, err = os.Stat(filepath.Join(s.auditDir, "2026-09-01.sqlite")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("old shard exists", err) + } + for _, name := range []string{"keep.txt", "not-a-date.sqlite", "2026-09-01.sqlite.backup"} { + if _, err = os.Stat(filepath.Join(s.auditDir, name)); err != nil { + t.Fatal("unrelated file removed", name, err) + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err = s.FilterCommands(ctx, CommandFilter{}, 0); !errors.Is(err, context.Canceled) { + t.Fatal("cancellation lost", err) + } + ctx, cancel = context.WithCancel(context.Background()) + err = s.WalkCommands(ctx, CommandFilter{}, 0, 0, func(CommandRecord) error { cancel(); return nil }) + if !errors.Is(err, context.Canceled) { + t.Fatal("mid-export cancellation lost", err) + } +} + +func TestAuditShardsConcurrentReadWritePrune(t *testing.T) { + s, _, _ := shardFixture(t) + now := time.Now().UTC() + p := model.Proxy{ID: "gateway", Type: "mysql"} + if err := s.recordCommandAt(p, "source", model.Command{Text: "old"}, now.Add(-8*24*time.Hour)); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + errCh := make(chan error, 100) + for worker := 0; worker < 3; worker++ { + wg.Add(1) + go func(worker int) { + defer wg.Done() + for i := 0; i < 20; i++ { + var err error + switch worker { + case 0: + err = s.RecordCommand(p, "source", model.Command{Text: "current"}) + case 1: + _, err = s.Commands("", "", 0) + case 2: + s.pruneCommands(now.Add(-7 * 24 * time.Hour)) + } + if err != nil { + errCh <- err + } + } + }(worker) + } + wg.Wait() + close(errCh) + for err := range errCh { + t.Error(err) + } + recs, err := s.Commands("", "", 0) + if err != nil || len(recs) != 20 { + t.Fatal("concurrent writes lost", len(recs), err) + } + s.Close() + if err = s.RecordCommand(p, "source", model.Command{Text: "closed"}); err == nil { + t.Fatal("write after close accepted") + } + if _, err = s.Commands("", "", 0); err == nil { + t.Fatal("read after close accepted") + } +} diff --git a/internal/store/commands.go b/internal/store/commands.go new file mode 100644 index 0000000..f8a80db --- /dev/null +++ b/internal/store/commands.go @@ -0,0 +1,165 @@ +package store + +import ( + "agentdb/internal/model" + "agentdb/internal/security" + "context" + "database/sql" + "errors" + "strings" + "time" +) + +type CommandRecord struct { + RowID int64 `json:"rowId"` + ID string `json:"id"` + ProxyID string `json:"proxyId"` + TargetID string `json:"targetId"` + Source string `json:"source"` + Protocol string `json:"protocol"` + At string `json:"at"` + model.Command +} + +func (s *Store) RecordCommand(p model.Proxy, source string, cmd model.Command) error { + return s.recordCommandAt(p, source, cmd, time.Now().UTC()) +} +func (s *Store) recordCommandAt(p model.Proxy, source string, cmd model.Command, at time.Time) error { + s.auditOps.RLock() + defer s.auditOps.RUnlock() + id := security.Token() + blob, err := s.seal(cmd, "command:"+id) + if err != nil { + return err + } + db, err := s.auditDB(at.UTC().Format("2006-01-02")) + if err != nil { + return err + } + _, err = db.Exec("INSERT INTO commands(id,proxy_id,target_id,source,protocol,kind,decision,content,at) VALUES(?,?,?,?,?,?,?,?,?)", id, p.ID, p.TargetID, source, p.Type, cmd.Kind, cmd.Decision, blob, at.UTC().Format(time.RFC3339Nano)) + return err +} +func (s *Store) Commands(gateway, protocol string, before int64) ([]CommandRecord, error) { + return s.FilterCommands(context.Background(), CommandFilter{Gateway: gateway, Protocol: protocol}, before) +} + +type CommandFilter struct{ Gateway, Protocol, Target, Source, Decision, Kind, Keyword, From, To string } + +func (s *Store) FilterCommands(ctx context.Context, filter CommandFilter, before int64) ([]CommandRecord, error) { + out := []CommandRecord{} + err := s.WalkCommands(ctx, filter, before, 200, func(rec CommandRecord) error { out = append(out, rec); return nil }) + return out, err +} + +// Filtering decrypted text before pagination also finds matches on older pages. +func (s *Store) WalkCommands(ctx context.Context, f CommandFilter, before int64, limit int, visit func(CommandRecord) error) error { + s.auditOps.RLock() + defer s.auditOps.RUnlock() + if err := ctx.Err(); err != nil { + return err + } + if s.closed { + return errors.New("store is closed") + } + days, err := s.auditDays() + if err != nil { + return err + } + remaining := limit + for _, day := range days { + if err := ctx.Err(); err != nil { + return err + } + date, _ := time.Parse("2006-01-02", day) + base := (date.Unix() / 86400) * shardStride + if before > 0 && base >= before { + continue + } + if f.From != "" { + from, e := time.Parse(time.RFC3339Nano, f.From) + if e == nil && date.Add(24*time.Hour).Before(from) { + continue + } + } + if f.To != "" { + to, e := time.Parse(time.RFC3339Nano, f.To) + if e == nil && date.After(to) { + continue + } + } + cursor := int64(0) + if before > base && before < base+shardStride { + cursor = before - base + } + db, e := s.auditDB(day) + if e != nil { + return e + } + count, e := s.walkCommandDB(ctx, db, f, cursor, remaining, base, visit) + if e != nil { + return e + } + if limit > 0 { + remaining -= count + if remaining <= 0 { + return nil + } + } + } + return nil +} +func (s *Store) walkCommandDB(ctx context.Context, db *sql.DB, f CommandFilter, before int64, limit int, base int64, visit func(CommandRecord) error) (int, error) { + + query := "SELECT rowid,id,proxy_id,target_id,source,protocol,content,at FROM commands WHERE (?=0 OR rowid=julianday(?)" + args = append(args, f.From) + } + if f.To != "" { + query += " AND julianday(at)<=julianday(?)" + args = append(args, f.To) + } + query += " ORDER BY rowid DESC" + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return 0, err + } + defer rows.Close() + count := 0 + keyword := strings.ToLower(f.Keyword) + for rows.Next() { + if err = ctx.Err(); err != nil { + return 0, err + } + var rec CommandRecord + var blob []byte + if err = rows.Scan(&rec.RowID, &rec.ID, &rec.ProxyID, &rec.TargetID, &rec.Source, &rec.Protocol, &blob, &rec.At); err != nil { + return 0, err + } + if err = s.unseal(blob, "command:"+rec.ID, &rec.Command); err != nil { + return 0, err + } + if keyword != "" && !strings.Contains(strings.ToLower(rec.Text), keyword) { + continue + } + if rec.RowID >= shardStride { + return 0, errors.New("audit shard row limit exceeded") + } + rec.RowID += base + if err = visit(rec); err != nil { + return 0, err + } + count++ + if limit > 0 && count >= limit { + return count, nil + } + } + return count, rows.Err() +} diff --git a/internal/store/commands_test.go b/internal/store/commands_test.go new file mode 100644 index 0000000..5663f80 --- /dev/null +++ b/internal/store/commands_test.go @@ -0,0 +1,56 @@ +package store + +import ( + "agentdb/internal/model" + "agentdb/internal/security" + "bytes" + "encoding/base64" + "path/filepath" + "testing" + "time" +) + +func TestEncryptedCommandAuditPaginationAndRetention(t *testing.T) { + v, _ := security.NewVault(base64.StdEncoding.EncodeToString(make([]byte, 32))) + s, err := Open(filepath.Join(t.TempDir(), "audit.sqlite"), v) + if err != nil { + t.Fatal(err) + } + defer s.Close() + p := model.Proxy{ID: "gateway", TargetID: "target", Type: "mysql"} + cmd := model.Command{Kind: "query", Text: "SELECT 'private-business-data'", Decision: "allowed"} + for i := 0; i < 205; i++ { + if err = s.RecordCommand(p, "127.0.0.1", cmd); err != nil { + t.Fatal(err) + } + } + records, err := s.Commands("gateway", "mysql", 0) + if err != nil || len(records) != 200 { + t.Fatal("pagination first page", err) + } + more, err := s.Commands("gateway", "mysql", records[len(records)-1].RowID) + if err != nil || len(more) != 5 { + t.Fatal("pagination next page", err) + } + var blob []byte + shard, err := s.auditDB(time.Now().UTC().Format("2006-01-02")) + if err != nil { + t.Fatal(err) + } + shard.QueryRow("SELECT content FROM commands LIMIT 1").Scan(&blob) + if bytes.Contains(blob, []byte("private-business-data")) { + t.Fatal("plaintext command persisted") + } + if records[0].Text != cmd.Text || records[0].Source != "127.0.0.1" || records[0].TargetID != "target" { + t.Fatal("audit metadata/content changed") + } + filtered, err := s.Commands("other", "", 0) + if err != nil || len(filtered) != 0 { + t.Fatal("gateway filter failed") + } + s.pruneCommands(time.Now().Add(24 * time.Hour).UTC()) + records, err = s.Commands("", "", 0) + if err != nil || len(records) != 0 { + t.Fatal("expired command logs retained") + } +} diff --git a/internal/store/event_shards.go b/internal/store/event_shards.go new file mode 100644 index 0000000..cd45bbc --- /dev/null +++ b/internal/store/event_shards.go @@ -0,0 +1,154 @@ +package store + +import ( + "context" + "errors" + "log" + "time" +) + +type EventFilter struct{ Gateway, Source, Category, Keyword, From, To string } + +func (s *Store) Event(id, source, category, detail string) { + s.auditOps.RLock() + defer s.auditOps.RUnlock() + at := time.Now().UTC() + db, err := s.auditDB(at.Format("2006-01-02")) + if err == nil { + _, err = db.Exec("INSERT INTO events(proxy_id,source,category,detail,at) VALUES(?,?,?,?,?)", id, source, category, detail, at.Format(time.RFC3339Nano)) + } + if err != nil { + log.Print("cannot persist connection event") + } +} +func (s *Store) Events() ([]Event, error) { + return s.FilterEvents(context.Background(), EventFilter{}, 0) +} +func (s *Store) FilterEvents(ctx context.Context, f EventFilter, before int64) ([]Event, error) { + out := []Event{} + err := s.WalkEvents(ctx, f, before, 300, func(e Event) error { out = append(out, e); return nil }) + return out, err +} +func (s *Store) WalkEvents(ctx context.Context, f EventFilter, before int64, limit int, visit func(Event) error) error { + s.auditOps.RLock() + defer s.auditOps.RUnlock() + days, err := s.auditDays() + if err != nil { + return err + } + count := 0 + for _, day := range days { + date, _ := time.Parse("2006-01-02", day) + base := date.Unix() / 86400 * shardStride + if before > 0 && base >= before { + continue + } + cursor := int64(0) + if before > base && before < base+shardStride { + cursor = before - base + } + query := "SELECT id,proxy_id,source,category,detail,at FROM events WHERE (?=0 OR id0" + args = append(args, f.Keyword) + } + if f.From != "" { + query += " AND julianday(at)>=julianday(?)" + args = append(args, f.From) + } + if f.To != "" { + query += " AND julianday(at)<=julianday(?)" + args = append(args, f.To) + } + query += " ORDER BY id DESC" + db, e := s.auditDB(day) + if e != nil { + return e + } + rows, e := db.QueryContext(ctx, query, args...) + if e != nil { + return e + } + for rows.Next() { + var event Event + if e = rows.Scan(&event.ID, &event.ProxyID, &event.Source, &event.Category, &event.Detail, &event.At); e != nil { + rows.Close() + return e + } + if event.ID >= shardStride { + rows.Close() + return errors.New("event shard row limit exceeded") + } + event.ID += base + if e = visit(event); e != nil { + rows.Close() + return e + } + count++ + if limit > 0 && count >= limit { + rows.Close() + return nil + } + } + e = rows.Err() + rows.Close() + if e != nil { + return e + } + } + return nil +} +func (s *Store) migrateEvents() error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + rows, err := tx.Query("SELECT id,proxy_id,source,category,detail,at FROM events ORDER BY id") + if err != nil { + return err + } + for rows.Next() { + var event Event + if err = rows.Scan(&event.ID, &event.ProxyID, &event.Source, &event.Category, &event.Detail, &event.At); err != nil { + rows.Close() + return err + } + date, e := time.Parse(time.RFC3339Nano, event.At) + if e != nil { + rows.Close() + return e + } + db, e := s.auditDB(date.UTC().Format("2006-01-02")) + if e != nil { + rows.Close() + return e + } + if _, e = db.Exec("INSERT OR IGNORE INTO events(id,proxy_id,source,category,detail,at) VALUES(?,?,?,?,?,?)", event.ID, event.ProxyID, event.Source, event.Category, event.Detail, event.At); e != nil { + rows.Close() + return e + } + var existing Event + e = db.QueryRow("SELECT id,proxy_id,source,category,detail,at FROM events WHERE id=?", event.ID).Scan(&existing.ID, &existing.ProxyID, &existing.Source, &existing.Category, &existing.Detail, &existing.At) + if e != nil || existing != event { + rows.Close() + return errors.New("event migration conflict") + } + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + if _, err = tx.Exec("DELETE FROM events"); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/store/event_shards_test.go b/internal/store/event_shards_test.go new file mode 100644 index 0000000..162d27f --- /dev/null +++ b/internal/store/event_shards_test.go @@ -0,0 +1,63 @@ +package store + +import ( + "agentdb/internal/security" + "context" + "encoding/base64" + "path/filepath" + "testing" + "time" +) + +func TestEventsShardingMigrationPaginationAndRetention(t *testing.T) { + vault, _ := security.NewVault(base64.StdEncoding.EncodeToString(make([]byte, 32))) + path := filepath.Join(t.TempDir(), "config.sqlite") + s, err := Open(path, vault) + if err != nil { + t.Fatal(err) + } + older := time.Now().UTC().Add(-24 * time.Hour) + today := time.Now().UTC() + for i := 0; i < 305; i++ { + at := older + if i >= 150 { + at = today + } + if _, err = s.db.Exec("INSERT INTO events(proxy_id,source,category,detail,at) VALUES(?,?,?,?,?)", "gateway", "127.0.0.1", "connected", "legacy event", at.Format(time.RFC3339Nano)); err != nil { + t.Fatal(err) + } + } + s.Close() + s, err = Open(path, vault) + if err != nil { + t.Fatal(err) + } + defer s.Close() + first, err := s.FilterEvents(context.Background(), EventFilter{Category: "connected"}, 0) + if err != nil || len(first) != 300 { + t.Fatal(len(first), err) + } + second, err := s.FilterEvents(context.Background(), EventFilter{}, first[len(first)-1].ID) + if err != nil || len(second) != 5 { + t.Fatal(len(second), err) + } + count := 0 + if err = s.WalkEvents(context.Background(), EventFilter{Keyword: "legacy"}, 0, 0, func(Event) error { count++; return nil }); err != nil || count != 305 { + t.Fatal(count, err) + } + var legacy int + s.db.QueryRow("SELECT COUNT(*) FROM events").Scan(&legacy) + if legacy != 0 { + t.Fatal("events left in config database") + } + s.Event("gateway", "127.0.0.1", "disconnected", "new event") + recent, err := s.FilterEvents(context.Background(), EventFilter{Category: "disconnected"}, 0) + if err != nil || len(recent) != 1 { + t.Fatal(err) + } + s.pruneCommands(today.Truncate(24 * time.Hour)) + remaining, err := s.FilterEvents(context.Background(), EventFilter{}, 0) + if err != nil || len(remaining) != 156 { + t.Fatal(len(remaining), err) + } +} diff --git a/internal/store/migration_test.go b/internal/store/migration_test.go new file mode 100644 index 0000000..ac2f392 --- /dev/null +++ b/internal/store/migration_test.go @@ -0,0 +1,89 @@ +package store + +import ( + "agentdb/internal/model" + "agentdb/internal/security" + "encoding/base64" + "path/filepath" + "strings" + "testing" +) + +func TestLegacyMigrationAndIndependentTargetStorage(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.sqlite") + vault, _ := security.NewVault(base64.StdEncoding.EncodeToString(make([]byte, 32))) + s, err := Open(path, vault) + if err != nil { + t.Fatal(err) + } + p := model.Defaults() + p.ID = "old" + p.Name = "original" + p.Type = "ssh" + p.AccessMode = "" + p.Enabled = true + p.TargetHost = "original.internal" + p.TargetPassword = "upstream-secret" + p.GatewayPassword = "client-secret" + p.SSH.HostKey = "stable-gateway-key" + p.SSH.TargetHostKey = "pinned-target-key" + p.SSH.PrivateKey = "upstream-private-key" + blob, err := s.seal(p, "proxy:"+p.ID) + if err != nil { + t.Fatal(err) + } + if _, err = s.db.Exec("INSERT INTO proxies VALUES(?,?)", p.ID, blob); err != nil { + t.Fatal(err) + } + s.Close() + s, err = Open(path, vault) + if err != nil { + t.Fatal(err) + } + defer s.Close() + got, err := s.Get("old") + if err != nil { + t.Fatal(err) + } + if got.TargetID == "" || !got.Enabled || got.TargetPassword != p.TargetPassword || got.SSH.HostKey != p.SSH.HostKey || got.SSH.TargetHostKey != p.SSH.TargetHostKey || got.SSH.PrivateKey != p.SSH.PrivateKey { + t.Fatal("migration lost credentials, pins or enabled state") + } + var raw []byte + if err = s.db.QueryRow("SELECT config FROM gateways WHERE id='old'").Scan(&raw); err != nil { + t.Fatal(err) + } + plain, err := vault.Open(raw, "proxy:old") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(plain), p.TargetPassword) || strings.Contains(string(plain), p.SSH.PrivateKey) || strings.Contains(string(plain), p.TargetHost) { + t.Fatal("upstream data duplicated in gateway") + } + target, err := s.GetTarget(got.TargetID) + if err != nil { + t.Fatal(err) + } + target.TargetHost = "updated.internal" + if err = s.SaveTarget(target); err != nil { + t.Fatal(err) + } + // Saving a stale runtime snapshot may update gateway state, never upstream data. + got.Enabled = false + if err = s.Save(got); err != nil { + t.Fatal(err) + } + reloaded, err := s.Get(got.ID) + if err != nil || reloaded.TargetHost != "updated.internal" { + t.Fatal("gateway save overwrote target") + } + if err = s.DeleteTarget(target.ID); err == nil { + t.Fatal("referenced target deletion accepted") + } + if err = s.migrateTargets(); err != nil { + t.Fatal(err) + } + targets, err := s.Targets() + if err != nil || len(targets) != 1 { + t.Fatal("migration is not idempotent") + } +} diff --git a/internal/store/outbounds.go b/internal/store/outbounds.go new file mode 100644 index 0000000..fce1244 --- /dev/null +++ b/internal/store/outbounds.go @@ -0,0 +1,166 @@ +package store + +import ( + "agentdb/internal/model" + "agentdb/internal/security" + "database/sql" + "errors" +) + +func (s *Store) GetOutbound(id string) (model.OutboundProfile, error) { + var p model.OutboundProfile + var blob []byte + err := s.db.QueryRow("SELECT config FROM outbounds WHERE id=?", id).Scan(&blob) + if err == nil { + err = s.unseal(blob, "outbound:"+id, &p) + } + return p, err +} +func (s *Store) Outbounds() ([]model.OutboundProfile, error) { + rows, err := s.db.Query("SELECT id,config FROM outbounds ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + out := []model.OutboundProfile{} + for rows.Next() { + var id string + var blob []byte + var p model.OutboundProfile + if err = rows.Scan(&id, &blob); err != nil { + return nil, err + } + if err = s.unseal(blob, "outbound:"+id, &p); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} +func (s *Store) saveOutbound(tx *sql.Tx, p model.OutboundProfile) error { + blob, err := s.seal(p, "outbound:"+p.ID) + if err != nil { + return err + } + _, err = tx.Exec("INSERT INTO outbounds(id,config) VALUES(?,?) ON CONFLICT(id) DO UPDATE SET config=excluded.config", p.ID, blob) + return err +} +func (s *Store) SaveOutbound(p model.OutboundProfile) error { + targets, err := s.Targets() + if err != nil { + return err + } + old, oldErr := s.GetOutbound(p.ID) + if oldErr != nil && !errors.Is(oldErr, sql.ErrNoRows) { + return oldErr + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err = s.saveOutbound(tx, p); err != nil { + return err + } + if oldErr == nil && old.Outbound != p.Outbound { + for _, t := range targets { + if t.OutboundID == p.ID { + t.SSH.TargetHostKey = "" + if err = s.saveTarget(tx, t); err != nil { + return err + } + } + } + } + return tx.Commit() +} +func (s *Store) OutboundReferences(id string) ([]string, error) { + targets, err := s.Targets() + if err != nil { + return nil, err + } + ids := []string{} + for _, t := range targets { + if t.OutboundID == id { + ids = append(ids, t.ID) + } + } + return ids, nil +} +func (s *Store) DeleteOutbound(id string) error { + refs, err := s.OutboundReferences(id) + if err != nil { + return err + } + if len(refs) > 0 { + return errors.New("outbound is referenced by targets") + } + _, err = s.db.Exec("DELETE FROM outbounds WHERE id=?", id) + return err +} + +func (s *Store) resolveOutbound(t model.Target) (model.Target, error) { + if t.OutboundID == "" { + return t, nil + } + p, err := s.GetOutbound(t.OutboundID) + if err == nil { + t.Outbound = p.Outbound + } + return t, err +} + +// Store target references only. Legacy inline input is promoted atomically. +func (s *Store) saveTarget(tx *sql.Tx, t model.Target) error { + if t.OutboundID == "" && t.Outbound.Mode != "" && t.Outbound.Mode != "direct" { + t.OutboundID = security.Token()[:16] + if err := s.saveOutbound(tx, model.OutboundProfile{ID: t.OutboundID, Name: t.Name + " proxy", Outbound: t.Outbound}); err != nil { + return err + } + } + if t.OutboundID != "" { + var blob []byte + if err := tx.QueryRow("SELECT config FROM outbounds WHERE id=?", t.OutboundID).Scan(&blob); err != nil { + return err + } + t.Outbound = model.Outbound{} + } else { + t.Outbound = model.Outbound{Mode: "direct", Auth: "none"} + } + blob, err := s.seal(t, "target:"+t.ID) + if err != nil { + return err + } + _, err = tx.Exec("INSERT INTO targets(id,config) VALUES(?,?) ON CONFLICT(id) DO UPDATE SET config=excluded.config", t.ID, blob) + return err +} + +func (s *Store) migrateOutbounds() error { + var done int + if err := s.db.QueryRow("SELECT COUNT(*) FROM migrations WHERE name='shared_outbounds'").Scan(&done); err != nil { + return err + } + if done > 0 { + return nil + } + targets, err := s.Targets() + if err != nil { + return err + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, t := range targets { + if t.OutboundID == "" && t.Outbound.Mode != "" && t.Outbound.Mode != "direct" { + if err = s.saveTarget(tx, t); err != nil { + return err + } + } + } + if _, err = tx.Exec("INSERT INTO migrations(name) VALUES('shared_outbounds')"); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/store/outbounds_test.go b/internal/store/outbounds_test.go new file mode 100644 index 0000000..c25cc41 --- /dev/null +++ b/internal/store/outbounds_test.go @@ -0,0 +1,131 @@ +package store + +import ( + "agentdb/internal/model" + "agentdb/internal/security" + "bytes" + "encoding/base64" + "path/filepath" + "testing" +) + +func outboundTestStore(t *testing.T) (*Store, string, *security.Vault) { + t.Helper() + path := filepath.Join(t.TempDir(), "agentdb.sqlite") + vault, err := security.NewVault(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{3}, 32))) + if err != nil { + t.Fatal(err) + } + s, err := Open(path, vault) + if err != nil { + t.Fatal(err) + } + return s, path, vault +} +func TestOutboundMigrationAndResolution(t *testing.T) { + s, path, vault := outboundTestStore(t) + legacy := model.Target{ID: "old-target", Name: "old", Type: "mysql", Outbound: model.Outbound{Mode: "socks5", Host: "proxy.internal", Port: 1080, Auth: "password", Username: "u", Password: "unique-proxy-secret"}} + blob, err := s.seal(legacy, "target:"+legacy.ID) + if err != nil { + t.Fatal(err) + } + if _, err = s.db.Exec("INSERT INTO targets(id,config) VALUES(?,?)", legacy.ID, blob); err != nil { + t.Fatal(err) + } + if _, err = s.db.Exec("DELETE FROM migrations WHERE name='shared_outbounds'"); err != nil { + t.Fatal(err) + } + s.Close() + s, err = Open(path, vault) + if err != nil { + t.Fatal(err) + } + defer s.Close() + got, err := s.GetTarget(legacy.ID) + if err != nil { + t.Fatal(err) + } + if got.OutboundID == "" || got.Outbound != legacy.Outbound { + t.Fatal("legacy outbound not migrated and resolved") + } + profiles, err := s.Outbounds() + if err != nil || len(profiles) != 1 { + t.Fatalf("profiles %v %v", profiles, err) + } + if err = s.db.QueryRow("SELECT config FROM targets WHERE id=?", legacy.ID).Scan(&blob); err != nil { + t.Fatal(err) + } + var persisted model.Target + if err = s.unseal(blob, "target:"+legacy.ID, &persisted); err != nil { + t.Fatal(err) + } + if persisted.Outbound != (model.Outbound{}) || persisted.OutboundID != got.OutboundID { + t.Fatal("target duplicated outbound configuration") + } + if err = s.migrateOutbounds(); err != nil { + t.Fatal(err) + } + profiles, err = s.Outbounds() + if err != nil || len(profiles) != 1 { + t.Fatal("migration is not idempotent") + } + for _, id := range []string{"gateway-a", "gateway-b"} { + if err = s.Save(model.Proxy{ID: id, TargetID: got.ID, Type: got.Type}); err != nil { + t.Fatal(err) + } + } + profile := profiles[0] + profile.Outbound.Host = "changed.internal" + got.SSH.TargetHostKey = "trusted-old-route" + if err = s.SaveTarget(got); err != nil { + t.Fatal(err) + } + if err = s.SaveOutbound(profile); err != nil { + t.Fatal(err) + } + ps, err := s.List() + if err != nil || len(ps) != 2 { + t.Fatal("gateway list failed", err) + } + for _, p := range ps { + if p.Outbound.Host != "changed.internal" || p.Outbound.Password != legacy.Outbound.Password || p.SSH.TargetHostKey != "" { + t.Fatal("shared route or trust invalidation failed") + } + } + if err = s.DeleteOutbound(profile.ID); err == nil { + t.Fatal("deleted referenced outbound") + } +} + +func TestOutboundMigrationFailureIsAtomic(t *testing.T) { + s, _, _ := outboundTestStore(t) + defer s.Close() + legacy := model.Target{ID: "a", Name: "old", Outbound: model.Outbound{Mode: "socks5", Host: "localhost", Port: 1080, Auth: "none"}} + blob, err := s.seal(legacy, "target:a") + if err != nil { + t.Fatal(err) + } + if _, err = s.db.Exec("INSERT INTO targets(id,config) VALUES('a',?),('z',?)", blob, []byte("bad cipher")); err != nil { + t.Fatal(err) + } + if _, err = s.db.Exec("DELETE FROM migrations WHERE name='shared_outbounds'"); err != nil { + t.Fatal(err) + } + if err = s.migrateOutbounds(); err == nil { + t.Fatal("corrupt migration succeeded") + } + var count int + if err = s.db.QueryRow("SELECT COUNT(*) FROM outbounds").Scan(&count); err != nil || count != 0 { + t.Fatal("migration partially persisted") + } + var preserved []byte + if err = s.db.QueryRow("SELECT config FROM targets WHERE id='a'").Scan(&preserved); err != nil || !bytes.Equal(blob, preserved) { + t.Fatal("migration changed source on failure") + } + if _, err = s.db.Exec("DELETE FROM targets WHERE id='z'"); err != nil { + t.Fatal(err) + } + if err = s.migrateOutbounds(); err != nil { + t.Fatal("migration retry failed", err) + } +} diff --git a/internal/store/services.go b/internal/store/services.go new file mode 100644 index 0000000..562367d --- /dev/null +++ b/internal/store/services.go @@ -0,0 +1,252 @@ +package store + +import ( + "agentdb/internal/model" + "database/sql" + "encoding/json" + "errors" +) + +func (s *Store) seal(value any, purpose string) ([]byte, error) { + raw, err := json.Marshal(value) + if err != nil { + return nil, err + } + return s.vault.Seal(raw, purpose) +} +func (s *Store) unseal(blob []byte, purpose string, value any) error { + raw, err := s.vault.Open(blob, purpose) + if err != nil { + return err + } + return json.Unmarshal(raw, value) +} + +// migrateTargets is atomic: a decryption/write failure leaves all legacy rows intact. +func (s *Store) migrateTargets() error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + rows, err := tx.Query("SELECT id,config FROM proxies") + if err != nil { + return err + } + var legacy []model.Proxy + for rows.Next() { + var id string + var blob []byte + if err = rows.Scan(&id, &blob); err != nil { + rows.Close() + return err + } + var p model.Proxy + if err = s.unseal(blob, "proxy:"+id, &p); err != nil { + rows.Close() + return err + } + legacy = append(legacy, p) + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + for _, p := range legacy { + if err = s.saveGateway(tx, p); err != nil { + return err + } + } + if _, err = tx.Exec("DELETE FROM proxies; PRAGMA user_version=2;"); err != nil { + return err + } + return tx.Commit() +} +func (s *Store) Save(p model.Proxy) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err = s.saveGateway(tx, p); err != nil { + return err + } + return tx.Commit() +} +func (s *Store) saveGateway(tx *sql.Tx, p model.Proxy) error { + if p.TargetID == "" { + // Legacy import compatibility; new API gateways must reference a target. + p.TargetID = "legacy-" + p.ID + t := model.TargetFromProxy(p) + t.Name = p.Name + " · 目标" + var count int + if err := tx.QueryRow("SELECT COUNT(*) FROM targets WHERE id=?", t.ID).Scan(&count); err != nil { + return err + } + if count == 0 { + if err := s.saveTarget(tx, t); err != nil { + return err + } + } + } + var targetBlob []byte + if err := tx.QueryRow("SELECT config FROM targets WHERE id=?", p.TargetID).Scan(&targetBlob); err != nil { + return err + } + var target model.Target + if err := s.unseal(targetBlob, "target:"+p.TargetID, &target); err != nil { + return err + } + if target.Type != p.Type { + return errors.New("gateway and target protocols must match") + } + blob, err := s.seal(p.GatewayOnly(), "proxy:"+p.ID) + if err != nil { + return err + } + _, err = tx.Exec("INSERT INTO gateways(id,target_id,config) VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET target_id=excluded.target_id,config=excluded.config", p.ID, p.TargetID, blob) + return err +} +func (s *Store) Get(id string) (model.Proxy, error) { + var p model.Proxy + var t model.Target + var pb, tb []byte + var tid string + err := s.db.QueryRow("SELECT g.config,t.id,t.config FROM gateways g JOIN targets t ON t.id=g.target_id WHERE g.id=?", id).Scan(&pb, &tid, &tb) + if err != nil { + return p, err + } + if err = s.unseal(pb, "proxy:"+id, &p); err != nil { + return p, err + } + if err = s.unseal(tb, "target:"+tid, &t); err != nil { + return p, err + } + t, err = s.resolveOutbound(t) + return t.Apply(p), err +} +func (s *Store) List() ([]model.Proxy, error) { + profiles, err := s.Outbounds() + if err != nil { + return nil, err + } + byID := map[string]model.Outbound{} + for _, profile := range profiles { + byID[profile.ID] = profile.Outbound + } + rows, err := s.db.Query("SELECT g.id,g.config,t.id,t.config FROM gateways g JOIN targets t ON t.id=g.target_id ORDER BY g.id") + if err != nil { + return nil, err + } + defer rows.Close() + out := []model.Proxy{} + for rows.Next() { + var id, tid string + var pb, tb []byte + var p model.Proxy + var t model.Target + if err = rows.Scan(&id, &pb, &tid, &tb); err != nil { + return nil, err + } + if err = s.unseal(pb, "proxy:"+id, &p); err != nil { + return nil, err + } + if err = s.unseal(tb, "target:"+tid, &t); err != nil { + return nil, err + } + if t.OutboundID != "" { + resolved, ok := byID[t.OutboundID] + if !ok { + return nil, errors.New("outbound not found") + } + t.Outbound = resolved + } + out = append(out, t.Apply(p)) + } + return out, rows.Err() +} +func (s *Store) Delete(id string) error { + _, err := s.db.Exec("DELETE FROM gateways WHERE id=?", id) + return err +} +func (s *Store) SaveTarget(t model.Target) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err = s.saveTarget(tx, t); err != nil { + return err + } + return tx.Commit() +} +func (s *Store) GetTarget(id string) (model.Target, error) { + var t model.Target + var blob []byte + err := s.db.QueryRow("SELECT config FROM targets WHERE id=?", id).Scan(&blob) + if err != nil { + return t, err + } + err = s.unseal(blob, "target:"+id, &t) + if err != nil { + return t, err + } + return s.resolveOutbound(t) +} +func (s *Store) Targets() ([]model.Target, error) { + profiles, err := s.Outbounds() + if err != nil { + return nil, err + } + byID := map[string]model.Outbound{} + for _, profile := range profiles { + byID[profile.ID] = profile.Outbound + } + rows, err := s.db.Query("SELECT id,config FROM targets ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + out := []model.Target{} + for rows.Next() { + var id string + var blob []byte + var t model.Target + if err = rows.Scan(&id, &blob); err != nil { + return nil, err + } + if err = s.unseal(blob, "target:"+id, &t); err != nil { + return nil, err + } + if t.OutboundID != "" { + resolved, ok := byID[t.OutboundID] + if !ok { + return nil, errors.New("outbound not found") + } + t.Outbound = resolved + } + out = append(out, t) + } + return out, rows.Err() +} +func (s *Store) TargetReferences(id string) ([]string, error) { + rows, err := s.db.Query("SELECT id FROM gateways WHERE target_id=? ORDER BY id", id) + if err != nil { + return nil, err + } + defer rows.Close() + ids := []string{} + for rows.Next() { + var id string + if err = rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} +func (s *Store) DeleteTarget(id string) error { + _, err := s.db.Exec("DELETE FROM targets WHERE id=?", id) + return err +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..01be228 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,144 @@ +package store + +import ( + "agentdb/internal/security" + "database/sql" + "path/filepath" + "sync" + + "errors" + "golang.org/x/crypto/bcrypt" + _ "modernc.org/sqlite" + "time" +) + +type Store struct { + db *sql.DB + vault *security.Vault + auditDir string + auditMu sync.Mutex + auditOps sync.RWMutex // Prevent shard deletion while readers or writers use its handle. + closed bool + audits map[string]*sql.DB +} +type Event struct { + ID int64 `json:"id"` + ProxyID string `json:"proxyId"` + Source string `json:"source"` + Category string `json:"category"` + Detail string `json:"detail"` + At string `json:"at"` +} + +func Open(path string, v *security.Vault) (*Store, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + s := &Store{db: db, vault: v, auditDir: filepath.Join(filepath.Dir(path), "audit"), audits: map[string]*sql.DB{}} + _, err = db.Exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; + CREATE TABLE IF NOT EXISTS proxies(id TEXT PRIMARY KEY, config BLOB NOT NULL); + CREATE TABLE IF NOT EXISTS admin(id INTEGER PRIMARY KEY CHECK(id=1), username TEXT NOT NULL, password BLOB NOT NULL, revision TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS events(id INTEGER PRIMARY KEY AUTOINCREMENT, proxy_id TEXT NOT NULL, source TEXT NOT NULL, category TEXT NOT NULL, detail TEXT NOT NULL, at TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS events_at ON events(at); + CREATE TABLE IF NOT EXISTS targets(id TEXT PRIMARY KEY, config BLOB NOT NULL); + CREATE TABLE IF NOT EXISTS outbounds(id TEXT PRIMARY KEY, config BLOB NOT NULL); + CREATE TABLE IF NOT EXISTS migrations(name TEXT PRIMARY KEY); + CREATE TABLE IF NOT EXISTS gateways(id TEXT PRIMARY KEY, target_id TEXT NOT NULL REFERENCES targets(id), config BLOB NOT NULL); + CREATE INDEX IF NOT EXISTS gateways_target ON gateways(target_id); + CREATE TABLE IF NOT EXISTS commands(id TEXT PRIMARY KEY,proxy_id TEXT NOT NULL,target_id TEXT NOT NULL,source TEXT NOT NULL,protocol TEXT NOT NULL,kind TEXT NOT NULL,decision TEXT NOT NULL,content BLOB NOT NULL,at TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS commands_at ON commands(at); + CREATE INDEX IF NOT EXISTS commands_proxy ON commands(proxy_id); + PRAGMA foreign_keys=ON;`) + if err != nil { + db.Close() + return nil, err + } + if err = s.migrateTargets(); err != nil { + db.Close() + return nil, err + } + if err = s.migrateOutbounds(); err != nil { + s.Close() + return nil, err + } + if err = s.migrateCommands(); err != nil { + s.Close() + return nil, err + } + if err = s.migrateEvents(); err != nil { + s.Close() + return nil, err + } + return s, nil +} +func (s *Store) Close() error { + s.auditOps.Lock() + defer s.auditOps.Unlock() + s.auditMu.Lock() + defer s.auditMu.Unlock() + s.closed = true + for _, db := range s.audits { + db.Close() + } + return s.db.Close() +} +func (s *Store) HasAdmin() (bool, error) { + var count int + err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&count) + return count > 0, err +} +func (s *Store) InitAdmin(user, password string) error { + var count int + if err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&count); err != nil { + return err + } + if count > 0 { + return nil + } + if len(user) == 0 || len(password) < 12 || len(password) > 72 { + return errors.New("first startup requires AGENTDB_ADMIN_USER and AGENTDB_ADMIN_PASSWORD (12-72 bytes)") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + _, err = s.db.Exec("INSERT INTO admin VALUES(1,?,?,?)", user, hash, security.Token()) + return err +} +func (s *Store) Verify(user, password string) (string, bool) { + var actual, rev string + var hash []byte + if err := s.db.QueryRow("SELECT username,password,revision FROM admin WHERE id=1").Scan(&actual, &hash, &rev); err != nil { + return "", false + } + err := bcrypt.CompareHashAndPassword(hash, []byte(password)) + return rev, err == nil && user == actual +} +func (s *Store) Revision() (string, error) { + var rev string + err := s.db.QueryRow("SELECT revision FROM admin WHERE id=1").Scan(&rev) + return rev, err +} +func (s *Store) ResetPassword(password string) error { + if len(password) < 12 || len(password) > 72 { + return errors.New("password must be 12-72 bytes") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + res, err := s.db.Exec("UPDATE admin SET password=?,revision=? WHERE id=1", hash, security.Token()) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n != 1 { + return errors.New("administrator not initialized") + } + return nil +} +func (s *Store) Prune() { + s.pruneCommands(time.Now().Add(-7 * 24 * time.Hour).UTC()) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..77e0c87 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,79 @@ +package store + +import ( + "agentdb/internal/model" + "agentdb/internal/security" + "bytes" + "encoding/base64" + "os" + "path/filepath" + "testing" +) + +func TestEncryptedPersistenceAndAdminReset(t *testing.T) { + path := filepath.Join(t.TempDir(), "agentdb.sqlite") + vault, err := security.NewVault(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32))) + if err != nil { + t.Fatal(err) + } + s, err := Open(path, vault) + if err != nil { + t.Fatal(err) + } + if err = s.InitAdmin("admin", "old-admin-password"); err != nil { + t.Fatal(err) + } + rev, ok := s.Verify("admin", "old-admin-password") + if !ok { + t.Fatal("initial authentication failed") + } + p := model.Defaults() + p.ID = "unique" + p.TargetPassword = "private-target-credential" + p.GatewayPassword = "private-gateway-credential" + p.Outbound.Password = "private-socks-credential" + p.SSH.PrivateKey = "private-key-material" + if err = s.Save(p); err != nil { + t.Fatal(err) + } + if err = s.ResetPassword("new-admin-password"); err != nil { + t.Fatal(err) + } + next, ok := s.Verify("admin", "new-admin-password") + if !ok || next == rev { + t.Fatal("reset must update credentials and session revision") + } + if _, ok = s.Verify("admin", "old-admin-password"); ok { + t.Fatal("old password accepted") + } + if err = s.Close(); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{p.TargetPassword, p.GatewayPassword, p.Outbound.Password, p.SSH.PrivateKey, "old-admin-password", "new-admin-password"} { + if bytes.Contains(raw, []byte(secret)) { + t.Fatal("plaintext secret persisted") + } + } + s, err = Open(path, vault) + if err != nil { + t.Fatal(err) + } + got, err := s.Get(p.ID) + if err != nil || got.TargetPassword != p.TargetPassword { + t.Fatal("encrypted configuration did not survive reopen") + } + s.Close() + wrong, _ := security.NewVault(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32))) + s, err = Open(path, wrong) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if _, err = s.List(); err == nil { + t.Fatal("wrong master key accepted") + } +} diff --git a/internal/testutil/outbound.go b/internal/testutil/outbound.go new file mode 100644 index 0000000..c70a4d1 --- /dev/null +++ b/internal/testutil/outbound.go @@ -0,0 +1,35 @@ +// Package testutil contains opt-in disposable integration fixture helpers. +package testutil + +import ( + "agentdb/internal/model" + "agentdb/internal/outbound" + "net" + "os" + "strconv" + "testing" +) + +// FixtureDial configures the real production dialer, allowing the exact same +// protocol assertions to run direct and through either SOCKS5 authentication. +func FixtureDial(t testing.TB, p *model.Proxy) model.DialFunc { + t.Helper() + p.Outbound = model.Outbound{Mode: "direct"} + if address := os.Getenv("AGENTDB_TEST_SOCKS_ADDR"); address != "" { + host, port, err := net.SplitHostPort(address) + if err != nil { + t.Fatal(err) + } + n, err := strconv.Atoi(port) + if err != nil { + t.Fatal(err) + } + p.Outbound = model.Outbound{Mode: "socks5", Host: host, Port: n, Auth: "none"} + if user := os.Getenv("AGENTDB_TEST_SOCKS_USER"); user != "" { + p.Outbound.Auth = "password" + p.Outbound.Username = user + p.Outbound.Password = os.Getenv("AGENTDB_TEST_SOCKS_PASSWORD") + } + } + return outbound.Dialer(*p) +} diff --git a/packaging/windows/README.md b/packaging/windows/README.md new file mode 100644 index 0000000..37de872 --- /dev/null +++ b/packaging/windows/README.md @@ -0,0 +1,62 @@ +# AgentDB Windows 双击运行包 + +适用于 Windows x64,无需安装 Go 或 Node.js。网页界面已嵌入 `agentdb.exe`。 + +顶部版本入口及 `agentdb.exe --version --json` 显示当前版本、Git 提交与构建时间。Kubernetes 出口内置 port-forward,无需安装 kubectl;目标服务可配置 kubeconfig 路径、context、命名空间与 Pod/Service/Deployment,详见 [Kubernetes](docs/kubernetes.md)。 + +## 启动 + +「代理」菜单独立维护通用出口;目标服务通过下拉框选择代理或直接连接。多个目标可复用同一代理。代理被引用时禁止删除,修改前先停止所有关联网关;旧目标的内联出口自动迁移为独立加密配置。 + +1. 完整解压 ZIP,双击 `agentdb.exe`。 +2. 浏览器自动打开;开发阶段默认免登录,直接进入控制台。 +3. 先添加目标服务,再创建网关并选择关联目标;多个网关可复用一个目标。以后双击即用。右上角支持浅色、深色、海蓝和跟随系统主题。 + +默认管理地址为 http://127.0.0.1:8080。保持程序窗口运行,按 Ctrl+C 停止服务;关闭窗口也会结束进程。不要重复启动多个窗口。端口被占用或初始化失败时,窗口显示错误并等待 Enter,不会闪退。浏览器未自动打开时,可复制窗口中的管理页面地址;开启认证后的首次设置须使用包含一次性 token 的完整地址。 + +## 管理员登录(可选) + +默认 `AGENTDB_AUTH_ENABLED=false`,仅允许本机管理,无需创建管理员。要启用登录,在 PowerShell 设置 `$env:AGENTDB_AUTH_ENABLED = 'true'` 后运行 `.\agentdb.exe`;首次会打开管理员设置页面,已有账号则直接登录。开启认证后密码仍使用 bcrypt 保存。 + +## 数据和备份 + +本地数据固定保存在 `%APPDATA%\AgentDB`,不依赖解压目录或启动时的工作目录。更新前停止服务并备份整个目录。0.2.0 自动将旧代理记录拆成目标与网关引用,保留凭据、SSH 指纹和启用状态;迁移后不要使用旧版程序直接打开新数据库。 + +- `agentdb.sqlite`:可选管理员 bcrypt 密码、独立加密的目标/网关配置。 +- `audit/YYYY-MM-DD.sqlite`:按 UTC 日期分库的命令审计与连接事件,升级时自动迁移旧日志。 +- `master-key.dpapi`:Windows DPAPI 加密的主密钥,绑定当前 Windows 用户。 +- `tls/`:自动生成的本机数据库网关证书和私钥。 +- `desktop.lock`:运行锁文件,进程退出自动释放,不需要删除。 + +停止服务后备份整个数据目录。不要单独删除密钥;密钥丢失后原代理配置无法解密。DPAPI 文件不能直接迁移给另一 Windows 用户或另一台电脑。跨机器部署请使用服务器模式,自行保管并备份主密钥。 + +管理员登录密码不可查看,通过在解压目录运行 `.\agentdb.exe reset-password` 交互重置(先停止服务)。目标服务、网关、SOCKS5 密码可分别查看;开启认证时需要再次输入管理员密码,开发模式直接查看。 + +## 数据库 TLS + +双击模式自动生成有效期一年的本机自签名证书,覆盖 `localhost`、`127.0.0.1` 和 `::1`。MySQL/MongoDB 客户端使用 `%APPDATA%\AgentDB\tls\cert.pem` 作为 CA;SSH 使用自身主机密钥。 + +证书到期前,停止服务并备份、移走 `tls` 目录,下次启动会生成新证书,需要更新客户端 CA。局域网访问代理时,应配置覆盖实际网关域名的受信任证书,通过 `AGENTDB_TLS_CERT` 和 `AGENTDB_TLS_KEY` 指定。 + +## 高级配置 + +设置 `AGENTDB_MASTER_KEY` 后使用手动密钥模式,不自动生成密钥、不自动打开浏览器,启用 `AGENTDB_AUTH_ENABLED=true` 时首次启动需提供 `AGENTDB_ADMIN_USER` / `AGENTDB_ADMIN_PASSWORD`。`agentdb.exe generate-key` 可生成主密钥,必须安全保存并在每次启动复用。 + +程序不自动读取 `.env`;`.env.example` 是环境变量参考。本地模式可设置 `AGENTDB_DATA` 指定数据库路径,密钥与 TLS 目录跟随该数据库目录;`AGENTDB_HTTP_ADDR` 必须使用回环 IP,默认 `127.0.0.1:8080`;`AGENTDB_NO_BROWSER=1` 禁止自动打开浏览器。 + +## 使用说明 + +- 六类目标服务支持直连、SOCKS5 或 SSH 转发出口;网关引用目标,MySQL、MongoDB 网关默认读写,可选只读。 +- SSH 保存配置后需探测主机指纹,通过可信渠道核对并确认后才能启动。 +- 修改目标前先停止所有引用网关;被引用的目标不能删除。修改网关前先停止该网关,停止会断开已有客户端。 +- 管理服务默认仅本机访问。远程管理必须设置 `AGENTDB_AUTH_ENABLED=true`,并配置 HTTPS 反向代理。 + +包内 `devcert.exe` 为独立开发证书工具(有效期 14 天),双击模式无需手动运行它。`SHA256SUMS.txt` 校验包内文件,发行包旁 `.zip.sha256` 校验整个压缩包。 + +真实 MySQL/MongoDB 多版本和 OpenSSH 的 Docker 兼容矩阵仍待实际环境验证,详见 `docs/testing.md`。 + +## 压缩与命令记录 + +在目标配置中可为 MySQL/MongoDB 开启 zlib 压缩,目标不支持时不压缩,连接测试显示实际算法;SSH 暂不支持压缩。 + +「命令审计」查看 SQL、MongoDB、SSH exec/交互输入及 SFTP 操作,支持按网关、协议筛选,内容加密保存 7 天。每条最多 16 KiB,超出标记截断。放行不表示目标执行成功;交互输入不等同远端实际执行命令,可能含程序输入。预编译 SQL 的参数值、MongoDB 批量文档序列正文、SFTP 文件内容及认证握手不记录。 diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..fecf825 --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,18 @@ +param([string]$Version) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'version.ps1') +$buildInfo = Get-AgentDBBuildInfo -Version $Version +Set-Location (Split-Path $PSScriptRoot -Parent) +$env:GOTOOLCHAIN = 'go1.25.0' +$goCommand = Get-Command go -ErrorAction SilentlyContinue +$goExecutable = if ($goCommand) { $goCommand.Source } else { 'C:\Program Files\Go\bin\go.exe' } +if (!(Test-Path -LiteralPath $goExecutable)) { throw 'Go is required' } +npm.cmd --prefix web ci --no-audit --no-fund +if ($LASTEXITCODE -ne 0) { throw 'Frontend dependency installation failed' } +npm.cmd --prefix web run build +if ($LASTEXITCODE -ne 0) { throw 'Frontend build failed' } +& $goExecutable test ./... +if ($LASTEXITCODE -ne 0) { throw 'Go tests failed' } +New-Item -ItemType Directory -Force bin | Out-Null +& $goExecutable build -trimpath -ldflags $buildInfo.LDFlags -o bin/agentdb.exe ./cmd/agentdb +if ($LASTEXITCODE -ne 0) { throw 'Go build failed' } diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..e0b862b --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env sh +set -eu +cd "$(dirname "$0")/.." +export GOTOOLCHAIN=go1.25.0 +version="${VERSION:-$(tr -d '\r\n' < VERSION)}" +if ! printf '%s\n' "$version" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then + echo 'Invalid version' >&2 + exit 1 +fi +commit="$(git rev-parse --verify HEAD 2>/dev/null || printf unknown)" +if [ "$commit" != unknown ] && [ -n "$(git status --porcelain)" ]; then commit="$commit-dirty"; fi +build_time="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +npm --prefix web ci --no-audit --no-fund +npm --prefix web run build +go test ./... +mkdir -p bin +go build -trimpath -ldflags "-s -w -X agentdb/internal/buildinfo.Version=$version -X agentdb/internal/buildinfo.Commit=$commit -X agentdb/internal/buildinfo.BuildTime=$build_time" -o bin/agentdb ./cmd/agentdb diff --git a/scripts/package.ps1 b/scripts/package.ps1 new file mode 100644 index 0000000..f037aad --- /dev/null +++ b/scripts/package.ps1 @@ -0,0 +1,57 @@ +param([string]$Version) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'version.ps1') +$buildInfo = Get-AgentDBBuildInfo -Version $Version +$Version = $buildInfo.Version +$repo = Split-Path $PSScriptRoot -Parent +$goCommand = Get-Command go -ErrorAction SilentlyContinue +$goExecutable = if ($goCommand) { $goCommand.Source } else { 'C:\Program Files\Go\bin\go.exe' } +if (!(Test-Path -LiteralPath $goExecutable)) { throw 'Go is required' } +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$name = "agentdb-$Version-windows-amd64" +$releaseRoot = Join-Path $repo "artifacts/releases/$stamp" +$stage = Join-Path $releaseRoot $name +$archive = Join-Path $releaseRoot "$name.zip" +$previous = @{} +foreach ($key in @('GOTOOLCHAIN','GOOS','GOARCH','CGO_ENABLED')) { $previous[$key] = [Environment]::GetEnvironmentVariable($key, 'Process') } +Push-Location $repo +try { + $env:GOTOOLCHAIN = 'go1.25.0' + $env:GOOS = 'windows' + $env:GOARCH = 'amd64' + $env:CGO_ENABLED = '0' + if (!(Test-Path web/node_modules/.bin/vite.cmd)) { + npm.cmd --prefix web ci --no-audit --no-fund + if ($LASTEXITCODE -ne 0) { throw 'Frontend dependency installation failed' } + } + npm.cmd --prefix web run build + if ($LASTEXITCODE -ne 0) { throw 'Frontend build failed' } + New-Item -ItemType Directory -Path (Join-Path $stage 'docs') -Force | Out-Null + & $goExecutable build -trimpath -ldflags $buildInfo.LDFlags -o (Join-Path $stage 'agentdb.exe') ./cmd/agentdb + if ($LASTEXITCODE -ne 0) { throw 'AgentDB release build failed' } + & $goExecutable build -trimpath -ldflags '-s -w' -o (Join-Path $stage 'devcert.exe') ./cmd/devcert + if ($LASTEXITCODE -ne 0) { throw 'Certificate utility build failed' } + Copy-Item -LiteralPath packaging/windows/README.md -Destination (Join-Path $stage 'README.md') + Copy-Item -LiteralPath .env.example -Destination (Join-Path $stage '.env.example') + foreach ($file in @('development-plan.md','readonly.md','testing.md','kubernetes.md')) { + Copy-Item -LiteralPath (Join-Path 'docs' $file) -Destination (Join-Path $stage "docs/$file") + } + $build = [ordered]@{ version = $Version; commit = $buildInfo.Commit; platform = 'windows-amd64'; builtAt = $buildInfo.BuildTime; go = (& $goExecutable version); revision = $buildInfo.Revision; workingTreeModified = $buildInfo.Dirty; embeddedWeb = $true } + [IO.File]::WriteAllText((Join-Path $stage 'VERSION'), "$Version`n", [Text.UTF8Encoding]::new($false)) + $utf8 = [Text.UTF8Encoding]::new($false) + [IO.File]::WriteAllText((Join-Path $stage 'build-info.json'), ($build | ConvertTo-Json) + "`n", $utf8) + $hashes = Get-ChildItem -LiteralPath $stage -File -Recurse | Sort-Object FullName | ForEach-Object { + $relative = $_.FullName.Substring($stage.Length + 1).Replace('\','/') + '{0} {1}' -f (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant(), $relative + } + [IO.File]::WriteAllText((Join-Path $stage 'SHA256SUMS.txt'), ($hashes -join "`n") + "`n", $utf8) + Add-Type -AssemblyName System.IO.Compression.FileSystem + [IO.Compression.ZipFile]::CreateFromDirectory($stage, $archive, [IO.Compression.CompressionLevel]::Optimal, $true) + $digest = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() + [IO.File]::WriteAllText("$archive.sha256", "$digest $name.zip`n", $utf8) + Write-Output $archive + Write-Output "SHA256: $digest" +} finally { + foreach ($key in $previous.Keys) { [Environment]::SetEnvironmentVariable($key, $previous[$key], 'Process') } + Pop-Location +} diff --git a/scripts/start-dev.ps1 b/scripts/start-dev.ps1 new file mode 100644 index 0000000..28487a7 --- /dev/null +++ b/scripts/start-dev.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference = 'Stop' +$projectRoot = Split-Path -Parent $PSScriptRoot +$binary = Join-Path $projectRoot 'bin\agentdb.exe' +if (-not (Test-Path -LiteralPath $binary)) { + throw 'Build first: powershell -ExecutionPolicy Bypass -File scripts\build.ps1' +} +# Use the same database and Windows-account-protected key on every launch. +$env:AGENTDB_DATA = Join-Path $projectRoot 'data\agentdb.sqlite' +$env:AGENTDB_MASTER_KEY = '' +$env:AGENTDB_AUTH_ENABLED = 'false' +$env:AGENTDB_ADMIN_USER = '' +$env:AGENTDB_ADMIN_PASSWORD = '' +$env:AGENTDB_HTTP_ADDR = '127.0.0.1:8080' +$env:AGENTDB_SECURE_COOKIE = 'false' +$env:AGENTDB_TLS_CERT = '' +$env:AGENTDB_TLS_KEY = '' +Write-Host "SQLite: $env:AGENTDB_DATA" +& $binary +exit $LASTEXITCODE diff --git a/scripts/test-matrix.ps1 b/scripts/test-matrix.ps1 new file mode 100644 index 0000000..6e2d40c --- /dev/null +++ b/scripts/test-matrix.ps1 @@ -0,0 +1,69 @@ +param([switch]$KeepFixtures) +$ErrorActionPreference = 'Stop' +$repo = Split-Path $PSScriptRoot -Parent +$compose = Join-Path $repo 'deploy/compose.test.yml' +$project = 'agentdb-matrix-' + [Guid]::NewGuid().ToString('N').Substring(0, 10) +$tempDir = Join-Path ([IO.Path]::GetTempPath()) $project +$caFile = Join-Path $tempDir 'ca.pem' +$goCommand = Get-Command go -ErrorAction SilentlyContinue +$goExecutable = if ($goCommand) { $goCommand.Source } else { 'C:\Program Files\Go\bin\go.exe' } +if (!(Test-Path -LiteralPath $goExecutable)) { throw 'Go is required' } +Get-Command docker -ErrorAction Stop | Out-Null +$names = @('GOTOOLCHAIN', 'AGENTDB_MYSQL_TEST_ADDR', 'AGENTDB_MYSQL_TEST_USER', 'AGENTDB_MYSQL_TEST_PASSWORD', 'AGENTDB_MYSQL_TEST_CA', 'AGENTDB_MONGO_TEST_ADDR', 'AGENTDB_MONGO_TEST_USER', 'AGENTDB_MONGO_TEST_PASSWORD', 'AGENTDB_MONGO_TEST_CA', 'AGENTDB_SSH_TEST_ADDR', 'AGENTDB_SSH_TEST_USER', 'AGENTDB_SSH_TEST_PASSWORD', 'AGENTDB_TEST_SOCKS_ADDR', 'AGENTDB_TEST_SOCKS_USER', 'AGENTDB_TEST_SOCKS_PASSWORD') +$previous = @{} +foreach ($name in $names) { $previous[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } +New-Item -ItemType Directory -Path $tempDir | Out-Null +Push-Location $repo +try { + $env:GOTOOLCHAIN = 'go1.25.0' + docker compose -f $compose -p $project up -d --build --wait --wait-timeout 300 + if ($LASTEXITCODE -ne 0) { throw 'Fixture startup failed' } + docker compose -f $compose -p $project run --rm --no-deps mongo80-init + if ($LASTEXITCODE -ne 0) { throw 'MongoDB replica initialization failed' } + docker compose -f $compose -p $project cp 'cert-init:/certs/ca.pem' $caFile + if ($LASTEXITCODE -ne 0) { throw 'Could not export test CA' } + $env:AGENTDB_MYSQL_TEST_USER = 'root' + $env:AGENTDB_MYSQL_TEST_PASSWORD = 'agentdb-test-password' + $env:AGENTDB_MYSQL_TEST_CA = $caFile + $env:AGENTDB_MONGO_TEST_USER = 'root' + $env:AGENTDB_MONGO_TEST_PASSWORD = 'agentdb-test-password' + $env:AGENTDB_MONGO_TEST_CA = Get-Content -LiteralPath $caFile -Raw + $env:AGENTDB_SSH_TEST_USER = 'agentdb' + $env:AGENTDB_SSH_TEST_PASSWORD = 'agentdb-test-password' + $mysqlTargets = @(@('mysql57', 13357), @('mysql80', 13380), @('mysql84', 13384)) + $mongoTargets = @(@('mongo60', 27060), @('mongo70', 27070), @('mongo80', 27080)) + foreach ($route in @('direct', 'socks-open', 'socks-auth')) { + $env:AGENTDB_TEST_SOCKS_ADDR = '' + $env:AGENTDB_TEST_SOCKS_USER = '' + $env:AGENTDB_TEST_SOCKS_PASSWORD = '' + if ($route -eq 'socks-open') { $env:AGENTDB_TEST_SOCKS_ADDR = '127.0.0.1:11080' } + if ($route -eq 'socks-auth') { + $env:AGENTDB_TEST_SOCKS_ADDR = '127.0.0.1:11081' + $env:AGENTDB_TEST_SOCKS_USER = 'socks' + $env:AGENTDB_TEST_SOCKS_PASSWORD = 'socks-test-password' + } + foreach ($target in $mysqlTargets) { + $env:AGENTDB_MYSQL_TEST_ADDR = if ($route -eq 'direct') { '127.0.0.1:' + $target[1] } else { $target[0] + ':3306' } + Write-Host "Testing MySQL $($target[0]) via $route" + & $goExecutable test -count=1 -timeout=120s ./internal/protocol/mysqlproxy -run '^TestRealMySQL$' -v + if ($LASTEXITCODE -ne 0) { throw "MySQL matrix failed: $($target[0]) / $route" } + } + foreach ($target in $mongoTargets) { + $env:AGENTDB_MONGO_TEST_ADDR = if ($route -eq 'direct') { '127.0.0.1:' + $target[1] } else { $target[0] + ':27017' } + Write-Host "Testing MongoDB $($target[0]) via $route" + & $goExecutable test -count=1 -timeout=120s ./internal/protocol/mongoproxy -run '^TestRealMongo$' -v + if ($LASTEXITCODE -ne 0) { throw "MongoDB matrix failed: $($target[0]) / $route" } + } + $env:AGENTDB_SSH_TEST_ADDR = if ($route -eq 'direct') { '127.0.0.1:12222' } else { 'ssh:22' } + Write-Host "Testing OpenSSH and SFTP via $route" + & $goExecutable test -count=1 -timeout=120s ./internal/protocol/sshproxy -run '^TestRealSSH$' -v + if ($LASTEXITCODE -ne 0) { throw "SSH matrix failed: $route" } + } +} finally { + if ($KeepFixtures) { Write-Host ('Fixtures retained. Cleanup: docker compose -f "{0}" -p {1} down -v' -f $compose, $project) } + else { docker compose -f $compose -p $project down -v } + foreach ($name in $names) { [Environment]::SetEnvironmentVariable($name, $previous[$name], 'Process') } + Pop-Location + if (Test-Path -LiteralPath $caFile) { Remove-Item -LiteralPath $caFile -Force } + Remove-Item -LiteralPath $tempDir +} diff --git a/scripts/test-matrix.sh b/scripts/test-matrix.sh new file mode 100644 index 0000000..06d9a48 --- /dev/null +++ b/scripts/test-matrix.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +command -v docker >/dev/null +command -v go >/dev/null +export GOTOOLCHAIN=go1.25.0 +matrix_tmp="$(mktemp -d)" +matrix_project="agentdb-matrix-$(date +%s)-$$" +compose=(docker compose -f deploy/compose.test.yml -p "$matrix_project") +cleanup() { + if [[ "${KEEP_FIXTURES:-0}" == 1 ]]; then + echo "Fixtures retained. Cleanup: docker compose -f deploy/compose.test.yml -p $matrix_project down -v" + else + "${compose[@]}" down -v + fi + rm -f "$matrix_tmp/ca.pem" + rmdir "$matrix_tmp" +} +trap cleanup EXIT +"${compose[@]}" up -d --build --wait --wait-timeout 300 +"${compose[@]}" run --rm --no-deps mongo80-init +"${compose[@]}" cp cert-init:/certs/ca.pem "$matrix_tmp/ca.pem" +export AGENTDB_MYSQL_TEST_USER=root +export AGENTDB_MYSQL_TEST_PASSWORD=agentdb-test-password +export AGENTDB_MYSQL_TEST_CA="$matrix_tmp/ca.pem" +export AGENTDB_MONGO_TEST_USER=root +export AGENTDB_MONGO_TEST_PASSWORD=agentdb-test-password +export AGENTDB_MONGO_TEST_CA="$(cat "$matrix_tmp/ca.pem")" +export AGENTDB_SSH_TEST_USER=agentdb +export AGENTDB_SSH_TEST_PASSWORD=agentdb-test-password +for route in direct socks-open socks-auth; do + export AGENTDB_TEST_SOCKS_ADDR='' AGENTDB_TEST_SOCKS_USER='' AGENTDB_TEST_SOCKS_PASSWORD='' + if [[ "$route" == socks-open ]]; then export AGENTDB_TEST_SOCKS_ADDR=127.0.0.1:11080; fi + if [[ "$route" == socks-auth ]]; then + export AGENTDB_TEST_SOCKS_ADDR=127.0.0.1:11081 AGENTDB_TEST_SOCKS_USER=socks AGENTDB_TEST_SOCKS_PASSWORD=socks-test-password + fi + for pair in mysql57:13357 mysql80:13380 mysql84:13384; do + service="${pair%%:*}"; port="${pair##*:}" + if [[ "$route" == direct ]]; then export AGENTDB_MYSQL_TEST_ADDR="127.0.0.1:$port"; else export AGENTDB_MYSQL_TEST_ADDR="$service:3306"; fi + echo "Testing MySQL $service via $route" + go test -count=1 -timeout=120s ./internal/protocol/mysqlproxy -run '^TestRealMySQL$' -v + done + for pair in mongo60:27060 mongo70:27070 mongo80:27080; do + service="${pair%%:*}"; port="${pair##*:}" + if [[ "$route" == direct ]]; then export AGENTDB_MONGO_TEST_ADDR="127.0.0.1:$port"; else export AGENTDB_MONGO_TEST_ADDR="$service:27017"; fi + echo "Testing MongoDB $service via $route" + go test -count=1 -timeout=120s ./internal/protocol/mongoproxy -run '^TestRealMongo$' -v + done + if [[ "$route" == direct ]]; then export AGENTDB_SSH_TEST_ADDR=127.0.0.1:12222; else export AGENTDB_SSH_TEST_ADDR=ssh:22; fi + echo "Testing OpenSSH and SFTP via $route" + go test -count=1 -timeout=120s ./internal/protocol/sshproxy -run '^TestRealSSH$' -v +done diff --git a/scripts/verify-desktop.mjs b/scripts/verify-desktop.mjs new file mode 100644 index 0000000..99bd381 --- /dev/null +++ b/scripts/verify-desktop.mjs @@ -0,0 +1,74 @@ +// Exercise the packaged Windows executable without credentials or startup commands. +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { randomBytes, X509Certificate } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { resolve, join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +const require = createRequire(new URL('../web/package.json', import.meta.url)) +const { chromium } = require('@playwright/test') +assert(process.argv[2], 'Provide packaged agentdb.exe') +const binary = resolve(process.argv[2]) +const temp = mkdtempSync(join(tmpdir(), 'agentdb-desktop-')) +const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.toUpperCase().startsWith('AGENTDB_') && k.toUpperCase() !== 'APPDATA')) +Object.assign(env, { APPDATA: temp, AGENTDB_NO_BROWSER: '1', AGENTDB_HTTP_ADDR: '127.0.0.1:0' }) +let child, browser +async function stop() { + if (child && child.exitCode === null) await new Promise(resolve => { child.once('exit', resolve); child.kill() }) +} +async function start() { + let output = '' + child = spawn(binary, [], { cwd: tmpdir(), env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + child.stdout.on('data', b => { output += b.toString() }) + child.stderr.on('data', b => { output += b.toString() }) + child.on('error', e => { output += e.message }) + for (let i = 0; i < 100; i++) { + const match = /http:\/\/127\.0\.0\.1:\d+(?:\/setup\?token=[\w-]+)?/.exec(output) + if (match) return match[0] + assert(child.exitCode === null, `Startup failed: ${output}`) + await delay(100) + } + throw new Error('Desktop startup timed out') +} +try { + const developmentURL = await start() + assert(!developmentURL.includes('setup'), 'Development startup must skip administrator setup') + const session = await fetch(developmentURL + '/api/v1/session') + assert.equal(session.status, 200) + assert.equal((await session.json()).authEnabled, false) + await stop() + env.AGENTDB_AUTH_ENABLED = 'true' + const url = await start() + assert(url.includes('/setup?token='), 'First launch must offer setup') + const keyPath = join(temp, 'AgentDB', 'master-key.dpapi') + const originalKey = readFileSync(keyPath) + const cert = new X509Certificate(readFileSync(join(temp, 'AgentDB', 'tls', 'cert.pem'))) + assert(cert.checkHost('localhost')) + browser = await chromium.launch({ channel: 'msedge', headless: true }) + const page = await browser.newPage() + await page.goto(url) + await page.getByRole('heading', { name: '欢迎使用 AgentDB' }).waitFor() + const password = randomBytes(24).toString('base64url') + await page.getByLabel('管理员用户名').fill('desktop-test') + await page.getByLabel('管理员密码', { exact: true }).fill(password) + await page.getByLabel('确认密码').fill(password) + await page.getByRole('button', { name: '保存并进入登录页面' }).click() + await page.waitForURL(new URL('/', url).href) + const base = new URL(url).origin + const auth = await fetch(base + '/api/v1/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'desktop-test', password }) }) + assert.equal(auth.status, 200) + await stop() + const secondURL = await start() + assert(!secondURL.includes('setup'), 'Restart must skip setup') + assert.deepEqual(readFileSync(keyPath), originalKey, 'Restart replaced local key') + await page.goto(secondURL) + const login = await fetch(secondURL + '/api/v1/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'desktop-test', password }) }) + assert.equal(login.status, 200, 'Saved administrator cannot log in after restart') + console.log('Desktop verified: default no-login development mode, optional administrator setup, automatic TLS, DPAPI persistence and authenticated restart.') +} finally { + if (browser) await browser.close() + await stop() + rmSync(temp, { recursive: true, force: true }) +} diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..890c61c --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { spawn, execFileSync } from 'node:child_process' +import { X509Certificate, createHash, createPrivateKey, randomBytes } from 'node:crypto' +import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { resolve, join, relative, isAbsolute } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' + +const directory = resolve(process.argv[2] || '') +assert(process.argv[2], 'Provide an extracted package directory') +const binary = join(directory, 'agentdb.exe') +const certTool = join(directory, 'devcert.exe') +for (const name of ['agentdb.exe', 'devcert.exe', 'README.md', '.env.example', 'build-info.json', 'SHA256SUMS.txt']) assert(existsSync(join(directory, name)), `Missing ${name}`) +for (const line of readFileSync(join(directory, 'SHA256SUMS.txt'), 'utf8').trim().split(/\r?\n/)) { + const match = /^([a-f0-9]{64}) (.+)$/.exec(line) + assert(match, 'Invalid checksum entry') + const filename = resolve(directory, match[2]) + const within = relative(directory, filename) + assert(within && !within.startsWith('..') && !isAbsolute(within), 'Checksum path outside package') + assert.equal(createHash('sha256').update(readFileSync(filename)).digest('hex'), match[1], `Checksum mismatch: ${match[2]}`) +} +const key = execFileSync(binary, ['generate-key'], { encoding: 'utf8', windowsHide: true }).trim() +assert.equal(Buffer.from(key, 'base64').length, 32) +const temp = mkdtempSync(join(tmpdir(), 'agentdb-package-smoke-')) +let child +try { + const tlsDir = join(temp, 'tls') + execFileSync(certTool, [tlsDir], { windowsHide: true }) + const cert = new X509Certificate(readFileSync(join(tlsDir, 'cert.pem'))) + assert(cert.checkHost('localhost')) + assert(cert.checkPrivateKey(createPrivateKey(readFileSync(join(tlsDir, 'key.pem'))))) + const listener = createServer() + await new Promise((resolve, reject) => { listener.once('error', reject); listener.listen(0, '127.0.0.1', resolve) }) + const port = listener.address().port + await new Promise(resolve => listener.close(resolve)) + const password = randomBytes(24).toString('base64url') + child = spawn(binary, [], { cwd: directory, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, AGENTDB_AUTH_ENABLED: 'true', AGENTDB_MASTER_KEY: key, AGENTDB_ADMIN_USER: 'package-test', AGENTDB_ADMIN_PASSWORD: password, AGENTDB_DATA: join(temp, 'test.sqlite'), AGENTDB_HTTP_ADDR: `127.0.0.1:${port}`, AGENTDB_SECURE_COOKIE: 'false', AGENTDB_TLS_CERT: join(tlsDir, 'cert.pem'), AGENTDB_TLS_KEY: join(tlsDir, 'key.pem') } }) + let startupError = '' + child.stderr.on('data', b => { startupError += b.toString() }) + child.on('error', e => { startupError += e.message }) + const base = `http://127.0.0.1:${port}` + let healthy = false + for (let i = 0; i < 60; i++) { + try { healthy = (await fetch(`${base}/healthz`, { signal: AbortSignal.timeout(500) })).ok } catch {} + if (healthy) break + if (child.exitCode !== null) throw new Error(`Package exited during startup: ${startupError}`) + await delay(100) + } + assert(healthy, 'Packaged application did not start') + const page = await fetch(base) + assert.equal(page.status, 200) + const html = await page.text() + const assets = [...html.matchAll(/(?:src|href)="(\/assets\/[^" ]+)"/g)].map(m => m[1]) + assert(assets.length >= 2, 'Built frontend was not embedded') + for (const asset of assets) assert.equal((await fetch(base + asset)).status, 200, `Missing embedded ${asset}`) + const auth = await fetch(`${base}/api/v1/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'package-test', password }) }) + assert.equal(auth.status, 200) + const cookie = auth.headers.get('set-cookie').split(';')[0] + const proxies = await fetch(`${base}/api/v1/proxies`, { headers: { Cookie: cookie } }) + assert.equal(proxies.status, 200) + assert.deepEqual(await proxies.json(), []) + console.log('Package verified: file checksums, key generation, certificate utility, isolated startup, embedded web assets and administrator login.') +} finally { + if (child && child.exitCode === null) { await new Promise(resolve => { child.once('exit', resolve); child.kill() }) } + rmSync(temp, { recursive: true, force: true }) +} diff --git a/scripts/version.ps1 b/scripts/version.ps1 new file mode 100644 index 0000000..437fea1 --- /dev/null +++ b/scripts/version.ps1 @@ -0,0 +1,21 @@ +function Get-AgentDBBuildInfo { + param([string]$Version) + $repoRoot = Split-Path $PSScriptRoot -Parent + if (!$Version) { $Version = (Get-Content -LiteralPath (Join-Path $repoRoot 'VERSION') -Raw).Trim() } + if ($Version -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$') { throw 'Invalid version' } + $revision = 'unknown' + $dirty = $false + if ((Test-Path -LiteralPath (Join-Path $repoRoot '.git')) -and (Get-Command git -ErrorAction SilentlyContinue)) { + $head = & git -C $repoRoot rev-parse --verify HEAD 2>$null + if ($LASTEXITCODE -eq 0) { + $revision = $head.Trim() + $dirty = [bool](& git -C $repoRoot status --porcelain) + } + } + $commit = if ($dirty) { "$revision-dirty" } else { $revision } + $buildTime = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') + return [PSCustomObject]@{ + Version = $Version; Commit = $commit; Revision = $revision; Dirty = $dirty; BuildTime = $buildTime + LDFlags = "-s -w -X agentdb/internal/buildinfo.Version=$Version -X agentdb/internal/buildinfo.Commit=$commit -X agentdb/internal/buildinfo.BuildTime=$buildTime" + } +} diff --git a/start-dev.cmd b/start-dev.cmd new file mode 100644 index 0000000..b0d6c28 --- /dev/null +++ b/start-dev.cmd @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\start-dev.ps1" +if errorlevel 1 pause diff --git a/version.go b/version.go new file mode 100644 index 0000000..9b1f3f3 --- /dev/null +++ b/version.go @@ -0,0 +1,11 @@ +package agentdb + +import ( + _ "embed" + "strings" +) + +//go:embed VERSION +var version string + +func Version() string { return strings.TrimSpace(version) } diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..d5eb9ce --- /dev/null +++ b/web/embed.go @@ -0,0 +1,12 @@ +package web + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed dist/* +var assets embed.FS + +func Handler() http.Handler { sub, _ := fs.Sub(assets, "dist"); return http.FileServer(http.FS(sub)) } diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f8f33b3 --- /dev/null +++ b/web/index.html @@ -0,0 +1 @@ +AgentDB · 连接管理
diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..b188b27 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1696 @@ +{ + "name": "agentdb-web", + "version": "0.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agentdb-web", + "version": "0.3.0", + "dependencies": { + "element-plus": "2.11.4", + "vue": "3.5.22" + }, + "devDependencies": { + "@playwright/test": "1.55.1", + "@vitejs/plugin-vue": "6.0.1", + "typescript": "5.9.3", + "vite": "7.1.7", + "vue-tsc": "3.0.8" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@playwright/test": { + "version": "1.55.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.29", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.16", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.29" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.23", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.23" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.23", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.23", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.23", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.22", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "@vue/shared": "3.5.22", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.22", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.22", + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.22", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "@vue/compiler-core": "3.5.22", + "@vue/compiler-dom": "3.5.22", + "@vue/compiler-ssr": "3.5.22", + "@vue/shared": "3.5.22", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.19", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.22", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.22", + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "3.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.23", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^2.0.5", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.22", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.22", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.22", + "@vue/shared": "3.5.22" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.22", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.22", + "@vue/runtime-core": "3.5.22", + "@vue/shared": "3.5.22", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.22", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.22", + "@vue/shared": "3.5.22" + }, + "peerDependencies": { + "vue": "3.5.22" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.22", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "9.13.0", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.16", + "@vueuse/metadata": "9.13.0", + "@vueuse/shared": "9.13.0", + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "9.13.0", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "9.13.0", + "license": "MIT", + "dependencies": { + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/alien-signals": { + "version": "2.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.23", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/element-plus": { + "version": "2.11.4", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.1", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "^9.1.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.13", + "escape-html": "^1.0.3", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.55.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.1", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.22", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.22", + "@vue/compiler-sfc": "3.5.22", + "@vue/runtime-dom": "3.5.22", + "@vue/server-renderer": "3.5.22", + "@vue/shared": "3.5.22" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.23", + "@vue/language-core": "3.0.8" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..a698cfc --- /dev/null +++ b/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "agentdb-web", + "private": true, + "version": "0.3.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vue-tsc --noEmit && vite build", + "test": "playwright test" + }, + "dependencies": { + "vue": "3.5.22", + "element-plus": "2.11.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "6.0.1", + "vite": "7.1.7", + "typescript": "5.9.3", + "vue-tsc": "3.0.8", + "@playwright/test": "1.55.1" + } +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..6f5a24c --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test"; +export default defineConfig({ + testDir: "./tests", + testMatch: "**/*.spec.ts", + workers: 1, + timeout: 45000, + use: { + baseURL: "http://127.0.0.1:18081", + channel: process.env.AGENTDB_BROWSER_CHANNEL || "msedge", + viewport: { width: 1440, height: 1000 }, + trace: "off", + screenshot: "only-on-failure", + }, + webServer: { + command: "node tests/start-server.mjs", + url: "http://127.0.0.1:18081/healthz", + timeout: 30000, + reuseExistingServer: false, + }, +}); diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..1d4f1ab --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,1342 @@ + + + diff --git a/web/src/Connections.vue b/web/src/Connections.vue new file mode 100644 index 0000000..3a39875 --- /dev/null +++ b/web/src/Connections.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/web/src/components/OutboundFields.vue b/web/src/components/OutboundFields.vue new file mode 100644 index 0000000..43e7d6e --- /dev/null +++ b/web/src/components/OutboundFields.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/web/src/components/OutboundManager.vue b/web/src/components/OutboundManager.vue new file mode 100644 index 0000000..e85ef10 --- /dev/null +++ b/web/src/components/OutboundManager.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/web/src/components/VersionInfo.vue b/web/src/components/VersionInfo.vue new file mode 100644 index 0000000..3319713 --- /dev/null +++ b/web/src/components/VersionInfo.vue @@ -0,0 +1,26 @@ + + + diff --git a/web/src/composables/useAudit.ts b/web/src/composables/useAudit.ts new file mode 100644 index 0000000..c7c7cb8 --- /dev/null +++ b/web/src/composables/useAudit.ts @@ -0,0 +1,97 @@ +import { reactive, ref } from "vue"; + +type AuditAPI = (path: string) => Promise; +type Report = (error: unknown) => void; + +function downloadAudit(resource: string, filters: Record, filename: string) { + const link = document.createElement("a"); + link.href = `/api/v1/${resource}?${new URLSearchParams({ ...filters, export: "csv" })}`; + link.download = filename; + link.click(); +} + +function dateFilters(filter: { from: string; to: string }) { + if (filter.from && filter.to && new Date(filter.from) > new Date(filter.to)) { + throw new Error("开始时间不能晚于结束时间"); + } + return { + from: filter.from ? new Date(filter.from).toISOString() : "", + to: filter.to ? new Date(filter.to).toISOString() : "", + }; +} + +export function useAudit(api: AuditAPI, report: Report) { + const events = ref([]); + const eventFilter = reactive({ gateway: "", source: "", category: "", keyword: "", from: "", to: "" }); + const appliedEventFilter = ref>({}); + const eventBefore = ref(0), eventLoading = ref(false), eventExporting = ref(false); + let eventRequest = 0; + + async function loadEvents(more = false) { + const request = ++eventRequest; + eventLoading.value = true; + try { + const query = new URLSearchParams({ ...appliedEventFilter.value, page: "1", before: String(more ? eventBefore.value : 0) }); + const result = await api(`events?${query}`); + if (request !== eventRequest) return; + events.value = more ? [...events.value, ...result.items] : result.items; + eventBefore.value = result.nextBefore; + } catch (error) { + if (request === eventRequest) report(error); + } finally { + if (request === eventRequest) eventLoading.value = false; + } + } + + function applyEventFilters(reset = false) { + if (reset) for (const key of Object.keys(eventFilter)) eventFilter[key as keyof typeof eventFilter] = ""; + try { + appliedEventFilter.value = { ...eventFilter, ...dateFilters(eventFilter) }; + void loadEvents(); + } catch (error) { report(error); } + } + + const commands = ref([]), commandBefore = ref(0), commandProtocol = ref(""), commandGateway = ref(""); + const commandDialog = ref(false), selectedCommand = ref(); + const commandFilter = reactive({ target: "", source: "", decision: "", kind: "", keyword: "", from: "", to: "" }); + const appliedCommandFilter = ref>({}); + const commandLoading = ref(false), commandExporting = ref(false); + let commandRequest = 0; + + async function loadCommands(more = false) { + const request = ++commandRequest; + commandLoading.value = true; + try { + const query = new URLSearchParams({ ...appliedCommandFilter.value, before: String(more ? commandBefore.value : 0) }); + const result = await api(`commands?${query}`); + if (request !== commandRequest) return; + commands.value = more ? [...commands.value, ...result.items] : result.items; + commandBefore.value = result.nextBefore; + } catch (error) { + if (request === commandRequest) report(error); + } finally { + if (request === commandRequest) commandLoading.value = false; + } + } + + function applyCommandFilters(reset = false) { + if (reset) { + commandProtocol.value = ""; + commandGateway.value = ""; + for (const key of Object.keys(commandFilter)) commandFilter[key as keyof typeof commandFilter] = ""; + } + try { + appliedCommandFilter.value = { protocol: commandProtocol.value, gateway: commandGateway.value, ...commandFilter, ...dateFilters(commandFilter) }; + void loadCommands(); + } catch (error) { report(error); } + } + + function exportEvents() { downloadAudit("events", appliedEventFilter.value, "agentdb-events.csv"); } + function exportCommands() { downloadAudit("commands", appliedCommandFilter.value, "agentdb-commands.csv"); } + + return { + events, eventFilter, eventBefore, eventLoading, eventExporting, loadEvents, applyEventFilters, exportEvents, + commands, commandBefore, commandProtocol, commandGateway, commandDialog, selectedCommand, + commandFilter, commandLoading, commandExporting, loadCommands, applyCommandFilters, exportCommands, + }; +} diff --git a/web/src/domain/outbound.ts b/web/src/domain/outbound.ts new file mode 100644 index 0000000..1c4abe6 --- /dev/null +++ b/web/src/domain/outbound.ts @@ -0,0 +1,9 @@ +export const outboundName = (mode: string) => ({ + direct: '直接连接', socks5: 'SOCKS5', ssh: 'SSH 转发', k8s: 'K8s port-forward', +}[mode] || mode); + +export function outboundDescription(value: any): string { + if (!value || value.mode === 'direct') return '直接连接'; + if (value.mode === 'k8s') return `${outboundName(value.mode)} · ${value.kubeContext || '当前 context'} · ${value.namespace || '默认命名空间'} · ${value.resource || ''}`; + return `${outboundName(value.mode)} · ${value.host}:${value.port}`; +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..77b6c5e --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,7 @@ +import { createApp } from "vue"; +import ElementPlus from "element-plus"; +import "element-plus/dist/index.css"; +import "element-plus/theme-chalk/dark/css-vars.css"; +import "./style.css"; +import App from "./App.vue"; +createApp(App).use(ElementPlus).mount("#app"); diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..51859ee --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,859 @@ +:root { + font-family: Inter, "Segoe UI", "Microsoft YaHei", sans-serif; + color: var(--app-text); + background: var(--app-bg); + font-synthesis: none; + --el-color-primary: #187766; + --el-color-primary-light-3: #519b8c; + --el-color-primary-light-5: #8bbab0; + --el-color-primary-light-7: #b9d7d0; + --el-color-primary-light-8: #d0e5df; + --el-color-primary-light-9: #eaf4f0; + --el-color-primary-dark-2: #105b4e; + --el-border-radius-base: 7px; + --el-font-size-base: 13px; + --el-text-color-primary: var(--app-text); + --el-border-color: var(--app-border); +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +button, +input { + font: inherit; +} +button { + cursor: pointer; +} +h1, +h2, +h3, +p { + margin-top: 0; +} +.brand { + display: flex; + align-items: center; + gap: 11px; + font-size: 23px; + font-weight: 650; + letter-spacing: -0.8px; +} +.brand-mark { + width: 35px; + height: 35px; + background: #b9edc8; + border-radius: 10px; + color: #143d32; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 31px; + line-height: 1; +} +.brand-mark span { + font-size: 22px; + margin-left: 1px; +} +.eyebrow { + font-size: 10px; + font-weight: 700; + letter-spacing: 2px; + color: #6a9387; +} +.app-shell { + display: flex; + min-height: 100vh; +} +.sidebar { + position: fixed; + width: 226px; + height: 100vh; + background: var(--app-sidebar); + color: #ecf6f0; + padding: 33px 22px; + display: flex; + flex-direction: column; +} +.sidebar .brand { + margin: 0 5px 53px; +} +.nav-caption { + color: #76928a; + font-size: 11px; + letter-spacing: 1px; + padding: 0 14px; + margin-bottom: 16px; +} +nav { + display: grid; + gap: 9px; +} +nav button { + border: 0; + background: transparent; + color: #aebfb9; + display: flex; + align-items: center; + text-align: left; + gap: 13px; + padding: 13px 14px; + border-radius: 7px; +} +nav button span { + font-size: 20px; + width: 20px; +} +nav button.active { + background: #25483f; + color: #d3f2de; +} +nav button:hover { + background: #21413a; +} +nav i { + font-style: normal; + margin-left: auto; + background: #3d5a4e; + padding: 2px 7px; + border-radius: 5px; + font-size: 11px; +} +.sidebar-bottom { + margin-top: auto; +} +.online { + font-size: 11px; + color: #a9c0b5; + margin: 25px 9px; +} +.online b, +.environment i { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: #8ad8a9; + margin-right: 8px; +} +.profile { + border-top: 1px solid #31483f; + padding-top: 22px; + display: flex; + align-items: center; + gap: 10px; +} +.profile > span { + background: #355548; + border-radius: 9px; + padding: 10px 14px; + color: #c6e3cf; +} +.profile div { + display: grid; + gap: 4px; +} +.profile strong { + font-size: 12px; +} +.profile small { + font-size: 10px; + color: #9ab5a7; +} +.profile button { + margin-left: auto; + color: #a4bbae; + background: none; + border: 0; + font-size: 22px; +} +.workspace { + margin-left: 226px; + width: calc(100% - 226px); +} +.topbar { + height: 72px; + border-bottom: 1px solid var(--app-border); + background: var(--app-surface); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 38px; + color: #7b8781; + font-size: 12px; +} +.topbar b { + margin: 0 15px; + color: #bcc5bf; + font-weight: 400; +} +.environment { + font-size: 9px; + letter-spacing: 1.5px; + color: #748b7e; +} +.content { + padding: 38px; + max-width: 1680px; + margin: auto; +} +.page-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin: 5px 0 29px; + gap: 18px; +} +.page-heading h1 { + font-size: 30px; + letter-spacing: -1px; + margin: 10px 0 9px; + font-weight: 650; +} +.page-heading p { + font-size: 12px; + color: #839087; + margin-bottom: 0; +} +.page-heading .el-button { + padding: 22px 23px; +} +.stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 18px; + margin-bottom: 28px; +} +.stats article { + border: 1px solid var(--app-border); + background: var(--app-surface); + padding: 22px 23px; + border-radius: 10px; + display: flex; + flex-direction: column; + gap: 15px; +} +.stats article > span { + font-size: 12px; + color: #75867a; +} +.stats strong { + font-size: 34px; + font-weight: 550; + letter-spacing: -1px; + font-variant-numeric: tabular-nums; + line-height: 1; +} +.stats small { + font-size: 10px; + color: #9aa59e; +} +.dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + margin-right: 7px; + vertical-align: middle; +} +.green { + background: #3e9b73; +} +.table-card { + background: var(--app-surface); + border: 1px solid var(--app-border); + border-radius: 10px; + overflow: hidden; +} +.table-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 22px; + border-bottom: 1px solid var(--app-border); + gap: 15px; +} +.tabs { + display: flex; + gap: 5px; +} +.tabs button { + background: none; + border: 0; + color: #87948a; + border-radius: 6px; + padding: 8px 13px; + font-size: 12px; +} +.tabs button.active { + background: #edf4ef; + color: #277758; + font-weight: 600; +} +.search { + max-width: 230px; +} +.el-table { + --el-table-header-bg-color: var(--app-subtle); + --el-table-header-text-color: #849086; + --el-table-row-hover-bg-color: var(--app-subtle); + --el-table-border-color: var(--app-border); + --el-table-text-color: var(--app-text); + font-size: 12px; +} +.el-table th.el-table__cell { + font-weight: 500; + font-size: 11px; + height: 44px; +} +.el-table td.el-table__cell { + height: 78px; +} +.el-table .cell { + padding: 0 22px; +} +.el-table .el-button { + font-size: 12px; +} +.proxy-name { + display: flex; + align-items: center; + gap: 12px; +} +.proxy-name > div { + display: grid; + gap: 3px; +} +.proxy-name strong { + font-size: 12px; + font-weight: 600; +} +.proxy-name small { + font-size: 10px; + color: #99a39b; +} +.type-icon { + width: 37px; + height: 39px; + border: 1px solid #e0ebe8; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 16px; + font-weight: 650; +} +.type-icon.mysql { + background: #eef5f8; + color: #477c8a; + border-color: #dce9ee; +} +.type-icon.mongodb { + background: #f0f7e9; + color: #6b9246; + border-color: #e0ead3; +} +.type-icon.ssh { + background: #f4f1f9; + color: #8c78a2; + border-color: #e6e0ed; +} +code { + font-family: "Cascadia Code", Consolas, monospace; + font-size: 11px; +} +.cell-sub { + display: block; + font-size: 10px; + color: #9aa69b; + line-height: 20px; +} +.status { + font-size: 11px; + white-space: nowrap; + color: #909c91; +} +.status i { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; + margin-right: 6px; +} +.status.running { + color: #318459; +} +.status.failed { + color: #b86240; +} +.mode-tag { + font-size: 10px; + background: var(--app-subtle); + border: 1px solid var(--app-border); + border-radius: 4px; + padding: 2px 7px; + color: #6d7d6b; +} +.table-footer { + display: flex; + justify-content: space-between; + padding: 18px 24px; + font-size: 10px; + color: #9aa698; +} +.empty-guide { + text-align: center; + padding: 35px 20px 43px; +} +.empty-guide > span { + font-size: 36px; + color: #76a58a; +} +.empty-guide h3 { + font-size: 15px; + margin: 12px 0; +} +.empty-guide p { + font-size: 12px; + color: #899788; +} +.empty-guide .el-button { + margin-top: 8px; +} +.workspace-footer { + display: flex; + justify-content: space-between; + font-size: 10px; + color: #a0aaa0; + margin-top: 25px; +} +.event-card { + margin-top: 26px; +} +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 23px; +} +.section-heading h3 { + font-size: 14px; + margin: 0; +} +.section-heading span { + font-size: 10px; + color: #8e9c8d; +} +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 18px; +} +.config-form .el-form-item { + margin-bottom: 22px; +} +.config-form .el-input-number { + width: 100%; +} +.config-form .el-tabs__content { + padding-top: 15px; +} +.setting-row { + border: 1px solid var(--app-border); + border-radius: 8px; + display: flex; + justify-content: space-between; + align-items: center; + padding: 18px; + margin-bottom: 20px; + gap: 12px; +} +.setting-row strong { + font-size: 13px; +} +.setting-row p, +.form-help { + font-size: 12px; + line-height: 1.7; + color: #8a978b; + margin: 6px 0 0; +} +.optional { + background: var(--app-subtle); + color: #6d916e; + font-weight: 400; + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + margin-left: 5px; +} +.fingerprint { + overflow-wrap: anywhere; + font: 11px monospace; + color: #7b907b; +} +.secret-result { + padding: 18px; + margin-top: 20px; + background: var(--app-bg); + border-radius: 8px; + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; +} +.secret-result code { + min-width: 0; + flex: 1; + overflow-wrap: anywhere; +} +.login-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 1fr 1fr; +} +.login-story { + padding: 50px 65px; + background: var(--app-sidebar); + color: white; + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 100vh; +} +.login-story h1 { + font-size: 48px; + line-height: 1.5; + letter-spacing: -1.5px; + margin: 22px 0; +} +.login-story p { + font-size: 14px; + line-height: 2; + color: #a0b8ad; +} +.login-story small { + color: #59796c; + font-size: 9px; + letter-spacing: 2px; +} +.protocol-pills { + display: flex; + gap: 12px; + margin-top: 35px; +} +.protocol-pills span { + border: 1px solid #3e5a4d; + border-radius: 30px; + padding: 8px 18px; + color: #b5ccbd; + font-size: 12px; +} +.login-panel { + display: flex; + align-items: center; + justify-content: center; + background: var(--app-subtle); + padding: 35px; +} +.login-card { + width: 355px; +} +.login-card h2 { + font-size: 26px; + margin: 14px 0 10px; +} +.login-card > p { + font-size: 13px; + color: #92a08f; + margin-bottom: 32px; +} +.login-card .el-form-item { + margin-bottom: 24px; +} +.full-width { + width: 100%; +} +.login-card .login-note { + font-size: 11px; + margin: 25px 0; + color: #a3ada0; +} +.loading { + height: 100vh; + display: grid; + place-items: center; + color: #718c79; +} +.el-message-box__message { + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.el-drawer__header { + margin-bottom: 12px; + font-size: 18px; + color: var(--app-text); +} +.el-drawer__footer { + border-top: 1px solid var(--app-border); +} +.mobile-proxy-list { + display: none; +} +.mobile-card-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.mobile-proxy-card { + padding: 20px 16px; + border-bottom: 1px solid var(--app-border); +} +.mobile-proxy-card > code { + display: block; + margin: 18px 0 4px; + overflow-wrap: anywhere; +} +.mobile-proxy-card .mobile-card-heading:nth-of-type(2) { + margin-top: 14px; +} +.mobile-card-actions { + display: flex; + gap: 9px; + margin-top: 18px; +} +.mobile-card-actions .el-button + .el-button { + margin-left: 0; +} +.mobile-proxy-card .proxy-name strong { + overflow-wrap: anywhere; +} +.el-select { + width: 100%; +} +@media (min-width: 1600px) { + .content { + padding: 45px 55px; + } +} +@media (max-width: 1050px) { + .sidebar { + width: 185px; + padding: 28px 15px; + } + .workspace { + margin-left: 185px; + width: calc(100% - 185px); + } + .content { + padding: 26px 22px; + } + .topbar { + padding: 0 22px; + } + .stats { + gap: 10px; + } + .stats article { + padding: 18px 15px; + } + .tabs button { + padding: 8px; + } + .login-story { + padding: 40px; + } + .login-story h1 { + font-size: 38px; + } +} +@media (max-width: 720px) { + .proxy-table { + display: none; + } + .mobile-proxy-list { + display: block; + } + .sidebar { + width: 66px; + padding: 25px 8px; + } + .sidebar .brand { + font-size: 0; + margin: 0 6px 35px; + } + .sidebar .brand-mark { + font-size: 30px; + } + .nav-caption, + .sidebar-bottom { + display: none; + } + nav button { + font-size: 0; + padding: 12px; + } + nav button span { + font-size: 21px; + } + nav i { + display: none; + } + .workspace { + margin-left: 66px; + width: calc(100% - 66px); + } + .content { + padding: 22px 14px; + } + .topbar { + padding: 0 16px; + height: 56px; + } + .environment { + display: none; + } + .page-heading { + align-items: flex-start; + } + .page-heading h1 { + font-size: 25px; + } + .page-heading p { + line-height: 1.8; + } + .page-heading .el-button { + padding: 16px 12px; + font-size: 12px; + } + .stats { + grid-template-columns: 1fr 1fr; + } + .table-toolbar { + flex-direction: column; + align-items: stretch; + padding: 13px; + } + .search { + max-width: none; + } + .tabs { + justify-content: space-between; + } + .table-footer { + padding: 15px; + } + .login-shell { + display: block; + } + .login-story { + display: none; + } + .login-panel { + min-height: 100vh; + padding: 28px; + } + .form-grid { + grid-template-columns: 1fr; + } + .section-heading span { + max-width: 120px; + text-align: right; + } +} + +:root { + --app-text: #243733; + --app-bg: #f5f7f6; + --app-surface: #ffffff; + --app-subtle: #f0f5f2; + --app-border: #dfe6e2; + --app-sidebar: #142d2b; +} +:root[data-theme="ocean"] { + --app-text: #253b53; + --app-bg: #eff5fc; + --app-subtle: #eaf2fb; + --app-border: #d6e2ef; + --app-sidebar: #132b49; + --el-color-primary: #286fc4; + --el-color-primary-light-3: #689bd6; + --el-color-primary-light-5: #94b7e1; + --el-color-primary-light-7: #bed4ed; + --el-color-primary-light-8: #d4e3f3; + --el-color-primary-light-9: #e9f1f9; + --el-color-primary-dark-2: #20599d; +} +:root[data-theme="dark"] { + --app-text: #e0e7ec; + --app-bg: #14191f; + --app-surface: #1d252e; + --app-subtle: #252f39; + --app-border: #37434e; + --app-sidebar: #10161c; + --el-color-primary: #67c9b4; + --el-color-primary-light-3: #529f90; + --el-color-primary-light-5: #427c72; + --el-color-primary-light-7: #315a55; + --el-color-primary-light-8: #294944; + --el-color-primary-light-9: #203833; + --el-color-primary-dark-2: #85d4c3; + --el-bg-color: var(--app-surface); + --el-bg-color-overlay: var(--app-surface); + --el-bg-color-page: var(--app-bg); + --el-fill-color-blank: var(--app-surface); + --el-fill-color-light: var(--app-subtle); + --el-text-color-primary: var(--app-text); + --el-text-color-regular: #c2ccd5; + --el-text-color-secondary: #a3b1bd; + --el-border-color: var(--app-border); + --el-border-color-light: var(--app-border); + --el-border-color-lighter: var(--app-border); +} +[data-theme="dark"] .tabs button.active, +[data-theme="ocean"] .tabs button.active { background: var(--el-color-primary-light-9); color: var(--el-color-primary); } +[data-theme="dark"] .type-icon { background: var(--app-subtle); border-color: var(--app-border); color: #bad7ec; } +[data-theme="dark"] .mode-tag { color: #c2ccd5; } +[data-theme="dark"] .cell-sub, +[data-theme="dark"] .form-help, +[data-theme="dark"] .setting-row p, +[data-theme="dark"] .page-heading p, +[data-theme="dark"] .target-card p { color: #a3b1bd; } +[data-theme="dark"] .status.running { color: #79d6a7; } +[data-theme="dark"] .status.failed { color: #f0a082; } +[data-theme="ocean"] nav button.active { background: #214b78; } +.theme-picker { position: fixed; z-index: 1000; top: 15px; right: 22px; display: flex; align-items: center; gap: 8px; color: var(--app-text); background: var(--app-surface); padding: 5px 9px; border: 1px solid var(--app-border); border-radius: 8px; font-size: 12px; } +.theme-picker select { color: inherit; background: inherit; border: 0; padding: 3px; font: inherit; max-width: 130px; } +.theme-picker select:focus-visible { outline: 2px solid var(--el-color-primary); } +.topbar .environment { margin-right: 190px; } +.target-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(310px, 100%), 1fr)); gap: 18px; } +.target-filter { grid-column: 1 / -1; display: flex; justify-content: flex-end; } +.target-filter .el-select { width: min(240px, 100%); } +.target-card { min-width: 0; padding: 24px; border: 1px solid var(--app-border); border-radius: 12px; background: var(--app-surface); overflow-wrap: anywhere; } +.target-card p { color: var(--el-text-color-secondary); font-size: 13px; } +.target-card h3 { margin: 0; } +.target-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.target-actions .el-button + .el-button { margin-left: 0; } +@media (max-width: 720px) { + .theme-picker { top: 11px; right: 10px; padding: 3px 5px; } + .theme-picker label { display: none; } + .theme-picker select { max-width: 96px; } + .topbar > span:first-child { font-size: 0; } + .topbar > span:first-child b { display: none; } + .target-card { padding: 18px; } +} +.command-filters .el-select { width: min(250px, 100%); } +.command-filters { flex-wrap: wrap; gap: 12px; } +.command-filters > .el-input { width: min(250px, 100%); } +.audit-note { padding: 16px 22px 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.8; } +.command-preview { display: block; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 65px; overflow: hidden; font-size: 12px; } +.command-detail { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 65vh; overflow: auto; background: var(--app-subtle); color: var(--app-text); padding: 18px; border-radius: 8px; } +[data-theme="dark"] .el-table td.el-table-fixed-column--right, +[data-theme="dark"] .el-table td.el-table-fixed-column--left { background: var(--app-surface); } +[data-theme="dark"] .el-table th.el-table-fixed-column--right { background: var(--app-subtle); } diff --git a/web/src/theme.ts b/web/src/theme.ts new file mode 100644 index 0000000..d6b989f --- /dev/null +++ b/web/src/theme.ts @@ -0,0 +1,23 @@ +import { ref, watch } from 'vue'; + +export const themes = [ + { value: 'light', label: '浅色 · 森绿' }, + { value: 'dark', label: '深色 · 夜间' }, + { value: 'ocean', label: '海蓝' }, + { value: 'system', label: '跟随系统' }, +]; +function savedTheme() { + try { const saved = localStorage.getItem('agentdb-theme'); return themes.some(t => t.value === saved) ? saved! : 'system'; } + catch { return 'system'; } +} +export const theme = ref(savedTheme()); +const media = window.matchMedia('(prefers-color-scheme: dark)'); +function apply() { + const actual = theme.value === 'system' ? (media.matches ? 'dark' : 'light') : theme.value; + document.documentElement.dataset.theme = actual; + document.documentElement.classList.toggle('dark', actual === 'dark'); + document.documentElement.style.colorScheme = actual === 'dark' ? 'dark' : 'light'; +} +watch(theme, () => { apply(); try { localStorage.setItem('agentdb-theme', theme.value); } catch {} }); +media.addEventListener('change', apply); +apply(); diff --git a/web/tests/groups.spec.ts b/web/tests/groups.spec.ts new file mode 100644 index 0000000..5d1fc85 --- /dev/null +++ b/web/tests/groups.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from '@playwright/test'; + +test('目标分组保存、筛选与清空', async ({ page }) => { + await page.goto('/'); + const session = await (await page.request.get('/api/v1/session')).json(); + const created = await page.request.post('/api/v1/targets', { headers: { 'X-CSRF-Token': session.csrfToken }, data: { name: '分组测试目标', type: 'mysql', targetHost: 'localhost', targetPort: 3306, targetUser: 'test', targetPassword: 'test-password', group: '开发' } }); + expect(created.ok()).toBe(true); + await page.getByRole('button', { name: /目标服务/ }).first().click(); + const card = page.locator('.target-card').filter({ hasText: '分组测试目标' }); + await expect(card).toContainText('开发'); + await card.getByRole('button', { name: '配置目标' }).click(); + const drawer = page.getByRole('dialog'); + const group = drawer.getByRole('combobox', { name: '目标所属分组' }); + await group.fill('生产'); + await page.getByRole('option', { name: '生产', exact: true }).click(); + await drawer.getByRole('button', { name: '保存配置' }).click(); + await expect(drawer).not.toBeVisible(); + await page.reload(); + await page.getByRole('button', { name: /目标服务/ }).first().click(); + await page.getByRole('combobox', { name: '目标分组', exact: true }).click(); + await page.getByRole('option', { name: '生产', exact: true }).click(); + await expect(card).toBeVisible(); + await page.getByRole('combobox', { name: '目标分组', exact: true }).click(); + await page.getByRole('option', { name: '未分组', exact: true }).click(); + await expect(card).not.toBeVisible(); + const target = (await created.json()).config; + const cleared = await page.request.put(`/api/v1/targets/${target.id}`, { headers: { 'X-CSRF-Token': session.csrfToken }, data: { group: '' } }); + expect(cleared.ok()).toBe(true); + await page.getByRole('button', { name: /目标服务/ }).first().click(); + await page.reload(); + await page.getByRole('button', { name: /目标服务/ }).first().click(); + await expect(card).toContainText('未分组'); +}); diff --git a/web/tests/kubernetes.spec.ts b/web/tests/kubernetes.spec.ts new file mode 100644 index 0000000..169266a --- /dev/null +++ b/web/tests/kubernetes.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '@playwright/test'; + +test('内置 K8s 出口配置、保存与版本信息', async ({ page }) => { + await page.goto('/'); + const build = await (await page.request.get('/api/v1/version')).json(); + await page.getByRole('button', { name: '版本信息', exact: true }).click(); + await expect(page.getByRole('dialog', { name: '版本信息' })).toContainText(build.version); + await expect(page.getByRole('dialog', { name: '版本信息' })).toContainText(build.platform); + await page.getByRole('dialog', { name: '版本信息' }).getByRole('button', { name: 'Close this dialog' }).click(); + await page.getByRole('navigation').getByRole('button', { name: /代理$/ }).click(); + await page.getByRole('button', { name: '新建代理' }).click(); + const drawer = page.getByRole('dialog'); + await drawer.locator('.el-form-item').filter({ hasText: /^代理名称/ }).getByRole('textbox').fill('K8s 测试代理'); + await drawer.locator('.el-radio-button').filter({ hasText: 'K8s port-forward' }).click(); + await drawer.getByPlaceholder('默认使用 KUBECONFIG 或 ~/.kube/config').fill('C:/agentdb-e2e-missing/kubeconfig'); + await drawer.getByPlaceholder('当前 context', { exact: true }).fill('test-cluster'); + await drawer.getByPlaceholder('当前 context 的命名空间').fill('demo'); + await drawer.getByPlaceholder('service/mysql 或 pod/mysql-0 或 deployment/mysql').fill('service/mysql'); + await page.screenshot({ path: 'test-results/kubernetes-desktop.png' }); + await drawer.getByRole('button', { name: '保存代理' }).click(); + await expect(drawer).not.toBeVisible(); + await page.getByRole('button', { name: /目标服务/ }).first().click(); + await page.getByRole('button', { name: '+ 新建目标' }).click(); + await drawer.getByPlaceholder('例如:生产数据库 · 只读分析').fill('K8s MySQL'); + await drawer.getByPlaceholder('数据库或服务器域名 / IP').fill('mysql.demo.svc'); + await drawer.locator('.el-form-item').filter({ hasText: /^目标用户名/ }).getByRole('textbox').fill('dbuser'); + await drawer.getByPlaceholder('输入目标密码').fill('test-only-password'); + await drawer.getByRole('combobox', { name: '代理出口' }).click(); + await page.getByRole('option', { name: 'K8s 测试代理', exact: true }).click(); + await drawer.getByRole('button', { name: '保存配置' }).click(); + await expect(drawer).not.toBeVisible(); + const card = page.locator('.target-card').filter({ hasText: 'K8s MySQL' }); + await expect(card).toContainText('K8s port-forward'); + await page.reload(); + await page.getByRole('navigation').getByRole('button', { name: /代理$/ }).click(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + await page.screenshot({ path: 'test-results/outbounds-desktop.png', fullPage: true }); + await page.getByRole('row').filter({ hasText: 'K8s 测试代理' }).getByRole('button', { name: '编辑代理' }).click(); + await expect(drawer.getByPlaceholder('当前 context', { exact: true })).toHaveValue('test-cluster'); + await expect(drawer.getByPlaceholder('service/mysql 或 pod/mysql-0 或 deployment/mysql')).toHaveValue('service/mysql'); + await page.setViewportSize({ width: 390, height: 844 }); + await expect.poll(async () => (await drawer.boundingBox())?.x).toBeGreaterThanOrEqual(0); + await expect.poll(async () => (await drawer.boundingBox())?.width).toBeLessThanOrEqual(390); + await page.screenshot({ path: 'test-results/kubernetes-mobile.png' }); + expect(await drawer.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true); + await drawer.getByRole('button', { name: '取消', exact: true }).click(); + await expect(drawer).not.toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + await page.screenshot({ path: 'test-results/outbounds-mobile.png', fullPage: true }); + const targets = await (await page.request.get('/api/v1/targets')).json(); + const saved = targets.find((row: any) => row.config.name === 'K8s MySQL'); + expect(saved.config.outboundId).toBeTruthy(); + expect(saved.config.outbound).toMatchObject({ mode: 'k8s', namespace: 'demo', resource: 'service/mysql', kubeContext: 'test-cluster' }); + expect(JSON.stringify(saved)).not.toContain('test-only-password'); + const session = await (await page.request.get('/api/v1/session')).json(); + const result = await page.request.post(`/api/v1/targets/${saved.config.id}/test`, { headers: { 'X-CSRF-Token': session.csrfToken } }); + expect(result.ok()).toBe(false); + expect(JSON.stringify(await result.json())).toContain('k8s'); +}); diff --git a/web/tests/start-server.mjs b/web/tests/start-server.mjs new file mode 100644 index 0000000..5308399 --- /dev/null +++ b/web/tests/start-server.mjs @@ -0,0 +1,44 @@ +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve, join } from "node:path"; +const directory = mkdtempSync(join(tmpdir(), "agentdb-e2e-")); +const binary = + process.env.AGENTDB_E2E_BINARY || + resolve( + "..", + "bin", + process.platform === "win32" ? "agentdb.exe" : "agentdb", + ); +const child = spawn(binary, [], { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + AGENTDB_MASTER_KEY: randomBytes(32).toString("base64"), + AGENTDB_DATA: join(directory, "test.sqlite"), + AGENTDB_AUTH_ENABLED: "", + AGENTDB_ADMIN_USER: "", + AGENTDB_ADMIN_PASSWORD: "", + AGENTDB_HTTP_ADDR: "127.0.0.1:18081", + AGENTDB_TLS_CERT: "", + AGENTDB_TLS_KEY: "", + AGENTDB_SECURE_COOKIE: "false", + }, +}); +child.stdout.pipe(process.stdout); +child.stderr.pipe(process.stderr); +child.on("error", (error) => { + console.error(error.message); + process.exitCode = 1; +}); +const stop = () => child.kill("SIGTERM"); +process.on("SIGTERM", stop); +process.on("SIGINT", stop); +child.on("exit", (code) => { + try { + rmSync(directory, { recursive: true, force: true }); + } catch {} + process.exit(code || 0); +}); diff --git a/web/tests/workspace.spec.ts b/web/tests/workspace.spec.ts new file mode 100644 index 0000000..6f96994 --- /dev/null +++ b/web/tests/workspace.spec.ts @@ -0,0 +1,256 @@ +import { test, expect, Page } from "@playwright/test"; + +test('拖拽网关保存目标关联,审计筛选和导出', async ({page})=>{ + await page.goto('/'); + await target(page,'MySQL','拖拽原目标'); + await gateway(page,'MySQL','拖拽原目标','拖拽网关'); + await target(page,'MySQL','拖拽新目标'); + await page.getByRole('button',{name:/拖拽连接/}).click(); + await page.getByRole('button',{name:'选择网关 拖拽网关',exact:true}).dragTo(page.getByRole('button',{name:'连接目标 拖拽新目标',exact:true})); + await expect(page.getByText('关联已保存')).toBeVisible(); + await page.reload(); + const allTargets=await(await page.request.get('/api/v1/targets')).json(); + const allGateways=await(await page.request.get('/api/v1/proxies')).json(); + expect(allGateways.find((g:any)=>g.config.name==='拖拽网关').config.targetId).toBe(allTargets.find((t:any)=>t.config.name==='拖拽新目标').config.id); + await page.getByRole('button',{name:/命令审计/}).click(); + await page.getByPlaceholder('来源 IP(精确匹配)').fill('127.0.0.1'); + await page.getByPlaceholder('命令关键字').fill('SELECT'); + const request=page.waitForRequest(r=>r.url().includes('/api/v1/commands?')&&r.url().includes('keyword=SELECT')); + await page.getByRole('button',{name:'查询',exact:true}).click(); + expect((await request).url()).toContain('source=127.0.0.1'); + const download=page.waitForEvent('download'); + await page.getByRole('button',{name:'导出 CSV'}).click(); + expect((await download).suggestedFilename()).toBe('agentdb-commands.csv'); + await page.getByRole('button',{name:'重置',exact:true}).click(); + await expect(page.getByPlaceholder('命令关键字')).toHaveValue(''); +}); + +async function target(page: Page, type: string, name: string, outbound: string | boolean = false) { + if (outbound === true) { + const profileName = name + ' SOCKS5'; + await createOutbound(page, profileName, 'socks5'); + outbound = profileName; + } + await page.getByRole('button', { name: /目标服务/ }).first().click(); + await page.getByRole('button', { name: '+ 新建目标' }).click(); + const drawer = page.getByRole('dialog'); + await drawer.locator('.el-radio-button').filter({ hasText: new RegExp(`^${type}$`) }).click(); + await drawer.getByPlaceholder('例如:生产数据库 · 只读分析').fill(name); + await drawer.getByPlaceholder('数据库或服务器域名 / IP').fill('127.0.0.1'); + await drawer.locator('.el-form-item').filter({ hasText: /^目标用户名/ }).getByRole('textbox').fill('upstream'); + await drawer.getByPlaceholder('输入目标密码').fill('target-test-password'); + await expect(drawer.getByRole('switch', { name: '只读模式' })).toHaveCount(0); + if (['MySQL','MongoDB'].includes(type)) { + await expect(drawer.getByRole('switch', { name: '目标连接压缩' })).not.toBeChecked(); + await drawer.locator('.setting-row').filter({ hasText: '目标连接压缩' }).locator('.el-switch__core').click(); + } else await expect(drawer.getByRole('switch', { name: '目标连接压缩' })).toHaveCount(0); + if (typeof outbound === 'string') { + await drawer.getByRole('combobox', { name: '代理出口' }).click(); + await page.getByRole('option', { name: outbound, exact: true }).click(); + } + await drawer.getByRole('button', { name: '保存配置' }).click(); + await expect(drawer).not.toBeVisible(); + await expect(page.locator('.target-card').filter({ hasText: name })).toBeVisible(); +} + +async function createOutbound(page: Page, name: string, mode: 'socks5'|'ssh') { + await page.getByRole('navigation').getByRole('button', { name: /代理$/ }).click(); + await page.getByRole('button', { name: '新建代理' }).click(); + const d = page.getByRole('dialog'); + await d.locator('.el-form-item').filter({ hasText: /^代理名称/ }).getByRole('textbox').fill(name); + await d.locator('.el-radio-button').filter({ hasText: mode === 'ssh' ? /^SSH 转发$/ : /^SOCKS5$/ }).click(); + await d.locator('.el-form-item').filter({ hasText: /主机$/ }).getByRole('textbox').fill('127.0.0.1'); + if (mode === 'socks5') await d.locator('.el-radio').filter({ hasText: '用户名与密码' }).click(); + await d.locator('.el-form-item').filter({ hasText: /用户名$/ }).getByRole('textbox').fill(mode === 'ssh' ? 'jump' : 'socks'); + await d.getByRole('textbox', { name: new RegExp(`${mode === 'ssh' ? 'SSH 转发' : 'SOCKS5'} 密码`) }).fill(mode === 'ssh' ? 'jump-test-password' : 'socks-test-password'); + if (mode === 'ssh') await d.getByPlaceholder('SHA256:…').fill('SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + await d.getByRole('button', { name: '保存代理' }).click(); + await expect(d).not.toBeVisible(); + await page.getByRole('button', { name: /目标服务/ }).first().click(); +} +async function gateway(page: Page, type: string, targetName: string, name: string) { + await page.getByRole('button', { name: /网关服务/ }).first().click(); + await page.getByRole('button', { name: '+ 新建网关' }).click(); + const drawer = page.getByRole('dialog'); + await drawer.locator('.el-radio-button').filter({ hasText: new RegExp(`^${type}$`) }).click(); + await drawer.getByPlaceholder('例如:生产数据库 · 只读分析').fill(name); + await drawer.getByRole('combobox', { name: '关联目标服务' }).click(); + await page.getByRole('option', { name: new RegExp(targetName) }).click(); + await expect(drawer.getByPlaceholder('数据库或服务器域名 / IP')).toHaveCount(0); + if (['MySQL','MongoDB'].includes(type)) await expect(drawer.getByRole('switch', { name: '只读模式' })).not.toBeChecked(); + await drawer.locator('.el-form-item').filter({ hasText: /^网关用户名/ }).getByRole('textbox').fill('gateway'); + await drawer.getByPlaceholder('客户端使用此密码连接').fill('gateway-test-password'); + await expect(drawer.getByText('留空允许所有来源 IP;填写后仅允许列表中的 IP 或网段连接。')).toBeVisible(); + await drawer.getByRole('button', { name: '保存配置' }).click(); + await expect(drawer).not.toBeVisible(); +} + +test('默认免登录,目标复用与网关独立只读策略', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto('/'); + await expect(page.getByRole('heading', { name: '网关服务', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: '登录管理控制台' })).toHaveCount(0); + await target(page, 'MySQL', '共享数据库'); + await gateway(page, 'MySQL', '共享数据库', '分析入口'); + await gateway(page, 'MySQL', '共享数据库', '业务入口'); + const row = page.getByRole('row').filter({ hasText: '分析入口' }); + await row.getByRole('button', { name: '配置', exact: true }).click(); + await page.getByRole('dialog').locator('.setting-row').filter({ hasText: '只读模式' }).locator('.el-switch__core').click(); + await page.getByRole('dialog').getByRole('button', { name: '保存配置' }).click(); + await expect(row).toContainText('只读'); + await expect(page.getByRole('row').filter({ hasText: '业务入口' })).toContainText('读写'); + await page.getByRole('button', { name: /目标服务/ }).first().click(); + const card = page.locator('.target-card').filter({ hasText: '共享数据库' }); + await expect(card).toContainText('被 2 个网关引用'); + await expect(card.getByRole('button', { name: '删除目标' })).toBeDisabled(); + await card.getByRole('button', { name: '配置目标' }).click(); + await page.getByRole('dialog').getByPlaceholder('数据库或服务器域名 / IP').fill('new.internal'); + await page.getByRole('dialog').getByRole('button', { name: '保存配置' }).click(); + await expect(card).toContainText('new.internal'); + const views = await (await page.request.get('/api/v1/proxies')).json(); + const shared = views.filter((v: any) => ['分析入口', '业务入口'].includes(v.config.name)); + expect(shared).toHaveLength(2); + for (const view of shared) expect(view.config.allowCIDRs || []).toEqual([]); + expect(shared[0].config.targetId).toBe(shared[1].config.targetId); + for (const view of shared) { expect(view.config.targetHost).toBe('new.internal'); expect(view.secretStatus.targetPassword).toBe(true); } + await card.getByRole('button', { name: '查看密码' }).click(); + const secret = page.getByRole('dialog'); + await expect(secret.getByText('管理员登录密码')).toHaveCount(0); + await expect(secret.getByRole('button', { name: '验证并查看' })).toHaveCount(0); + await expect(secret.locator('.secret-result').first()).toContainText('upstream'); + await expect(secret.locator('.secret-result').last()).toContainText('target-test-password'); + await secret.getByRole('button', { name: 'Close this dialog' }).click(); + await page.route('**/api/v1/targets/*/test', route => route.fulfill({ json: { version: '5.7.44', tls: true, compression: 'zlib', fingerprint: 'SHA256:test-fingerprint' } })); + await card.getByRole('button', { name: '测试连接' }).click(); + const connection = page.getByRole('dialog', { name: '连接测试', exact: true }); + for (const value of ['5.7.44', '已启用并验证证书', 'zlib', 'SHA256:test-fingerprint', 'new.internal:3306', '测试总耗时']) await expect(connection).toContainText(value); + await connection.getByRole('button', { name: '关闭', exact: true }).click(); + await page.route('**/api/v1/targets/*/test', route => route.fulfill({ status: 422, json: { error: 'SOCKS5 authentication failed', stage: 'socks_auth_failed' } })); + await card.getByRole('button', { name: '测试连接' }).click(); + await expect(connection).toContainText('连接失败'); + await expect(connection).toContainText('socks_auth_failed'); + await expect(connection).toContainText('SOCKS5 authentication failed'); + await connection.getByRole('button', { name: '关闭', exact: true }).click(); + expect(errors).toEqual([]); +}); + +test('目标支持 SSH 转发出口并保留凭据', async ({ page }) => { + await page.goto('/'); + await createOutbound(page, 'SSH 跳板代理', 'ssh'); + await target(page, 'MySQL', 'SSH 跳板目标', 'SSH 跳板代理'); + const card = page.locator('.target-card').filter({ hasText: 'SSH 跳板目标' }); + await expect(card).toContainText('SSH 转发'); + const views = await (await page.request.get('/api/v1/targets')).json(); + const view = views.find((v: any) => v.config.name === 'SSH 跳板目标'); + expect(view.config.outboundId).toBeTruthy(); + expect(view.config.outbound.port).toBe(22); + expect(view.config.outbound.password).toBeUndefined(); + await card.getByRole('button', { name: '查看密码' }).click(); + const dialog = page.getByRole('dialog'); + await dialog.locator('.el-select__wrapper').click(); + await page.getByRole('option', { name: 'SSH 转发密码' }).click(); + await expect(dialog.locator('.secret-result').first()).toContainText('jump'); + await expect(dialog.locator('.secret-result').last()).toContainText('jump-test-password'); +}); + +test('MongoDB、SSH 目标独立保存 SOCKS5 凭据', async ({ page }) => { + await page.goto('/'); + for (const [type, name] of [['MongoDB', '文档目标'], ['SSH', '运维目标']]) { + await target(page, type, name, true); + await gateway(page, type, name, name + '入口'); + } + const targets = await (await page.request.get('/api/v1/targets')).json(); + for (const name of ['文档目标', '运维目标']) { + const view = targets.find((t: any) => t.config.name === name); + expect(view.secretStatus.targetPassword).toBe(true); + expect(view.secretStatus.socksPassword).toBe(true); + expect(JSON.stringify(view)).not.toContain('socks-test-password'); + expect(view.gatewayIds).toHaveLength(1); + expect(view.config.targetCompression).toBe(name === '文档目标' ? 'zlib' : 'none'); + } +}); + +test('共享代理菜单维护、多目标引用与删除保护', async ({ page }) => { + await page.goto('/'); + await createOutbound(page, '共享出口管理测试', 'socks5'); + await target(page, 'MySQL', '共享出口 MySQL', '共享出口管理测试'); + await target(page, 'MongoDB', '共享出口 MongoDB', '共享出口管理测试'); + await page.getByRole('navigation').getByRole('button', { name: /代理$/ }).click(); + const row = page.getByRole('row').filter({ hasText: '共享出口管理测试' }); + await expect(row).toContainText('共享出口 MySQL'); + await expect(row).toContainText('共享出口 MongoDB'); + await expect(row.getByRole('button', { name: '删除代理' })).toBeDisabled(); + const profiles = await (await page.request.get('/api/v1/outbounds')).json(); + const profile = profiles.find((v: any) => v.config.name === '共享出口管理测试'); + expect(profile.targetIds).toHaveLength(2); + expect(profile.secretStatus.password).toBe(true); + expect(JSON.stringify(profile)).not.toContain('socks-test-password'); + const session = await (await page.request.get('/api/v1/session')).json(); + const deletion = await page.request.delete(`/api/v1/outbounds/${profile.config.id}`, { headers: { 'X-CSRF-Token': session.csrfToken } }); + expect(deletion.status()).toBe(409); + await row.getByRole('button', { name: '编辑代理' }).click(); + const drawer = page.getByRole('dialog'); + await drawer.locator('.el-form-item').filter({ hasText: /^SOCKS5 主机/ }).getByRole('textbox').fill('updated.proxy.internal'); + await drawer.getByRole('button', { name: '保存代理' }).click(); + await expect(drawer).not.toBeVisible(); + await page.reload(); + const targets = await (await page.request.get('/api/v1/targets')).json(); + const linked = targets.filter((v: any) => v.config.outboundId === profile.config.id); + expect(linked).toHaveLength(2); + for (const view of linked) { + expect(view.config.outbound.host).toBe('updated.proxy.internal'); + expect(view.secretStatus.socksPassword).toBe(true); + } + for (const name of ['共享出口 MySQL', '共享出口 MongoDB']) { + await page.getByRole('button', { name: /目标服务/ }).first().click(); + await page.locator('.target-card').filter({ hasText: name }).getByRole('button', { name: '配置目标' }).click(); + await drawer.getByRole('combobox', { name: '代理出口' }).click(); + await page.getByRole('option', { name: '直接连接', exact: true }).click(); + await drawer.getByRole('button', { name: '保存配置' }).click(); + await expect(drawer).not.toBeVisible(); + } + await page.getByRole('navigation').getByRole('button', { name: /代理$/ }).click(); + await expect(row.getByRole('button', { name: '删除代理' })).toBeEnabled(); + await row.getByRole('button', { name: '删除代理' }).click(); + await page.getByRole('dialog', { name: '删除代理', exact: true }).getByRole('button', { name: '删除', exact: true }).click(); + await expect(row).toHaveCount(0); +}); + +test('Redis、RabbitMQ、PostgreSQL 配置与事件导出',async({page})=>{ + await page.goto('/'); + for(const type of ['Redis','RabbitMQ','PostgreSQL']){await target(page,type,type+'目标',true);await gateway(page,type,type+'目标',type+'网关')} + const targets=await(await page.request.get('/api/v1/targets')).json(); + for(const [type,port] of [['redis',6379],['rabbitmq',5672],['pgsql',5432]]){const row=targets.find((t:any)=>t.config.type===type);expect(row.config.targetPort).toBe(port);expect(row.config.outbound.mode).toBe('socks5')} + await page.getByRole('button',{name:/连接与审计/}).click(); + await page.getByPlaceholder('事件详情关键字').fill('redis'); + await page.getByRole('button',{name:'查询',exact:true}).click(); + const download=page.waitForEvent('download');await page.getByRole('button',{name:'导出 CSV'}).click();expect((await download).suggestedFilename()).toBe('agentdb-events.csv'); +}); + +test('主题切换、持久化、跟随系统及移动布局', async ({ page }) => { + await page.goto('/'); + for (const value of ['light', 'ocean', 'dark']) { + await page.getByLabel('主题', { exact: true }).selectOption(value); + await expect(page.locator('html')).toHaveAttribute('data-theme', value); + await page.screenshot({ path: `test-results/theme-${value}.png`, fullPage: true }); + } + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await page.getByRole('button', { name: '+ 新建网关' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.screenshot({ path: 'test-results/theme-dark-drawer.png', fullPage: true }); + await page.getByRole('dialog').getByRole('button', { name: '取消', exact: true }).click(); + await page.getByLabel('主题', { exact: true }).selectOption('system'); + await page.emulateMedia({ colorScheme: 'light' }); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await page.emulateMedia({ colorScheme: 'dark' }); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await page.setViewportSize({ width: 390, height: 844 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + await page.screenshot({ path: 'test-results/theme-mobile.png', fullPage: true }); + await page.getByRole('button', { name: /命令审计/ }).click(); + await expect(page.getByRole('heading', { name: '命令审计', exact: true })).toBeVisible(); + await expect(page.getByText('暂无命令记录;通过网关执行命令后将自动记录。')).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..18a98bf --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM"], + "types": ["vite/client"], + "noEmit": true + }, + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..203c559 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +export default defineConfig({ + plugins: [vue()], + server: { + proxy: { + "/api": "http://127.0.0.1:8080", + "/healthz": "http://127.0.0.1:8080", + }, + }, + build: { chunkSizeWarningLimit: 1100 }, +});