Files
opsxdb/server/internal/db/agent.go
T

1212 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package db
// agent.go —— JDBC agent 桥(Go 侧)。
//
// 架构:每种 agent 驱动(如达梦)由本机 JVM 进程承载官方 JDBC 驱动,
// Go 服务端通过 stdin/stdout 的 JSON Lines 协议与其通信(协议见 agents/README.md)。
// 目标:不引入 CGO/JNI,商用驱动 jar 由用户自备(许可合规),产品侧只做发现与编排。
//
// 安全约定(与 Java 侧一致):
// - query 必带 maxRows 硬截断(JDBC Statement.setMaxRows),深分页超过 agentMaxRows 直接拒绝
// - 每条语句 setQueryTimeout 兜底,timeoutSec 取自请求 ctx deadline
// - v1 限制:ctx 取消不会真正掐断 JDBC 执行(靠 timeoutSec 兜底),见 agents/README.md 已知限制
import (
"archive/zip"
"bufio"
"context"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"hash"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
agentProtocolVersion = 1
agentHelloTimeout = 30 * time.Second
agentMaxRows = 20000 // 与 Java 侧 setMaxRows 上限一致
// gson 2.12.1 固定版本 + sha1 钉死(下载自 Maven Central / 阿里云镜像,校验失败即拒用)
gsonVersion = "2.12.1"
gsonSHA1 = "4e773a317740b83b43cfc3d652962856041697cb"
// agent jar 与开源驱动 jar:首次连接时按需下载到 ~/.opsxdb/(DMG 不打包任何 Java 产物)。
// 下载源双托管(Gitee 主 / GitHub 备),sha256 钉版本;商用驱动(达梦)不代下。
agentJarName = "opsx-agent-jdbc.jar"
agentJarSHA256 = "6efc3c71965729f1fe09e6912bcdcd713d5e9d7a4ca87227eaa7a13af2b14227"
agentJarURLs = "https://gitee.com/attacker/opsxdb-driver/raw/main/dist/opsx-agent-jdbc.jar,https://raw.githubusercontent.com/attacker/opsxdb-driver/main/dist/opsx-agent-jdbc.jar"
)
// agentArtifact 驱动 jar 清单(开源、可再分发)。Maven 坐标 + sha256 与 opsxdb-driver
// 仓库 drivers/*.yaml 一致;商用驱动(达梦)不在此表,连接时给 manualSource 指引。
type agentArtifact struct {
File string // 落盘文件名
Maven string // group:artifact:version;空 = 不自动下载
SHA256 string
}
var agentDriverArtifacts = map[string][]agentArtifact{
"mongodb": {
{"mongodb-driver-sync-5.2.1.jar", "org.mongodb:mongodb-driver-sync:5.2.1", "c10ad97ab8a148c53ab8377a3fa89e49c2f9be277f1216c7f69967b7cc1e1699"},
{"mongodb-driver-core-5.2.1.jar", "org.mongodb:mongodb-driver-core:5.2.1", "5ae1d49fc6b1b42046cb16c1ce9f6952e36d0203f085d9fe045266460c51da19"},
{"bson-5.2.1.jar", "org.mongodb:bson:5.2.1", "5bca0022f4a933795788244b0ba3d7d92d6969730cf3b279bb37f9f7ffba5f35"},
},
"zookeeper": {
{"zookeeper-3.9.3.jar", "org.apache.zookeeper:zookeeper:3.9.3", "ad6570105768d18382a762325b4460368b7089a5a77c053e682b17942e0e72b5"},
{"zookeeper-jute-3.9.3.jar", "org.apache.zookeeper:zookeeper-jute:3.9.3", "be72e2c9099413a9878ce830b65bb22e0be755dc2a22b290550ab9e03b5b4cfa"},
{"slf4j-api-2.0.16.jar", "org.slf4j:slf4j-api:2.0.16", "a12578dde1ba00bd9b816d388a0b879928d00bab3c83c240f7013bf4196c579a"},
{"netty-common-4.1.113.Final.jar", "io.netty:netty-common:4.1.113.Final", "c0fb22d47111cb06aac2af67fe55e2e216a49fd00e767f4acb7488b280f8c327"},
{"netty-buffer-4.1.113.Final.jar", "io.netty:netty-buffer:4.1.113.Final", "a85c198180a8de997e8f64a62e054946a39af0708466c1bd67747d393d2feee1"},
{"netty-transport-4.1.113.Final.jar", "io.netty:netty-transport:4.1.113.Final", "cb8b97ff77d7c5f1c591c84d2dee3389a0eaa63a3137b7b8c0c64e1dbada6688"},
{"netty-resolver-4.1.113.Final.jar", "io.netty:netty-resolver:4.1.113.Final", "b2c110a547e7dc6e2b27017ea4ef98416d7d832d2cf40625d0273b90e61df8ab"},
{"netty-codec-4.1.113.Final.jar", "io.netty:netty-codec:4.1.113.Final", "c7669cbeda2b5c6284627b7ffafd6f1014a96639013b26ebb9d6bcb828f76542"},
{"netty-handler-4.1.113.Final.jar", "io.netty:netty-handler:4.1.113.Final", "7a583c4fe5880504d1257a4a6bfe6a635cd34ffe18aab0e4caabbf17d104f172"},
},
}
// AgentSpec 描述一种 agent 驱动的接入方式。
type AgentSpec struct {
DriverKey string // 与前端 DriverId 一致(dameng、elasticsearch…)
DriverClass string // JDBC 驱动类名(报错定位;Java 侧按 META-INF/services 自动注册)
URLTemplate string // jdbc url 模板,占位 {{.Host}}/{{.Port}}/{{.Database}}
JarsDirEnv string // 用户放置官方驱动 jar 的目录环境变量;缺省 ~/.opsxdb/agents/jdbc/drivers/<key>
MainClass string // agent 主类,缺省 OpsxJdbcAgent(JDBC 桥);非 JDBC 驱动(如 ZK)给专属主类
// TablesSQL 非空时:列表不走 DatabaseMetaData,改跑这条 SQL 并取首列(如 ES 的 SHOW TABLES)。
TablesSQL string
// DefaultDatabase 非空时:对象树只挂这一个库节点(ES 无多库概念,恒为 default)。
DefaultDatabase string
// JVMArgs 追加给 agent 进程的 JVM 参数(如 ZK 用 NIO socket 避开 netty 依赖)。
JVMArgs []string
}
// agentDriverSpecs —— agent 驱动注册表。新增驱动在此登记即可(达梦先行)。
// 注意:仅注册"官方只提供 JDBC、且 JDBC 不锁付费许可"的驱动。Elasticsearch 曾评估走
// x-pack-sql-jdbc,但其 JDBC 客户端要求付费许可(basic 下报 non-compliant),故 ES 走原生 HTTP(es.go)。
var agentDriverSpecs = map[string]AgentSpec{
"dameng": {
DriverKey: "dameng",
DriverClass: "dm.jdbc.driver.DmDriver",
URLTemplate: "jdbc:dm://{{.Host}}:{{.Port}}",
JarsDirEnv: "OPSXDB_DAMENG_JARS",
},
"zookeeper": {
DriverKey: "zookeeper",
DriverClass: "org.apache.zookeeper.ZooKeeper",
URLTemplate: "{{.Host}}:{{.Port}}",
JarsDirEnv: "OPSXDB_ZK_JARS",
MainClass: "OpsxZkAgent",
// 3.9 默认 Netty socket 需额外 6 个 netty jar;NIO 实现仍内置,零额外依赖
JVMArgs: []string{"-Dzookeeper.clientCnxnSocket=org.apache.zookeeper.ClientCnxnSocketNIO"},
},
"mongodb": {
DriverKey: "mongodb",
DriverClass: "com.mongodb.client.MongoClients",
URLTemplate: "mongodb://{{.Host}}:{{.Port}}",
JarsDirEnv: "OPSXDB_MONGO_JARS",
MainClass: "OpsxMongoAgent",
},
}
func agentSpec(d string) (AgentSpec, bool) {
s, ok := agentDriverSpecs[driverKey(d)]
return s, ok
}
// IsAgentDriver 该驱动是否走 JDBC agent 桥(前端据此展示"未就绪/需配置")。
func IsAgentDriver(d string) bool {
_, ok := agentSpec(d)
return ok
}
// isAgentDriver 包内简写(ops/open/session 调用点使用)。
func isAgentDriver(d string) bool { return IsAgentDriver(d) }
// AgentConfigured 驱动 jar 目录已配置且存在(未配置时给出许可友好的提示)。
func AgentConfigured(d string) (bool, string) {
spec, ok := agentSpec(d)
if !ok {
return false, ""
}
dir := agentJarsDir(spec)
entries, err := os.ReadDir(dir)
if err != nil {
return false, dir
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".jar") {
return true, dir
}
}
return false, dir
}
// ---------- 协议信封 ----------
type agentRequest struct {
ID uint64 `json:"id"`
Op string `json:"op"`
Args map[string]any `json:"args"`
}
type agentResponse struct {
ID uint64 `json:"id"`
OK bool `json:"ok"`
Result json.RawMessage `json:"result"`
Error string `json:"error"`
}
// ---------- AgentClient(一个 JVM 进程一个实例,多连接复用) ----------
type pendingCall struct {
ch chan agentResponse
}
// AgentClient 是与单个 JVM agent 进程的会话。
type AgentClient struct {
spec AgentSpec
cmd *exec.Cmd
stdin io.WriteCloser
pendingMu sync.Mutex
pending map[uint64]pendingCall
idGen atomic.Uint64
stderrTail *cappedBuffer // 排障用:JVM stderr 末尾若干 KB
readerDone chan struct{}
closeOnce sync.Once
}
// cappedBuffer 并发安全的环形缓冲,只保留末尾 cap 字节。
type cappedBuffer struct {
mu sync.Mutex
buf []byte
capN int
}
func newCappedBuffer(n int) *cappedBuffer { return &cappedBuffer{capN: n} }
func (b *cappedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
b.buf = append(b.buf, p...)
if len(b.buf) > b.capN {
b.buf = append([]byte(nil), b.buf[len(b.buf)-b.capN:]...)
}
return len(p), nil
}
func (b *cappedBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return string(b.buf)
}
// StartAgent 按 spec 启动(或复用)JVM agent 进程并完成握手。
// agent jar 与开源驱动 jar 在首次连接时按需下载(DMG 不打包 Java 产物,保持安装包瘦身)。
func StartAgent(spec AgentSpec) (*AgentClient, error) {
javaBin := os.Getenv("OPSXDB_JAVA_BIN")
if javaBin == "" {
p, err := exec.LookPath("java")
if err != nil {
return nil, errors.New("未找到 java:请安装 JDK 17+ 或设置 OPSXDB_JAVA_BIN")
}
javaBin = p
}
if err := ensureAgentRuntime(spec); err != nil {
return nil, err
}
home := agentHome()
gsonJar, err := ensureGson(home)
if err != nil {
return nil, err
}
agentJar := os.Getenv("OPSXDB_AGENT_JAR")
if agentJar == "" {
agentJar = filepath.Join(agentDir(), agentJarName)
}
jarsDir := agentJarsDir(spec)
mainClass := spec.MainClass
if mainClass == "" {
mainClass = "OpsxJdbcAgent"
}
classPath := agentJar + string(os.PathListSeparator) + gsonJar
if mainClass != "OpsxJdbcAgent" {
// 专属 agent(如 OpsxZkAgent)的主类直接依赖厂商 jar,须上应用类路径;
// JDBC 桥则用 --jars 隔离加载驱动,避免与 gson 等类冲突。
if jars, _ := filepath.Glob(filepath.Join(jarsDir, "*.jar")); len(jars) > 0 {
classPath += string(os.PathListSeparator) + strings.Join(jars, string(os.PathListSeparator))
}
}
args := append([]string{}, spec.JVMArgs...)
args = append(args, "-cp", classPath, mainClass, "--jars", jarsDir)
cmd := exec.Command(javaBin, args...)
return StartAgentFromCmd(cmd, spec)
}
// StartAgentFromCmd 以自定义命令启动 agent(测试注入 stub 进程用)。
func StartAgentFromCmd(cmd *exec.Cmd, spec AgentSpec) (*AgentClient, error) {
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
cli := &AgentClient{
spec: spec,
cmd: cmd,
stdin: stdin,
pending: map[uint64]pendingCall{},
stderrTail: newCappedBuffer(16 * 1024),
readerDone: make(chan struct{}),
}
cmd.Stderr = cli.stderrTail
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("启动 agent 进程失败: %w", err)
}
go cli.readerLoop(bufio.NewReader(stdout))
// 握手:Java 侧启动即推送一行 hello 公告
type hello struct {
Agent string `json:"agent"`
Version string `json:"version"`
Protocol int `json:"protocol"`
}
ctx, cancel := context.WithTimeout(context.Background(), agentHelloTimeout)
defer cancel()
var h hello
if err := cli.call(ctx, "hello", nil, &h); err != nil {
cli.Kill()
return nil, fmt.Errorf("agent 握手失败: %w(stderr: %s)", err, cli.stderrTail.String())
}
if h.Agent != "opsx-agent-jdbc" || h.Protocol != agentProtocolVersion {
cli.Kill()
return nil, fmt.Errorf("agent 协议不匹配: agent=%s protocol=%d(期望 protocol=%d)", h.Agent, h.Protocol, agentProtocolVersion)
}
return cli, nil
}
// readerLoop 单 goroutine 读 stdout,按 id 路由响应。
func (c *AgentClient) readerLoop(r *bufio.Reader) {
defer close(c.readerDone)
for {
line, err := r.ReadBytes('\n')
if len(line) > 0 {
var resp agentResponse
if uerr := json.Unmarshal(line, &resp); uerr == nil {
c.pendingMu.Lock()
pc, ok := c.pending[resp.ID]
if ok {
delete(c.pending, resp.ID)
}
c.pendingMu.Unlock()
if ok {
pc.ch <- resp
}
}
}
if err != nil {
c.pendingMu.Lock()
for id, pc := range c.pending {
delete(c.pending, id)
pc.ch <- agentResponse{ID: id, OK: false, Error: "agent 进程已退出"}
}
c.pendingMu.Unlock()
return
}
}
}
// call 串行写请求 + 等待响应;读由 readerLoop 并发分发。
func (c *AgentClient) call(ctx context.Context, op string, args map[string]any, out any) error {
id := c.idGen.Add(1)
ch := make(chan agentResponse, 1)
c.pendingMu.Lock()
c.pending[id] = pendingCall{ch: ch}
c.pendingMu.Unlock()
payload, err := json.Marshal(agentRequest{ID: id, Op: op, Args: args})
if err != nil {
return err
}
writeMu.Lock()
_, werr := c.stdin.Write(append(payload, '\n'))
writeMu.Unlock()
if werr != nil {
c.pendingMu.Lock()
delete(c.pending, id)
c.pendingMu.Unlock()
return fmt.Errorf("写入 agent 失败: %w", werr)
}
select {
case resp := <-ch:
if !resp.OK {
return fmt.Errorf("agent: %s", resp.Error)
}
if out != nil && len(resp.Result) > 0 {
if uerr := json.Unmarshal(resp.Result, out); uerr != nil {
return fmt.Errorf("解析 agent 响应失败: %w", uerr)
}
}
return nil
case <-ctx.Done():
c.pendingMu.Lock()
delete(c.pending, id)
c.pendingMu.Unlock()
return ctx.Err()
}
}
// 全进程共享的写锁:多个 AgentClient(不同 JVM)并发写各自 stdin 不需要互斥,
// 但同一客户端的并发 call 必须串行写,避免行交错。
var writeMu sync.Mutex
// Kill 立即终止 JVM(不优雅 shutdown;句柄 close 由 withAgent 负责)。
func (c *AgentClient) Kill() {
c.closeOnce.Do(func() {
_ = c.stdin.Close()
if c.cmd.Process != nil {
_ = c.cmd.Process.Kill()
}
<-c.readerDone
})
}
// Shutdown 优雅退出(op shutdown 后进程自退)。
func (c *AgentClient) Shutdown(ctx context.Context) {
ctx2, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
_ = c.call(ctx2, "shutdown", nil, nil)
c.Kill()
}
// ---------- agent 进程池(每驱动一个 JVM,长驻复用) ----------
var agentPool = struct {
sync.Mutex
m map[string]*AgentClient
}{m: map[string]*AgentClient{}}
func getAgentClient(d string) (*AgentClient, error) {
spec, ok := agentSpec(d)
if !ok {
return nil, unsupportedDriverErr(ConnReq{Driver: d})
}
agentPool.Lock()
defer agentPool.Unlock()
if cli, ok := agentPool.m[spec.DriverKey]; ok {
return cli, nil
}
cli, err := StartAgent(spec)
if err != nil {
return nil, err
}
agentPool.m[spec.DriverKey] = cli
return cli, nil
}
// ---------- URL 与连接打开 ----------
// renderAgentURL 按模板渲染 jdbc url(达梦: jdbc:dm://host:port)。
func renderAgentURL(spec AgentSpec, c ConnReq) (string, error) {
repl := strings.NewReplacer(
"{{.Host}}", c.Host,
"{{.Port}}", fmt.Sprintf("%d", c.Port),
"{{.Database}}", c.Database,
)
url := repl.Replace(spec.URLTemplate)
if c.Host == "" {
return "", fmt.Errorf("%s 需要主机地址", spec.DriverKey)
}
if c.Port == 0 {
return "", fmt.Errorf("%s 需要端口", spec.DriverKey)
}
return url, nil
}
// agentOpen 在 agent 进程内打开一个 JDBC 连接,返回句柄。
func agentOpen(ctx context.Context, cli *AgentClient, c ConnReq) (string, error) {
spec := cli.spec
url, err := renderAgentURL(spec, c)
if err != nil {
return "", err
}
args := map[string]any{
"url": url,
"user": c.User,
"password": c.Password,
"readOnly": false,
"db": c.Database, // 非 JDBC 桥(Mongo DSL)用它路由库;JDBC 桥忽略
}
var res struct {
Handle string `json:"handle"`
}
if err := cli.call(ctx, "open", args, &res); err != nil {
return "", err
}
if res.Handle == "" {
return "", errors.New("agent 未返回句柄")
}
return res.Handle, nil
}
// ---------- 与 withDB 对称的 agent 执行入口 ----------
// withAgent 取(或建)agent 连接并执行 fn;会话内按 database 缓存句柄,直连模式用完即关。
func withAgent(c ConnReq, fn func(cli *AgentClient, handle string) error) error {
if c.SessionID != "" {
s, err := GetSession(c.SessionID)
if err != nil {
return err
}
cli, handle, err := s.agentConn(c)
if err != nil {
return err
}
return fn(cli, handle)
}
cli, err := getAgentClient(c.Driver)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
handle, err := agentOpen(ctx, cli, c)
if err != nil {
return err
}
defer func() {
ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel2()
_ = cli.call(ctx2, "close", map[string]any{"handle": handle}, nil)
}()
return fn(cli, handle)
}
// agentTimeoutSec 从 ctx deadline 推算语句超时(兜底,防慢查询挂死业务库)。
func agentTimeoutSec(ctx context.Context, def int) int {
if dl, ok := ctx.Deadline(); ok {
sec := int(time.Until(dl).Seconds()) + 1
if sec > 0 && sec < 3600 {
return sec
}
}
return def
}
// ---------- agent 版元数据 / 查询(ops.go 分支调用) ----------
func agentListDatabases(ctx context.Context, c ConnReq) ([]string, error) {
spec, _ := agentSpec(c.Driver)
// 无多库概念的驱动(ES):对象树只挂一个固定库节点
if spec.DefaultDatabase != "" {
return []string{spec.DefaultDatabase}, nil
}
var out []string
err := withAgent(c, func(cli *AgentClient, handle string) error {
var res struct {
Names []string `json:"names"`
}
if err := cli.call(ctx, "metaDatabases", map[string]any{"handle": handle}, &res); err != nil {
return err
}
out = res.Names
return nil
})
if out == nil {
out = []string{}
}
return out, err
}
func agentListTables(ctx context.Context, c ConnReq) ([]string, error) {
spec, _ := agentSpec(c.Driver)
// 专属列表 SQL(ES SHOW TABLES):跑查询取首列
if spec.TablesSQL != "" {
var out []string
err := withAgent(c, func(cli *AgentClient, handle string) error {
var res struct {
Columns []string `json:"columns"`
Rows []map[string]interface{} `json:"rows"`
}
if err := cli.call(ctx, "query", map[string]any{
"handle": handle, "sql": spec.TablesSQL, "maxRows": 10000, "timeoutSec": agentTimeoutSec(ctx, 30),
}, &res); err != nil {
return err
}
if len(res.Columns) == 0 {
return nil
}
first := res.Columns[0]
for _, row := range res.Rows {
if v, ok := row[first].(string); ok && v != "" {
out = append(out, v)
}
}
return nil
})
if out == nil {
out = []string{}
}
return out, err
}
var out []string
err := withAgent(c, func(cli *AgentClient, handle string) error {
var res struct {
Names []string `json:"names"`
}
if err := cli.call(ctx, "metaTables", map[string]any{"handle": handle, "schema": c.Database}, &res); err != nil {
return err
}
out = res.Names
return nil
})
if out == nil {
out = []string{}
}
return out, err
}
func agentListColumns(ctx context.Context, c ConnReq) ([]ColumnInfo, error) {
var out []ColumnInfo
err := withAgent(c, func(cli *AgentClient, handle string) error {
var res struct {
Columns []ColumnInfo `json:"columns"`
}
if err := cli.call(ctx, "metaColumns", map[string]any{"handle": handle, "schema": c.Database, "table": c.Table}, &res); err != nil {
return err
}
out = res.Columns
return nil
})
if out == nil {
out = []ColumnInfo{}
}
return out, err
}
// agentVerb 提取语句动词:ZK 风格 "GET /path" 取首词;Mongo DSL "db.c.find({...})" 取第二段。
func agentVerb(sql string) string {
first := strings.TrimSpace(sql)
if fields := strings.Fields(first); len(fields) > 0 {
first = fields[0]
}
if strings.HasPrefix(first, "db.") {
parts := strings.SplitN(first, ".", 3)
if len(parts) == 3 {
v := parts[2]
if i := strings.IndexAny(v, "({"); i >= 0 {
v = v[:i]
}
return strings.ToUpper(v)
}
}
if i := strings.IndexAny(first, "({"); i >= 0 {
first = first[:i]
}
return strings.ToUpper(first)
}
// agentExecSQL 对应 execOnDB:查询带 maxRows 硬截断 + offset 分页;非查询返回 affectedRows。
func agentExecSQL(ctx context.Context, c ConnReq, start time.Time) ExecResult {
var res ExecResult
err := withAgent(c, func(cli *AgentClient, handle string) error {
timeout := agentTimeoutSec(ctx, 60)
// ZK 专属动词路由:GET/LS 走 query(返回行列),CREATE/SET/DELETE 走 exec
useQuery := isQuerySQL(c.SQL)
if driverKey(c.Driver) == "zookeeper" {
verb := agentVerb(c.SQL)
useQuery = verb == "GET" || verb == "LS"
}
if driverKey(c.Driver) == "mongodb" {
verb := agentVerb(c.SQL)
useQuery = verb == "FIND" || verb == "COUNT"
}
if !useQuery {
var r struct {
AffectedRows int64 `json:"affectedRows"`
}
if err := cli.call(ctx, "exec", map[string]any{"handle": handle, "sql": c.SQL, "timeoutSec": timeout, "db": c.Database}, &r); err != nil {
return err
}
res = ExecResult{
Columns: []string{"affected_rows"},
Rows: []map[string]interface{}{{"affected_rows": r.AffectedRows}},
RowCount: 1,
TimeMs: time.Since(start).Milliseconds(),
}
return nil
}
limit := rowLimit(c)
offset := c.Offset
if offset < 0 {
offset = 0
}
if offset+limit > agentMaxRows {
return fmt.Errorf("agent 驱动暂不支持深分页(offset+limit ≤ %d)", agentMaxRows)
}
var r struct {
Columns []string `json:"columns"`
Rows []map[string]interface{} `json:"rows"`
Truncated bool `json:"truncated"`
}
if err := cli.call(ctx, "query", map[string]any{
"handle": handle, "sql": c.SQL,
"maxRows": offset + limit, "timeoutSec": timeout, "db": c.Database,
}, &r); err != nil {
return err
}
rows := r.Rows
truncated := r.Truncated
if offset > 0 {
if len(rows) > offset {
rows = rows[offset:]
} else {
rows = []map[string]interface{}{}
}
}
if rows == nil {
rows = []map[string]interface{}{}
}
res = ExecResult{
Columns: r.Columns,
Rows: rows,
RowCount: len(rows),
TimeMs: time.Since(start).Milliseconds(),
Truncated: truncated,
Limit: limit,
Offset: offset,
}
return nil
})
if err != nil {
return execFail(start, err)
}
return res
}
func agentExplain(ctx context.Context, c ConnReq, sqlText string) ExplainResult {
// v1:通用 EXPLAIN 前缀(达梦支持);方言化计划格式后续按驱动补。
cc := c
cc.SQL = "EXPLAIN " + sqlText
cc.Limit = 200
var res ExplainResult
err := withAgent(cc, func(cli *AgentClient, handle string) error {
var r struct {
Columns []string `json:"columns"`
Rows []map[string]interface{} `json:"rows"`
}
if err := cli.call(ctx, "query", map[string]any{
"handle": handle, "sql": cc.SQL, "maxRows": 200, "timeoutSec": agentTimeoutSec(ctx, 30),
}, &r); err != nil {
return err
}
res = ExplainResult{Columns: r.Columns, Rows: r.Rows}
return nil
})
if err != nil {
return ExplainResult{Error: errMsg(err)}
}
return res
}
// agentPing 供 TestConnection / OpenSession 探活。
func agentPing(c ConnReq) error {
return withAgent(c, func(cli *AgentClient, handle string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return cli.call(ctx, "ping", map[string]any{"handle": handle}, nil)
})
}
// ---------- agent 运行环境(全部在用户家目录,连接时按需落盘) ----------
// agentHome 根目录(~/.opsxdb)
func agentHome() string {
if h := os.Getenv("OPSXDB_HOME"); h != "" {
return h
}
home, err := os.UserHomeDir()
if err != nil {
return ".opsxdb"
}
return filepath.Join(home, ".opsxdb")
}
// agentDir agent jar 目录:~/.opsxdb/agent
func agentDir() string {
return filepath.Join(agentHome(), "agent")
}
// agentJarsDir 厂商驱动 jar 目录:~/.opsxdb/drivers/<key>(用户家目录,DMG 不打包)
func agentJarsDir(spec AgentSpec) string {
if v := os.Getenv(spec.JarsDirEnv); v != "" {
return v
}
return filepath.Join(agentHome(), "drivers", spec.DriverKey)
}
// ensureAgentRuntime 连接时按需下载:agent jar(双托管源 + sha256)与开源驱动 jar(Maven 镜像 + sha256)。
// 商用驱动不在下载范围,由后续 AgentConfigured 给出 manualSource 指引。
// 网络失败时回退离线驱动包(~/.opsxdb/offline-driver-pack.zip,pack.sh 产物)。
func ensureAgentRuntime(spec AgentSpec) error {
if err := ensureAgentJar(); err != nil {
return err
}
artifacts, ok := agentDriverArtifacts[spec.DriverKey]
if !ok {
return nil // 商用/未知驱动:不代下,由 AgentConfigured 校验并给指引
}
dir := agentJarsDir(spec)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for _, a := range artifacts {
dest := filepath.Join(dir, a.File)
if _, err := os.Stat(dest); err == nil {
continue
}
if a.Maven == "" || a.SHA256 == "" {
continue
}
if err := downloadMavenAllMirrors(a, dest); err != nil {
// 离线/内网:从离线驱动包导入
if impErr := importFromOfflinePack(map[string]string{
"drivers/" + spec.DriverKey + "/" + a.File: dest,
}); impErr == nil {
continue
}
return fmt.Errorf("%s 驱动 jar 下载失败: %w(离线环境可放置离线包 ~/.opsxdb/offline-driver-pack.zip 或手动放 %s)",
spec.DriverKey, err, dir)
}
}
return nil
}
// downloadMavenAllMirrors 按镜像顺序下载并校验;全部失败返回最后一个错误。
func downloadMavenAllMirrors(a agentArtifact, dest string) error {
mirrors := []string{
os.Getenv("MAVEN_MIRROR"),
"https://maven.aliyun.com/repository/central",
"https://repo1.maven.org/maven2",
}
var lastErr error
for _, base := range mirrors {
if base == "" {
continue
}
if err := downloadPinned(base+"/"+mavenPath(a.Maven), dest, a.SHA256); err == nil {
return nil
} else {
lastErr = err
}
}
return lastErr
}
// offlinePackPath 离线驱动包位置(OPSXDB_OFFLINE_ZIP 可覆盖)。
func offlinePackPath() string {
if v := os.Getenv("OPSXDB_OFFLINE_ZIP"); v != "" {
return v
}
return filepath.Join(agentHome(), "offline-driver-pack.zip")
}
// importFromOfflinePack 从离线包按 manifest 校验后提取文件。
// files: zip 内相对路径 → 本地目标路径。
func importFromOfflinePack(files map[string]string) error {
zr, err := zip.OpenReader(offlinePackPath())
if err != nil {
return err
}
defer zr.Close()
// 先读 manifest 建 hash 表
hashes := map[string]string{}
for _, f := range zr.File {
if f.Name != "pack/manifest.json" {
continue
}
rc, err := f.Open()
if err != nil {
return err
}
var m struct {
Files []struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
} `json:"files"`
}
err = json.NewDecoder(rc).Decode(&m)
rc.Close()
if err != nil {
return err
}
for _, e := range m.Files {
hashes["pack/"+e.Path] = e.SHA256
}
break
}
for rel, dest := range files {
rel = "pack/" + strings.TrimPrefix(rel, "pack/")
want, ok := hashes[rel]
if !ok {
return fmt.Errorf("离线包 %s 中不存在 %s", offlinePackPath(), rel)
}
for _, f := range zr.File {
if f.Name != rel {
continue
}
rc, err := f.Open()
if err != nil {
return err
}
data, err := io.ReadAll(io.LimitReader(rc, 64<<20))
rc.Close()
if err != nil {
return err
}
if got := fmt.Sprintf("%x", sha256.Sum256(data)); got != want {
return fmt.Errorf("离线包 %s 校验失败: %s", rel, got)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
if err := os.WriteFile(dest, data, 0o644); err != nil {
return err
}
return nil
}
return fmt.Errorf("离线包 %s 中找不到 %s", offlinePackPath(), rel)
}
return nil
}
// ensureAgentJar agent jar:本地缺时先网络下载,失败回退离线包。
func ensureAgentJar() error {
agentJar := os.Getenv("OPSXDB_AGENT_JAR")
if agentJar != "" {
return nil // 显式指定,由调用方负责存在性
}
agentJar = filepath.Join(agentDir(), agentJarName)
if _, err := os.Stat(agentJar); err == nil {
return nil
}
if err := os.MkdirAll(agentDir(), 0o755); err != nil {
return err
}
var lastErr error
for _, u := range strings.Split(agentJarURLs, ",") {
if err := downloadPinned(u, agentJar, agentJarSHA256); err == nil {
return nil
} else {
lastErr = err
}
}
if err := importFromOfflinePack(map[string]string{"pack/agent/" + agentJarName: agentJar}); err == nil {
return nil
}
return fmt.Errorf("agent jar 获取失败: %w(联网下载与离线包均未成功;可手动放置到 %s 或设置 OPSXDB_AGENT_JAR)", lastErr, agentJar)
}
// mavenPath group:artifact:version → org/mongodb/bson/5.2.1/bson-5.2.1.jar
func mavenPath(gav string) string {
parts := strings.Split(gav, ":")
if len(parts) != 3 {
return ""
}
return strings.ReplaceAll(parts[0], ".", "/") + "/" + parts[1] + "/" + parts[2] + "/" + parts[1] + "-" + parts[2] + ".jar"
}
// ensureGson 下载并校验 gson(不存在时)。多源回退:Maven Central → 阿里云镜像。
func ensureGson(home string) (string, error) {
libs := filepath.Join(home, "libs")
if err := os.MkdirAll(libs, 0o755); err != nil {
return "", err
}
name := fmt.Sprintf("gson-%s.jar", gsonVersion)
dest := filepath.Join(libs, name)
if _, err := os.Stat(dest); err == nil {
return dest, nil
}
urls := []string{
fmt.Sprintf("https://repo1.maven.org/maven2/com/google/code/gson/gson/%s/%s", gsonVersion, name),
fmt.Sprintf("https://maven.aliyun.com/repository/central/com/google/code/gson/gson/%s/%s", gsonVersion, name),
}
var lastErr error
for _, u := range urls {
if err := downloadPinned(u, dest, gsonSHA1); err != nil {
lastErr = err
continue
}
return dest, nil
}
return "", fmt.Errorf("gson 下载失败(已按 sha1 钉版本校验): %w", lastErr)
}
// downloadPinned 下载到临时文件并校验 sha1,通过后原子改名。
func downloadPinned(url, dest, wantHash string) error {
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s → HTTP %d", url, resp.StatusCode)
}
tmp := dest + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
// 按期望哈希长度自动选算法:64 位 hex = sha256(驱动/agent 清单),40 位 = sha1(gson 的 .sha1 惯例)
var h hash.Hash
if len(wantHash) == 64 {
h = sha256.New()
} else {
h = sha1.New()
}
_, copyErr := io.Copy(io.MultiWriter(f, h), resp.Body)
closeErr := f.Close()
if copyErr != nil {
_ = os.Remove(tmp)
return copyErr
}
if closeErr != nil {
_ = os.Remove(tmp)
return closeErr
}
if got := hex.EncodeToString(h.Sum(nil)); got != wantHash {
_ = os.Remove(tmp)
return fmt.Errorf("%s 校验失败: got=%s 期望=%s", url, got, wantHash)
}
return os.Rename(tmp, dest)
}
// mongoVerbAsSQL 把 Mongo DSL 写动词映射为 SQL 危险词,复用既有词表与确认流:
// deleteOne/deleteMany → DELETE(无 WHERE 必触发确认),updateOne/updateMany → UPDATE。
func mongoVerbAsSQL(sql string) string {
repl := strings.NewReplacer(
"deleteOne", "DELETE",
"deleteMany", "DELETE",
"updateOne", "UPDATE",
"updateMany", "UPDATE",
)
return repl.Replace(sql)
}
// ---------- 驱动管理(UI 状态/预装/离线导入) ----------
type JarStatus struct {
File string `json:"file"`
Size int64 `json:"size"`
Installed bool `json:"installed"`
}
type DriverStatus struct {
ID string `json:"id"`
Label string `json:"label"`
Runtime string `json:"runtime"` // agent
License string `json:"license"` // oss / proprietary
AutoFetch bool `json:"autoFetch"` // 开源可自动下载
Jars []JarStatus `json:"jars"`
AllInstalled bool `json:"allInstalled"`
JDKRequired bool `json:"jdkRequired"` // agent 驱动恒 true
JDKOK bool `json:"jdkOk"`
JavaVersion string `json:"javaVersion"` // 探测到的版本(如 21.0.12)
AgentInstalled bool `json:"agentInstalled"` // agent jar 是否就位
OfflinePack bool `json:"offlinePack"` // 离线驱动包是否就位
}
// DriversStatus 汇总所有 agent 驱动的安装状态,供驱动管理界面展示。
func DriversStatus() []DriverStatus {
javaBin, _ := exec.LookPath("java")
javaVersion := ""
if javaBin != "" {
if out, err := exec.Command(javaBin, "-version").CombinedOutput(); err == nil {
javaVersion = parseJavaVersion(string(out))
}
}
_, offlineErr := os.Stat(offlinePackPath())
out := []DriverStatus{}
// 按注册表顺序输出(map 遍历乱序,用固定顺序)
keys := []string{"mongodb", "zookeeper", "dameng"}
for _, k := range keys {
spec, ok := agentDriverSpecs[k]
if !ok {
continue
}
artifacts, auto := agentDriverArtifacts[k]
st := DriverStatus{
ID: spec.DriverKey,
Label: spec.DriverKey,
Runtime: "agent",
AutoFetch: auto,
JDKRequired: true,
JDKOK: javaBin != "",
JavaVersion: javaVersion,
OfflinePack: offlineErr == nil,
AgentInstalled: fileExists(filepath.Join(agentDir(), agentJarName)),
}
if os.Getenv("OPSXDB_AGENT_JAR") != "" {
st.AgentInstalled = fileExists(os.Getenv("OPSXDB_AGENT_JAR"))
}
if st.AutoFetch {
st.License = "oss"
dir := agentJarsDir(spec)
for _, a := range artifacts {
p := filepath.Join(dir, a.File)
js := JarStatus{File: a.File, Installed: fileExists(p)}
if fi, err := os.Stat(p); err == nil {
js.Size = fi.Size()
}
st.Jars = append(st.Jars, js)
}
st.AllInstalled = st.AgentInstalled && everyJar(st.Jars)
} else {
st.License = "proprietary"
// 商用:只报告目录里是否已有 jar
dir := agentJarsDir(spec)
entries, _ := os.ReadDir(dir)
n := 0
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".jar") {
n++
}
}
st.License = "proprietary"
st.Jars = []JarStatus{}
st.AllInstalled = st.AgentInstalled && n > 0
}
out = append(out, st)
}
return out
}
// InstallDriver 预装指定驱动的运行时(agent jar + 开源驱动 jar),供驱动管理界面"安装"按钮。
func InstallDriver(driverKey string) error {
spec, ok := agentDriverSpecs[driverKey]
if !ok {
return fmt.Errorf("未知 agent 驱动: %s", driverKey)
}
if _, err := exec.LookPath(os.Getenv("OPSXDB_JAVA_BIN")); err != nil && os.Getenv("OPSXDB_JAVA_BIN") == "" {
if _, err := exec.LookPath("java"); err != nil {
return fmt.Errorf("未找到 java,请先安装 JDK 17+")
}
}
return ensureAgentRuntime(spec)
}
// ImportOfflinePack 全量导入离线驱动包(上传 zip → 校验 manifest → 落盘 ~/.opsxdb/)。
func ImportOfflinePack(zipPath string) (int, error) {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return 0, err
}
defer zr.Close()
hashes := map[string]string{}
var entries []*zip.File
for _, f := range zr.File {
entries = append(entries, f)
if f.Name != "pack/manifest.json" {
continue
}
rc, err := f.Open()
if err != nil {
return 0, err
}
var m struct {
Files []struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
} `json:"files"`
}
err = json.NewDecoder(rc).Decode(&m)
rc.Close()
if err != nil {
return 0, err
}
for _, e := range m.Files {
hashes["pack/"+e.Path] = e.SHA256
}
}
if len(hashes) == 0 {
return 0, fmt.Errorf("离线包缺 manifest.json")
}
home := agentHome()
imported := 0
for _, f := range entries {
want, ok := hashes[f.Name]
if !ok || strings.HasSuffix(f.Name, "/") {
continue
}
rel := strings.TrimPrefix(f.Name, "pack/")
// 只允许写入白名单前缀,防 zip 路径穿越
if !strings.HasPrefix(rel, "agent/") && !strings.HasPrefix(rel, "libs/") && !strings.HasPrefix(rel, "drivers/") {
continue
}
rc, err := f.Open()
if err != nil {
return imported, err
}
data, err := io.ReadAll(io.LimitReader(rc, 64<<20))
rc.Close()
if err != nil {
return imported, err
}
if got := fmt.Sprintf("%x", sha256.Sum256(data)); got != want {
return imported, fmt.Errorf("%s 校验失败", rel)
}
dest := filepath.Join(home, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return imported, err
}
if err := os.WriteFile(dest, data, 0o644); err != nil {
return imported, err
}
imported++
}
return imported, nil
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func everyJar(js []JarStatus) bool {
for _, j := range js {
if !j.Installed {
return false
}
}
return true
}
// parseJavaVersion 从 `java -version` 输出提取版本号(输出在 stderr/stdout 第一行)。
func parseJavaVersion(out string) string {
for _, line := range strings.Split(out, "\n") {
if i := strings.Index(line, "version \""); i >= 0 {
rest := line[i+len("version \""):]
if j := strings.Index(rest, "\""); j > 0 {
return rest[:j]
}
}
}
return ""
}