Skip to content

Commit 8d4890b

Browse files
feat: add ssh key authentication support
1 parent 757e102 commit 8d4890b

File tree

8 files changed

+195
-19
lines changed

8 files changed

+195
-19
lines changed

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,34 @@ conn := clickhouse.OpenDB(&clickhouse.Options{
271271
})
272272
```
273273

274+
## SSH Authentication (Native Protocol)
275+
276+
ClickHouse-go supports SSH key-based authentication (requires ClickHouse server with SSH auth enabled).
277+
278+
**Options struct:**
279+
```go
280+
conn, err := clickhouse.Open(&clickhouse.Options{
281+
Addr: []string{"127.0.0.1:9000"},
282+
Auth: clickhouse.Auth{
283+
Database: "default",
284+
Username: "default",
285+
},
286+
SSHKeyFile: "/path/to/id_ed25519",
287+
SSHKeyPassphrase: "your_passphrase_if_any",
288+
})
289+
```
290+
291+
**DSN parameters:**
292+
- `ssh_key_file` — path to SSH private key (RSA, ECDSA, Ed25519)
293+
- `ssh_key_passphrase` — passphrase for encrypted key (optional)
294+
295+
Example DSN:
296+
```
297+
clickhouse://default@127.0.0.1:9000/default?ssh_key_file=/path/to/id_ed25519&ssh_key_passphrase=your_passphrase_if_any
298+
```
299+
300+
See [`examples/ssh_auth.go`](examples/ssh_auth.go) for a complete example.
301+
274302
## Client info
275303
276304

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: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ require (
7474
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect
7575
go.opentelemetry.io/otel/metric v1.37.0 // indirect
7676
go.opentelemetry.io/proto/otlp v1.0.0 // indirect
77-
golang.org/x/crypto v0.39.0 // indirect
78-
golang.org/x/sys v0.33.0 // indirect
77+
golang.org/x/crypto v0.40.0 // indirect
78+
golang.org/x/sys v0.34.0 // indirect
7979
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 // indirect
8080
)
81+
82+
replace github.com/ClickHouse/ch-go => ../ch-go

go.sum

Lines changed: 10 additions & 12 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=
@@ -183,8 +181,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
183181
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
184182
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
185183
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
186-
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
187-
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
184+
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
185+
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
188186
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
189187
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
190188
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -211,17 +209,17 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
211209
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
212210
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
213211
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
214-
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
215-
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
212+
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
213+
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
216214
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
217-
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
218-
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
215+
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
216+
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
219217
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
220218
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
221219
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
222220
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
223-
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
224-
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
221+
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
222+
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
225223
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
226224
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
227225
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

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)