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:. // 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) } return rewriteHostForTunnel(c, tun.LocalPort), tun, nil } // rewriteHostForTunnel 把连接改写到隧道本地端口(127.0.0.1:)。 // 改写会丢失真实 Host,导致隧道内 TLS ServerName 校验失败(127.0.0.1 不在证书 SAN 里), // 故改写前把原 Host 记入 TLSServerName(用户显式指定时保留其值)。 func rewriteHostForTunnel(c ConnReq, localPort int) ConnReq { out := c if strings.TrimSpace(out.TLSServerName) == "" { out.TLSServerName = out.Host } out.Host = "127.0.0.1" out.Port = localPort // Avoid nested tunnel if helpers re-enter out.SSHEnabled = false return out }