Skip to content

Commit 8f174de

Browse files
Copilotianchen0119
andauthored
fix: resolve golangci-lint failures in auth_test.go (#16)
* Initial plan * feat: add mTLS support to plugin API client (JWTClient) Co-authored-by: ianchen0119 <42661015+ianchen0119@users.noreply.github.com> * fix: resolve lint errors in auth_test.go (errcheck + gofmt -s) Co-authored-by: ianchen0119 <42661015+ianchen0119@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ianchen0119 <42661015+ianchen0119@users.noreply.github.com>
1 parent 2c7a28a commit 8f174de

5 files changed

Lines changed: 295 additions & 13 deletions

File tree

plugin/gthulhu/auth.go

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package gthulhu
22

33
import (
44
"bytes"
5+
"crypto/tls"
56
"crypto/x509"
67
"encoding/json"
78
"encoding/pem"
@@ -11,6 +12,8 @@ import (
1112
"os"
1213
"strings"
1314
"time"
15+
16+
reg "github.com/Gthulhu/plugin/plugin/internal/registry"
1417
)
1518

1619
// TokenRequest represents the request structure for JWT token generation
@@ -47,20 +50,65 @@ type JWTClient struct {
4750
authEnabled bool
4851
}
4952

50-
// NewJWTClient creates a new JWT client
53+
// NewJWTClient creates a new JWT client. When mtlsCfg.Enable is true the
54+
// underlying HTTP client is configured with mutual TLS so the plugin
55+
// authenticates itself to the API server and verifies the server certificate
56+
// against the shared CA.
5157
func NewJWTClient(
5258
publicKeyPath,
5359
apiBaseURL string,
5460
authEnabled bool,
55-
) *JWTClient {
61+
mtlsCfg reg.MTLSConfig,
62+
) (*JWTClient, error) {
63+
httpClient := &http.Client{
64+
Timeout: 30 * time.Second,
65+
}
66+
67+
if mtlsCfg.Enable {
68+
tlsClient, err := buildMTLSClient(mtlsCfg)
69+
if err != nil {
70+
return nil, err
71+
}
72+
httpClient = tlsClient
73+
}
74+
5675
return &JWTClient{
5776
publicKeyPath: publicKeyPath,
5877
apiBaseURL: strings.TrimSuffix(apiBaseURL, "/"),
59-
httpClient: &http.Client{
60-
Timeout: 30 * time.Second,
61-
},
62-
authEnabled: authEnabled,
78+
httpClient: httpClient,
79+
authEnabled: authEnabled,
80+
}, nil
81+
}
82+
83+
// buildMTLSClient constructs an HTTP client with mutual TLS configured.
84+
func buildMTLSClient(mtlsCfg reg.MTLSConfig) (*http.Client, error) {
85+
cert, err := tls.X509KeyPair([]byte(mtlsCfg.CertPem), []byte(mtlsCfg.KeyPem))
86+
if err != nil {
87+
return nil, fmt.Errorf("load mTLS client certificate: %w", err)
88+
}
89+
90+
caPool := x509.NewCertPool()
91+
if !caPool.AppendCertsFromPEM([]byte(mtlsCfg.CAPem)) {
92+
return nil, fmt.Errorf("parse mTLS CA certificate")
93+
}
94+
95+
tlsCfg := &tls.Config{
96+
Certificates: []tls.Certificate{cert},
97+
RootCAs: caPool,
98+
MinVersion: tls.VersionTLS12,
6399
}
100+
101+
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
102+
if !ok {
103+
return nil, fmt.Errorf("unexpected default transport type %T", http.DefaultTransport)
104+
}
105+
mtlsTransport := defaultTransport.Clone()
106+
mtlsTransport.TLSClientConfig = tlsCfg
107+
108+
return &http.Client{
109+
Timeout: 30 * time.Second,
110+
Transport: mtlsTransport,
111+
}, nil
64112
}
65113

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

212+
// Preserve any custom transport (e.g. mTLS) already configured on the client.
213+
transport := c.httpClient.Transport
214+
if transport == nil {
215+
transport = http.DefaultTransport
216+
}
217+
164218
// Create a custom transport that adds the Authorization header
165219
client := &http.Client{
166220
Timeout: 30 * time.Second,
167221
Transport: &authenticatedTransport{
168222
token: c.token,
169-
transport: http.DefaultTransport,
223+
transport: transport,
170224
},
171225
}
172226

plugin/gthulhu/auth_test.go

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
package gthulhu
2+
3+
import (
4+
"crypto/ecdsa"
5+
"crypto/elliptic"
6+
"crypto/rand"
7+
"crypto/tls"
8+
"crypto/x509"
9+
"crypto/x509/pkix"
10+
"encoding/pem"
11+
"math/big"
12+
"net"
13+
"net/http"
14+
"net/http/httptest"
15+
"strings"
16+
"testing"
17+
"time"
18+
19+
reg "github.com/Gthulhu/plugin/plugin/internal/registry"
20+
)
21+
22+
// authTestCerts holds PEM-encoded self-signed CA + leaf cert for unit testing.
23+
type authTestCerts struct {
24+
caPEM string
25+
certPEM string
26+
keyPEM string
27+
}
28+
29+
// generateAuthTestCerts creates a minimal self-signed CA and a leaf cert/key signed by it.
30+
func generateAuthTestCerts(t *testing.T) authTestCerts {
31+
t.Helper()
32+
33+
notBefore := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
34+
notAfter := time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)
35+
36+
// Generate CA key + cert
37+
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
38+
if err != nil {
39+
t.Fatalf("generate CA key: %v", err)
40+
}
41+
caTemplate := &x509.Certificate{
42+
SerialNumber: big.NewInt(1),
43+
Subject: pkix.Name{CommonName: "test-ca"},
44+
NotBefore: notBefore,
45+
NotAfter: notAfter,
46+
IsCA: true,
47+
BasicConstraintsValid: true,
48+
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
49+
}
50+
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
51+
if err != nil {
52+
t.Fatalf("create CA cert: %v", err)
53+
}
54+
caCert, err := x509.ParseCertificate(caDER)
55+
if err != nil {
56+
t.Fatalf("parse CA cert: %v", err)
57+
}
58+
caPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}))
59+
60+
// Generate leaf key + cert signed by CA
61+
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
62+
if err != nil {
63+
t.Fatalf("generate leaf key: %v", err)
64+
}
65+
leafTemplate := &x509.Certificate{
66+
SerialNumber: big.NewInt(2),
67+
Subject: pkix.Name{CommonName: "test-leaf"},
68+
NotBefore: notBefore,
69+
NotAfter: notAfter,
70+
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
71+
KeyUsage: x509.KeyUsageDigitalSignature,
72+
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
73+
}
74+
leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, caCert, &leafKey.PublicKey, caKey)
75+
if err != nil {
76+
t.Fatalf("create leaf cert: %v", err)
77+
}
78+
certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}))
79+
80+
leafKeyDER, err := x509.MarshalECPrivateKey(leafKey)
81+
if err != nil {
82+
t.Fatalf("marshal leaf key: %v", err)
83+
}
84+
keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: leafKeyDER}))
85+
86+
return authTestCerts{caPEM: caPEM, certPEM: certPEM, keyPEM: keyPEM}
87+
}
88+
89+
func TestNewJWTClientMTLSDisabled(t *testing.T) {
90+
mtlsCfg := reg.MTLSConfig{Enable: false}
91+
c, err := NewJWTClient("", "http://localhost", false, mtlsCfg)
92+
if err != nil {
93+
t.Fatalf("unexpected error: %v", err)
94+
}
95+
if c == nil {
96+
t.Fatal("expected non-nil JWTClient")
97+
}
98+
// Transport should be nil (plain http.Client default)
99+
if c.httpClient.Transport != nil {
100+
t.Errorf("expected nil transport for plain HTTP, got %T", c.httpClient.Transport)
101+
}
102+
}
103+
104+
func TestNewJWTClientMTLSBadCert(t *testing.T) {
105+
mtlsCfg := reg.MTLSConfig{
106+
Enable: true,
107+
CertPem: "not-valid-pem",
108+
KeyPem: "not-valid-pem",
109+
CAPem: "not-valid-pem",
110+
}
111+
_, err := NewJWTClient("", "https://localhost", false, mtlsCfg)
112+
if err == nil {
113+
t.Fatal("expected error for invalid cert PEM, got nil")
114+
}
115+
if !strings.Contains(err.Error(), "load mTLS client certificate") {
116+
t.Errorf("unexpected error message: %v", err)
117+
}
118+
}
119+
120+
func TestNewJWTClientMTLSBadCA(t *testing.T) {
121+
certs := generateAuthTestCerts(t)
122+
mtlsCfg := reg.MTLSConfig{
123+
Enable: true,
124+
CertPem: certs.certPEM,
125+
KeyPem: certs.keyPEM,
126+
CAPem: "not-a-valid-ca-pem",
127+
}
128+
_, err := NewJWTClient("", "https://localhost", false, mtlsCfg)
129+
if err == nil {
130+
t.Fatal("expected error for invalid CA PEM, got nil")
131+
}
132+
if !strings.Contains(err.Error(), "parse mTLS CA certificate") {
133+
t.Errorf("unexpected error message: %v", err)
134+
}
135+
}
136+
137+
func TestNewJWTClientMTLSEnabled(t *testing.T) {
138+
certs := generateAuthTestCerts(t)
139+
mtlsCfg := reg.MTLSConfig{
140+
Enable: true,
141+
CertPem: certs.certPEM,
142+
KeyPem: certs.keyPEM,
143+
CAPem: certs.caPEM,
144+
}
145+
c, err := NewJWTClient("", "https://localhost", false, mtlsCfg)
146+
if err != nil {
147+
t.Fatalf("unexpected error: %v", err)
148+
}
149+
if c == nil {
150+
t.Fatal("expected non-nil JWTClient")
151+
}
152+
// Transport should be an mTLS-capable *http.Transport
153+
if _, ok := c.httpClient.Transport.(*http.Transport); !ok {
154+
t.Errorf("expected *http.Transport, got %T", c.httpClient.Transport)
155+
}
156+
}
157+
158+
// TestNewJWTClientMTLSEndToEnd verifies that a JWTClient configured with mTLS
159+
// can successfully complete a round-trip against an mTLS-enforcing httptest server.
160+
func TestNewJWTClientMTLSEndToEnd(t *testing.T) {
161+
certs := generateAuthTestCerts(t)
162+
163+
// Build mTLS test server that requires a client cert signed by our CA.
164+
serverCert, err := tls.X509KeyPair([]byte(certs.certPEM), []byte(certs.keyPEM))
165+
if err != nil {
166+
t.Fatalf("load server cert: %v", err)
167+
}
168+
caPool := x509.NewCertPool()
169+
if !caPool.AppendCertsFromPEM([]byte(certs.caPEM)) {
170+
t.Fatal("append CA cert")
171+
}
172+
serverTLSCfg := &tls.Config{
173+
Certificates: []tls.Certificate{serverCert},
174+
ClientAuth: tls.RequireAndVerifyClientCert,
175+
ClientCAs: caPool,
176+
}
177+
178+
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
179+
w.WriteHeader(http.StatusOK)
180+
}))
181+
server.TLS = serverTLSCfg
182+
server.StartTLS()
183+
defer server.Close()
184+
185+
mtlsCfg := reg.MTLSConfig{
186+
Enable: true,
187+
CertPem: certs.certPEM,
188+
KeyPem: certs.keyPEM,
189+
CAPem: certs.caPEM,
190+
}
191+
// authEnabled=false so no token fetch is attempted; we only verify the TLS handshake.
192+
c, err := NewJWTClient("", server.URL, false, mtlsCfg)
193+
if err != nil {
194+
t.Fatalf("NewJWTClient: %v", err)
195+
}
196+
197+
resp, err := c.MakeAuthenticatedRequest("GET", server.URL, nil)
198+
if err != nil {
199+
t.Fatalf("MakeAuthenticatedRequest over mTLS: %v", err)
200+
}
201+
defer func() {
202+
if err := resp.Body.Close(); err != nil {
203+
t.Logf("resp.Body.Close(): %v", err)
204+
}
205+
}()
206+
207+
if resp.StatusCode != http.StatusOK {
208+
t.Errorf("status = %d; want %d", resp.StatusCode, http.StatusOK)
209+
}
210+
}

plugin/gthulhu/gthulhu.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func init() {
3434
config.APIConfig.PublicKeyPath,
3535
config.APIConfig.BaseURL,
3636
config.APIConfig.AuthEnabled,
37+
config.APIConfig.MTLS,
3738
)
3839
if err != nil {
3940
return nil, err
@@ -312,8 +313,13 @@ func (g *GthulhuPlugin) InitJWTClient(
312313
publicKeyPath,
313314
apiBaseURL string,
314315
authEnabled bool,
316+
mtlsCfg reg.MTLSConfig,
315317
) error {
316-
g.jwtClient = NewJWTClient(publicKeyPath, apiBaseURL, authEnabled)
318+
client, err := NewJWTClient(publicKeyPath, apiBaseURL, authEnabled, mtlsCfg)
319+
if err != nil {
320+
return err
321+
}
322+
g.jwtClient = client
317323
return nil
318324
}
319325

plugin/internal/registry/registry.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,23 @@ type Scheduler struct {
3838
SliceNsMin uint64 `yaml:"slice_ns_min"`
3939
}
4040

41+
// MTLSConfig holds the mutual TLS configuration used for plugin → API server communication.
42+
// CertPem and KeyPem are the plugin's own certificate/key pair signed by the private CA.
43+
// CAPem is the private CA certificate used to verify the API server's certificate.
44+
type MTLSConfig struct {
45+
Enable bool `yaml:"enable"`
46+
CertPem string `yaml:"cert_pem"`
47+
KeyPem string `yaml:"key_pem"`
48+
CAPem string `yaml:"ca_pem"`
49+
}
50+
4151
type APIConfig struct {
42-
PublicKeyPath string `yaml:"public_key_path"`
43-
BaseURL string `yaml:"base_url"`
44-
Interval int `yaml:"interval"`
45-
Enabled bool `yaml:"enabled"`
46-
AuthEnabled bool `yaml:"auth_enabled"`
52+
PublicKeyPath string `yaml:"public_key_path"`
53+
BaseURL string `yaml:"base_url"`
54+
Interval int `yaml:"interval"`
55+
Enabled bool `yaml:"enabled"`
56+
AuthEnabled bool `yaml:"auth_enabled"`
57+
MTLS MTLSConfig `yaml:"mtls"`
4758
}
4859

4960
// SchedConfig holds the configuration parameters for creating a scheduler plugin

plugin/plugin.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type (
1111
Sched = reg.Sched
1212
CustomScheduler = reg.CustomScheduler
1313
Scheduler = reg.Scheduler
14+
MTLSConfig = reg.MTLSConfig
1415
APIConfig = reg.APIConfig
1516
SchedConfig = reg.SchedConfig
1617
PluginFactory = reg.PluginFactory

0 commit comments

Comments
 (0)