feat: OpsxDB 桌面数据库客户端首版入库
含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、 Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
No files matched your search
@@ -0,0 +1,33 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
target/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
release/
|
||||||
|
*.local
|
||||||
|
.vite/
|
||||||
|
coverage/
|
||||||
|
.turbo/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
apps/server/opsxapi
|
||||||
|
apps/server/server
|
||||||
|
apps/desktop/src-tauri/gen/
|
||||||
|
*.exe
|
||||||
|
*.dylib
|
||||||
|
*.so
|
||||||
|
Cargo.lock
|
||||||
|
!apps/desktop/src-tauri/Cargo.lock
|
||||||
|
|
||||||
|
# local review / sync artifacts
|
||||||
|
.ui-review-*/
|
||||||
|
_refs/
|
||||||
|
_sync/
|
||||||
|
opsxdb-refs.tar.gz
|
||||||
|
apps/server/bin/
|
||||||
|
logs/
|
||||||
|
apps/desktop/src-tauri/binaries/opsxdb-server-*
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
.PHONY: install web gui all server stop status build-web test desktop
|
||||||
|
|
||||||
|
install:
|
||||||
|
npm install
|
||||||
|
npm --prefix apps/desktop install
|
||||||
|
|
||||||
|
web:
|
||||||
|
./run.sh web
|
||||||
|
|
||||||
|
gui:
|
||||||
|
./run.sh gui
|
||||||
|
|
||||||
|
all:
|
||||||
|
./run.sh all
|
||||||
|
|
||||||
|
server:
|
||||||
|
./run.sh server
|
||||||
|
|
||||||
|
stop:
|
||||||
|
./run.sh stop
|
||||||
|
|
||||||
|
status:
|
||||||
|
./run.sh status
|
||||||
|
|
||||||
|
desktop:
|
||||||
|
./run.sh desktop
|
||||||
|
|
||||||
|
build-web:
|
||||||
|
npm run build:web
|
||||||
|
|
||||||
|
test:
|
||||||
|
npm run test:core
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
> **产品形态:桌面 GUI(Tauri)**。`http://127.0.0.1:5174` 仅为开发壳。文档 `/docs`、官网 `/` 由本机后端提供。
|
||||||
|
|
||||||
|
# OpsxDB
|
||||||
|
|
||||||
|
给运维和开发用的 **数据库客户端**(连接管理 / SQL 工作台 / AI 写 SQL)。
|
||||||
|
|
||||||
|
从 OpsxAPI 脚手架分叉:同一套 monorepo(web + Gin + 可选 Tauri),产品面换成 DB。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/ui 连接选择器 + SQL 工作台 + AI 面板
|
||||||
|
packages/core 共享工具(保留)
|
||||||
|
packages/transport 浏览器 / Gin / Tauri(保留兼容)
|
||||||
|
apps/web Vite + React(:5174)
|
||||||
|
apps/desktop Tauri 2 壳
|
||||||
|
apps/server Gin:health + MySQL 驱动 + AI stub(:8788)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 怎么跑
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:/opt/homebrew/bin:$PATH"
|
||||||
|
export OPSXDB_SERVER_PORT=8788 OPSXDB_WEB_PORT=5174
|
||||||
|
|
||||||
|
npm install
|
||||||
|
./run.sh web # Gin :8788 + Vite :5174
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 http://127.0.0.1:5174/
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:8788/api/health
|
||||||
|
curl -s -X POST http://127.0.0.1:8788/api/ai/chat \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"action":"generate","prompt":"查最近用户"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 日常怎么用
|
||||||
|
|
||||||
|
1. **新建连接** → 选类型,或粘贴 `mysql://user:pass@host:3306/db` 点「填入」
|
||||||
|
2. **测试** → **保存并连接**
|
||||||
|
3. 左侧点库展开表;**点表名**打开前 100 行(展开表看列)
|
||||||
|
4. 编辑器 **⌘/Ctrl+Enter** 执行当前语句或选区(不是整页脚本)
|
||||||
|
5. 结果:**复制 / CSV / INSERT**;双击单元格复制
|
||||||
|
|
||||||
|
## 已做(便捷向)
|
||||||
|
|
||||||
|
- 连接选择器:搜索 / 分类 / 最近 / 图标网格;URL 拆字段;按驱动表单(SQLite 文件、Redis、Oracle Service、SSL)
|
||||||
|
- 工作台:懒加载对象树、点表看数据、CodeMirror SQL、多 Tab、可拖分栏
|
||||||
|
- 会话:连接后查询只带 `sessionId`,账密不再每次上送;本机密码做了混淆
|
||||||
|
- 结果截断提示、危险 SQL 确认、查询历史、主题/Tab 持久化、快捷键
|
||||||
|
- 驱动:MySQL 族 / PostgreSQL 族(含 GaussDB、人大金仓)/ SQL Server / Oracle / SQLite / Redis
|
||||||
|
- AI 面板仍是 stub(生成/解释/优化/修复/方言/样例)
|
||||||
|
|
||||||
|
## 端口
|
||||||
|
|
||||||
|
| 服务 | 端口 | 环境变量 |
|
||||||
|
|------|------|----------|
|
||||||
|
| Gin | 8788 | `OPSXDB_SERVER_PORT` / `OPSXDB_ADDR` |
|
||||||
|
| Vite | 5174 | `OPSXDB_WEB_PORT` |
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: OpsxDB Server
|
||||||
|
version: 0.2.0
|
||||||
|
description: Database workbench API — sessions, metadata, SQL exec
|
||||||
|
servers:
|
||||||
|
- url: http://127.0.0.1:8788
|
||||||
|
paths:
|
||||||
|
/health:
|
||||||
|
get:
|
||||||
|
summary: Health check
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
/api/health:
|
||||||
|
get:
|
||||||
|
summary: Health check
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
/api/db/test-connection:
|
||||||
|
post:
|
||||||
|
summary: Ping a database without creating a session
|
||||||
|
/api/db/open:
|
||||||
|
post:
|
||||||
|
summary: Ping and create a server-side session (password stays on server)
|
||||||
|
/api/db/close:
|
||||||
|
post:
|
||||||
|
summary: Close a session
|
||||||
|
/api/db/databases:
|
||||||
|
post:
|
||||||
|
summary: List databases (sessionId or full conn)
|
||||||
|
/api/db/tables:
|
||||||
|
post:
|
||||||
|
summary: List tables in a database
|
||||||
|
/api/db/columns:
|
||||||
|
post:
|
||||||
|
summary: List columns of a table
|
||||||
|
/api/db/exec:
|
||||||
|
post:
|
||||||
|
summary: Execute SQL or Redis command
|
||||||
|
/api/ai/chat:
|
||||||
|
post:
|
||||||
|
summary: AI actions (currently stub)
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
ConnReq:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
driver: { type: string }
|
||||||
|
host: { type: string }
|
||||||
|
port: { type: integer }
|
||||||
|
user: { type: string }
|
||||||
|
password: { type: string }
|
||||||
|
database: { type: string }
|
||||||
|
service: { type: string }
|
||||||
|
ssl: { type: boolean }
|
||||||
|
sessionId: { type: string }
|
||||||
|
sql: { type: string }
|
||||||
|
table: { type: string }
|
||||||
|
limit: { type: integer }
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
{
|
||||||
|
"name": "@opsxdb/desktop",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@opsxdb/desktop",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli": {
|
||||||
|
"version": "2.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||||
|
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"tauri": "tauri.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/tauri"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||||
|
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||||
|
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||||
|
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||||
|
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||||
|
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||||
|
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||||
|
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||||
|
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||||
|
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||||
|
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||||
|
"version": "2.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||||
|
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||||
|
"version": "2.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||||
|
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||||
|
"version": "2.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||||
|
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||||
|
"version": "2.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||||
|
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||||
|
"version": "2.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||||
|
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||||
|
"version": "2.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||||
|
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||||
|
"version": "2.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||||
|
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||||
|
"version": "2.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||||
|
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||||
|
"version": "2.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||||
|
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||||
|
"version": "2.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||||
|
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||||
|
"version": "2.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||||
|
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@opsxdb/desktop",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "opsxdb-desktop"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "OpsxDB Tauri desktop shell"
|
||||||
|
authors = ["OpsxDB"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "opsxdb_desktop_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] } # path via Manager
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] }
|
||||||
|
tokio = { version = "1", features = ["time"] }
|
||||||
|
url = "2"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Sidecar binaries
|
||||||
|
|
||||||
|
`./run.sh package` 会把 `opsxdb-server` 复制为带目标三元组后缀的文件,例如:
|
||||||
|
|
||||||
|
- `opsxdb-server-aarch64-apple-darwin`
|
||||||
|
- `opsxdb-server-x86_64-apple-darwin`
|
||||||
|
|
||||||
|
Tauri `externalBin` 在构建时按当前 triple 解析。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capabilities for the OpsxDB main window",
|
||||||
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="112" fill="#E11D2E"/>
|
||||||
|
<g transform="rotate(-20 256 256)">
|
||||||
|
<path d="M256 108 L290 216 L404 216 L312 286 L344 398 L256 332 L168 398 L200 286 L108 216 L222 216 Z"
|
||||||
|
fill="none" stroke="#FFFFFF" stroke-width="30" stroke-linejoin="round" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 411 B |
|
After Width: | Height: | Size: 898 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,150 @@
|
|||||||
|
mod sidecar;
|
||||||
|
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct BodySpec {
|
||||||
|
mode: String,
|
||||||
|
raw: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct HttpSpec {
|
||||||
|
method: String,
|
||||||
|
url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
headers: HashMap<String, String>,
|
||||||
|
query: Option<HashMap<String, String>>,
|
||||||
|
body: Option<BodySpec>,
|
||||||
|
timeout_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct HttpResult {
|
||||||
|
status: u16,
|
||||||
|
headers: HashMap<String, String>,
|
||||||
|
body: String,
|
||||||
|
time_ms: u128,
|
||||||
|
size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WsSpec {
|
||||||
|
url: String,
|
||||||
|
protocols: Option<Vec<String>>,
|
||||||
|
headers: Option<HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_http_url(raw: &str) -> Result<url::Url, String> {
|
||||||
|
let parsed = url::Url::parse(raw).map_err(|e| format!("invalid URL: {e}"))?;
|
||||||
|
match parsed.scheme() {
|
||||||
|
"http" | "https" => {}
|
||||||
|
_ => return Err("only http/https schemes are allowed".into()),
|
||||||
|
}
|
||||||
|
match parsed.host_str().unwrap_or("").to_ascii_lowercase().as_str() {
|
||||||
|
"169.254.169.254" | "metadata.google.internal" => {
|
||||||
|
return Err("blocked metadata host".into())
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn http_send(spec: HttpSpec) -> Result<HttpResult, String> {
|
||||||
|
let mut url = validate_http_url(&spec.url)?;
|
||||||
|
if let Some(query) = &spec.query {
|
||||||
|
let mut pairs = url.query_pairs_mut();
|
||||||
|
for (key, value) in query {
|
||||||
|
pairs.append_pair(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let method = reqwest::Method::from_bytes(spec.method.as_bytes())
|
||||||
|
.map_err(|e| format!("invalid method: {e}"))?;
|
||||||
|
let timeout = Duration::from_millis(spec.timeout_ms.unwrap_or(30_000));
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(timeout)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut request = client.request(method, url);
|
||||||
|
for (key, value) in spec.headers {
|
||||||
|
request = request.header(&key, &value);
|
||||||
|
}
|
||||||
|
if let Some(body) = spec.body {
|
||||||
|
if body.mode != "none" {
|
||||||
|
request = request.body(body.raw.unwrap_or_default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let response = request.send().await.map_err(|e| e.to_string())?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let headers = response
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
|
||||||
|
.collect();
|
||||||
|
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
|
||||||
|
let size = bytes.len();
|
||||||
|
let body = String::from_utf8_lossy(&bytes).into_owned();
|
||||||
|
Ok(HttpResult {
|
||||||
|
status,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
time_ms: started.elapsed().as_millis(),
|
||||||
|
size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn ws_connect(spec: WsSpec) -> Result<String, String> {
|
||||||
|
let _ = (&spec.protocols, &spec.headers);
|
||||||
|
// TODO(week2): hold WebSocket sessions in managed state and emit messages.
|
||||||
|
Err(format!(
|
||||||
|
"WebSocket native tunnel is not implemented yet for {} (use browser WS in MVP)",
|
||||||
|
spec.url
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn ws_send(session_id: String, data: String) -> Result<(), String> {
|
||||||
|
let _ = (session_id, data);
|
||||||
|
Err("WebSocket native send is a Week 2 stub".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn ws_close(session_id: String) -> Result<(), String> {
|
||||||
|
let _ = session_id;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.manage(sidecar::SidecarState::default())
|
||||||
|
.setup(|app| {
|
||||||
|
let state = app.state::<sidecar::SidecarState>();
|
||||||
|
sidecar::ensure_backend(app.handle(), &state);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
http_send,
|
||||||
|
ws_connect,
|
||||||
|
ws_send,
|
||||||
|
ws_close
|
||||||
|
])
|
||||||
|
.build(tauri::generate_context!())
|
||||||
|
.expect("error while building OpsxDB desktop")
|
||||||
|
.run(|app, event| {
|
||||||
|
let state = app.state::<sidecar::SidecarState>();
|
||||||
|
sidecar::on_run_event(app, &event, &state);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
opsxdb_desktop_lib::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
//! 桌面启动时拉起本机 OpsxDB Gin 后端(默认 127.0.0.1:8788)。
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tauri::{AppHandle, Manager, RunEvent};
|
||||||
|
|
||||||
|
const HEALTH: &str = "http://127.0.0.1:8788/health";
|
||||||
|
const ADDR: &str = "127.0.0.1:8788";
|
||||||
|
|
||||||
|
pub struct SidecarState {
|
||||||
|
child: Mutex<Option<Child>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SidecarState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
child: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn health_ok() -> bool {
|
||||||
|
ureq_get(HEALTH).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 轻量 GET,避免再引 reqwest 阻塞;用 std 即可。
|
||||||
|
fn ureq_get(url: &str) -> Result<(), ()> {
|
||||||
|
let Ok(resp) = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(1))
|
||||||
|
.build()
|
||||||
|
.and_then(|c| c.get(url).send())
|
||||||
|
else {
|
||||||
|
return Err(());
|
||||||
|
};
|
||||||
|
if resp.status().is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn candidate_bins(app: &AppHandle) -> Vec<PathBuf> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
// 打包资源:externalBin → 与可执行文件同级的 binaries 或 resource
|
||||||
|
if let Ok(dir) = app.path().resource_dir() {
|
||||||
|
out.push(dir.join("opsxdb-server"));
|
||||||
|
out.push(dir.join("binaries").join("opsxdb-server"));
|
||||||
|
}
|
||||||
|
if let Ok(exe) = app.path().executable_dir() {
|
||||||
|
out.push(exe.join("opsxdb-server"));
|
||||||
|
out.push(exe.join("binaries").join("opsxdb-server"));
|
||||||
|
}
|
||||||
|
// 开发态:仓库 apps/server/bin
|
||||||
|
out.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../server/bin/opsxdb-server"));
|
||||||
|
out.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../apps/server/bin/opsxdb-server"));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_server(bin: &PathBuf) -> std::io::Result<Child> {
|
||||||
|
Command::new(bin)
|
||||||
|
.env("OPSXDB_ADDR", ADDR)
|
||||||
|
.env("GIN_MODE", "release")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_backend(app: &AppHandle, state: &SidecarState) {
|
||||||
|
if health_ok() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for bin in candidate_bins(app) {
|
||||||
|
if !bin.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match spawn_server(&bin) {
|
||||||
|
Ok(child) => {
|
||||||
|
*state.child.lock().unwrap() = Some(child);
|
||||||
|
for _ in 0..30 {
|
||||||
|
if health_ok() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_backend(state: &SidecarState) {
|
||||||
|
if let Ok(mut g) = state.child.lock() {
|
||||||
|
if let Some(mut child) = g.take() {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_run_event(app: &AppHandle, event: &RunEvent, state: &SidecarState) {
|
||||||
|
if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) {
|
||||||
|
stop_backend(state);
|
||||||
|
let _ = app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "OpsxDB",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.opsx.db",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "bash -lc 'curl -fsS http://127.0.0.1:5174 >/dev/null 2>&1 || npm --prefix ../.. run dev:web'",
|
||||||
|
"devUrl": "http://127.0.0.1:5174",
|
||||||
|
"beforeBuildCommand": "bash -lc 'cd ../.. && VITE_OPSXDB_API=http://127.0.0.1:8788 npm run build:web'",
|
||||||
|
"frontendDist": "../../web/dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "OpsxDB",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 800,
|
||||||
|
"minWidth": 960,
|
||||||
|
"minHeight": 640,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false,
|
||||||
|
"label": "main"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/opsxdb-server"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"opsxdb/server/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
addr := resolveListenAddr()
|
||||||
|
log.Printf("OpsxDB server listening on %s", addr)
|
||||||
|
if err := server.NewRouter().Run(addr); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveListenAddr:默认仅本机 127.0.0.1:8788(桌面 sidecar 同机访问)。
|
||||||
|
// 显式对外:OPSXDB_BIND_PUBLIC=1 或 OPSXDB_ADDR=:8788 / 0.0.0.0:8788。
|
||||||
|
func resolveListenAddr() string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv("OPSXDB_ADDR")); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
pub := strings.ToLower(strings.TrimSpace(os.Getenv("OPSXDB_BIND_PUBLIC")))
|
||||||
|
if pub == "1" || pub == "true" || pub == "yes" {
|
||||||
|
return ":8788"
|
||||||
|
}
|
||||||
|
return "127.0.0.1:8788"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
server:
|
||||||
|
addr: ":8788"
|
||||||
|
proxy:
|
||||||
|
timeout_ms: 30000
|
||||||
|
# Environment overrides:
|
||||||
|
# OPSXDB_ADDR=:8788
|
||||||
|
# OPSXDB_PROXY_TIMEOUT_MS=30000
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
module opsxdb/server
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1
|
||||||
|
github.com/jackc/pgx/v5 v5.7.2
|
||||||
|
github.com/microsoft/go-mssqldb v1.8.0
|
||||||
|
github.com/redis/go-redis/v9 v9.7.0
|
||||||
|
github.com/sijms/go-ora/v2 v2.8.22
|
||||||
|
golang.org/x/crypto v0.48.0
|
||||||
|
modernc.org/sqlite v1.34.5
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/alicebob/miniredis/v2 v2.39.0 // indirect
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||||
|
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||||
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||||
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.55.3 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.8.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80=
|
||||||
|
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
|
||||||
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
|
||||||
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||||
|
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||||
|
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||||
|
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||||
|
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-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
|
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/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.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
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/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
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/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw=
|
||||||
|
github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo=
|
||||||
|
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||||
|
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||||
|
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.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/sijms/go-ora/v2 v2.8.22 h1:3ABgRzVKxS439cEgSLjFKutIwOyhnyi4oOSBywEdOlU=
|
||||||
|
github.com/sijms/go-ora/v2 v2.8.22/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU=
|
||||||
|
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/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
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.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
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/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
|
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/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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=
|
||||||
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
|
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
|
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||||
|
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChatReq struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
SQL string `json:"sql"`
|
||||||
|
Driver string `json:"driver"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatRes struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
SQL string `json:"sql,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Chat(req ChatReq) ChatRes {
|
||||||
|
driver := req.Driver
|
||||||
|
if driver == "" {
|
||||||
|
driver = "mysql"
|
||||||
|
}
|
||||||
|
db := req.Database
|
||||||
|
if db == "" {
|
||||||
|
db = "your_db"
|
||||||
|
}
|
||||||
|
sql := strings.TrimSpace(req.SQL)
|
||||||
|
prompt := strings.TrimSpace(req.Prompt)
|
||||||
|
|
||||||
|
switch strings.ToLower(req.Action) {
|
||||||
|
case "generate", "ask":
|
||||||
|
q := prompt
|
||||||
|
if q == "" {
|
||||||
|
q = "示例查询"
|
||||||
|
}
|
||||||
|
gen := fmt.Sprintf("-- 根据描述生成(%s / %s)\n-- 需求: %s\nSELECT *\nFROM `%s`.some_table\nWHERE 1=1\nLIMIT 100;", driver, db, q, db)
|
||||||
|
return ChatRes{
|
||||||
|
Content: fmt.Sprintf("已根据「%s」生成 SQL(stub,可替换为真实模型)。\n\n```sql\n%s\n```", q, gen),
|
||||||
|
SQL: gen,
|
||||||
|
}
|
||||||
|
case "explain":
|
||||||
|
if sql == "" {
|
||||||
|
sql = "SELECT 1"
|
||||||
|
}
|
||||||
|
return ChatRes{Content: fmt.Sprintf("## 解释 SQL\n\n```sql\n%s\n```\n\n- 这是一条在 **%s** 上执行的查询。\n- 建议检查 WHERE 条件是否走索引,避免 `SELECT *` 拉取过大结果集。\n- (stub 回复)", sql, driver)}
|
||||||
|
case "optimize":
|
||||||
|
opt := sql
|
||||||
|
if opt == "" {
|
||||||
|
opt = "SELECT id, name FROM users WHERE status = 1 LIMIT 100;"
|
||||||
|
} else if strings.Contains(strings.ToUpper(opt), "SELECT *") {
|
||||||
|
opt = strings.Replace(opt, "*", "id /* TODO: 显式列 */", 1)
|
||||||
|
}
|
||||||
|
opt = strings.TrimSpace(opt)
|
||||||
|
if !strings.Contains(strings.ToUpper(opt), "LIMIT") {
|
||||||
|
opt = opt + "\nLIMIT 100;"
|
||||||
|
}
|
||||||
|
return ChatRes{
|
||||||
|
Content: fmt.Sprintf("## 优化建议\n\n1. 避免不必要的全表扫描\n2. 显式列出列名\n3. 加合理 LIMIT\n\n```sql\n%s\n```", opt),
|
||||||
|
SQL: opt,
|
||||||
|
}
|
||||||
|
case "fix":
|
||||||
|
fixed := sql
|
||||||
|
if fixed == "" {
|
||||||
|
fixed = "SELECT 1 AS ok;"
|
||||||
|
}
|
||||||
|
msg := req.Error
|
||||||
|
if msg == "" {
|
||||||
|
msg = "未知错误"
|
||||||
|
}
|
||||||
|
return ChatRes{
|
||||||
|
Content: fmt.Sprintf("## 修复错误\n\n原错误:`%s`\n\n尝试修正后的 SQL:\n\n```sql\n%s\n```\n\n(stub:请结合真实报错继续调整)", msg, fixed),
|
||||||
|
SQL: fixed,
|
||||||
|
}
|
||||||
|
case "dialect":
|
||||||
|
out := sql
|
||||||
|
if out == "" {
|
||||||
|
out = "SELECT * FROM dual"
|
||||||
|
}
|
||||||
|
out = strings.ReplaceAll(out, "`", "\"")
|
||||||
|
return ChatRes{
|
||||||
|
Content: fmt.Sprintf("## 方言转换 → PostgreSQL 风格(示例)\n\n```sql\n%s\n```", out),
|
||||||
|
SQL: out,
|
||||||
|
}
|
||||||
|
case "sample":
|
||||||
|
sample := fmt.Sprintf("SELECT id, name, created_at\nFROM `%s`.users\nORDER BY id DESC\nLIMIT 20;", db)
|
||||||
|
return ChatRes{
|
||||||
|
Content: fmt.Sprintf("## 样例 SQL(%s)\n\n```sql\n%s\n```", driver, sample),
|
||||||
|
SQL: sample,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ChatRes{Content: "未知 AI 动作。可用:ask / generate / explain / optimize / fix / dialect / sample"}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// IsDangerousSQL 识别可能破坏数据的语句,供前端二次确认。
|
||||||
|
func IsDangerousSQL(sql string) bool {
|
||||||
|
s := stripSQLLead(sql)
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
up := strings.ToUpper(s)
|
||||||
|
for _, p := range []string{"DROP ", "DROP\t", "TRUNCATE ", "ALTER ", "GRANT ", "REVOKE "} {
|
||||||
|
if strings.HasPrefix(up, strings.TrimSpace(p)) || strings.HasPrefix(up, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(up, "DELETE") {
|
||||||
|
return !containsWhere(up)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(up, "UPDATE") {
|
||||||
|
return !containsWhere(up)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsWhere(up string) bool {
|
||||||
|
return strings.Contains(up, " WHERE ") || strings.Contains(up, "\nWHERE ") || strings.Contains(up, "\tWHERE ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripSQLLead(sql string) string {
|
||||||
|
s := strings.TrimSpace(sql)
|
||||||
|
for {
|
||||||
|
if strings.HasPrefix(s, "--") {
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
s = strings.TrimSpace(s[i+1:])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(s, "/*") {
|
||||||
|
if i := strings.Index(s, "*/"); i >= 0 {
|
||||||
|
s = strings.TrimSpace(s[i+2:])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func quoteMySQLIdent(s string) string {
|
||||||
|
return "`" + strings.ReplaceAll(s, "`", "") + "`"
|
||||||
|
}
|
||||||
|
|
||||||
|
func quotePGIdent(s string) string {
|
||||||
|
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitSchemaTable(name string) (schema, table string) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if i := strings.LastIndex(name, "."); i > 0 {
|
||||||
|
return name[:i], name[i+1:]
|
||||||
|
}
|
||||||
|
return "", name
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "github.com/microsoft/go-mssqldb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openMSSQL(c ConnReq) (*sql.DB, error) {
|
||||||
|
host := c.Host
|
||||||
|
if host == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
port := c.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 1433
|
||||||
|
}
|
||||||
|
user := c.User
|
||||||
|
if user == "" {
|
||||||
|
user = "sa"
|
||||||
|
}
|
||||||
|
dbName := c.Database
|
||||||
|
if dbName == "" {
|
||||||
|
dbName = "master"
|
||||||
|
}
|
||||||
|
mode := EffectiveSSLMode(c)
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("database", dbName)
|
||||||
|
q.Set("connection timeout", "5")
|
||||||
|
switch mode {
|
||||||
|
case "disable":
|
||||||
|
q.Set("encrypt", "disable")
|
||||||
|
case "skip-verify":
|
||||||
|
q.Set("encrypt", "true")
|
||||||
|
q.Set("TrustServerCertificate", "true")
|
||||||
|
if ca := strings.TrimSpace(c.SSLCA); ca != "" {
|
||||||
|
q.Set("certificate", ca)
|
||||||
|
}
|
||||||
|
case "require", "verify-ca":
|
||||||
|
q.Set("encrypt", "true")
|
||||||
|
q.Set("TrustServerCertificate", "false")
|
||||||
|
ca := strings.TrimSpace(c.SSLCA)
|
||||||
|
if mode == "verify-ca" && ca == "" {
|
||||||
|
return nil, fmt.Errorf("verify-ca 模式需要填写 CA 证书路径")
|
||||||
|
}
|
||||||
|
if ca != "" {
|
||||||
|
// go-mssqldb msdsn certificate= builds RootCAs from PEM/DER
|
||||||
|
q.Set("certificate", ca)
|
||||||
|
}
|
||||||
|
if host != "" {
|
||||||
|
q.Set("hostnameincertificate", host)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("未知 SSL 模式: %s", mode)
|
||||||
|
}
|
||||||
|
dsn := fmt.Sprintf("sqlserver://%s:%s@%s:%d?%s",
|
||||||
|
url.QueryEscape(user), url.QueryEscape(c.Password), host, port, q.Encode())
|
||||||
|
return sql.Open("sqlserver", dsn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDatabasesMSSQL(db *sql.DB) ([]string, error) {
|
||||||
|
rows, err := db.Query(`SELECT name FROM sys.databases WHERE name NOT IN ('master','tempdb','model','msdb') ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func listTablesMSSQL(db *sql.DB, database string) ([]string, error) {
|
||||||
|
_ = database
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT TABLE_SCHEMA + '.' + TABLE_NAME
|
||||||
|
FROM INFORMATION_SCHEMA.TABLES
|
||||||
|
WHERE TABLE_TYPE = 'BASE TABLE'
|
||||||
|
ORDER BY 1`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func listColumnsMSSQL(dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
|
||||||
|
schema, t := splitSchemaTable(table)
|
||||||
|
if schema == "" {
|
||||||
|
schema = "dbo"
|
||||||
|
}
|
||||||
|
rows, err := dbh.Query(`
|
||||||
|
SELECT COLUMN_NAME, DATA_TYPE,
|
||||||
|
ISNULL(CAST(CHARACTER_MAXIMUM_LENGTH AS varchar), ''),
|
||||||
|
IS_NULLABLE, COLUMN_DEFAULT
|
||||||
|
FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @p1 AND TABLE_NAME = @p2
|
||||||
|
ORDER BY ORDINAL_POSITION`, schema, t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ColumnInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var name, typ, extra, nullable string
|
||||||
|
var def sql.NullString
|
||||||
|
if err := rows.Scan(&name, &typ, &extra, &nullable, &def); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if extra != "" && extra != "-1" {
|
||||||
|
typ = typ + "(" + extra + ")"
|
||||||
|
}
|
||||||
|
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "YES"}
|
||||||
|
if def.Valid {
|
||||||
|
col.Default = def.String
|
||||||
|
}
|
||||||
|
out = append(out, col)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []ColumnInfo{}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mysqldriver "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openMySQL(c ConnReq) (*sql.DB, error) {
|
||||||
|
host := c.Host
|
||||||
|
if host == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
port := c.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 3306
|
||||||
|
}
|
||||||
|
user := c.User
|
||||||
|
if user == "" {
|
||||||
|
user = "root"
|
||||||
|
}
|
||||||
|
cfg := mysqldriver.NewConfig()
|
||||||
|
cfg.User = user
|
||||||
|
cfg.Passwd = c.Password
|
||||||
|
cfg.Net = "tcp"
|
||||||
|
cfg.Addr = net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||||
|
cfg.DBName = c.Database
|
||||||
|
cfg.ParseTime = true
|
||||||
|
cfg.Timeout = 5 * time.Second
|
||||||
|
cfg.ReadTimeout = 30 * time.Second
|
||||||
|
cfg.WriteTimeout = 30 * time.Second
|
||||||
|
cfg.Loc = time.Local
|
||||||
|
cfg.Params = map[string]string{"charset": "utf8mb4"}
|
||||||
|
mode := EffectiveSSLMode(c)
|
||||||
|
switch mode {
|
||||||
|
case "disable":
|
||||||
|
// no TLS
|
||||||
|
case "skip-verify":
|
||||||
|
if c.SSLCA == "" && c.SSLCert == "" && c.SSLKey == "" {
|
||||||
|
cfg.TLSConfig = "skip-verify"
|
||||||
|
} else {
|
||||||
|
name, err := registerMySQLTLS(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.TLSConfig = name
|
||||||
|
}
|
||||||
|
case "require", "verify-ca":
|
||||||
|
if c.SSLCA == "" && c.SSLCert == "" && c.SSLKey == "" && mode == "require" {
|
||||||
|
cfg.TLSConfig = "true"
|
||||||
|
} else {
|
||||||
|
name, err := registerMySQLTLS(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.TLSConfig = name
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("未知 SSL 模式: %s", mode)
|
||||||
|
}
|
||||||
|
return sql.Open("mysql", cfg.FormatDSN())
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerMySQLTLS(c ConnReq) (string, error) {
|
||||||
|
tlsCfg, err := tlsConfigFrom(c)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if tlsCfg == nil {
|
||||||
|
return "", fmt.Errorf("SSL 已启用但未能构建 TLS 配置")
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(EffectiveSSLMode(c) + "|" + c.SSLCA + "|" + c.SSLCert + "|" + c.SSLKey + "|" + c.Host))
|
||||||
|
name := "opsxdb-" + hex.EncodeToString(sum[:8])
|
||||||
|
mysqldriver.DeregisterTLSConfig(name)
|
||||||
|
if err := mysqldriver.RegisterTLSConfig(name, tlsCfg); err != nil {
|
||||||
|
return "", fmt.Errorf("注册 MySQL TLS 配置失败: %w", err)
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDatabasesMySQL(db *sql.DB) ([]string, error) {
|
||||||
|
rows, err := db.Query("SHOW DATABASES")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func listTablesMySQL(db *sql.DB, database string) ([]string, error) {
|
||||||
|
q := "SHOW TABLES"
|
||||||
|
if database != "" {
|
||||||
|
q = "SHOW TABLES FROM " + quoteMySQLIdent(database)
|
||||||
|
}
|
||||||
|
rows, err := db.Query(q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func listColumnsMySQL(db *sql.DB, database, table string) ([]ColumnInfo, error) {
|
||||||
|
ident := quoteMySQLIdent(table)
|
||||||
|
if database != "" {
|
||||||
|
ident = quoteMySQLIdent(database) + "." + ident
|
||||||
|
}
|
||||||
|
rows, err := db.Query("SHOW FULL COLUMNS FROM " + ident)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ColumnInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var field, typ, null, key string
|
||||||
|
var collation, extra, priv, comment sql.NullString
|
||||||
|
var def sql.NullString
|
||||||
|
if err := rows.Scan(&field, &typ, &collation, &null, &key, &def, &extra, &priv, &comment); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
col := ColumnInfo{Name: field, Type: typ, Nullable: null == "YES", Key: key, Comment: comment.String}
|
||||||
|
if def.Valid {
|
||||||
|
col.Default = def.String
|
||||||
|
}
|
||||||
|
out = append(out, col)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []ColumnInfo{}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func driverKey(d string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMySQLFamily(d string) bool {
|
||||||
|
switch driverKey(d) {
|
||||||
|
case "mysql", "mariadb", "tidb", "oceanbase", "polardb", "tdsql":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPostgresFamily(d string) bool {
|
||||||
|
switch driverKey(d) {
|
||||||
|
case "postgresql", "postgres", "gaussdb", "opengauss", "kingbase":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open 按驱动打开 *sql.DB
|
||||||
|
func Open(c ConnReq) (*sql.DB, error) {
|
||||||
|
var (
|
||||||
|
db *sql.DB
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case isMySQLFamily(c.Driver):
|
||||||
|
db, err = openMySQL(c)
|
||||||
|
case isPostgresFamily(c.Driver):
|
||||||
|
db, err = openPostgres(c)
|
||||||
|
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
|
||||||
|
db, err = openMSSQL(c)
|
||||||
|
case driverKey(c.Driver) == "oracle":
|
||||||
|
db, err = openOracle(c)
|
||||||
|
case driverKey(c.Driver) == "sqlite":
|
||||||
|
db, err = openSQLite(c)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("驱动「%s」尚未实现(当前支持: mysql/mariadb/tidb、postgresql、sqlserver、oracle、sqlite、redis)", c.Driver)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetConnMaxLifetime(2 * time.Minute)
|
||||||
|
db.SetMaxOpenConns(5)
|
||||||
|
db.SetMaxIdleConns(2)
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnection(c ConnReq) error {
|
||||||
|
dial, tun, err := ApplySSHTunnel(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tun != nil {
|
||||||
|
defer tun.Close()
|
||||||
|
}
|
||||||
|
if err := testDial(dial); err != nil {
|
||||||
|
return fmt.Errorf("数据库: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDial(c ConnReq) error {
|
||||||
|
if isRedis(c.Driver) {
|
||||||
|
return testRedis(c)
|
||||||
|
}
|
||||||
|
db, err := Open(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
return db.Ping()
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ListDatabases(c ConnReq) ([]string, error) {
|
||||||
|
if isRedis(c.Driver) {
|
||||||
|
return listDatabasesRedis(c)
|
||||||
|
}
|
||||||
|
cc := c
|
||||||
|
switch {
|
||||||
|
case isMySQLFamily(c.Driver):
|
||||||
|
cc.Database = ""
|
||||||
|
case isPostgresFamily(c.Driver):
|
||||||
|
if cc.Database == "" {
|
||||||
|
cc.Database = "postgres"
|
||||||
|
}
|
||||||
|
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
|
||||||
|
if cc.Database == "" {
|
||||||
|
cc.Database = "master"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db, err := Open(cc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
switch {
|
||||||
|
case isMySQLFamily(c.Driver):
|
||||||
|
return listDatabasesMySQL(db)
|
||||||
|
case isPostgresFamily(c.Driver):
|
||||||
|
return listDatabasesPostgres(db)
|
||||||
|
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
|
||||||
|
return listDatabasesMSSQL(db)
|
||||||
|
case driverKey(c.Driver) == "oracle":
|
||||||
|
return listDatabasesOracle(db)
|
||||||
|
case driverKey(c.Driver) == "sqlite":
|
||||||
|
return listDatabasesSQLite(db)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported driver")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListTables(c ConnReq) ([]string, error) {
|
||||||
|
if isRedis(c.Driver) {
|
||||||
|
return listTablesRedis(c)
|
||||||
|
}
|
||||||
|
db, err := Open(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
switch {
|
||||||
|
case isMySQLFamily(c.Driver):
|
||||||
|
return listTablesMySQL(db, c.Database)
|
||||||
|
case isPostgresFamily(c.Driver):
|
||||||
|
return listTablesPostgres(db, c.Database)
|
||||||
|
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
|
||||||
|
return listTablesMSSQL(db, c.Database)
|
||||||
|
case driverKey(c.Driver) == "oracle":
|
||||||
|
return listTablesOracle(db, c.Database)
|
||||||
|
case driverKey(c.Driver) == "sqlite":
|
||||||
|
return listTablesSQLite(db, c.Database)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported driver")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListColumns(c ConnReq) ([]ColumnInfo, error) {
|
||||||
|
if c.Table == "" {
|
||||||
|
return nil, fmt.Errorf("缺少表名")
|
||||||
|
}
|
||||||
|
if isRedis(c.Driver) {
|
||||||
|
return listColumnsRedis(c, c.Table)
|
||||||
|
}
|
||||||
|
db, err := Open(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
switch {
|
||||||
|
case isMySQLFamily(c.Driver):
|
||||||
|
return listColumnsMySQL(db, c.Database, c.Table)
|
||||||
|
case isPostgresFamily(c.Driver):
|
||||||
|
return listColumnsPostgres(db, c.Database, c.Table)
|
||||||
|
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
|
||||||
|
return listColumnsMSSQL(db, c.Database, c.Table)
|
||||||
|
case driverKey(c.Driver) == "oracle":
|
||||||
|
return listColumnsOracle(db, c.Database, c.Table)
|
||||||
|
case driverKey(c.Driver) == "sqlite":
|
||||||
|
return listColumnsSQLite(db, c.Database, c.Table)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported driver")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rowLimit(c ConnReq) int {
|
||||||
|
n := c.Limit
|
||||||
|
if n <= 0 {
|
||||||
|
n = DefaultRowLimit
|
||||||
|
}
|
||||||
|
if n > MaxRowLimit {
|
||||||
|
n = MaxRowLimit
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func errMsg(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return "已取消"
|
||||||
|
}
|
||||||
|
// client gone / deadline tied to request cancel
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "context canceled") {
|
||||||
|
return "已取消"
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func execFail(start time.Time, err error) ExecResult {
|
||||||
|
return ExecResult{
|
||||||
|
Columns: []string{},
|
||||||
|
Rows: []map[string]interface{}{},
|
||||||
|
TimeMs: time.Since(start).Milliseconds(),
|
||||||
|
Error: errMsg(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecSQL 执行查询 / 非查询语句。ctx 来自 HTTP 请求,客户端断开/Abort 时可取消 DB 调用。
|
||||||
|
func ExecSQL(ctx context.Context, c ConnReq) ExecResult {
|
||||||
|
start := time.Now()
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return execFail(start, err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(c.SQL) == "" {
|
||||||
|
return ExecResult{Columns: []string{}, Rows: []map[string]interface{}{}, Error: "empty sql"}
|
||||||
|
}
|
||||||
|
if isRedis(c.Driver) {
|
||||||
|
return execRedis(ctx, c)
|
||||||
|
}
|
||||||
|
db, err := Open(c)
|
||||||
|
if err != nil {
|
||||||
|
return execFail(start, err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
if !isQuerySQL(c.SQL) {
|
||||||
|
res, err := db.ExecContext(ctx, c.SQL)
|
||||||
|
if err != nil {
|
||||||
|
return execFail(start, err)
|
||||||
|
}
|
||||||
|
affected, _ := res.RowsAffected()
|
||||||
|
return ExecResult{
|
||||||
|
Columns: []string{"affected_rows"},
|
||||||
|
Rows: []map[string]interface{}{{"affected_rows": affected}},
|
||||||
|
RowCount: 1,
|
||||||
|
TimeMs: time.Since(start).Milliseconds(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, c.SQL)
|
||||||
|
if err != nil {
|
||||||
|
return execFail(start, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
cols, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return execFail(start, err)
|
||||||
|
}
|
||||||
|
limit := rowLimit(c)
|
||||||
|
var resultRows []map[string]interface{}
|
||||||
|
truncated := false
|
||||||
|
for rows.Next() {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return execFail(start, err)
|
||||||
|
}
|
||||||
|
raw := make([]interface{}, len(cols))
|
||||||
|
ptrs := make([]interface{}, len(cols))
|
||||||
|
for i := range raw {
|
||||||
|
ptrs[i] = &raw[i]
|
||||||
|
}
|
||||||
|
if err := rows.Scan(ptrs...); err != nil {
|
||||||
|
return execFail(start, err)
|
||||||
|
}
|
||||||
|
m := make(map[string]interface{}, len(cols))
|
||||||
|
for i, col := range cols {
|
||||||
|
m[col] = normalize(raw[i])
|
||||||
|
}
|
||||||
|
resultRows = append(resultRows, m)
|
||||||
|
if len(resultRows) >= limit {
|
||||||
|
truncated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return execFail(start, err)
|
||||||
|
}
|
||||||
|
if resultRows == nil {
|
||||||
|
resultRows = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
return ExecResult{
|
||||||
|
Columns: cols,
|
||||||
|
Rows: resultRows,
|
||||||
|
RowCount: len(resultRows),
|
||||||
|
TimeMs: time.Since(start).Milliseconds(),
|
||||||
|
Truncated: truncated,
|
||||||
|
Limit: limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isQuerySQL(sql string) bool {
|
||||||
|
s := stripSQLLead(sql)
|
||||||
|
if s == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
up := strings.ToUpper(s)
|
||||||
|
for _, p := range []string{"SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "WITH", "PRAGMA"} {
|
||||||
|
if strings.HasPrefix(up, p+" ") || up == p || strings.HasPrefix(up, p+"\t") || strings.HasPrefix(up, p+"\n") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsQuerySQL(t *testing.T) {
|
||||||
|
if !isQuerySQL("SELECT 1") {
|
||||||
|
t.Fatal("select")
|
||||||
|
}
|
||||||
|
if isQuerySQL("DELETE FROM t") {
|
||||||
|
t.Fatal("delete is not query")
|
||||||
|
}
|
||||||
|
if !isQuerySQL("-- hi\nSELECT 1") {
|
||||||
|
t.Fatal("comment then select")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDangerousSQL(t *testing.T) {
|
||||||
|
if !IsDangerousSQL("DROP TABLE users") {
|
||||||
|
t.Fatal("drop")
|
||||||
|
}
|
||||||
|
if !IsDangerousSQL("DELETE FROM users") {
|
||||||
|
t.Fatal("delete no where")
|
||||||
|
}
|
||||||
|
if IsDangerousSQL("DELETE FROM users WHERE id=1") {
|
||||||
|
t.Fatal("delete with where is ok")
|
||||||
|
}
|
||||||
|
if IsDangerousSQL("SELECT * FROM users") {
|
||||||
|
t.Fatal("select")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrMsgCanceled(t *testing.T) {
|
||||||
|
if got := errMsg(context.Canceled); got != "已取消" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
ora "github.com/sijms/go-ora/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openOracle(c ConnReq) (*sql.DB, error) {
|
||||||
|
host := c.Host
|
||||||
|
if host == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
port := c.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 1521
|
||||||
|
}
|
||||||
|
user := c.User
|
||||||
|
if user == "" {
|
||||||
|
user = "system"
|
||||||
|
}
|
||||||
|
service := c.Service
|
||||||
|
if service == "" {
|
||||||
|
if c.Database != "" {
|
||||||
|
service = c.Database
|
||||||
|
} else {
|
||||||
|
service = "XEPDB1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dsn := fmt.Sprintf("oracle://%s:%s@%s:%d/%s", url.PathEscape(user), url.PathEscape(c.Password), host, port, service)
|
||||||
|
mode := EffectiveSSLMode(c)
|
||||||
|
if mode != "disable" {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("SSL", "true")
|
||||||
|
if mode == "skip-verify" {
|
||||||
|
q.Set("SSL VERIFY", "false")
|
||||||
|
} else {
|
||||||
|
q.Set("SSL VERIFY", "true")
|
||||||
|
}
|
||||||
|
dsn = dsn + "?" + q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsCfg, err := tlsConfigFrom(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tlsCfg != nil {
|
||||||
|
connector := ora.NewConnector(dsn)
|
||||||
|
oc, ok := connector.(*ora.OracleConnector)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("oracle: connector 类型异常")
|
||||||
|
}
|
||||||
|
oc.WithTLSConfig(tlsCfg)
|
||||||
|
return sql.OpenDB(oc), nil
|
||||||
|
}
|
||||||
|
return sql.Open("oracle", dsn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDatabasesOracle(db *sql.DB) ([]string, error) {
|
||||||
|
// 返回当前用户可见 schema
|
||||||
|
rows, err := db.Query(`SELECT username FROM all_users ORDER BY username`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func listTablesOracle(db *sql.DB, schema string) ([]string, error) {
|
||||||
|
var (
|
||||||
|
rows *sql.Rows
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if schema == "" {
|
||||||
|
rows, err = db.Query(`SELECT table_name FROM user_tables ORDER BY table_name`)
|
||||||
|
} else {
|
||||||
|
rows, err = db.Query(`SELECT table_name FROM all_tables WHERE owner = UPPER(:1) ORDER BY table_name`, schema)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func listColumnsOracle(dbh *sql.DB, schema, table string) ([]ColumnInfo, error) {
|
||||||
|
owner, t := splitSchemaTable(table)
|
||||||
|
if owner == "" {
|
||||||
|
owner = schema
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
rows *sql.Rows
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if owner == "" {
|
||||||
|
rows, err = dbh.Query(`
|
||||||
|
SELECT column_name, data_type, nullable, data_default
|
||||||
|
FROM user_tab_columns WHERE table_name = UPPER(:1)
|
||||||
|
ORDER BY column_id`, t)
|
||||||
|
} else {
|
||||||
|
rows, err = dbh.Query(`
|
||||||
|
SELECT column_name, data_type, nullable, data_default
|
||||||
|
FROM all_tab_columns WHERE owner = UPPER(:1) AND table_name = UPPER(:2)
|
||||||
|
ORDER BY column_id`, owner, t)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ColumnInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var name, typ, nullable string
|
||||||
|
var def sql.NullString
|
||||||
|
if err := rows.Scan(&name, &typ, &nullable, &def); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "Y"}
|
||||||
|
if def.Valid {
|
||||||
|
col.Default = def.String
|
||||||
|
}
|
||||||
|
out = append(out, col)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []ColumnInfo{}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openPostgres(c ConnReq) (*sql.DB, error) {
|
||||||
|
host := c.Host
|
||||||
|
if host == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
port := c.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 5432
|
||||||
|
}
|
||||||
|
user := c.User
|
||||||
|
if user == "" {
|
||||||
|
user = "postgres"
|
||||||
|
}
|
||||||
|
dbName := c.Database
|
||||||
|
if dbName == "" {
|
||||||
|
dbName = "postgres"
|
||||||
|
}
|
||||||
|
mode := EffectiveSSLMode(c)
|
||||||
|
sslmode := "disable"
|
||||||
|
switch mode {
|
||||||
|
case "disable":
|
||||||
|
sslmode = "disable"
|
||||||
|
case "require", "skip-verify":
|
||||||
|
// libpq require ≈ encrypt without CA/hostname verify
|
||||||
|
sslmode = "require"
|
||||||
|
case "verify-ca":
|
||||||
|
sslmode = "verify-ca"
|
||||||
|
if strings.TrimSpace(c.SSLCA) == "" {
|
||||||
|
return nil, fmt.Errorf("verify-ca 模式需要填写 CA 证书路径")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("未知 SSL 模式: %s", mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s connect_timeout=5",
|
||||||
|
pqEscape(host), port, pqEscape(user), pqEscape(c.Password), pqEscape(dbName), sslmode)
|
||||||
|
if ca := strings.TrimSpace(c.SSLCA); ca != "" {
|
||||||
|
fmt.Fprintf(&b, " sslrootcert=%s", pqEscape(ca))
|
||||||
|
}
|
||||||
|
if cert := strings.TrimSpace(c.SSLCert); cert != "" {
|
||||||
|
fmt.Fprintf(&b, " sslcert=%s", pqEscape(cert))
|
||||||
|
}
|
||||||
|
if key := strings.TrimSpace(c.SSLKey); key != "" {
|
||||||
|
fmt.Fprintf(&b, " sslkey=%s", pqEscape(key))
|
||||||
|
}
|
||||||
|
return sql.Open("pgx", b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func pqEscape(v string) string {
|
||||||
|
if v == "" {
|
||||||
|
return "''"
|
||||||
|
}
|
||||||
|
needs := strings.ContainsAny(v, " '\\")
|
||||||
|
if !needs {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "'" + strings.ReplaceAll(strings.ReplaceAll(v, `\`, `\\`), `'`, `\'`) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDatabasesPostgres(db *sql.DB) ([]string, error) {
|
||||||
|
rows, err := db.Query(`SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY 1`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func listTablesPostgres(db *sql.DB, database string) ([]string, error) {
|
||||||
|
// 当前连接库下的 public 表;切库需重新 Open
|
||||||
|
_ = database
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT CASE WHEN schemaname = 'public' THEN tablename ELSE schemaname || '.' || tablename END
|
||||||
|
FROM pg_catalog.pg_tables
|
||||||
|
WHERE schemaname NOT IN ('pg_catalog','information_schema')
|
||||||
|
ORDER BY 1`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func listColumnsPostgres(dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
|
||||||
|
schema, t := splitSchemaTable(table)
|
||||||
|
if schema == "" {
|
||||||
|
schema = "public"
|
||||||
|
}
|
||||||
|
rows, err := dbh.Query(`
|
||||||
|
SELECT column_name, data_type,
|
||||||
|
COALESCE(character_maximum_length::text, numeric_precision::text, ''),
|
||||||
|
is_nullable, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = $1 AND table_name = $2
|
||||||
|
ORDER BY ordinal_position`, schema, t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ColumnInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var name, typ, extra, nullable string
|
||||||
|
var def sql.NullString
|
||||||
|
if err := rows.Scan(&name, &typ, &extra, &nullable, &def); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if extra != "" {
|
||||||
|
typ = typ + "(" + extra + ")"
|
||||||
|
}
|
||||||
|
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "YES"}
|
||||||
|
if def.Valid {
|
||||||
|
col.Default = def.String
|
||||||
|
}
|
||||||
|
out = append(out, col)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []ColumnInfo{}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
func redisOpts(c ConnReq) (*redis.Options, error) {
|
||||||
|
host := c.Host
|
||||||
|
if host == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
port := c.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 6379
|
||||||
|
}
|
||||||
|
db := 0
|
||||||
|
if c.Database != "" {
|
||||||
|
if n, err := strconv.Atoi(c.Database); err == nil {
|
||||||
|
db = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
opts := &redis.Options{
|
||||||
|
Addr: fmt.Sprintf("%s:%d", host, port),
|
||||||
|
Password: c.Password,
|
||||||
|
Username: c.User,
|
||||||
|
DB: db,
|
||||||
|
DialTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
if SSLEnabled(c) {
|
||||||
|
tlsCfg, err := tlsConfigFrom(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opts.TLSConfig = tlsCfg
|
||||||
|
}
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openRedis(c ConnReq) (*redis.Client, error) {
|
||||||
|
opts, err := redisOpts(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return redis.NewClient(opts), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRedis(c ConnReq) error {
|
||||||
|
cli, err := openRedis(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return cli.Ping(ctx).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDatabasesRedis(c ConnReq) ([]string, error) {
|
||||||
|
cli, err := openRedis(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
// INFO keyspace 可拿已用 DB;统一暴露 0-15 便于切换
|
||||||
|
_ = ctx
|
||||||
|
out := make([]string, 16)
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
out[i] = strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listTablesRedis(c ConnReq) ([]string, error) {
|
||||||
|
cli, err := openRedis(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var keys []string
|
||||||
|
var cursor uint64
|
||||||
|
for {
|
||||||
|
batch, next, err := cli.Scan(ctx, cursor, "*", 200).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys = append(keys, batch...)
|
||||||
|
cursor = next
|
||||||
|
if cursor == 0 || len(keys) >= 500 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if keys == nil {
|
||||||
|
keys = []string{}
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func execRedis(parent context.Context, c ConnReq) ExecResult {
|
||||||
|
start := time.Now()
|
||||||
|
sql := strings.TrimSpace(c.SQL)
|
||||||
|
if sql == "" {
|
||||||
|
return ExecResult{Error: "empty sql", TimeMs: 0}
|
||||||
|
}
|
||||||
|
cli, err := openRedis(c)
|
||||||
|
if err != nil {
|
||||||
|
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
if parent == nil {
|
||||||
|
parent = context.Background()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
fields := splitRedisCmd(sql)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return ExecResult{Error: "empty command", TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
cmd := strings.ToUpper(fields[0])
|
||||||
|
args := fields[1:]
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "PING":
|
||||||
|
val, err := cli.Ping(ctx).Result()
|
||||||
|
if err != nil {
|
||||||
|
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": val}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
case "GET":
|
||||||
|
if len(args) < 1 {
|
||||||
|
return ExecResult{Error: "GET key", TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
val, err := cli.Get(ctx, args[0]).Result()
|
||||||
|
if err == redis.Nil {
|
||||||
|
return ExecResult{Columns: []string{"key", "value"}, Rows: []map[string]interface{}{}, RowCount: 0, TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
return ExecResult{Columns: []string{"key", "value"}, Rows: []map[string]interface{}{{"key": args[0], "value": val}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
case "SET":
|
||||||
|
if len(args) < 2 {
|
||||||
|
return ExecResult{Error: "SET key value", TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
if err := cli.Set(ctx, args[0], strings.Join(args[1:], " "), 0).Err(); err != nil {
|
||||||
|
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
return ExecResult{Columns: []string{"affected_rows"}, Rows: []map[string]interface{}{{"affected_rows": 1}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
case "DEL":
|
||||||
|
if len(args) < 1 {
|
||||||
|
return ExecResult{Error: "DEL key [key...]", TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
n, err := cli.Del(ctx, args...).Result()
|
||||||
|
if err != nil {
|
||||||
|
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
return ExecResult{Columns: []string{"affected_rows"}, Rows: []map[string]interface{}{{"affected_rows": n}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
case "KEYS":
|
||||||
|
pat := "*"
|
||||||
|
if len(args) > 0 {
|
||||||
|
pat = args[0]
|
||||||
|
}
|
||||||
|
keys, err := cli.Keys(ctx, pat).Result()
|
||||||
|
if err != nil {
|
||||||
|
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
rows := make([]map[string]interface{}, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
rows = append(rows, map[string]interface{}{"key": k})
|
||||||
|
}
|
||||||
|
return ExecResult{Columns: []string{"key"}, Rows: rows, RowCount: len(rows), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
case "SCAN":
|
||||||
|
keys, err := listTablesRedis(c)
|
||||||
|
if err != nil {
|
||||||
|
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
rows := make([]map[string]interface{}, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
rows = append(rows, map[string]interface{}{"key": k})
|
||||||
|
}
|
||||||
|
return ExecResult{Columns: []string{"key"}, Rows: rows, RowCount: len(rows), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
case "HGETALL":
|
||||||
|
if len(args) < 1 {
|
||||||
|
return ExecResult{Error: "HGETALL key", TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
c2 := c
|
||||||
|
c2.Key = args[0]
|
||||||
|
c2.Limit = redisValueRowCap
|
||||||
|
v := GetRedisKey(c2)
|
||||||
|
if v.Error != "" && v.Type == "" {
|
||||||
|
return ExecResult{Error: v.Error, TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
|
||||||
|
case "LRANGE":
|
||||||
|
if len(args) < 1 {
|
||||||
|
return ExecResult{Error: "LRANGE key [start stop]", TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
c2 := c
|
||||||
|
c2.Key = args[0]
|
||||||
|
c2.Limit = redisValueRowCap
|
||||||
|
v := GetRedisKey(c2)
|
||||||
|
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
|
||||||
|
case "SMEMBERS":
|
||||||
|
if len(args) < 1 {
|
||||||
|
return ExecResult{Error: "SMEMBERS key", TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
c2 := c
|
||||||
|
c2.Key = args[0]
|
||||||
|
v := GetRedisKey(c2)
|
||||||
|
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
|
||||||
|
case "ZRANGE":
|
||||||
|
if len(args) < 1 {
|
||||||
|
return ExecResult{Error: "ZRANGE key [start stop]", TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
c2 := c
|
||||||
|
c2.Key = args[0]
|
||||||
|
v := GetRedisKey(c2)
|
||||||
|
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
|
||||||
|
case "TYPE":
|
||||||
|
if len(args) < 1 {
|
||||||
|
return ExecResult{Error: "TYPE key", TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
t, err := cli.Type(ctx, args[0]).Result()
|
||||||
|
if err != nil {
|
||||||
|
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
return ExecResult{Columns: []string{"key", "type"}, Rows: []map[string]interface{}{{"key": args[0], "type": t}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
case "TTL":
|
||||||
|
if len(args) < 1 {
|
||||||
|
return ExecResult{Error: "TTL key", TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
n, err := cli.TTL(ctx, args[0]).Result()
|
||||||
|
if err != nil {
|
||||||
|
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
return ExecResult{Columns: []string{"key", "ttl_sec"}, Rows: []map[string]interface{}{{"key": args[0], "ttl_sec": int64(n.Seconds())}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
default:
|
||||||
|
return ExecResult{Error: fmt.Sprintf("暂不支持命令 %s(可用: PING/GET/SET/DEL/KEYS/SCAN/TYPE/TTL/HGETALL/LRANGE/SMEMBERS/ZRANGE)", cmd), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitRedisCmd(s string) []string {
|
||||||
|
var out []string
|
||||||
|
var cur strings.Builder
|
||||||
|
inQ := false
|
||||||
|
esc := false
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case esc:
|
||||||
|
cur.WriteRune(r)
|
||||||
|
esc = false
|
||||||
|
case r == '\\':
|
||||||
|
esc = true
|
||||||
|
case r == '"':
|
||||||
|
inQ = !inQ
|
||||||
|
case !inQ && (r == ' ' || r == '\t' || r == '\n'):
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
out = append(out, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
cur.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur.Len() > 0 {
|
||||||
|
out = append(out, cur.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRedis(d string) bool {
|
||||||
|
return driverKey(d) == "redis"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRedisDriver exported for router guards.
|
||||||
|
func IsRedisDriver(d string) bool {
|
||||||
|
return isRedis(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listColumnsRedis(c ConnReq, key string) ([]ColumnInfo, error) {
|
||||||
|
cli, err := openRedis(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
typ, err := cli.Type(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ttl, _ := cli.TTL(ctx, key).Result()
|
||||||
|
return []ColumnInfo{
|
||||||
|
{Name: key, Type: typ, Comment: fmt.Sprintf("ttl=%ds", int64(ttl.Seconds()))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const redisKeyListCap = 500
|
||||||
|
const redisValueRowCap = 500
|
||||||
|
|
||||||
|
func redisKeyName(c ConnReq) string {
|
||||||
|
if c.Key != "" {
|
||||||
|
return c.Key
|
||||||
|
}
|
||||||
|
return c.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func redisScanPattern(c ConnReq) string {
|
||||||
|
p := strings.TrimSpace(c.Pattern)
|
||||||
|
if p == "" {
|
||||||
|
return "*"
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func redisValueLimit(c ConnReq) int {
|
||||||
|
n := c.Limit
|
||||||
|
if n <= 0 {
|
||||||
|
n = redisValueRowCap
|
||||||
|
}
|
||||||
|
if n > MaxRowLimit {
|
||||||
|
n = MaxRowLimit
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func ttlSeconds(d time.Duration) int64 {
|
||||||
|
// go-redis maps Redis TTL -1 (no expire) / -2 (missing) to -1ns/-2ns
|
||||||
|
// (some versions use -1s/-2s). Truncating with d/time.Second turns those into 0.
|
||||||
|
switch d {
|
||||||
|
case time.Duration(-1), -1 * time.Second:
|
||||||
|
return -1
|
||||||
|
case time.Duration(-2), -2 * time.Second:
|
||||||
|
return -2
|
||||||
|
}
|
||||||
|
return int64(d / time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRedisKeys SCAN + pipeline TYPE/TTL. Cap ~500.
|
||||||
|
func ListRedisKeys(c ConnReq) RedisKeysResult {
|
||||||
|
start := time.Now()
|
||||||
|
cli, err := openRedis(c)
|
||||||
|
if err != nil {
|
||||||
|
return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
pat := redisScanPattern(c)
|
||||||
|
var names []string
|
||||||
|
var cursor uint64
|
||||||
|
truncated := false
|
||||||
|
for {
|
||||||
|
batch, next, err := cli.Scan(ctx, cursor, pat, 200).Result()
|
||||||
|
if err != nil {
|
||||||
|
return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
names = append(names, batch...)
|
||||||
|
cursor = next
|
||||||
|
if len(names) >= redisKeyListCap {
|
||||||
|
truncated = true
|
||||||
|
names = names[:redisKeyListCap]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if cursor == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if names == nil {
|
||||||
|
names = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]RedisKeyInfo, len(names))
|
||||||
|
if len(names) == 0 {
|
||||||
|
return RedisKeysResult{Keys: out, Truncated: truncated, TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline TYPE + TTL (avoid N+1)
|
||||||
|
pipe := cli.Pipeline()
|
||||||
|
typeCmds := make([]*redis.StatusCmd, len(names))
|
||||||
|
ttlCmds := make([]*redis.DurationCmd, len(names))
|
||||||
|
for i, k := range names {
|
||||||
|
typeCmds[i] = pipe.Type(ctx, k)
|
||||||
|
ttlCmds[i] = pipe.TTL(ctx, k)
|
||||||
|
}
|
||||||
|
_, _ = pipe.Exec(ctx)
|
||||||
|
for i, k := range names {
|
||||||
|
typ := "none"
|
||||||
|
if t, err := typeCmds[i].Result(); err == nil {
|
||||||
|
typ = t
|
||||||
|
}
|
||||||
|
ttl := int64(-2)
|
||||||
|
if d, err := ttlCmds[i].Result(); err == nil {
|
||||||
|
ttl = ttlSeconds(d)
|
||||||
|
}
|
||||||
|
out[i] = RedisKeyInfo{Name: k, Type: typ, TTL: ttl}
|
||||||
|
}
|
||||||
|
return RedisKeysResult{Keys: out, Truncated: truncated, TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRedisKey typed value → ResultPane columns/rows.
|
||||||
|
func GetRedisKey(c ConnReq) RedisKeyValue {
|
||||||
|
start := time.Now()
|
||||||
|
key := redisKeyName(c)
|
||||||
|
if key == "" {
|
||||||
|
return RedisKeyValue{Error: "缺少 key", TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
cli, err := openRedis(c)
|
||||||
|
if err != nil {
|
||||||
|
return RedisKeyValue{Name: key, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
typ, err := cli.Type(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return RedisKeyValue{Name: key, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
ttl := int64(-2)
|
||||||
|
if d, err := cli.TTL(ctx, key).Result(); err == nil {
|
||||||
|
ttl = ttlSeconds(d)
|
||||||
|
}
|
||||||
|
limit := redisValueLimit(c)
|
||||||
|
base := RedisKeyValue{Name: key, Type: typ, TTL: ttl, Limit: limit}
|
||||||
|
|
||||||
|
switch typ {
|
||||||
|
case "none":
|
||||||
|
base.Columns = []string{"value"}
|
||||||
|
base.Rows = []map[string]interface{}{}
|
||||||
|
base.RowCount = 0
|
||||||
|
base.Error = "key 不存在"
|
||||||
|
base.TimeMs = time.Since(start).Milliseconds()
|
||||||
|
return base
|
||||||
|
case "string":
|
||||||
|
val, err := cli.Get(ctx, key).Result()
|
||||||
|
if err == redis.Nil {
|
||||||
|
base.Columns = []string{"value"}
|
||||||
|
base.Rows = []map[string]interface{}{}
|
||||||
|
base.RowCount = 0
|
||||||
|
base.TimeMs = time.Since(start).Milliseconds()
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
base.Error = errMsg(err)
|
||||||
|
base.TimeMs = time.Since(start).Milliseconds()
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
base.Columns = []string{"value"}
|
||||||
|
base.Rows = []map[string]interface{}{{"value": val}}
|
||||||
|
base.RowCount = 1
|
||||||
|
base.TimeMs = time.Since(start).Milliseconds()
|
||||||
|
return base
|
||||||
|
case "hash":
|
||||||
|
fields, err := cli.HGetAll(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
base.Error = errMsg(err)
|
||||||
|
base.TimeMs = time.Since(start).Milliseconds()
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
base.Columns = []string{"field", "value"}
|
||||||
|
rows := make([]map[string]interface{}, 0, len(fields))
|
||||||
|
for f, v := range fields {
|
||||||
|
rows = append(rows, map[string]interface{}{"field": f, "value": v})
|
||||||
|
if len(rows) >= limit {
|
||||||
|
base.Truncated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base.Rows = rows
|
||||||
|
base.RowCount = len(rows)
|
||||||
|
base.TimeMs = time.Since(start).Milliseconds()
|
||||||
|
return base
|
||||||
|
case "list":
|
||||||
|
vals, err := cli.LRange(ctx, key, 0, int64(limit)-1).Result()
|
||||||
|
if err != nil {
|
||||||
|
base.Error = errMsg(err)
|
||||||
|
base.TimeMs = time.Since(start).Milliseconds()
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
truncated := false
|
||||||
|
n, _ := cli.LLen(ctx, key).Result()
|
||||||
|
if n > int64(len(vals)) {
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
|
base.Columns = []string{"index", "value"}
|
||||||
|
rows := make([]map[string]interface{}, 0, len(vals))
|
||||||
|
for i, v := range vals {
|
||||||
|
rows = append(rows, map[string]interface{}{"index": i, "value": v})
|
||||||
|
}
|
||||||
|
base.Rows = rows
|
||||||
|
base.RowCount = len(rows)
|
||||||
|
base.Truncated = truncated
|
||||||
|
base.TimeMs = time.Since(start).Milliseconds()
|
||||||
|
return base
|
||||||
|
case "set":
|
||||||
|
base.Columns = []string{"member"}
|
||||||
|
var rows []map[string]interface{}
|
||||||
|
var scursor uint64
|
||||||
|
truncated := false
|
||||||
|
for {
|
||||||
|
batch, next, err := cli.SScan(ctx, key, scursor, "*", 200).Result()
|
||||||
|
if err != nil {
|
||||||
|
base.Error = errMsg(err)
|
||||||
|
base.TimeMs = time.Since(start).Milliseconds()
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
for _, m := range batch {
|
||||||
|
rows = append(rows, map[string]interface{}{"member": m})
|
||||||
|
if len(rows) >= limit {
|
||||||
|
truncated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scursor = next
|
||||||
|
if truncated || scursor == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rows == nil {
|
||||||
|
rows = []map[string]interface{}{}
|
||||||
|
}
|
||||||
|
base.Rows = rows
|
||||||
|
base.RowCount = len(rows)
|
||||||
|
base.Truncated = truncated
|
||||||
|
base.TimeMs = time.Since(start).Milliseconds()
|
||||||
|
return base
|
||||||
|
case "zset":
|
||||||
|
vals, err := cli.ZRangeWithScores(ctx, key, 0, int64(limit-1)).Result()
|
||||||
|
if err != nil {
|
||||||
|
base.Error = errMsg(err)
|
||||||
|
base.TimeMs = time.Since(start).Milliseconds()
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
truncated := false
|
||||||
|
n, _ := cli.ZCard(ctx, key).Result()
|
||||||
|
if n > int64(len(vals)) {
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
|
base.Columns = []string{"score", "member"}
|
||||||
|
rows := make([]map[string]interface{}, 0, len(vals))
|
||||||
|
for _, z := range vals {
|
||||||
|
rows = append(rows, map[string]interface{}{"score": z.Score, "member": z.Member})
|
||||||
|
}
|
||||||
|
base.Rows = rows
|
||||||
|
base.RowCount = len(rows)
|
||||||
|
base.Truncated = truncated
|
||||||
|
base.TimeMs = time.Since(start).Milliseconds()
|
||||||
|
return base
|
||||||
|
default:
|
||||||
|
// stream / recon / module types: meta only
|
||||||
|
base.Columns = []string{"info"}
|
||||||
|
base.Rows = []map[string]interface{}{{"info": fmt.Sprintf("类型 %s 暂不展开,可用命令查看", typ)}}
|
||||||
|
base.RowCount = 1
|
||||||
|
base.TimeMs = time.Since(start).Milliseconds()
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRedisKey DEL one key.
|
||||||
|
func DeleteRedisKey(c ConnReq) ExecResult {
|
||||||
|
start := time.Now()
|
||||||
|
key := redisKeyName(c)
|
||||||
|
if key == "" {
|
||||||
|
return ExecResult{Error: "缺少 key", TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
cli, err := openRedis(c)
|
||||||
|
if err != nil {
|
||||||
|
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
n, err := cli.Del(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
|
||||||
|
}
|
||||||
|
return ExecResult{
|
||||||
|
Columns: []string{"affected_rows"},
|
||||||
|
Rows: []map[string]interface{}{{"affected_rows": n}},
|
||||||
|
RowCount: 1,
|
||||||
|
TimeMs: time.Since(start).Milliseconds(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListGetDeleteRedisKeys(t *testing.T) {
|
||||||
|
mr, err := miniredis.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer mr.Close()
|
||||||
|
|
||||||
|
_ = mr.Set("str:a", "hello")
|
||||||
|
mr.HSet("hash:b", "f1", "v1")
|
||||||
|
mr.Push("list:c", "x", "y")
|
||||||
|
mr.SAdd("set:d", "m1", "m2")
|
||||||
|
_, _ = mr.ZAdd("zset:e", 1.5, "zm")
|
||||||
|
|
||||||
|
host, portStr, err := net.SplitHostPort(mr.Addr())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
port, _ := strconv.Atoi(portStr)
|
||||||
|
base := ConnReq{Driver: "redis", Host: host, Port: port, Database: "0"}
|
||||||
|
|
||||||
|
list := ListRedisKeys(base)
|
||||||
|
if list.Error != "" {
|
||||||
|
t.Fatal(list.Error)
|
||||||
|
}
|
||||||
|
if len(list.Keys) < 5 {
|
||||||
|
t.Fatalf("want >=5 keys, got %d %#v", len(list.Keys), list.Keys)
|
||||||
|
}
|
||||||
|
byName := map[string]RedisKeyInfo{}
|
||||||
|
for _, k := range list.Keys {
|
||||||
|
byName[k.Name] = k
|
||||||
|
}
|
||||||
|
if byName["str:a"].Type != "string" {
|
||||||
|
t.Fatalf("str type %s", byName["str:a"].Type)
|
||||||
|
}
|
||||||
|
if byName["hash:b"].Type != "hash" {
|
||||||
|
t.Fatalf("hash type %s", byName["hash:b"].Type)
|
||||||
|
}
|
||||||
|
// Real Redis returns -1 (no expire); miniredis may report 0.
|
||||||
|
if byName["str:a"].TTL < -1 {
|
||||||
|
t.Fatalf("unexpected ttl %d", byName["str:a"].TTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
gv := GetRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "str:a"})
|
||||||
|
if gv.Error != "" || gv.RowCount != 1 || gv.Rows[0]["value"] != "hello" {
|
||||||
|
t.Fatalf("get string %#v", gv)
|
||||||
|
}
|
||||||
|
gh := GetRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "hash:b"})
|
||||||
|
if gh.Error != "" || gh.RowCount < 1 || gh.Columns[0] != "field" {
|
||||||
|
t.Fatalf("get hash %#v", gh)
|
||||||
|
}
|
||||||
|
gl := GetRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "list:c"})
|
||||||
|
if gl.Error != "" || gl.RowCount < 2 {
|
||||||
|
t.Fatalf("get list %#v", gl)
|
||||||
|
}
|
||||||
|
gs := GetRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "set:d"})
|
||||||
|
if gs.Error != "" || gs.RowCount < 2 {
|
||||||
|
t.Fatalf("get set %#v", gs)
|
||||||
|
}
|
||||||
|
gz := GetRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "zset:e"})
|
||||||
|
if gz.Error != "" || gz.RowCount != 1 {
|
||||||
|
t.Fatalf("get zset %#v", gz)
|
||||||
|
}
|
||||||
|
|
||||||
|
pat := ListRedisKeys(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Pattern: "str:*"})
|
||||||
|
if pat.Error != "" || len(pat.Keys) != 1 || pat.Keys[0].Name != "str:a" {
|
||||||
|
t.Fatalf("pattern %#v", pat)
|
||||||
|
}
|
||||||
|
|
||||||
|
del := DeleteRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "str:a"})
|
||||||
|
if del.Error != "" {
|
||||||
|
t.Fatal(del.Error)
|
||||||
|
}
|
||||||
|
if mr.Exists("str:a") {
|
||||||
|
t.Fatal("key should be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTTLSecondsSentinels(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in time.Duration
|
||||||
|
want int64
|
||||||
|
}{
|
||||||
|
{time.Duration(-1), -1},
|
||||||
|
{-1 * time.Nanosecond, -1},
|
||||||
|
{-1 * time.Second, -1},
|
||||||
|
{time.Duration(-2), -2},
|
||||||
|
{-2 * time.Nanosecond, -2},
|
||||||
|
{-2 * time.Second, -2},
|
||||||
|
{0, 0},
|
||||||
|
{5 * time.Second, 5},
|
||||||
|
{1500 * time.Millisecond, 1},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := ttlSeconds(c.in)
|
||||||
|
if got != c.want {
|
||||||
|
t.Fatalf("ttlSeconds(%v)=%d want %d", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionTTL = 45 * time.Minute
|
||||||
|
const maxSessions = 64
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
ID string
|
||||||
|
Conn ConnReq
|
||||||
|
mu sync.Mutex
|
||||||
|
sqlPool map[string]*sql.DB
|
||||||
|
redisCli *redis.Client
|
||||||
|
tunnel *SSHTunnel
|
||||||
|
LastUsed time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sessionsMu sync.Mutex
|
||||||
|
sessions = map[string]*Session{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(2 * time.Minute)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
expireSessions()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSessionID() string {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenSession(c ConnReq) (string, error) {
|
||||||
|
c.SQL = ""
|
||||||
|
c.Table = ""
|
||||||
|
c.Key = ""
|
||||||
|
c.Pattern = ""
|
||||||
|
c.SessionID = ""
|
||||||
|
|
||||||
|
dial, tun, err := ApplySSHTunnel(c)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := testDial(dial); err != nil {
|
||||||
|
if tun != nil {
|
||||||
|
tun.Close()
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("数据库: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionsMu.Lock()
|
||||||
|
if len(sessions) >= maxSessions {
|
||||||
|
sessionsMu.Unlock()
|
||||||
|
if tun != nil {
|
||||||
|
tun.Close()
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("连接会话已满,请关闭不用的连接后重试")
|
||||||
|
}
|
||||||
|
id := newSessionID()
|
||||||
|
// Persist dialed endpoints (127.0.0.1:local) so later ops reuse the tunnel.
|
||||||
|
stored := dial
|
||||||
|
stored.SSHEnabled = false
|
||||||
|
s := &Session{
|
||||||
|
ID: id,
|
||||||
|
Conn: stored,
|
||||||
|
sqlPool: map[string]*sql.DB{},
|
||||||
|
tunnel: tun,
|
||||||
|
LastUsed: time.Now(),
|
||||||
|
}
|
||||||
|
sessions[id] = s
|
||||||
|
sessionsMu.Unlock()
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSession(id string) (*Session, error) {
|
||||||
|
if id == "" {
|
||||||
|
return nil, fmt.Errorf("缺少 sessionId")
|
||||||
|
}
|
||||||
|
sessionsMu.Lock()
|
||||||
|
s, ok := sessions[id]
|
||||||
|
sessionsMu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("会话已失效,请重新连接")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.LastUsed = time.Now()
|
||||||
|
s.mu.Unlock()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CloseSession(id string) {
|
||||||
|
sessionsMu.Lock()
|
||||||
|
s, ok := sessions[id]
|
||||||
|
if ok {
|
||||||
|
delete(sessions, id)
|
||||||
|
}
|
||||||
|
sessionsMu.Unlock()
|
||||||
|
if ok {
|
||||||
|
s.closeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expireSessions() {
|
||||||
|
cutoff := time.Now().Add(-sessionTTL)
|
||||||
|
sessionsMu.Lock()
|
||||||
|
var dead []*Session
|
||||||
|
for id, s := range sessions {
|
||||||
|
s.mu.Lock()
|
||||||
|
stale := s.LastUsed.Before(cutoff)
|
||||||
|
s.mu.Unlock()
|
||||||
|
if stale {
|
||||||
|
dead = append(dead, s)
|
||||||
|
delete(sessions, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessionsMu.Unlock()
|
||||||
|
for _, s := range dead {
|
||||||
|
s.closeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) closeAll() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for k, dbh := range s.sqlPool {
|
||||||
|
_ = dbh.Close()
|
||||||
|
delete(s.sqlPool, k)
|
||||||
|
}
|
||||||
|
if s.redisCli != nil {
|
||||||
|
_ = s.redisCli.Close()
|
||||||
|
s.redisCli = nil
|
||||||
|
}
|
||||||
|
if s.tunnel != nil {
|
||||||
|
s.tunnel.Close()
|
||||||
|
s.tunnel = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) ConnFor(req ConnReq) ConnReq {
|
||||||
|
c := s.Conn
|
||||||
|
// SQLite 的 database 是文件路径,对象树里的 "main" 不能覆盖它
|
||||||
|
if req.Database != "" && driverKey(c.Driver) != "sqlite" {
|
||||||
|
c.Database = req.Database
|
||||||
|
}
|
||||||
|
c.SQL = req.SQL
|
||||||
|
c.Table = req.Table
|
||||||
|
c.Key = req.Key
|
||||||
|
c.Pattern = req.Pattern
|
||||||
|
c.Limit = req.Limit
|
||||||
|
c.SessionID = s.ID
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveConn(req ConnReq) (ConnReq, error) {
|
||||||
|
if req.SessionID == "" {
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
s, err := GetSession(req.SessionID)
|
||||||
|
if err != nil {
|
||||||
|
return req, err
|
||||||
|
}
|
||||||
|
return s.ConnFor(req), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openSQLite(c ConnReq) (*sql.DB, error) {
|
||||||
|
path := c.Database
|
||||||
|
if path == "" {
|
||||||
|
path = c.Host
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return nil, fmt.Errorf("SQLite 需要 database 填文件路径")
|
||||||
|
}
|
||||||
|
return sql.Open("sqlite", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDatabasesSQLite(_ *sql.DB) ([]string, error) {
|
||||||
|
return []string{"main"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listTablesSQLite(db *sql.DB, _ string) ([]string, error) {
|
||||||
|
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY 1`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func listColumnsSQLite(dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
|
||||||
|
rows, err := dbh.Query(`PRAGMA table_info(` + quotePGIdent(table) + `)`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ColumnInfo
|
||||||
|
for rows.Next() {
|
||||||
|
var cid int
|
||||||
|
var name, typ string
|
||||||
|
var notnull, pk int
|
||||||
|
var def sql.NullString
|
||||||
|
if err := rows.Scan(&cid, &name, &typ, ¬null, &def, &pk); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
col := ColumnInfo{Name: name, Type: typ, Nullable: notnull == 0}
|
||||||
|
if pk > 0 {
|
||||||
|
col.Key = "PRI"
|
||||||
|
}
|
||||||
|
if def.Valid {
|
||||||
|
col.Default = def.String
|
||||||
|
}
|
||||||
|
out = append(out, col)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []ColumnInfo{}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSHTunnel holds a local TCP listener that forwards through an SSH session
|
||||||
|
// to the remote database host:port (local port forward semantics).
|
||||||
|
type SSHTunnel struct {
|
||||||
|
LocalPort int
|
||||||
|
listener net.Listener
|
||||||
|
client *ssh.Client
|
||||||
|
cancel chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SSHTunnel) Close() {
|
||||||
|
if t == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.once.Do(func() {
|
||||||
|
close(t.cancel)
|
||||||
|
if t.listener != nil {
|
||||||
|
_ = t.listener.Close()
|
||||||
|
}
|
||||||
|
if t.client != nil {
|
||||||
|
_ = t.client.Close()
|
||||||
|
}
|
||||||
|
t.wg.Wait()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartLocalForward dials SSH and listens on 127.0.0.1:0, forwarding to c.Host:c.Port.
|
||||||
|
func StartLocalForward(c ConnReq) (*SSHTunnel, error) {
|
||||||
|
sshHost := strings.TrimSpace(c.SSHHost)
|
||||||
|
if sshHost == "" {
|
||||||
|
return nil, fmt.Errorf("请填写 SSH 主机")
|
||||||
|
}
|
||||||
|
sshPort := c.SSHPort
|
||||||
|
if sshPort <= 0 {
|
||||||
|
sshPort = 22
|
||||||
|
}
|
||||||
|
sshUser := strings.TrimSpace(c.SSHUser)
|
||||||
|
if sshUser == "" {
|
||||||
|
return nil, fmt.Errorf("请填写 SSH 用户")
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, err := sshAuthMethods(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &ssh.ClientConfig{
|
||||||
|
User: sshUser,
|
||||||
|
Auth: auth,
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // product tunnel; host-key pinning later
|
||||||
|
Timeout: 12 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := net.JoinHostPort(sshHost, fmt.Sprintf("%d", sshPort))
|
||||||
|
client, err := ssh.Dial("tcp", addr, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("无法连接跳板机 %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
_ = client.Close()
|
||||||
|
return nil, fmt.Errorf("本地监听失败: %w", err)
|
||||||
|
}
|
||||||
|
localPort := ln.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
remoteHost := c.Host
|
||||||
|
if remoteHost == "" {
|
||||||
|
remoteHost = "127.0.0.1"
|
||||||
|
}
|
||||||
|
remotePort := c.Port
|
||||||
|
if remotePort <= 0 {
|
||||||
|
remotePort = 3306
|
||||||
|
}
|
||||||
|
remoteAddr := net.JoinHostPort(remoteHost, fmt.Sprintf("%d", remotePort))
|
||||||
|
|
||||||
|
tun := &SSHTunnel{
|
||||||
|
LocalPort: localPort,
|
||||||
|
listener: ln,
|
||||||
|
client: client,
|
||||||
|
cancel: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
tun.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer tun.wg.Done()
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-tun.cancel:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tun.wg.Add(1)
|
||||||
|
go func(local net.Conn) {
|
||||||
|
defer tun.wg.Done()
|
||||||
|
defer local.Close()
|
||||||
|
remote, err := client.Dial("tcp", remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer remote.Close()
|
||||||
|
pipe(local, remote)
|
||||||
|
}(conn)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return tun, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sshAuthMethods(c ConnReq) ([]ssh.AuthMethod, error) {
|
||||||
|
authMode := strings.ToLower(strings.TrimSpace(c.SSHAuth))
|
||||||
|
if authMode == "" {
|
||||||
|
authMode = "password"
|
||||||
|
}
|
||||||
|
switch authMode {
|
||||||
|
case "password":
|
||||||
|
if c.SSHPassword == "" {
|
||||||
|
return nil, fmt.Errorf("请填写 SSH 密码")
|
||||||
|
}
|
||||||
|
return []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, nil
|
||||||
|
case "privatekey", "key", "private_key":
|
||||||
|
keyMaterial, err := loadSSHPrivateKey(c.SSHPrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var signer ssh.Signer
|
||||||
|
if strings.TrimSpace(c.SSHPassphrase) != "" {
|
||||||
|
signer, err = ssh.ParsePrivateKeyWithPassphrase(keyMaterial, []byte(c.SSHPassphrase))
|
||||||
|
} else {
|
||||||
|
signer, err = ssh.ParsePrivateKey(keyMaterial)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("解析私钥失败: %w", err)
|
||||||
|
}
|
||||||
|
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("不支持的 SSH 认证方式: %s", c.SSHAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSSHPrivateKey(raw string) ([]byte, error) {
|
||||||
|
s := strings.TrimSpace(raw)
|
||||||
|
if s == "" {
|
||||||
|
return nil, fmt.Errorf("请填写私钥 PEM 或路径")
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "BEGIN") && strings.Contains(s, "PRIVATE KEY") {
|
||||||
|
return []byte(s), nil
|
||||||
|
}
|
||||||
|
path := s
|
||||||
|
if strings.HasPrefix(path, "~/") {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("展开 ~ 失败: %w", err)
|
||||||
|
}
|
||||||
|
path = filepath.Join(home, path[2:])
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取私钥文件失败: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipe(a, b net.Conn) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _ = io.Copy(a, b)
|
||||||
|
_ = a.Close()
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _ = io.Copy(b, a)
|
||||||
|
_ = b.Close()
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplySSHTunnel starts a local forward when sshEnabled and rewrites Host/Port to 127.0.0.1:<local>.
|
||||||
|
// Caller must Close the returned tunnel (may be nil).
|
||||||
|
func ApplySSHTunnel(c ConnReq) (ConnReq, *SSHTunnel, error) {
|
||||||
|
if !c.SSHEnabled {
|
||||||
|
return c, nil, nil
|
||||||
|
}
|
||||||
|
tun, err := StartLocalForward(c)
|
||||||
|
if err != nil {
|
||||||
|
return c, nil, fmt.Errorf("SSH: %w", err)
|
||||||
|
}
|
||||||
|
out := c
|
||||||
|
out.Host = "127.0.0.1"
|
||||||
|
out.Port = tun.LocalPort
|
||||||
|
// Avoid nested tunnel if helpers re-enter
|
||||||
|
out.SSHEnabled = false
|
||||||
|
return out, tun, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EffectiveSSLMode resolves ssl / sslMode for drivers.
|
||||||
|
// Checkbox off or mode disable → disable. Legacy ssl:true with empty mode → require.
|
||||||
|
func EffectiveSSLMode(c ConnReq) string {
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(c.SSLMode))
|
||||||
|
switch mode {
|
||||||
|
case "disable", "require", "skip-verify", "verify-ca":
|
||||||
|
if mode == "disable" {
|
||||||
|
return "disable"
|
||||||
|
}
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
if c.SSL {
|
||||||
|
return "require"
|
||||||
|
}
|
||||||
|
return "disable"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSLEnabled is true when TLS should be negotiated.
|
||||||
|
func SSLEnabled(c ConnReq) bool {
|
||||||
|
return EffectiveSSLMode(c) != "disable"
|
||||||
|
}
|
||||||
|
|
||||||
|
// tlsConfigFrom builds a *tls.Config from ConnReq paths/mode.
|
||||||
|
// Returns (nil, nil) when SSL is disabled.
|
||||||
|
func tlsConfigFrom(c ConnReq) (*tls.Config, error) {
|
||||||
|
mode := EffectiveSSLMode(c)
|
||||||
|
if mode == "disable" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
}
|
||||||
|
if host := strings.TrimSpace(c.Host); host != "" {
|
||||||
|
cfg.ServerName = host
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case "skip-verify":
|
||||||
|
cfg.InsecureSkipVerify = true
|
||||||
|
case "require":
|
||||||
|
// Encrypt; verify with system / provided CAs when present.
|
||||||
|
case "verify-ca":
|
||||||
|
// Prefer explicit CA; still verify chain (hostname via ServerName when set).
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("未知 SSL 模式: %s", mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if path := strings.TrimSpace(c.SSLCA); path != "" {
|
||||||
|
pem, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取 CA 证书失败 (%s): %w", path, err)
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM(pem) {
|
||||||
|
return nil, fmt.Errorf("CA 证书不是有效 PEM: %s", path)
|
||||||
|
}
|
||||||
|
cfg.RootCAs = pool
|
||||||
|
if mode == "verify-ca" {
|
||||||
|
// verify-ca: trust given CA but allow hostname mismatch (common for IP hosts)
|
||||||
|
cfg.InsecureSkipVerify = true
|
||||||
|
roots := pool
|
||||||
|
cfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||||
|
if len(rawCerts) == 0 {
|
||||||
|
return fmt.Errorf("服务端未提供证书")
|
||||||
|
}
|
||||||
|
certs := make([]*x509.Certificate, 0, len(rawCerts))
|
||||||
|
for i, raw := range rawCerts {
|
||||||
|
cert, err := x509.ParseCertificate(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("解析服务端证书失败: %w", err)
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
// keep leaf for Intermediates below
|
||||||
|
}
|
||||||
|
certs = append(certs, cert)
|
||||||
|
}
|
||||||
|
leaf := certs[0]
|
||||||
|
inter := x509.NewCertPool()
|
||||||
|
for _, cert := range certs[1:] {
|
||||||
|
inter.AddCert(cert)
|
||||||
|
}
|
||||||
|
_, err := leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: inter})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if mode == "verify-ca" {
|
||||||
|
return nil, fmt.Errorf("verify-ca 模式需要填写 CA 证书路径")
|
||||||
|
}
|
||||||
|
|
||||||
|
certPath := strings.TrimSpace(c.SSLCert)
|
||||||
|
keyPath := strings.TrimSpace(c.SSLKey)
|
||||||
|
if certPath != "" || keyPath != "" {
|
||||||
|
if certPath == "" || keyPath == "" {
|
||||||
|
return nil, fmt.Errorf("客户端证书与密钥需同时填写")
|
||||||
|
}
|
||||||
|
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("加载客户端证书失败: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Certificates = []tls.Certificate{cert}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEffectiveSSLMode(t *testing.T) {
|
||||||
|
if got := EffectiveSSLMode(ConnReq{}); got != "disable" {
|
||||||
|
t.Fatalf("empty -> disable, got %s", got)
|
||||||
|
}
|
||||||
|
if got := EffectiveSSLMode(ConnReq{SSL: true}); got != "require" {
|
||||||
|
t.Fatalf("ssl true -> require, got %s", got)
|
||||||
|
}
|
||||||
|
if got := EffectiveSSLMode(ConnReq{SSL: true, SSLMode: "skip-verify"}); got != "skip-verify" {
|
||||||
|
t.Fatalf("mode wins, got %s", got)
|
||||||
|
}
|
||||||
|
if got := EffectiveSSLMode(ConnReq{SSL: true, SSLMode: "disable"}); got != "disable" {
|
||||||
|
t.Fatalf("explicit disable, got %s", got)
|
||||||
|
}
|
||||||
|
if !SSLEnabled(ConnReq{SSLMode: "require"}) {
|
||||||
|
t.Fatal("require should enable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSConfigFromDisable(t *testing.T) {
|
||||||
|
cfg, err := tlsConfigFrom(ConnReq{})
|
||||||
|
if err != nil || cfg != nil {
|
||||||
|
t.Fatalf("disable => nil,nil got %#v %v", cfg, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSConfigFromSkipVerify(t *testing.T) {
|
||||||
|
cfg, err := tlsConfigFrom(ConnReq{SSL: true, SSLMode: "skip-verify", Host: "db.example"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg == nil || !cfg.InsecureSkipVerify {
|
||||||
|
t.Fatalf("expected InsecureSkipVerify, got %#v", cfg)
|
||||||
|
}
|
||||||
|
if cfg.ServerName != "db.example" {
|
||||||
|
t.Fatalf("ServerName %q", cfg.ServerName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSConfigFromVerifyCARequiresPath(t *testing.T) {
|
||||||
|
_, err := tlsConfigFrom(ConnReq{SSLMode: "verify-ca"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error without CA")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSConfigFromVerifyCAWithPEM(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
caPath := filepath.Join(dir, "ca.pem")
|
||||||
|
if err := writeTestCA(caPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := tlsConfigFrom(ConnReq{SSLMode: "verify-ca", SSLCA: caPath, Host: "127.0.0.1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg == nil || cfg.RootCAs == nil {
|
||||||
|
t.Fatal("expected RootCAs")
|
||||||
|
}
|
||||||
|
if cfg.VerifyPeerCertificate == nil {
|
||||||
|
t.Fatal("expected custom verify for verify-ca")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTLSConfigFromBadCAPath(t *testing.T) {
|
||||||
|
_, err := tlsConfigFrom(ConnReq{SSLMode: "require", SSLCA: "/no/such/ca.pem"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected read error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestCA(path string) error {
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "opsxdb-test-ca"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
IsCA: true,
|
||||||
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const DefaultRowLimit = 1000
|
||||||
|
const MaxRowLimit = 10000
|
||||||
|
|
||||||
|
// ConnReq 连接 / 执行请求。sessionId 有值时服务端用会话里的账密,请求里的 password 可空。
|
||||||
|
type ConnReq struct {
|
||||||
|
Driver string `json:"driver"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Database string `json:"database"`
|
||||||
|
SQL string `json:"sql"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
SSL bool `json:"ssl"`
|
||||||
|
SSLMode string `json:"sslMode"` // disable|require|skip-verify|verify-ca
|
||||||
|
SSLCA string `json:"sslCA"`
|
||||||
|
SSLCert string `json:"sslCert"`
|
||||||
|
SSLKey string `json:"sslKey"`
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
Table string `json:"table"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Key string `json:"key"` // Redis key (get/delete)
|
||||||
|
Pattern string `json:"pattern"` // Redis SCAN match
|
||||||
|
|
||||||
|
SSHEnabled bool `json:"sshEnabled"`
|
||||||
|
SSHHost string `json:"sshHost"`
|
||||||
|
SSHPort int `json:"sshPort"`
|
||||||
|
SSHUser string `json:"sshUser"`
|
||||||
|
SSHAuth string `json:"sshAuth"` // password | privateKey
|
||||||
|
SSHPassword string `json:"sshPassword"`
|
||||||
|
SSHPrivateKey string `json:"sshPrivateKey"`
|
||||||
|
SSHPassphrase string `json:"sshPassphrase"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColumnInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Nullable bool `json:"nullable"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Default any `json:"default,omitempty"`
|
||||||
|
Comment string `json:"comment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecResult 与前端 QueryResult 字段对齐
|
||||||
|
type ExecResult struct {
|
||||||
|
Columns []string `json:"columns"`
|
||||||
|
Rows []map[string]interface{} `json:"rows"`
|
||||||
|
RowCount int `json:"rowCount"`
|
||||||
|
TimeMs int64 `json:"timeMs"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalize(v interface{}) interface{} {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case nil:
|
||||||
|
return nil
|
||||||
|
case []byte:
|
||||||
|
return string(t)
|
||||||
|
case time.Time:
|
||||||
|
return t.Format(time.RFC3339Nano)
|
||||||
|
default:
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis key browse (dedicated endpoints; do not reshape SQL tables []).
|
||||||
|
type RedisKeyInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
TTL int64 `json:"ttl"` // seconds; -1 no expire; -2 missing
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedisKeysResult struct {
|
||||||
|
Keys []RedisKeyInfo `json:"keys"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
TimeMs int64 `json:"timeMs"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisKeyValue feeds ResultPane (columns/rows) plus type/ttl meta.
|
||||||
|
type RedisKeyValue struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
TTL int64 `json:"ttl"`
|
||||||
|
Columns []string `json:"columns"`
|
||||||
|
Rows []map[string]interface{} `json:"rows"`
|
||||||
|
RowCount int `json:"rowCount"`
|
||||||
|
TimeMs int64 `json:"timeMs"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0e1116; --panel: #151a21; --border: #243044;
|
||||||
|
--text: #e6edf7; --muted: #8b9bb4; --accent: #3b82f6;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font: 13px/1.55 ui-sans-serif, system-ui, sans-serif; background: var(--bg); color: var(--text); }
|
||||||
|
.top {
|
||||||
|
height: 44px; border-bottom: 1px solid var(--border); background: var(--panel);
|
||||||
|
display: flex; align-items: center; justify-content: space-between; padding: 0 16px;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 8px; color: var(--text); text-decoration: none; font-weight: 600; }
|
||||||
|
.brand em { color: var(--muted); font-style: normal; font-weight: 500; }
|
||||||
|
.logo {
|
||||||
|
width: 24px; height: 24px; border-radius: 6px; background: var(--accent);
|
||||||
|
display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff;
|
||||||
|
}
|
||||||
|
.top a { color: var(--muted); text-decoration: none; font-size: 12.5px; }
|
||||||
|
.layout { display: flex; min-height: calc(100vh - 44px); }
|
||||||
|
.side {
|
||||||
|
width: 200px; border-right: 1px solid var(--border); padding: 12px 10px;
|
||||||
|
background: var(--panel); display: flex; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
display: block; padding: 6px 8px; border-radius: 6px; color: var(--muted);
|
||||||
|
text-decoration: none; font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.nav-link:hover { color: var(--text); background: #1a2230; }
|
||||||
|
.nav-link.active { color: var(--text); background: color-mix(in srgb, var(--accent) 18%, transparent); }
|
||||||
|
.content { flex: 1; padding: 24px 28px; max-width: 720px; }
|
||||||
|
.content h1 { font-size: 20px; margin: 0 0 14px; }
|
||||||
|
.content h2 { font-size: 15px; margin: 22px 0 8px; }
|
||||||
|
.content p, .content li { color: #c5d0e0; }
|
||||||
|
.content code, .content pre {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
|
||||||
|
}
|
||||||
|
.content pre {
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.content code { background: #1a2230; padding: 1px 5px; border-radius: 4px; }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<h1>连库入门</h1>
|
||||||
|
<p>在连库页选择驱动,填写主机 / 端口 / 账号,测试通过后保存并打开。</p>
|
||||||
|
<ul>
|
||||||
|
<li>密码仅存本机(本地加密存储)。</li>
|
||||||
|
<li>会话由本地后端维护;后端未启动时顶部会提示,不会白屏。</li>
|
||||||
|
<li>断线后可在底栏重连,恢复当前库上下文。</li>
|
||||||
|
</ul>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<h1>常见问题</h1>
|
||||||
|
<h2>打开后提示本地后端未就绪?</h2>
|
||||||
|
<p>确认应用已拉起 sidecar;或手动 <code>./run.sh server</code> 后重试。默认只监听 <code>127.0.0.1:8788</code>。</p>
|
||||||
|
<h2>浏览器也能用吗?</h2>
|
||||||
|
<p>可以调试,但<strong>产品说明与对外沟通应以桌面版为准</strong>,不要暗示 Web 即产品。</p>
|
||||||
|
<h2>官网 / 文档在哪?</h2>
|
||||||
|
<p>本地后端提供:<code>http://127.0.0.1:8788/</code>(官网)、<code>/docs</code>(文档)。</p>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<h1>安装与启动</h1>
|
||||||
|
<p>OpsxDB <strong>正式形态是桌面 GUI</strong>(Tauri)。开发态浏览器 <code>:5174</code> 只用于调试,不是产品交付。</p>
|
||||||
|
<h2>桌面版</h2>
|
||||||
|
<ol>
|
||||||
|
<li>安装本机构建产物(macOS <code>.dmg</code> / <code>.app</code>,或 <code>npm run build:desktop</code> 产出)。</li>
|
||||||
|
<li>双击启动:应用会拉起本地后端(默认 <code>127.0.0.1:8788</code>),进入工作台。</li>
|
||||||
|
<li>无需手动执行 <code>./run.sh web</code>。</li>
|
||||||
|
</ol>
|
||||||
|
<h2>开发调试</h2>
|
||||||
|
<pre><code>./run.sh gui # 或 ./run.sh web
|
||||||
|
</code></pre>
|
||||||
|
<p>开发壳地址:<code>http://127.0.0.1:5174</code>。</p>
|
||||||
|
<p>改后端(<code>apps/server</code>)后请执行 <code>./run.sh restart</code>(或等价重启 Gin),避免 <code>:8788</code> 仍跑旧二进制。</p>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<h1>工作台简介</h1>
|
||||||
|
<p>深色密度布局:左侧连接与对象树,中间编辑器与多结果 Tab,可选 AI 侧栏。</p>
|
||||||
|
<ul>
|
||||||
|
<li>多结果集:成功结果保留;失败不盖掉已有成功集。</li>
|
||||||
|
<li>执行计划:有则展示,无则禁用。</li>
|
||||||
|
<li>书签:紧凑列表,回填 SQL,本地持久化。</li>
|
||||||
|
</ul>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Package docs:用户文档骨架(嵌入 HTML,侧栏+正文密度对齐工作台)。
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed content assets template.html
|
||||||
|
var files embed.FS
|
||||||
|
|
||||||
|
type page struct {
|
||||||
|
Slug string
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
var order = []page{
|
||||||
|
{Slug: "install", Title: "安装与启动"},
|
||||||
|
{Slug: "connect", Title: "连库入门"},
|
||||||
|
{Slug: "workbench", Title: "工作台简介"},
|
||||||
|
{Slug: "faq", Title: "常见问题"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
tpl string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Handler {
|
||||||
|
raw, err := files.ReadFile("template.html")
|
||||||
|
if err != nil {
|
||||||
|
panic("docs: template.html 缺失: " + err.Error())
|
||||||
|
}
|
||||||
|
return &Handler{tpl: string(raw)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Register(r *gin.Engine) {
|
||||||
|
r.GET("/docs", func(c *gin.Context) { c.Redirect(http.StatusFound, "/docs/install") })
|
||||||
|
r.GET("/docs/:slug", h.Page)
|
||||||
|
r.GET("/docs/assets/*file", h.Asset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Page(c *gin.Context) {
|
||||||
|
slug := c.Param("slug")
|
||||||
|
bodyBytes, err := files.ReadFile("content/" + slug + ".html")
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title := slug
|
||||||
|
nav := strings.Builder{}
|
||||||
|
for _, p := range order {
|
||||||
|
cls := "nav-link"
|
||||||
|
if p.Slug == slug {
|
||||||
|
cls += " active"
|
||||||
|
title = p.Title
|
||||||
|
}
|
||||||
|
nav.WriteString(`<a class="` + cls + `" href="/docs/` + p.Slug + `">` + p.Title + `</a>`)
|
||||||
|
}
|
||||||
|
html := h.tpl
|
||||||
|
html = strings.ReplaceAll(html, "{{TITLE}}", title)
|
||||||
|
html = strings.ReplaceAll(html, "{{NAV}}", nav.String())
|
||||||
|
html = strings.ReplaceAll(html, "{{BODY}}", string(bodyBytes))
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
_, _ = c.Writer.WriteString(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Asset(c *gin.Context) {
|
||||||
|
name := strings.TrimPrefix(c.Param("file"), "/")
|
||||||
|
if strings.Contains(name, "..") {
|
||||||
|
c.Status(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := files.ReadFile("assets/" + name)
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ct := "application/octet-stream"
|
||||||
|
if strings.HasSuffix(name, ".css") {
|
||||||
|
ct = "text/css; charset=utf-8"
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", ct)
|
||||||
|
c.Data(http.StatusOK, ct, data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{TITLE}} · OpsxDB 文档</title>
|
||||||
|
<link rel="stylesheet" href="/docs/assets/docs.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="top">
|
||||||
|
<a class="brand" href="/docs"><span class="logo">DB</span> OpsxDB <em>文档</em></a>
|
||||||
|
<nav><a href="/home">官网</a></nav>
|
||||||
|
</header>
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="side">{{NAV}}</aside>
|
||||||
|
<main class="content">{{BODY}}</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var blockedHosts = map[string]struct{}{
|
||||||
|
"169.254.169.254": {},
|
||||||
|
"metadata.google.internal": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateURL applies MVP SSRF basics. Week 2 should add configurable CIDR
|
||||||
|
// deny-lists and DNS-rebinding protection before exposing this beyond localhost.
|
||||||
|
func ValidateURL(raw string) (*url.URL, error) {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid url: %w", err)
|
||||||
|
}
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return nil, fmt.Errorf("only http/https schemes are allowed")
|
||||||
|
}
|
||||||
|
if u.Hostname() == "" {
|
||||||
|
return nil, fmt.Errorf("url host is required")
|
||||||
|
}
|
||||||
|
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
|
||||||
|
if _, blocked := blockedHosts[host]; blocked {
|
||||||
|
return nil, fmt.Errorf("blocked metadata host")
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(host); ip != nil && ip.Equal(net.ParseIP("169.254.169.254")) {
|
||||||
|
return nil, fmt.Errorf("blocked metadata address")
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateURL(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
raw string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"https://example.com/a", true},
|
||||||
|
{"file:///etc/passwd", false},
|
||||||
|
{"http://169.254.169.254/latest/meta-data", false},
|
||||||
|
{"http://metadata.google.internal/computeMetadata/v1", false},
|
||||||
|
} {
|
||||||
|
_, err := ValidateURL(tc.raw)
|
||||||
|
if tc.ok && err != nil {
|
||||||
|
t.Fatalf("%s: %v", tc.raw, err)
|
||||||
|
}
|
||||||
|
if !tc.ok && err == nil {
|
||||||
|
t.Fatalf("expected rejection: %s", tc.raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||