diff --git a/README.md b/README.md index 2daf3e9fab..0c2ce28c95 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,77 @@ conn := clickhouse.OpenDB(&clickhouse.Options{ }) ``` +## SSH Authentication (Native Protocol) + +ClickHouse-go supports SSH key-based authentication (requires ClickHouse server with SSH auth enabled). + +**Options struct:** +```go +conn, err := clickhouse.Open(&clickhouse.Options{ + Addr: []string{"127.0.0.1:9000"}, + Auth: clickhouse.Auth{ + Database: "default", + Username: "default", + }, + SSHKeyFile: "/path/to/id_ed25519", + SSHKeyPassphrase: "your_passphrase_if_any", +}) +``` + +**DSN parameters:** +- `ssh_key_file` — path to SSH private key (RSA, ECDSA, Ed25519) +- `ssh_key_passphrase` — passphrase for encrypted key (optional) + +Example DSN: +``` +clickhouse://default@127.0.0.1:9000/default?ssh_key_file=/path/to/id_ed25519&ssh_key_passphrase=your_passphrase_if_any +``` + +See [`examples/ssh_auth.go`](examples/ssh_auth.go) for a complete example. + +## SSH Key Authentication + +ClickHouse SSH key authentication is supported for users configured with SSH keys on the server. You can authenticate using either a file-based SSH private key or an in-memory/custom SSH signer. + +### File-based SSH key + +```go +conn, err := clickhouse.Open(&clickhouse.Options{ + Addr: []string{"127.0.0.1:9000"}, + Auth: clickhouse.Auth{ + Database: "default", + Username: "default", + }, + SSHKeyFile: "/path/to/id_ed25519", + SSHKeyPassphrase: "your_passphrase_if_any", +}) +``` + +Or via DSN: + +``` +clickhouse://default@127.0.0.1:9000/default?ssh_key_file=/path/to/id_ed25519&ssh_key_passphrase=your_passphrase_if_any +``` + +### In-memory or custom SSH signer + +```go +keyData, err := os.ReadFile("/path/to/id_ed25519") +signer, err := ssh.ParsePrivateKey(keyData) +conn, err := clickhouse.Open(&clickhouse.Options{ + Addr: []string{"127.0.0.1:9000"}, + Auth: clickhouse.Auth{ + Database: "default", + Username: "default", + }, + SSHSigner: signer, +}) +``` + +If both `SSHSigner` and `SSHKeyFile` are set, `SSHSigner` takes precedence. + +--- + ## Client info diff --git a/clickhouse_options.go b/clickhouse_options.go index 3a47546958..b7cc306248 100644 --- a/clickhouse_options.go +++ b/clickhouse_options.go @@ -30,6 +30,7 @@ import ( "time" "github.com/ClickHouse/ch-go/compress" + "golang.org/x/crypto/ssh" ) type CompressionMethod byte @@ -162,6 +163,11 @@ type Options struct { // Use this instead of Auth.Username and Auth.Password if you're using JWT auth. GetJWT GetJWTFunc + // SSH authentication. + SSHKeyFile string // Path to SSH private key file (optional) + SSHKeyPassphrase string // Passphrase for SSH key (if encrypted, optional) + SSHSigner ssh.Signer // In-memory or custom SSH signer (takes precedence if set) + scheme string ReadTimeout time.Duration } @@ -327,6 +333,10 @@ func (o *Options) fromDSN(in string) error { return fmt.Errorf("clickhouse [dsn parse]: http_proxy: %s", err) } o.HTTPProxyURL = proxyURL + case "ssh_key_file": + o.SSHKeyFile = params.Get(v) + case "ssh_key_passphrase": + o.SSHKeyPassphrase = params.Get(v) default: switch p := strings.ToLower(params.Get(v)); p { case "true": diff --git a/conn_handshake.go b/conn_handshake.go index 34e65df8bc..e91b7f98aa 100644 --- a/conn_handshake.go +++ b/conn_handshake.go @@ -19,10 +19,13 @@ package clickhouse import ( _ "embed" + "encoding/base64" "fmt" "time" + chssh "github.com/ClickHouse/ch-go/ssh" "github.com/ClickHouse/clickhouse-go/v2/lib/proto" + "golang.org/x/crypto/ssh" ) func (c *connect) handshake(auth Auth) error { @@ -79,6 +82,63 @@ func (c *connect) handshake(auth Auth) error { c.debugf("[handshake] downgrade client proto") } c.debugf("[handshake] <- %s", c.server) + + // Handle SSH authentication if configured + if c.opt.SSHKeyFile != "" { + if err := c.performSSHAuthentication(); err != nil { + return err + } + } + + return nil +} + +func (c *connect) performSSHAuthentication() error { + var sshKey ssh.Signer + if c.opt.SSHSigner != nil { + sshKey = c.opt.SSHSigner + } else if c.opt.SSHKeyFile != "" { + var err error + sshKey, err = chssh.LoadPrivateKeyFromFile(c.opt.SSHKeyFile, c.opt.SSHKeyPassphrase) + if err != nil { + return fmt.Errorf("failed to load SSH key: %w", err) + } + } else { + return fmt.Errorf("no SSH key provided: set SSHSigner or SSHKeyFile") + } + + c.buffer.Reset() + c.buffer.PutByte(proto.ClientSSHChallengeRequest) + if err := c.flush(); err != nil { + return fmt.Errorf("send SSH challenge request: %w", err) + } + + packet, err := c.reader.ReadByte() + if err != nil { + return fmt.Errorf("read SSH challenge response: %w", err) + } + if packet != proto.ServerSSHChallenge { + return fmt.Errorf("unexpected packet [%d] from server during SSH authentication", packet) + } + + challenge, err := c.reader.Str() + if err != nil { + return fmt.Errorf("read SSH challenge string: %w", err) + } + + sig, err := sshKey.Sign(nil, []byte(challenge)) + if err != nil { + return fmt.Errorf("sign SSH challenge: %w", err) + } + signature := base64.StdEncoding.EncodeToString(sig.Blob) + + c.buffer.Reset() + c.buffer.PutByte(proto.ClientSSHChallengeResponse) + c.buffer.PutString(signature) + if err := c.flush(); err != nil { + return fmt.Errorf("send SSH challenge response: %w", err) + } + return nil } diff --git a/conn_handshake_test.go b/conn_handshake_test.go new file mode 100644 index 0000000000..2bcbaaa6fb --- /dev/null +++ b/conn_handshake_test.go @@ -0,0 +1,77 @@ +package clickhouse + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "os" + "testing" + + "golang.org/x/crypto/ssh" +) + +func TestSSHAuthenticationOptions(t *testing.T) { + t.Run("MissingKeyFile", func(t *testing.T) { + opt := &Options{ + SSHKeyFile: "/nonexistent/path/to/key", + } + c := &connect{opt: opt} + err := c.performSSHAuthentication() + if err == nil { + t.Fatal("expected error for missing SSH key file") + } + }) + + t.Run("InvalidKeyFile", func(t *testing.T) { + f, err := os.CreateTemp("", "invalid_key*") + if err != nil { + t.Fatal(err) + } + defer os.Remove(f.Name()) + f.WriteString("not a key") + f.Close() + opt := &Options{ + SSHKeyFile: f.Name(), + } + c := &connect{opt: opt} + err = c.performSSHAuthentication() + if err == nil { + t.Fatal("expected error for invalid SSH key file") + } + }) + + t.Run("WrongPassphrase", func(t *testing.T) { + t.Skip("Needs a real encrypted key for full test") + // Provide a valid encrypted key and wrong passphrase, expect error + }) + + t.Run("Integration", func(t *testing.T) { + t.Skip("Integration test: requires ClickHouse server with SSH auth enabled and valid key") + // Provide valid key, connect, expect success + }) + + t.Run("InMemorySSHSigner", func(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + privateKeyPEM := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + } + pemBytes := pem.EncodeToMemory(privateKeyPEM) + signer, err := ssh.ParsePrivateKey(pemBytes) + if err != nil { + t.Fatal(err) + } + opt := &Options{ + SSHSigner: signer, + } + c := &connect{opt: opt} + err = c.performSSHAuthentication() + if err == nil { + t.Fatal("expected error for missing server challenge (no connection)") + } + }) +} diff --git a/examples/ssh_auth.go b/examples/ssh_auth.go new file mode 100644 index 0000000000..1b7dc832ee --- /dev/null +++ b/examples/ssh_auth.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/ClickHouse/clickhouse-go/v2" + "golang.org/x/crypto/ssh" +) + +func main() { + // File-based SSH key + conn, err := clickhouse.Open(&clickhouse.Options{ + Addr: []string{"127.0.0.1:9000"}, + Auth: clickhouse.Auth{ + Database: "default", + Username: "default", + }, + SSHKeyFile: "/path/to/id_ed25519", + SSHKeyPassphrase: "your_passphrase_if_any", + }) + if err != nil { + log.Fatalf("failed to open connection: %v", err) + } + if err := conn.Ping(context.Background()); err != nil { + log.Fatalf("failed to ping: %v", err) + } + fmt.Println("SSH authentication succeeded (file-based)") + + // In-memory SSH signer + keyData, err := os.ReadFile("/path/to/id_ed25519") + if err != nil { + log.Fatalf("failed to read key: %v", err) + } + signer, err := ssh.ParsePrivateKey(keyData) + if err != nil { + log.Fatalf("failed to parse key: %v", err) + } + conn2, err := clickhouse.Open(&clickhouse.Options{ + Addr: []string{"127.0.0.1:9000"}, + Auth: clickhouse.Auth{ + Database: "default", + Username: "default", + }, + SSHSigner: signer, + }) + if err != nil { + log.Fatalf("failed to open connection (SSHSigner): %v", err) + } + if err := conn2.Ping(context.Background()); err != nil { + log.Fatalf("failed to ping (SSHSigner): %v", err) + } + fmt.Println("SSH authentication succeeded (SSHSigner)") +} diff --git a/go.mod b/go.mod index 5d36b12487..da27f14e37 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.23.0 toolchain go1.24.1 require ( - github.com/ClickHouse/ch-go v0.66.1 + github.com/ClickHouse/ch-go v0.67.0 github.com/andybalholm/brotli v1.2.0 github.com/docker/docker v28.3.2+incompatible github.com/docker/go-units v0.5.0 @@ -16,6 +16,7 @@ require ( github.com/stretchr/testify v1.10.0 github.com/testcontainers/testcontainers-go v0.38.0 go.opentelemetry.io/otel/trace v1.37.0 + golang.org/x/crypto v0.40.0 golang.org/x/net v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -73,6 +74,5 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/proto/otlp v1.0.0 // indirect - golang.org/x/crypto v0.40.0 // indirect golang.org/x/sys v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index d8fd0856bb..c52c8fc0ea 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/ClickHouse/ch-go v0.66.1 h1:LQHFslfVYZsISOY0dnOYOXGkOUvpv376CCm8g7W74A4= -github.com/ClickHouse/ch-go v0.66.1/go.mod h1:NEYcg3aOFv2EmTJfo4m2WF7sHB/YFbLUuIWv9iq76xY= +github.com/ClickHouse/ch-go v0.67.0 h1:18MQF6vZHj+4/hTRaK7JbS/TIzn4I55wC+QzO24uiqc= +github.com/ClickHouse/ch-go v0.67.0/go.mod h1:2MSAeyVmgt+9a2k2SQPPG1b4qbTPzdGDpf1+bcHh+18= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= @@ -173,8 +173,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMey go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= diff --git a/lib/proto/const.go b/lib/proto/const.go index cae29e9a59..d1210ca611 100644 --- a/lib/proto/const.go +++ b/lib/proto/const.go @@ -41,11 +41,13 @@ const ( ) const ( - ClientHello = 0 - ClientQuery = 1 - ClientData = 2 - ClientCancel = 3 - ClientPing = 4 + ClientHello = 0 + ClientQuery = 1 + ClientData = 2 + ClientCancel = 3 + ClientPing = 4 + ClientSSHChallengeRequest = 11 + ClientSSHChallengeResponse = 12 ) const ( @@ -80,4 +82,5 @@ const ( ServerReadTaskRequest = 13 ServerProfileEvents = 14 ServerTreeReadTaskRequest = 15 + ServerSSHChallenge = 18 )