Skip to content

Commit bc815b0

Browse files
feat: add mTLS client certificate support
1 parent 45f32c4 commit bc815b0

3 files changed

Lines changed: 80 additions & 4 deletions

File tree

.github/workflows/go.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,12 @@ jobs:
7777
- name: Init and start spice app (Windows)
7878
if: matrix.os == 'windows-latest'
7979
run: |
80+
spice install
8081
spice init spice_qs
8182
cd spice_qs
8283
spice add spiceai/quickstart
83-
Start-Process -FilePath "spice" -ArgumentList "run" -RedirectStandardOutput "spice.log" -RedirectStandardError "spice.err.log"
84+
$spicedPath = Join-Path $HOME ".spice\bin\spiced"
85+
Start-Process -FilePath $spicedPath -WorkingDirectory (Get-Location) -RedirectStandardOutput "spice.log" -RedirectStandardError "spice.err.log"
8486
# Wait for Spice to be ready
8587
Write-Host "Waiting for Spice to be ready..."
8688
for ($i = 1; $i -le 60; $i++) {

client.go

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package gospice
22

33
import (
44
"context"
5+
"crypto/tls"
56
"crypto/x509"
67
"fmt"
78
"io"
89
"math"
910
"net/http"
11+
"os"
1012
"strings"
1113
"time"
1214

@@ -42,6 +44,10 @@ type SpiceClient struct {
4244
backoffPolicy backoff.BackOff
4345
maxRetries uint
4446
userAgent string
47+
48+
tlsClientCertFile string
49+
tlsClientKeyFile string
50+
tlsRootCertFile string
4551
}
4652

4753
// NewSpiceClient creates a new SpiceClient
@@ -114,6 +120,32 @@ func WithSpiceCloudAddress() SpiceClientModifier {
114120
}
115121
}
116122

123+
// WithTLSClientCertificate configures the client to present a client certificate
124+
// during the TLS handshake for mutual TLS (mTLS) authentication.
125+
// Both certFile and keyFile must be PEM-encoded.
126+
func WithTLSClientCertificate(certFile, keyFile string) SpiceClientModifier {
127+
return func(c *SpiceClient) error {
128+
if certFile == "" || keyFile == "" {
129+
return fmt.Errorf("both certFile and keyFile are required for mTLS")
130+
}
131+
c.tlsClientCertFile = certFile
132+
c.tlsClientKeyFile = keyFile
133+
return nil
134+
}
135+
}
136+
137+
// WithTLSRootCertificate configures the client to use a custom CA certificate
138+
// file for server verification instead of (in addition to) the system certificate store.
139+
func WithTLSRootCertificate(caFile string) SpiceClientModifier {
140+
return func(c *SpiceClient) error {
141+
if caFile == "" {
142+
return fmt.Errorf("caFile is required")
143+
}
144+
c.tlsRootCertFile = caFile
145+
return nil
146+
}
147+
}
148+
117149
// Init initializes the SpiceClient
118150
func (c *SpiceClient) Init(opts ...SpiceClientModifier) error {
119151
for _, opt := range opts {
@@ -128,13 +160,42 @@ func (c *SpiceClient) Init(opts ...SpiceClientModifier) error {
128160
return fmt.Errorf("error getting system cert pool: %w", err)
129161
}
130162

163+
if c.tlsRootCertFile != "" {
164+
caPem, err := os.ReadFile(c.tlsRootCertFile)
165+
if err != nil {
166+
return fmt.Errorf("error reading TLS root certificate '%s': %w", c.tlsRootCertFile, err)
167+
}
168+
if !systemCertPool.AppendCertsFromPEM(caPem) {
169+
return fmt.Errorf("failed to append CA certificate from '%s'", c.tlsRootCertFile)
170+
}
171+
}
172+
131173
flightClient, err := c.createClient(c.flightAddress, systemCertPool)
132174
if err != nil {
133175
return fmt.Errorf("error creating Spice Flight client: %w", err)
134176
}
135177

136178
c.flightClient = flightClient
137179

180+
// Update the HTTP client transport with the same TLS configuration
181+
// (custom CA and/or client certificate) used by the Flight client.
182+
httpTlsConfig := &tls.Config{
183+
RootCAs: systemCertPool,
184+
MinVersion: tls.VersionTLS12,
185+
}
186+
if c.tlsClientCertFile != "" && c.tlsClientKeyFile != "" {
187+
clientCert, err := tls.LoadX509KeyPair(c.tlsClientCertFile, c.tlsClientKeyFile)
188+
if err != nil {
189+
return fmt.Errorf("error loading client certificate for HTTP mTLS: %w", err)
190+
}
191+
httpTlsConfig.Certificates = []tls.Certificate{clientCert}
192+
}
193+
c.httpClient.Transport = &http.Transport{
194+
MaxIdleConnsPerHost: 10,
195+
DisableCompression: false,
196+
TLSClientConfig: httpTlsConfig,
197+
}
198+
138199
// Initialize ADBC client - non-fatal, will be initialized lazily if needed
139200
// This allows health checks to work even if ADBC connection fails initially
140201
_ = c.initADBC()
@@ -226,7 +287,20 @@ func (c *SpiceClient) createClient(address string, systemCertPool *x509.CertPool
226287
address = strings.TrimPrefix(address, "grpc://")
227288
grpcDialOpts = append(grpcDialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
228289
} else {
229-
grpcDialOpts = append(grpcDialOpts, grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(systemCertPool, "")))
290+
tlsConfig := &tls.Config{
291+
RootCAs: systemCertPool,
292+
MinVersion: tls.VersionTLS12,
293+
}
294+
295+
if c.tlsClientCertFile != "" && c.tlsClientKeyFile != "" {
296+
clientCert, err := tls.LoadX509KeyPair(c.tlsClientCertFile, c.tlsClientKeyFile)
297+
if err != nil {
298+
return nil, fmt.Errorf("error loading client certificate for mTLS: %w", err)
299+
}
300+
tlsConfig.Certificates = []tls.Certificate{clientCert}
301+
}
302+
303+
grpcDialOpts = append(grpcDialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)))
230304
}
231305

232306
client, err := flight.NewClientWithMiddleware(

config.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ func LoadConfig() ClientConfig {
2323

2424
func LoadLocalConfig() ClientConfig {
2525
return ClientConfig{
26-
HttpUrl: getEnvOrDefault("SPICE_LOCAL_HTTP_URL", "http://localhost:8090"),
27-
FlightUrl: getEnvOrDefault("SPICE_LOCAL_FLIGHT_URL", "grpc://localhost:50051"),
26+
HttpUrl: getEnvOrDefault("SPICE_LOCAL_HTTP_URL", "http://127.0.0.1:8090"),
27+
FlightUrl: getEnvOrDefault("SPICE_LOCAL_FLIGHT_URL", "grpc://127.0.0.1:50051"),
2828
}
2929
}
3030

0 commit comments

Comments
 (0)