Skip to content

Commit db35107

Browse files
feat: add ssh key authentication support
1 parent 2482bad commit db35107

File tree

8 files changed

+186
-9
lines changed

8 files changed

+186
-9
lines changed

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,34 @@ conn := clickhouse.OpenDB(&clickhouse.Options{
308308
})
309309
```
310310

311+
## SSH Authentication (Native Protocol)
312+
313+
ClickHouse-go supports SSH key-based authentication (requires ClickHouse server with SSH auth enabled).
314+
315+
**Options struct:**
316+
```go
317+
conn, err := clickhouse.Open(&clickhouse.Options{
318+
Addr: []string{"127.0.0.1:9000"},
319+
Auth: clickhouse.Auth{
320+
Database: "default",
321+
Username: "default",
322+
},
323+
SSHKeyFile: "/path/to/id_ed25519",
324+
SSHKeyPassphrase: "your_passphrase_if_any",
325+
})
326+
```
327+
328+
**DSN parameters:**
329+
- `ssh_key_file` — path to SSH private key (RSA, ECDSA, Ed25519)
330+
- `ssh_key_passphrase` — passphrase for encrypted key (optional)
331+
332+
Example DSN:
333+
```
334+
clickhouse://default@127.0.0.1:9000/default?ssh_key_file=/path/to/id_ed25519&ssh_key_passphrase=your_passphrase_if_any
335+
```
336+
337+
See [`examples/ssh_auth.go`](examples/ssh_auth.go) for a complete example.
338+
311339
## Client info
312340
313341

clickhouse_options.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ type Options struct {
162162
// Use this instead of Auth.Username and Auth.Password if you're using JWT auth.
163163
GetJWT GetJWTFunc
164164

165+
// SSH authentication
166+
SSHKeyFile string // path to SSH private key file
167+
SSHKeyPassphrase string // passphrase for SSH key (if encrypted)
168+
165169
scheme string
166170
ReadTimeout time.Duration
167171
}
@@ -327,6 +331,10 @@ func (o *Options) fromDSN(in string) error {
327331
return fmt.Errorf("clickhouse [dsn parse]: http_proxy: %s", err)
328332
}
329333
o.HTTPProxyURL = proxyURL
334+
case "ssh_key_file":
335+
o.SSHKeyFile = params.Get(v)
336+
case "ssh_key_passphrase":
337+
o.SSHKeyPassphrase = params.Get(v)
330338
default:
331339
switch p := strings.ToLower(params.Get(v)); p {
332340
case "true":

conn_handshake.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"fmt"
2323
"time"
2424

25+
chssh "github.com/ClickHouse/ch-go/ssh"
2526
"github.com/ClickHouse/clickhouse-go/v2/lib/proto"
2627
)
2728

@@ -79,6 +80,61 @@ func (c *connect) handshake(auth Auth) error {
7980
c.debugf("[handshake] downgrade client proto")
8081
}
8182
c.debugf("[handshake] <- %s", c.server)
83+
84+
// Handle SSH authentication if configured
85+
if c.opt.SSHKeyFile != "" {
86+
if err := c.performSSHAuthentication(); err != nil {
87+
return err
88+
}
89+
}
90+
91+
return nil
92+
}
93+
94+
func (c *connect) performSSHAuthentication() error {
95+
// Load SSH key
96+
sshKey, err := chssh.LoadPrivateKeyFromFile(c.opt.SSHKeyFile, c.opt.SSHKeyPassphrase)
97+
if err != nil {
98+
return fmt.Errorf("failed to load SSH key: %w", err)
99+
}
100+
101+
// Send SSH challenge request
102+
c.buffer.Reset()
103+
c.buffer.PutByte(proto.ClientSSHChallengeRequest)
104+
if err := c.flush(); err != nil {
105+
return fmt.Errorf("send SSH challenge request: %w", err)
106+
}
107+
108+
// Read SSH challenge response
109+
packet, err := c.reader.ReadByte()
110+
if err != nil {
111+
return fmt.Errorf("read SSH challenge response: %w", err)
112+
}
113+
114+
if packet != proto.ServerSSHChallenge {
115+
return fmt.Errorf("unexpected packet [%d] from server during SSH authentication", packet)
116+
}
117+
118+
// Read challenge string
119+
challenge, err := c.reader.Str()
120+
if err != nil {
121+
return fmt.Errorf("read SSH challenge string: %w", err)
122+
}
123+
124+
// Sign the challenge
125+
signature, err := sshKey.SignString(challenge)
126+
if err != nil {
127+
return fmt.Errorf("sign SSH challenge: %w", err)
128+
}
129+
130+
// Send SSH challenge response
131+
c.buffer.Reset()
132+
c.buffer.PutByte(proto.ClientSSHChallengeResponse)
133+
c.buffer.PutString(signature)
134+
if err := c.flush(); err != nil {
135+
return fmt.Errorf("send SSH challenge response: %w", err)
136+
}
137+
82138
return nil
83139
}
84140

conn_handshake_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package clickhouse
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
func TestSSHAuthenticationOptions(t *testing.T) {
9+
t.Run("MissingKeyFile", func(t *testing.T) {
10+
opt := &Options{
11+
SSHKeyFile: "/nonexistent/path/to/key",
12+
}
13+
c := &connect{opt: opt}
14+
err := c.performSSHAuthentication()
15+
if err == nil {
16+
t.Fatal("expected error for missing SSH key file")
17+
}
18+
})
19+
20+
t.Run("InvalidKeyFile", func(t *testing.T) {
21+
f, err := os.CreateTemp("", "invalid_key*")
22+
if err != nil {
23+
t.Fatal(err)
24+
}
25+
defer os.Remove(f.Name())
26+
f.WriteString("not a key")
27+
f.Close()
28+
opt := &Options{
29+
SSHKeyFile: f.Name(),
30+
}
31+
c := &connect{opt: opt}
32+
err = c.performSSHAuthentication()
33+
if err == nil {
34+
t.Fatal("expected error for invalid SSH key file")
35+
}
36+
})
37+
38+
t.Run("WrongPassphrase", func(t *testing.T) {
39+
t.Skip("Needs a real encrypted key for full test")
40+
// Provide a valid encrypted key and wrong passphrase, expect error
41+
})
42+
43+
t.Run("Integration", func(t *testing.T) {
44+
t.Skip("Integration test: requires ClickHouse server with SSH auth enabled and valid key")
45+
// Provide valid key, connect, expect success
46+
})
47+
}

examples/ssh_auth.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
8+
"github.com/ClickHouse/clickhouse-go/v2"
9+
)
10+
11+
func main() {
12+
// Using Options struct
13+
conn, err := clickhouse.Open(&clickhouse.Options{
14+
Addr: []string{"127.0.0.1:9000"},
15+
Auth: clickhouse.Auth{
16+
Database: "default",
17+
Username: "default",
18+
},
19+
SSHKeyFile: "/path/to/id_ed25519",
20+
SSHKeyPassphrase: "your_passphrase_if_any",
21+
})
22+
if err != nil {
23+
log.Fatalf("failed to open connection: %v", err)
24+
}
25+
if err := conn.Ping(context.Background()); err != nil {
26+
log.Fatalf("failed to ping: %v", err)
27+
}
28+
fmt.Println("SSH authentication succeeded (Options struct)")
29+
30+
// Using DSN
31+
// dsn := "clickhouse://default@127.0.0.1:9000/default?ssh_key_file=/path/to/id_ed25519&ssh_key_passphrase=your_passphrase_if_any"
32+
// conn, err := clickhouse.Open(&clickhouse.Options{})
33+
// ...
34+
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,7 @@ require (
7575
go.opentelemetry.io/proto/otlp v1.0.0 // indirect
7676
golang.org/x/crypto v0.40.0 // indirect
7777
golang.org/x/sys v0.34.0 // indirect
78+
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 // indirect
7879
)
80+
81+
replace github.com/ClickHouse/ch-go => ../ch-go

go.sum

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af
44
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
55
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
66
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
7-
github.com/ClickHouse/ch-go v0.66.1 h1:LQHFslfVYZsISOY0dnOYOXGkOUvpv376CCm8g7W74A4=
8-
github.com/ClickHouse/ch-go v0.66.1/go.mod h1:NEYcg3aOFv2EmTJfo4m2WF7sHB/YFbLUuIWv9iq76xY=
97
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
108
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
119
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg=
@@ -173,8 +171,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMey
173171
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
174172
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
175173
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
176-
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
177-
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
174+
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
175+
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
178176
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
179177
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
180178
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=

lib/proto/const.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@ const (
4141
)
4242

4343
const (
44-
ClientHello = 0
45-
ClientQuery = 1
46-
ClientData = 2
47-
ClientCancel = 3
48-
ClientPing = 4
44+
ClientHello = 0
45+
ClientQuery = 1
46+
ClientData = 2
47+
ClientCancel = 3
48+
ClientPing = 4
49+
ClientSSHChallengeRequest = 11
50+
ClientSSHChallengeResponse = 12
4951
)
5052

5153
const (
@@ -80,4 +82,5 @@ const (
8082
ServerReadTaskRequest = 13
8183
ServerProfileEvents = 14
8284
ServerTreeReadTaskRequest = 15
85+
ServerSSHChallenge = 18
8386
)

0 commit comments

Comments
 (0)