diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index bdbbee4..0499054 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -77,32 +77,68 @@ jobs: - name: Init and start spice app (Windows) if: matrix.os == 'windows-latest' run: | + spice install spice init spice_qs cd spice_qs spice add spiceai/quickstart - Start-Process -FilePath "spice" -ArgumentList "run" -RedirectStandardOutput "spice.log" -RedirectStandardError "spice.err.log" - # Wait for Spice to be ready - Write-Host "Waiting for Spice to be ready..." + shell: pwsh + + - name: Test + if: matrix.os != 'windows-latest' + env: + SPICE_API_KEY: ${{ secrets.SPICE_CLOUD_API_KEY }} + run: go test -v ./... + + - name: Start runtime and test (Windows) + if: matrix.os == 'windows-latest' + env: + SPICE_API_KEY: ${{ secrets.SPICE_CLOUD_API_KEY }} + run: | + $spicedPath = Join-Path $HOME ".spice\bin\spiced" + $spicedDir = Join-Path (Get-Location) "spice_qs" + $logPath = Join-Path $spicedDir "spice.log" + $errPath = Join-Path $spicedDir "spice.err.log" + $proc = Start-Process -FilePath $spicedPath -WorkingDirectory $spicedDir -RedirectStandardOutput $logPath -RedirectStandardError $errPath -PassThru + Write-Host "Started spiced PID=$($proc.Id)" + # Wait for Spice HTTP to be ready + Write-Host "Waiting for Spice HTTP to be ready..." for ($i = 1; $i -le 60; $i++) { try { - $response = Invoke-WebRequest -Uri "http://localhost:8090/v1/ready" -UseBasicParsing -ErrorAction Stop + $response = Invoke-WebRequest -Uri "http://127.0.0.1:8090/v1/ready" -UseBasicParsing -ErrorAction Stop if ($response.Content -match "ready") { - Write-Host "Spice is ready!" + Write-Host "Spice HTTP is ready!" break } - } catch { - # Ignore errors, keep waiting - } - Write-Host "Waiting... ($i/60)" + } catch { } + Write-Host "Waiting HTTP... ($i/60)" Start-Sleep -Seconds 1 } + # Wait for Spice Flight (gRPC) to be ready by checking the TCP port + Write-Host "Waiting for Spice Flight to be ready..." + for ($i = 1; $i -le 60; $i++) { + $tcp = New-Object System.Net.Sockets.TcpClient + try { + $tcp.Connect("127.0.0.1", 50051) + if ($tcp.Connected) { + Write-Host "Spice Flight is ready!" + $tcp.Close() + break + } + } catch { } + finally { $tcp.Close() } + Write-Host "Waiting Flight... ($i/60)" + Start-Sleep -Seconds 1 + } + try { + go test -v ./... + $testExit = $LASTEXITCODE + } finally { + Write-Host "Stopping spiced PID=$($proc.Id)" + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + } + exit $testExit shell: pwsh - - name: Test - env: - SPICE_API_KEY: ${{ secrets.SPICE_CLOUD_API_KEY }} - run: go test -v ./... - - name: Print Spice logs (Unix) if: always() && matrix.os != 'windows-latest' run: | diff --git a/client.go b/client.go index def9ebc..6045679 100644 --- a/client.go +++ b/client.go @@ -2,11 +2,13 @@ package gospice import ( "context" + "crypto/tls" "crypto/x509" "fmt" "io" "math" "net/http" + "os" "strings" "time" @@ -42,6 +44,10 @@ type SpiceClient struct { backoffPolicy backoff.BackOff maxRetries uint userAgent string + + tlsClientCertFile string + tlsClientKeyFile string + tlsRootCertFile string } // NewSpiceClient creates a new SpiceClient @@ -114,6 +120,32 @@ func WithSpiceCloudAddress() SpiceClientModifier { } } +// WithTLSClientCertificate configures the client to present a client certificate +// during the TLS handshake for mutual TLS (mTLS) authentication. +// Both certFile and keyFile must be PEM-encoded. +func WithTLSClientCertificate(certFile, keyFile string) SpiceClientModifier { + return func(c *SpiceClient) error { + if certFile == "" || keyFile == "" { + return fmt.Errorf("both certFile and keyFile are required for mTLS") + } + c.tlsClientCertFile = certFile + c.tlsClientKeyFile = keyFile + return nil + } +} + +// WithTLSRootCertificate configures the client to use a custom CA certificate +// file for server verification instead of (in addition to) the system certificate store. +func WithTLSRootCertificate(caFile string) SpiceClientModifier { + return func(c *SpiceClient) error { + if caFile == "" { + return fmt.Errorf("caFile is required") + } + c.tlsRootCertFile = caFile + return nil + } +} + // Init initializes the SpiceClient func (c *SpiceClient) Init(opts ...SpiceClientModifier) error { for _, opt := range opts { @@ -128,6 +160,16 @@ func (c *SpiceClient) Init(opts ...SpiceClientModifier) error { return fmt.Errorf("error getting system cert pool: %w", err) } + if c.tlsRootCertFile != "" { + caPem, err := os.ReadFile(c.tlsRootCertFile) + if err != nil { + return fmt.Errorf("error reading TLS root certificate '%s': %w", c.tlsRootCertFile, err) + } + if !systemCertPool.AppendCertsFromPEM(caPem) { + return fmt.Errorf("failed to append CA certificate from '%s'", c.tlsRootCertFile) + } + } + flightClient, err := c.createClient(c.flightAddress, systemCertPool) if err != nil { return fmt.Errorf("error creating Spice Flight client: %w", err) @@ -135,6 +177,25 @@ func (c *SpiceClient) Init(opts ...SpiceClientModifier) error { c.flightClient = flightClient + // Update the HTTP client transport with the same TLS configuration + // (custom CA and/or client certificate) used by the Flight client. + httpTlsConfig := &tls.Config{ + RootCAs: systemCertPool, + MinVersion: tls.VersionTLS12, + } + if c.tlsClientCertFile != "" && c.tlsClientKeyFile != "" { + clientCert, err := tls.LoadX509KeyPair(c.tlsClientCertFile, c.tlsClientKeyFile) + if err != nil { + return fmt.Errorf("error loading client certificate for HTTP mTLS: %w", err) + } + httpTlsConfig.Certificates = []tls.Certificate{clientCert} + } + c.httpClient.Transport = &http.Transport{ + MaxIdleConnsPerHost: 10, + DisableCompression: false, + TLSClientConfig: httpTlsConfig, + } + // Initialize ADBC client - non-fatal, will be initialized lazily if needed // This allows health checks to work even if ADBC connection fails initially _ = c.initADBC() @@ -226,7 +287,20 @@ func (c *SpiceClient) createClient(address string, systemCertPool *x509.CertPool address = strings.TrimPrefix(address, "grpc://") grpcDialOpts = append(grpcDialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) } else { - grpcDialOpts = append(grpcDialOpts, grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(systemCertPool, ""))) + tlsConfig := &tls.Config{ + RootCAs: systemCertPool, + MinVersion: tls.VersionTLS12, + } + + if c.tlsClientCertFile != "" && c.tlsClientKeyFile != "" { + clientCert, err := tls.LoadX509KeyPair(c.tlsClientCertFile, c.tlsClientKeyFile) + if err != nil { + return nil, fmt.Errorf("error loading client certificate for mTLS: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{clientCert} + } + + grpcDialOpts = append(grpcDialOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) } client, err := flight.NewClientWithMiddleware( diff --git a/config.go b/config.go index fa598f7..ec1b471 100644 --- a/config.go +++ b/config.go @@ -23,8 +23,8 @@ func LoadConfig() ClientConfig { func LoadLocalConfig() ClientConfig { return ClientConfig{ - HttpUrl: getEnvOrDefault("SPICE_LOCAL_HTTP_URL", "http://localhost:8090"), - FlightUrl: getEnvOrDefault("SPICE_LOCAL_FLIGHT_URL", "grpc://localhost:50051"), + HttpUrl: getEnvOrDefault("SPICE_LOCAL_HTTP_URL", "http://127.0.0.1:8090"), + FlightUrl: getEnvOrDefault("SPICE_LOCAL_FLIGHT_URL", "grpc://127.0.0.1:50051"), } }