如何使用go-sql-driver通过标准TCP/IP over SSH连接到MySQL?

3
我目前在Windows 8.1上使用MySQL Workbench,通过标准TCP/IP over SSH连接到Linux服务器上的远程MySQL数据库。基本上我有以下信息:
  • SSH主机名:dbserver.myorg.com:ssh-port
  • SSH用户名:myRemoteLoginUsername
  • SSH密码:(存储在保险库中)
  • SSH密钥文件:指向本地.ppk文件的路径

  • MySQL主机名:127.0.0.1

  • MySQL服务器端口:3306
  • 用户名:myRemoteDbUsername
  • 密码:(存储在保险库中)
  • 默认模式:myRemoteDatabaseName

如何在Go命令应用程序中使用github.com/go-sql-driver/mysql连接到数据库?

在sql.Open语句中,我的DataSourceName字符串应该是什么样的?

    db, err := sql.Open("mysql", <DataSourceName> ) {}

准备工作中是否需要额外的工作来准备一个可工作的DataSourceName字符串?

在我的Windows电脑上,我安装了putty。我了解到隧道技术并添加了一个动态隧道用于3306端口(D3306)。我期望这样做可以让我通过连接到localhost:3306自动将请求转发到远程数据库,只要我使用putty连接到远程主机,但这并没有像预期的那样工作。


1
缺少的是,你打算如何管理你的SSH隧道。它是由PuTTY还是你的Go代码来管理?换句话说,你想要一个“完整的Go”解决方案吗?如果答案是否定的,那么你的代码看起来就像是任何Go+MySQL教程中的示例代码,唯一的区别是你使用localhost:3306作为连接端点,而不是remoteserver:3306。请注意,只有当你的SSH隧道在本地主机上侦听并且TCP端口3306上才是真实的。 - kostix
首选方案是“完整的Go”解决方案,就像我在MySQL Workbench中所做的那样。如果使用putty实现太困难了,也可以。我尝试使用localhost:3306的putty连接,但只得到了连接超时的错误。 - user4849927
2个回答

1

我承诺提供我的示例,下面是内容。基本上,我的解决方案通过ssh隧道连接到远程服务器,并通过该隧道查询远程数据库。ssh隧道是解决方案的一部分。

我需要做的第一件事是将我的PuTTY .ppk私钥文件转换为有效的OpenSSH .pem密钥文件。这可以使用PuTTYgen中的导出功能轻松完成。由于我想支持密码加密的私钥,我还需要一个函数来解密密钥并将其从解密后的原始格式重新格式化为golang.org/x/crypto/ssh/ParsePrivateKey接受的有效格式,该格式用于获取签名者列表以进行身份验证。

解决方案本身包含在两个文件中的软件包中。应用程序的主要部分在main.go中完成,其中包含所有相关数据分配以及与数据库查询相关的代码。与ssh隧道和密钥处理有关的所有内容都包含在sshTunnel.go中。

该解决方案不提供安全密码存储机制,也不要求输入密码。密码在代码中提供。但是,实现密码请求的回调方法并不太复杂。

请注意:从性能角度来看,这不是理想的解决方案。它还缺乏适当的错误处理。我提供这个作为示例。

这个示例是一个经过测试并且可行的示例。我从一台Windows 8.1电脑上开发并使用它。数据库服务器在远程Linux系统上。您只需要更改main.go中的数据和查询部分即可。
以下是包含在main.go中的第一部分:
// mysqlSSHtunnel project main.go
// Establish an ssh tunnel and connect to a remote mysql server using
// go-sql-driver for database queries. Encrypted private key pem files
// are supported.
//
// This is an example to give an idea. It's far from a performant solution. It 
// lacks of proper error handling and I'm sure it could really be much better 
// implemented. Please forgive me, as I just started with Go about 2 weeks ago.
//
// The database used in this example is from a real Opensimulator installation.
// It queries the migrations table in the opensim database.
//
package main

import (
    "database/sql"
    "fmt"
    _ "github.com/go-sql-driver/mysql"
    "os"
)

// Declare your connection data and user credentials here
const (
    // ssh connection related data
    sshServerHost     = "test.example.com"
    sshServerPort     = 22
    sshUserName       = "tester"
    sshPrivateKeyFile = "testkey.pem" // exported as OpenSSH key from .ppk
    sshKeyPassphrase  = "testoster0n" // key file encrytion password

    // ssh tunneling related data
    sshLocalHost  = "localhost" // local localhost ip (client side)
    sshLocalPort  = 9000        // local port used to forward the connection
    sshRemoteHost = "127.0.0.1" // remote local ip (server side)
    sshRemotePort = 3306        // remote MySQL port

    // MySQL access data
    mySqlUsername = "opensim"
    mySqlPassword = "h0tgrits"
    mySqlDatabase = "opensimdb"
)

// The main entry point of the application
func main() {
    fmt.Println("-> mysqlSSHtunnel")

    tunnel := sshTunnel() // Initialize sshTunnel
    go tunnel.Start()     // Start the sshTunnel

    // Declare the dsn (aka database connection string)
    // dsn := "opensim:h0tgrits@tcp(localhost:9000)/opensimdb"
    dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s",
        mySqlUsername, mySqlPassword, sshLocalHost, sshLocalPort, mySqlDatabase)

    // Open the database
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        dbErrorHandler(err)
    }
    defer db.Close() // keep it open until we are finished

    // Simple select query to check migrations (provided here as an example)
    rows, err := db.Query("SELECT * FROM migrations")
    if err != nil {
        dbErrorHandler(err)
    }
    defer rows.Close()

    // Iterate though the rows returned and print them
    for rows.Next() {
        var version int
        var name string
        if err := rows.Scan(&name, &version); err != nil {
            dbErrorHandler(err)
        }
        fmt.Printf("%s, %d\n", name, version)
    }
    if err := rows.Err(); err != nil {
        dbErrorHandler(err)
    }

    // Done for now
    fmt.Println("<- mysqlSSHtunnel")
}

// Simple mySql error handling (yet to implement)
func dbErrorHandler(err error) {
    switch err := err.(type) {
    default:
        fmt.Printf("Error %s\n", err)
        os.Exit(-1)
    }
}

现在是 sshTunnel.go 的第二部分:

// mysqlSSHtunnel project sshTunnel.go
//
// Everything regarding the ssh tunnel goes here. Credits go to Svett Ralchev.
// Look at http://blog.ralch.com/tutorial/golang-ssh-tunneling for an excellent
// explanation and most ssh-tunneling related details used in this code.
//
// PEM key decryption is valid for password proected SSH-2 RSA Keys generated as
// .ppk files for putty and exported as OpenSSH .pem keyfile using PuTTYgen.
//
package main

import (
    "bytes"
    "crypto/x509"
    "encoding/base64"
    "encoding/pem"
    "fmt"
    "golang.org/x/crypto/ssh"
    "io"
    "io/ioutil"
    "net"
)

// Define an endpoint with ip and port
type Endpoint struct {
    Host string
    Port int
}

// Returns an endpoint as ip:port formatted string
func (endpoint *Endpoint) String() string {
    return fmt.Sprintf("%s:%d", endpoint.Host, endpoint.Port)
}

// Define the endpoints along the tunnel
type SSHtunnel struct {
    Local  *Endpoint
    Server *Endpoint
    Remote *Endpoint
    Config *ssh.ClientConfig
}

// Start the tunnel
func (tunnel *SSHtunnel) Start() error {
    listener, err := net.Listen("tcp", tunnel.Local.String())
    if err != nil {
        return err
    }
    defer listener.Close()

    for {
        conn, err := listener.Accept()
        if err != nil {
            return err
        }
        go tunnel.forward(conn)
    }
}

// Port forwarding
func (tunnel *SSHtunnel) forward(localConn net.Conn) {
    // Establish connection to the intermediate server
    serverConn, err := ssh.Dial("tcp", tunnel.Server.String(), tunnel.Config)
    if err != nil {
        fmt.Printf("Server dial error: %s\n", err)
        return
    }

    // access the target server
    remoteConn, err := serverConn.Dial("tcp", tunnel.Remote.String())
    if err != nil {
        fmt.Printf("Remote dial error: %s\n", err)
        return
    }

    // Transfer the data between  and the remote server
    copyConn := func(writer, reader net.Conn) {
        _, err := io.Copy(writer, reader)
        if err != nil {
            fmt.Printf("io.Copy error: %s", err)
        }
    }

    go copyConn(localConn, remoteConn)
    go copyConn(remoteConn, localConn)
}

// Decrypt encrypted PEM key data with a passphrase and embed it to key prefix
// and postfix header data to make it valid for further private key parsing.
func DecryptPEMkey(buffer []byte, passphrase string) []byte {
    block, _ := pem.Decode(buffer)
    der, err := x509.DecryptPEMBlock(block, []byte(passphrase))
    if err != nil {
        fmt.Println("decrypt failed: ", err)
    }
    encoded := base64.StdEncoding.EncodeToString(der)
    encoded = "-----BEGIN RSA PRIVATE KEY-----\n" + encoded +
        "\n-----END RSA PRIVATE KEY-----\n"
    return []byte(encoded)
}

// Get the signers from the OpenSSH key file (.pem) and return them for use in
// the Authentication method. Decrypt encrypted key data with the passphrase.
func PublicKeyFile(file string, passphrase string) ssh.AuthMethod {
    buffer, err := ioutil.ReadFile(file)
    if err != nil {
        return nil
    }

    if bytes.Contains(buffer, []byte("ENCRYPTED")) {
        // Decrypt the key with the passphrase if it has been encrypted
        buffer = DecryptPEMkey(buffer, passphrase)
    }

    // Get the signers from the key
    signers, err := ssh.ParsePrivateKey(buffer)
    if err != nil {
        return nil
    }
    return ssh.PublicKeys(signers)
}

// Define the ssh tunnel using its endpoint and config data
func sshTunnel() *SSHtunnel {
    localEndpoint := &Endpoint{
        Host: sshLocalHost,
        Port: sshLocalPort,
    }

    serverEndpoint := &Endpoint{
        Host: sshServerHost,
        Port: sshServerPort,
    }

    remoteEndpoint := &Endpoint{
        Host: sshRemoteHost,
        Port: sshRemotePort,
    }

    sshConfig := &ssh.ClientConfig{
        User: sshUserName,
        Auth: []ssh.AuthMethod{
            PublicKeyFile(sshPrivateKeyFile, sshKeyPassphrase)},
    }

    return &SSHtunnel{
        Config: sshConfig,
        Local:  localEndpoint,
        Server: serverEndpoint,
        Remote: remoteEndpoint,
    }
}

0

嗯,我认为你可以用“完整的Go”来实现。

SSH部分和端口转发

我会从类似this的东西开始(我没有找到更好的例子)。

请注意此代码存在两个问题:

  1. 它实际上是不正确的:它在连接到远程套接字之前连接到一个客户端连接,而应该相反:接受一个客户端连接到一个端口转发的本地套接字,然后使用活动的SSH会话连接到远程套接字,如果成功,则生成两个goroutine来传输这两个套接字之间的数据。

  2. 在配置SSH客户端时,它明确允许基于密码的身份验证,但没有明确原因。由于您正在使用基于pubkey的身份验证,因此不需要这样做。

可能会使您困扰的障碍是管理访问您的SSH密钥。问题在于,好的密钥应该受到密码短语的保护。

您说密钥的密码存储在“valut”中,我真的不知道“valut”是什么。

在我使用的系统上,SSH客户端要么要求输入密码以解密密钥,要么使用所谓的“SSH代理”进行工作:
  • 在基于Linux的系统上,通常是后台运行的OpenSSH的ssh-agent二进制文件,通过Unix域套接字访问,并通过检查名为SSH_AUTH_SOCK的环境变量来定位。
  • 在Windows上,我使用PuTTY,它有自己的代理pageant.exe。我不知道PuTTY SSH客户端使用哪种方式来定位它。

要访问OpenSSH代理,golang.org/x/crypto/ssh提供了agent子包,可用于定位代理并与其通信。如果您需要从pageant获取密钥,则需要弄清楚它使用的协议并实现它。

MySQL部分

下一步将是将此与go-sql-driver集成。

我会尽可能简单地开始:

  1. 当您的SSH端口转发工作正常时, 使其在本地主机上的随机端口上侦听传入连接。 打开连接后,从返回的连接对象中获取端口。
  2. 使用该端口号构造连接字符串,以传递给您将创建的sql.DB实例,以使用go-sql-driver

然后,驱动程序将连接到您的端口转发端口,而您的SSH层将完成其余工作。

在您完成此操作后,我建议您探索您选择的驱动程序是否允许进行更精细的调整,例如允许您直接传递io.ReadWriter实例(已打开的套接字),以便您可以跳过端口转发设置并仅生成通过SSH转发的新TCP连接,即跳过“本地侦听”步骤。


在向SO提问之前,我也找到了您提供的示例,但是无法弄清如何实现它。我也不确定它是否适用于我拥有的密钥格式(.ppk文件)。 - user4849927
“存储在保险库中”意味着密码将被输入一次,然后以安全的方式存储以供以后使用。 - user4849927
我的密钥受到密码保护。我可以创建一个没有密码的密钥,这会让事情变得更容易,但这违反了我的安全规则。 - user4849927
我会在本周稍后再仔细查看,并回来告诉你我的解决方案。 - user4849927
好的,纯粹的 golang.org/x/crypto/ssh 无法使用 .ppk 密钥:这是 PuTTY 自己的格式,接近于 OpenSSH 使用的 PEM,但并不完全相同。因此,如果您决定尝试直接读取密钥而不是与 PuTTY 的代理通信,则可以使用 puttygen.exe 将您的 .ppk 密钥转换为与 OpenSSH(因此也与 crypto/ssh)兼容的格式。 - kostix
实际上,按照您的建议,使用puttygen.exe将.ppk文件导出为.pem文件非常容易。同时,我成功地建立了所需的连接。稍微整理一下代码后,我会稍后在这里发布我的解决方案。 - user4849927

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接