init
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
data
|
||||||
|
bin
|
||||||
|
artifacts
|
||||||
|
web/node_modules
|
||||||
|
web/test-results
|
||||||
|
web/playwright-report
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.ico binary
|
||||||
|
*.exe binary
|
||||||
+10
@@ -25,3 +25,13 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.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
|
||||||
|
|||||||
@@ -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/<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.
|
||||||
+27
@@ -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"]
|
||||||
@@ -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 等机制。
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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:
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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。
|
||||||
@@ -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;特定云平台认证和真实集群兼容性需在实际环境验证。
|
||||||
@@ -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 转发出口执行相同规则。
|
||||||
+129
@@ -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 进程退出失败,单独重跑通过。
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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") }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(`<!doctype html>
|
||||||
|
<html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AgentDB · 首次设置</title>
|
||||||
|
<style>body{margin:0;background:#f2f5fa;color:#18304a;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;background:white;padding:36px;border-radius:18px;box-shadow:0 12px 50px #18304a12;width:min(440px,92vw)}h1{margin-top:0}p{color:#62758a;line-height:1.6}label{display:block;margin-top:18px}input,button{box-sizing:border-box;width:100%;padding:12px;border-radius:8px;font:inherit}input{margin-top:7px;border:1px solid #cbd5e1}button{margin-top:24px;border:0;background:#2563eb;color:white;cursor:pointer}.error{color:#b42318}</style>
|
||||||
|
<main><h1>欢迎使用 AgentDB</h1><p>设置管理员账号后即可管理 MySQL、MongoDB 和 SSH 代理。下次启动直接登录。</p>
|
||||||
|
{{if .}}<p class="error" role="alert">{{.}}</p>{{end}}
|
||||||
|
<form method="post"><label>管理员用户名<input name="username" autocomplete="username" maxlength="128" value="admin" required></label><label>管理员密码<input type="password" name="password" autocomplete="new-password" minlength="12" maxlength="72" required></label><label>确认密码<input type="password" name="confirm" autocomplete="new-password" minlength="12" maxlength="72" required></label><p>密码须为 12–72 字节(建议使用英文、数字和符号)。</p><button type="submit">保存并进入登录页面</button></form></main></html>`))
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "连接失败"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) })
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) })
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)<julianday(?)", cutoff.Format(time.RFC3339Nano))
|
||||||
|
db.Exec("DELETE FROM events WHERE julianday(at)<julianday(?)", cutoff.Format(time.RFC3339Nano))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"agentdb/internal/model"
|
||||||
|
"agentdb/internal/security"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func shardFixture(t *testing.T) (*Store, string, *security.Vault) {
|
||||||
|
t.Helper()
|
||||||
|
v, err := security.NewVault(base64.StdEncoding.EncodeToString(make([]byte, 32)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "agentdb.sqlite")
|
||||||
|
s, err := Open(path, v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
return s, path, v
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditShardsCrossDayPaginationFilteringAndWalk(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 day := 0; day < 3; day++ {
|
||||||
|
for i := 0; i < 137; i++ {
|
||||||
|
text := fmt.Sprintf("SELECT %d, %d", day, i)
|
||||||
|
if day == 2 && i < 3 {
|
||||||
|
text = "SELECT 'old-page-match'"
|
||||||
|
}
|
||||||
|
err := s.recordCommandAt(p, "192.0.2.1", model.Command{Kind: "query", Decision: "allowed", Text: text}, now.AddDate(0, 0, -day).Add(time.Duration(i)*time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var cursor int64
|
||||||
|
for page, expected := range []int{200, 200, 11, 0} {
|
||||||
|
recs, err := s.Commands("gateway", "mysql", cursor)
|
||||||
|
if err != nil || len(recs) != expected {
|
||||||
|
t.Fatalf("page %d got %d want %d: %v", page, len(recs), expected, err)
|
||||||
|
}
|
||||||
|
for _, rec := range recs {
|
||||||
|
if seen[rec.ID] || (cursor != 0 && rec.RowID >= 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user