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,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
|
||||
}
|
||||
Reference in new issue
Block a user