feat: OpsxDB 桌面数据库客户端首版入库
含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、 Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
This commit is contained in:
commit
f8740d27f7
240 files changed
+21625
No files matched your search
@@ -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"`
|
||||
}
|
||||
Reference in new issue
Block a user