Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 61 additions & 7 deletions plugin/gthulhu/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package gthulhu

import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
Expand All @@ -11,6 +12,8 @@ import (
"os"
"strings"
"time"

reg "github.com/Gthulhu/plugin/plugin/internal/registry"
)

// TokenRequest represents the request structure for JWT token generation
Expand Down Expand Up @@ -47,20 +50,65 @@ type JWTClient struct {
authEnabled bool
}

// NewJWTClient creates a new JWT client
// NewJWTClient creates a new JWT client. When mtlsCfg.Enable is true the
// underlying HTTP client is configured with mutual TLS so the plugin
// authenticates itself to the API server and verifies the server certificate
// against the shared CA.
func NewJWTClient(
publicKeyPath,
apiBaseURL string,
authEnabled bool,
) *JWTClient {
mtlsCfg reg.MTLSConfig,
) (*JWTClient, error) {
Comment on lines +53 to +62
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewJWTClient changed from returning only *JWTClient to returning (*JWTClient, error) and now requires an mTLS config parameter. Because this is an exported constructor, this is a breaking API change for any external callers. Consider keeping the old signature as a wrapper (defaulting mTLS disabled) and introducing a new constructor (or functional options) for mTLS to preserve compatibility.

Copilot uses AI. Check for mistakes.
httpClient := &http.Client{
Timeout: 30 * time.Second,
}

if mtlsCfg.Enable {
tlsClient, err := buildMTLSClient(mtlsCfg)
if err != nil {
return nil, err
}
httpClient = tlsClient
}

return &JWTClient{
publicKeyPath: publicKeyPath,
apiBaseURL: strings.TrimSuffix(apiBaseURL, "/"),
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
authEnabled: authEnabled,
httpClient: httpClient,
authEnabled: authEnabled,
}, nil
}

// buildMTLSClient constructs an HTTP client with mutual TLS configured.
func buildMTLSClient(mtlsCfg reg.MTLSConfig) (*http.Client, error) {
cert, err := tls.X509KeyPair([]byte(mtlsCfg.CertPem), []byte(mtlsCfg.KeyPem))
if err != nil {
return nil, fmt.Errorf("load mTLS client certificate: %w", err)
}

caPool := x509.NewCertPool()
if !caPool.AppendCertsFromPEM([]byte(mtlsCfg.CAPem)) {
return nil, fmt.Errorf("parse mTLS CA certificate")
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error returned when the CA PEM cannot be appended is very generic (no indication that the PEM contained zero certs / was invalid). Since AppendCertsFromPEM doesn’t provide an underlying error, consider returning a more actionable message (e.g., explicitly saying the CA PEM contained no valid certificates) while keeping the existing prefix so tests can continue to match.

Suggested change
return nil, fmt.Errorf("parse mTLS CA certificate")
return nil, fmt.Errorf("parse mTLS CA certificate: CA PEM contained no valid certificates")

Copilot uses AI. Check for mistakes.
}

tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caPool,
MinVersion: tls.VersionTLS12,
}

defaultTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, fmt.Errorf("unexpected default transport type %T", http.DefaultTransport)
}
mtlsTransport := defaultTransport.Clone()
mtlsTransport.TLSClientConfig = tlsCfg

return &http.Client{
Timeout: 30 * time.Second,
Transport: mtlsTransport,
}, nil
}

// loadPublicKey loads the RSA public key from PEM file
Expand Down Expand Up @@ -161,12 +209,18 @@ func (c *JWTClient) GetAuthenticatedClient() (*http.Client, error) {
return nil, err
}

// Preserve any custom transport (e.g. mTLS) already configured on the client.
transport := c.httpClient.Transport
if transport == nil {
transport = http.DefaultTransport
}

// Create a custom transport that adds the Authorization header
client := &http.Client{
Timeout: 30 * time.Second,
Transport: &authenticatedTransport{
token: c.token,
transport: http.DefaultTransport,
transport: transport,
},
}

Expand Down
210 changes: 210 additions & 0 deletions plugin/gthulhu/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package gthulhu

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

reg "github.com/Gthulhu/plugin/plugin/internal/registry"
)

// authTestCerts holds PEM-encoded self-signed CA + leaf cert for unit testing.
type authTestCerts struct {
caPEM string
certPEM string
keyPEM string
}

// generateAuthTestCerts creates a minimal self-signed CA and a leaf cert/key signed by it.
func generateAuthTestCerts(t *testing.T) authTestCerts {
t.Helper()

notBefore := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
notAfter := time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)

// Generate CA key + cert
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate CA key: %v", err)
}
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test-ca"},
NotBefore: notBefore,
NotAfter: notAfter,
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
if err != nil {
t.Fatalf("create CA cert: %v", err)
}
caCert, err := x509.ParseCertificate(caDER)
if err != nil {
t.Fatalf("parse CA cert: %v", err)
}
caPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}))

// Generate leaf key + cert signed by CA
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate leaf key: %v", err)
}
leafTemplate := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{CommonName: "test-leaf"},
NotBefore: notBefore,
NotAfter: notAfter,
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
}
leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, caCert, &leafKey.PublicKey, caKey)
if err != nil {
t.Fatalf("create leaf cert: %v", err)
}
certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}))

leafKeyDER, err := x509.MarshalECPrivateKey(leafKey)
if err != nil {
t.Fatalf("marshal leaf key: %v", err)
}
keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: leafKeyDER}))

return authTestCerts{caPEM: caPEM, certPEM: certPEM, keyPEM: keyPEM}
}

func TestNewJWTClientMTLSDisabled(t *testing.T) {
mtlsCfg := reg.MTLSConfig{Enable: false}
c, err := NewJWTClient("", "http://localhost", false, mtlsCfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c == nil {
t.Fatal("expected non-nil JWTClient")
}
// Transport should be nil (plain http.Client default)
if c.httpClient.Transport != nil {
t.Errorf("expected nil transport for plain HTTP, got %T", c.httpClient.Transport)
}
}

func TestNewJWTClientMTLSBadCert(t *testing.T) {
mtlsCfg := reg.MTLSConfig{
Enable: true,
CertPem: "not-valid-pem",
KeyPem: "not-valid-pem",
CAPem: "not-valid-pem",
}
_, err := NewJWTClient("", "https://localhost", false, mtlsCfg)
if err == nil {
t.Fatal("expected error for invalid cert PEM, got nil")
}
if !strings.Contains(err.Error(), "load mTLS client certificate") {
t.Errorf("unexpected error message: %v", err)
}
}

func TestNewJWTClientMTLSBadCA(t *testing.T) {
certs := generateAuthTestCerts(t)
mtlsCfg := reg.MTLSConfig{
Enable: true,
CertPem: certs.certPEM,
KeyPem: certs.keyPEM,
CAPem: "not-a-valid-ca-pem",
}
_, err := NewJWTClient("", "https://localhost", false, mtlsCfg)
if err == nil {
t.Fatal("expected error for invalid CA PEM, got nil")
}
if !strings.Contains(err.Error(), "parse mTLS CA certificate") {
t.Errorf("unexpected error message: %v", err)
}
}

func TestNewJWTClientMTLSEnabled(t *testing.T) {
certs := generateAuthTestCerts(t)
mtlsCfg := reg.MTLSConfig{
Enable: true,
CertPem: certs.certPEM,
KeyPem: certs.keyPEM,
CAPem: certs.caPEM,
}
c, err := NewJWTClient("", "https://localhost", false, mtlsCfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c == nil {
t.Fatal("expected non-nil JWTClient")
}
// Transport should be an mTLS-capable *http.Transport
if _, ok := c.httpClient.Transport.(*http.Transport); !ok {
t.Errorf("expected *http.Transport, got %T", c.httpClient.Transport)
}
}

// TestNewJWTClientMTLSEndToEnd verifies that a JWTClient configured with mTLS
// can successfully complete a round-trip against an mTLS-enforcing httptest server.
func TestNewJWTClientMTLSEndToEnd(t *testing.T) {
certs := generateAuthTestCerts(t)

// Build mTLS test server that requires a client cert signed by our CA.
serverCert, err := tls.X509KeyPair([]byte(certs.certPEM), []byte(certs.keyPEM))
if err != nil {
t.Fatalf("load server cert: %v", err)
}
caPool := x509.NewCertPool()
if !caPool.AppendCertsFromPEM([]byte(certs.caPEM)) {
t.Fatal("append CA cert")
}
serverTLSCfg := &tls.Config{
Certificates: []tls.Certificate{serverCert},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: caPool,
}

server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
server.TLS = serverTLSCfg
server.StartTLS()
defer server.Close()

mtlsCfg := reg.MTLSConfig{
Enable: true,
CertPem: certs.certPEM,
KeyPem: certs.keyPEM,
CAPem: certs.caPEM,
}
// authEnabled=false so no token fetch is attempted; we only verify the TLS handshake.
c, err := NewJWTClient("", server.URL, false, mtlsCfg)
if err != nil {
t.Fatalf("NewJWTClient: %v", err)
}

resp, err := c.MakeAuthenticatedRequest("GET", server.URL, nil)
if err != nil {
t.Fatalf("MakeAuthenticatedRequest over mTLS: %v", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
t.Logf("resp.Body.Close(): %v", err)
}
}()

if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d; want %d", resp.StatusCode, http.StatusOK)
}
}
8 changes: 7 additions & 1 deletion plugin/gthulhu/gthulhu.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func init() {
config.APIConfig.PublicKeyPath,
config.APIConfig.BaseURL,
config.APIConfig.AuthEnabled,
config.APIConfig.MTLS,
)
if err != nil {
return nil, err
Expand Down Expand Up @@ -312,8 +313,13 @@ func (g *GthulhuPlugin) InitJWTClient(
publicKeyPath,
apiBaseURL string,
authEnabled bool,
mtlsCfg reg.MTLSConfig,
) error {
g.jwtClient = NewJWTClient(publicKeyPath, apiBaseURL, authEnabled)
client, err := NewJWTClient(publicKeyPath, apiBaseURL, authEnabled, mtlsCfg)
if err != nil {
return err
}
g.jwtClient = client
return nil
}

Expand Down
21 changes: 16 additions & 5 deletions plugin/internal/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,23 @@ type Scheduler struct {
SliceNsMin uint64 `yaml:"slice_ns_min"`
}

// MTLSConfig holds the mutual TLS configuration used for plugin → API server communication.
// CertPem and KeyPem are the plugin's own certificate/key pair signed by the private CA.
// CAPem is the private CA certificate used to verify the API server's certificate.
type MTLSConfig struct {
Enable bool `yaml:"enable"`
CertPem string `yaml:"cert_pem"`
KeyPem string `yaml:"key_pem"`
CAPem string `yaml:"ca_pem"`
Comment on lines +42 to +48
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Field names like CertPem/KeyPem/CAPem don’t follow the usual Go initialism style used elsewhere in the repo (e.g., BaseURL). Since this is a newly introduced exported config type, it’s a good time to rename these to CertPEM/KeyPEM/CAPEM (and update references) to avoid locking in an inconsistent public API.

Suggested change
// CertPem and KeyPem are the plugin's own certificate/key pair signed by the private CA.
// CAPem is the private CA certificate used to verify the API server's certificate.
type MTLSConfig struct {
Enable bool `yaml:"enable"`
CertPem string `yaml:"cert_pem"`
KeyPem string `yaml:"key_pem"`
CAPem string `yaml:"ca_pem"`
// CertPEM and KeyPEM are the plugin's own certificate/key pair signed by the private CA.
// CAPEM is the private CA certificate used to verify the API server's certificate.
type MTLSConfig struct {
Enable bool `yaml:"enable"`
CertPEM string `yaml:"cert_pem"`
KeyPEM string `yaml:"key_pem"`
CAPEM string `yaml:"ca_pem"`

Copilot uses AI. Check for mistakes.
}

type APIConfig struct {
PublicKeyPath string `yaml:"public_key_path"`
BaseURL string `yaml:"base_url"`
Interval int `yaml:"interval"`
Enabled bool `yaml:"enabled"`
AuthEnabled bool `yaml:"auth_enabled"`
PublicKeyPath string `yaml:"public_key_path"`
BaseURL string `yaml:"base_url"`
Interval int `yaml:"interval"`
Enabled bool `yaml:"enabled"`
AuthEnabled bool `yaml:"auth_enabled"`
MTLS MTLSConfig `yaml:"mtls"`
}
Comment on lines +41 to 58
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR metadata says this is only fixing golangci-lint failures in auth_test.go, but this hunk introduces a new mTLS configuration surface (MTLSConfig + APIConfig.MTLS). Please update the PR title/description to reflect the new feature/API change, or split the lint-only fix into a separate PR so reviewers/consumers can assess the behavioral change explicitly.

Copilot uses AI. Check for mistakes.

// SchedConfig holds the configuration parameters for creating a scheduler plugin
Expand Down
1 change: 1 addition & 0 deletions plugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type (
Sched = reg.Sched
CustomScheduler = reg.CustomScheduler
Scheduler = reg.Scheduler
MTLSConfig = reg.MTLSConfig
APIConfig = reg.APIConfig
SchedConfig = reg.SchedConfig
PluginFactory = reg.PluginFactory
Expand Down
Loading