From 01e65f56ab82cf192ffd47dbbdb5a0761f8f8d35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 08:41:04 +0000 Subject: [PATCH 1/7] Initial plan From 84512b49a1494ba36ca0498c128e98412b6f00c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 08:53:58 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20add=20mTLS=20support=20for=20Manage?= =?UTF-8?q?r=20=E2=86=94=20Decision=20Maker=20communication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ianchen0119 <42661015+ianchen0119@users.noreply.github.com> --- README.md | 156 ++++++++++++++++++++++++ config/dm_config.default.toml | 20 ++- config/dm_config.go | 1 + config/manager_config.default.toml | 20 ++- config/manager_config.go | 11 ++ decisionmaker/app/module.go | 3 + decisionmaker/app/rest_app.go | 54 ++++++-- manager/app/module.go | 3 + manager/client/deicison_maker.go | 54 ++++++-- manager/client/deicison_maker_test.go | 169 ++++++++++++++++++++++++++ 10 files changed, 473 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 70ed4aa..e53ecc4 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,13 @@ client_id = "your-client-id" [account] admin_email = "admin@example.com" admin_password = "your-password" + +# mTLS for Manager → Decision Maker communication (optional, default: disabled) +[mtls] +enable = false +cert_pem = "..." # Manager's client certificate (signed by private CA) +key_pem = "..." # Manager's client private key +ca_pem = "..." # Private CA certificate (to verify Decision Maker's server cert) ``` #### Decision Maker Configuration (`config/dm_config.toml`) @@ -252,6 +259,13 @@ level = "info" [token] rsa_private_key_pem = "..." token_duration_hr = 24 + +# mTLS server for Manager → Decision Maker communication (optional, default: disabled) +[mtls] +enable = false +cert_pem = "..." # Decision Maker's server certificate (signed by private CA) +key_pem = "..." # Decision Maker's server private key +ca_pem = "..." # Private CA certificate (to verify Manager's client cert) ``` ### 3. Start Services @@ -276,6 +290,148 @@ go run main.go decisionmaker -c dm_config -d /path/to/config Please refer to https://github.com/Gthulhu/chart?tab=readme-ov-file#testing for testing the API endpoints using curl. +## mTLS Setup: Manager ↔ Decision Maker + +The Manager communicates with every Decision Maker node using **mutual TLS (mTLS)**. Both sides authenticate each other with certificates signed by a shared **private CA**, so neither plain-text traffic nor untrusted connections are accepted. + +> **Note**: The Manager's external HTTP API (web GUI, `/api/v1/…`) intentionally remains plain HTTP. In a production cluster this endpoint is typically exposed through a Kubernetes Ingress with TLS termination. + +### Why mTLS? + +Scheduling decisions affect the Linux kernel scheduler on every node. A compromised connection between the Manager and a Decision Maker could allow an attacker to manipulate per-process CPU priorities. mTLS provides: + +- **Server authentication** – the Manager verifies it is talking to a genuine Decision Maker. +- **Client authentication** – the Decision Maker verifies only the authorised Manager can push intents. +- **Encrypted channel** – all scheduling intents are protected in transit. + +### Step-by-step: Generate certificates with a private CA + +The commands below use only the OpenSSL CLI. Replace `` with the actual IP or hostname of each Decision Maker node. + +#### 1. Create the private CA + +```bash +# Generate CA private key (EC P-256 recommended; RSA-4096 also works) +openssl ecparam -name prime256v1 -genkey -noout -out ca.key + +# Self-signed CA certificate (10-year validity) +openssl req -new -x509 -days 3650 \ + -key ca.key \ + -out ca.crt \ + -subj "/CN=Gthulhu-Private-CA" +``` + +#### 2. Generate the Manager client certificate + +```bash +# Manager private key +openssl ecparam -name prime256v1 -genkey -noout -out manager.key + +# Certificate signing request +openssl req -new \ + -key manager.key \ + -out manager.csr \ + -subj "/CN=gthulhu-manager" + +# Sign with the private CA (2-year validity, client-auth EKU) +openssl x509 -req -days 730 \ + -in manager.csr \ + -CA ca.crt -CAkey ca.key -CAcreateserial \ + -extfile <(printf "extendedKeyUsage=clientAuth") \ + -out manager.crt +``` + +#### 3. Generate a Decision Maker server certificate + +Repeat for each DM node, setting the correct IP/DNS in `subjectAltName`. + +```bash +# Decision Maker private key +openssl ecparam -name prime256v1 -genkey -noout -out dm.key + +# CSR +openssl req -new \ + -key dm.key \ + -out dm.csr \ + -subj "/CN=gthulhu-decisionmaker" + +# Sign with the private CA (2-year validity, server-auth + client-auth EKUs + SAN) +openssl x509 -req -days 730 \ + -in dm.csr \ + -CA ca.crt -CAkey ca.key -CAcreateserial \ + -extfile <(printf "subjectAltName=IP:\nextendedKeyUsage=serverAuth,clientAuth") \ + -out dm.crt +``` + +#### 4. Embed certificates in configuration + +Paste the PEM file contents into the respective config files. + +**`config/manager_config.toml`** + +```toml +[mtls] +enable = true +cert_pem = """ +-----BEGIN CERTIFICATE----- + +-----END CERTIFICATE----- +""" +key_pem = """ +-----BEGIN EC PRIVATE KEY----- + +-----END EC PRIVATE KEY----- +""" +ca_pem = """ +-----BEGIN CERTIFICATE----- + +-----END CERTIFICATE----- +""" +``` + +**`config/dm_config.toml`** + +```toml +[mtls] +enable = true +cert_pem = """ +-----BEGIN CERTIFICATE----- + +-----END CERTIFICATE----- +""" +key_pem = """ +-----BEGIN EC PRIVATE KEY----- + +-----END EC PRIVATE KEY----- +""" +ca_pem = """ +-----BEGIN CERTIFICATE----- + +-----END CERTIFICATE----- +""" +``` + +#### 5. Verify + +Start both services and confirm the Decision Maker log contains: + +``` +starting dm server with mTLS on port :8080 +``` + +And the Manager log shows successful intent reconciliation without TLS errors. + +### Kubernetes: mounting certificates as Secrets + +In a production deployment, store PEM content in Kubernetes Secrets and mount them as environment variables or files, then reference them in the TOML config. + +```bash +kubectl create secret generic gthulhu-mtls-certs \ + --from-file=ca.crt \ + --from-file=manager.crt \ + --from-file=manager.key +``` + ## Kubernetes Deployment ### Deployment Architecture diff --git a/config/dm_config.default.toml b/config/dm_config.default.toml index adae6aa..4f39669 100644 --- a/config/dm_config.default.toml +++ b/config/dm_config.default.toml @@ -61,4 +61,22 @@ spxkAVwlY6g1ZER7IFXlzhz6wYuDBayRhA/2zBPgtGesfpTd7H24AlJ6qB+mThHs X6m7Mp9nAMhRyXhULslO3trWFbFCa2dbQkDSyBRvsb2HZtztoLVyo1mtUg== -----END RSA PRIVATE KEY----- """ -token_duration_hr = 24 \ No newline at end of file +token_duration_hr = 24 + +[mtls] +enable = false +cert_pem = """ +-----BEGIN CERTIFICATE----- +YOUR_DM_CERTIFICATE_HERE +-----END CERTIFICATE----- +""" +key_pem = """ +-----BEGIN EC PRIVATE KEY----- +YOUR_DM_PRIVATE_KEY_HERE +-----END EC PRIVATE KEY----- +""" +ca_pem = """ +-----BEGIN CERTIFICATE----- +YOUR_CA_CERTIFICATE_HERE +-----END CERTIFICATE----- +""" \ No newline at end of file diff --git a/config/dm_config.go b/config/dm_config.go index a640a1b..edda064 100644 --- a/config/dm_config.go +++ b/config/dm_config.go @@ -10,6 +10,7 @@ type DecisionMakerConfig struct { Server ServerConfig `mapstructure:"server"` Logging LoggingConfig `mapstructure:"logging"` Token TokenConfig `mapstructure:"token"` + MTLS MTLSConfig `mapstructure:"mtls"` } var ( diff --git a/config/manager_config.default.toml b/config/manager_config.default.toml index 9a0d3db..2036d6d 100644 --- a/config/manager_config.default.toml +++ b/config/manager_config.default.toml @@ -98,4 +98,22 @@ admin_password = "your-password-here" [k8s] kube_config_path = "/path/to/kubeconfig" -in_cluster = false \ No newline at end of file +in_cluster = false + +[mtls] +enable = false +cert_pem = """ +-----BEGIN CERTIFICATE----- +YOUR_MANAGER_CERTIFICATE_HERE +-----END CERTIFICATE----- +""" +key_pem = """ +-----BEGIN EC PRIVATE KEY----- +YOUR_MANAGER_PRIVATE_KEY_HERE +-----END EC PRIVATE KEY----- +""" +ca_pem = """ +-----BEGIN CERTIFICATE----- +YOUR_CA_CERTIFICATE_HERE +-----END CERTIFICATE----- +""" \ No newline at end of file diff --git a/config/manager_config.go b/config/manager_config.go index 729f9c6..47860a9 100644 --- a/config/manager_config.go +++ b/config/manager_config.go @@ -36,6 +36,17 @@ type ManageConfig struct { Key KeyConfig `mapstructure:"key"` Account AccountConfig `mapstructure:"account"` K8S K8SConfig `mapstructure:"k8s"` + MTLS MTLSConfig `mapstructure:"mtls"` +} + +// MTLSConfig holds the mutual TLS configuration used for Manager ↔ Decision Maker communication. +// CertPem and KeyPem are the service's own certificate/key pair signed by the private CA. +// CAPem is the private CA certificate used to verify the peer's certificate. +type MTLSConfig struct { + Enable bool `mapstructure:"enable"` + CertPem SecretValue `mapstructure:"cert_pem"` + KeyPem SecretValue `mapstructure:"key_pem"` + CAPem SecretValue `mapstructure:"ca_pem"` } type MongoDBConfig struct { diff --git a/decisionmaker/app/module.go b/decisionmaker/app/module.go index b6b40c2..21e477f 100644 --- a/decisionmaker/app/module.go +++ b/decisionmaker/app/module.go @@ -19,6 +19,9 @@ func ConfigModule(cfg config.DecisionMakerConfig) (fx.Option, error) { fx.Provide(func(dmCfg config.DecisionMakerConfig) config.TokenConfig { return dmCfg.Token }), + fx.Provide(func(dmCfg config.DecisionMakerConfig) config.MTLSConfig { + return dmCfg.MTLS + }), ), nil } diff --git a/decisionmaker/app/rest_app.go b/decisionmaker/app/rest_app.go index da79637..aeeb456 100644 --- a/decisionmaker/app/rest_app.go +++ b/decisionmaker/app/rest_app.go @@ -2,6 +2,10 @@ package app import ( "context" + "crypto/tls" + "crypto/x509" + "fmt" + "net" "github.com/Gthulhu/api/config" "github.com/Gthulhu/api/decisionmaker/rest" @@ -32,11 +36,11 @@ func NewRestApp(configName string, configDirPath string) (*fx.App, error) { return app, nil } -func StartRestApp(lc fx.Lifecycle, cfg config.ServerConfig, handler *rest.Handler) error { +func StartRestApp(lc fx.Lifecycle, cfg config.ServerConfig, mtlsCfg config.MTLSConfig, handler *rest.Handler) error { engine := echo.New() - handler.SetupRoutes(engine) - - // TODO: setup middleware, logging, etc. + if err := handler.SetupRoutes(engine); err != nil { + return err + } lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { @@ -45,9 +49,15 @@ func StartRestApp(lc fx.Lifecycle, cfg config.ServerConfig, handler *rest.Handle serverHost = ":8082" } go func() { - logger.Logger(ctx).Info().Msgf("starting dm server on port %s", serverHost) - if err := engine.Start(serverHost); err != nil { - logger.Logger(ctx).Fatal().Err(err).Msgf("start rest server fail on port %s", serverHost) + if mtlsCfg.Enable { + if err := startTLSServer(ctx, engine, serverHost, mtlsCfg); err != nil { + logger.Logger(ctx).Fatal().Err(err).Msgf("start dm rest server with mTLS fail on port %s", serverHost) + } + } else { + logger.Logger(ctx).Info().Msgf("starting dm server on port %s", serverHost) + if err := engine.Start(serverHost); err != nil { + logger.Logger(ctx).Fatal().Err(err).Msgf("start rest server fail on port %s", serverHost) + } } }() return nil @@ -60,3 +70,33 @@ func StartRestApp(lc fx.Lifecycle, cfg config.ServerConfig, handler *rest.Handle return nil } + +// startTLSServer starts the Echo server with mTLS: the server presents its own certificate and +// requires the connecting client (Manager) to present a certificate signed by the shared CA. +func startTLSServer(ctx context.Context, engine *echo.Echo, addr string, mtlsCfg config.MTLSConfig) error { + cert, err := tls.X509KeyPair([]byte(mtlsCfg.CertPem.Value()), []byte(mtlsCfg.KeyPem.Value())) + if err != nil { + return fmt.Errorf("load mTLS server certificate: %w", err) + } + + caPool := x509.NewCertPool() + if !caPool.AppendCertsFromPEM([]byte(mtlsCfg.CAPem.Value())) { + return fmt.Errorf("parse mTLS CA certificate") + } + + tlsCfg := &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: caPool, + } + + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("create listener: %w", err) + } + tlsListener := tls.NewListener(ln, tlsCfg) + engine.Listener = tlsListener + + logger.Logger(ctx).Info().Msgf("starting dm server with mTLS on port %s", addr) + return engine.Start("") +} diff --git a/manager/app/module.go b/manager/app/module.go index 508854d..454ca74 100644 --- a/manager/app/module.go +++ b/manager/app/module.go @@ -33,6 +33,9 @@ func ConfigModule(cfg config.ManageConfig) (fx.Option, error) { fx.Provide(func(managerCfg config.ManageConfig) config.K8SConfig { return managerCfg.K8S }), + fx.Provide(func(managerCfg config.ManageConfig) config.MTLSConfig { + return managerCfg.MTLS + }), ), nil } diff --git a/manager/client/deicison_maker.go b/manager/client/deicison_maker.go index ccfca93..a39ab8a 100644 --- a/manager/client/deicison_maker.go +++ b/manager/client/deicison_maker.go @@ -3,6 +3,8 @@ package client import ( "bytes" "context" + "crypto/tls" + "crypto/x509" "encoding/json" "fmt" "net/http" @@ -16,23 +18,57 @@ import ( "github.com/Gthulhu/api/pkg/logger" ) -func NewDecisionMakerClient(keyConfig config.KeyConfig) domain.DecisionMakerAdapter { +func NewDecisionMakerClient(keyConfig config.KeyConfig, mtlsCfg config.MTLSConfig) (domain.DecisionMakerAdapter, error) { + httpClient := http.DefaultClient + + if mtlsCfg.Enable { + cert, err := tls.X509KeyPair([]byte(mtlsCfg.CertPem.Value()), []byte(mtlsCfg.KeyPem.Value())) + if err != nil { + return nil, fmt.Errorf("load mTLS client certificate: %w", err) + } + + caPool := x509.NewCertPool() + if !caPool.AppendCertsFromPEM([]byte(mtlsCfg.CAPem.Value())) { + return nil, fmt.Errorf("parse mTLS CA certificate") + } + + tlsCfg := &tls.Config{ + Certificates: []tls.Certificate{cert}, + RootCAs: caPool, + } + httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsCfg, + }, + } + } + return &DecisionMakerClient{ - Client: http.DefaultClient, + Client: httpClient, + mtlsEnabled: mtlsCfg.Enable, tokenPublicKey: keyConfig.DMPublicKeyPem.Value(), clientID: keyConfig.ClientID, tokenCache: cache.New[string, string](), - } + }, nil } type DecisionMakerClient struct { *http.Client + mtlsEnabled bool tokenPublicKey string clientID string tokenCache *cache.Cache[string, string] } +// scheme returns "https" when mTLS is enabled, "http" otherwise. +func (dm *DecisionMakerClient) scheme() string { + if dm.mtlsEnabled { + return "https" + } + return "http" +} + func (dm *DecisionMakerClient) SendSchedulingIntent(ctx context.Context, decisionMaker *domain.DecisionMakerPod, intents []*domain.ScheduleIntent) error { token, err := dm.GetToken(ctx, decisionMaker) if err != nil { @@ -61,7 +97,7 @@ func (dm *DecisionMakerClient) SendSchedulingIntent(ctx context.Context, decisio if err != nil { return err } - endpoint := "http://" + decisionMaker.Host + ":" + strconv.Itoa(decisionMaker.Port) + "/api/v1/intents" + endpoint := dm.scheme() + "://" + decisionMaker.Host + ":" + strconv.Itoa(decisionMaker.Port) + "/api/v1/intents" req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(jsonBody)) if err != nil { return err @@ -85,7 +121,7 @@ func (dm *DecisionMakerClient) GetIntentMerkleRoot(ctx context.Context, decision return "", err } - endpoint := "http://" + decisionMaker.Host + ":" + strconv.Itoa(decisionMaker.Port) + "/api/v1/intents/merkle" + endpoint := dm.scheme() + "://" + decisionMaker.Host + ":" + strconv.Itoa(decisionMaker.Port) + "/api/v1/intents/merkle" req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return "", err @@ -124,7 +160,7 @@ func (dm *DecisionMakerClient) GetToken(ctx context.Context, decisionMaker *doma if err != nil { return "", err } - endpoint := "http://" + decisionMaker.Host + ":" + strconv.Itoa(decisionMaker.Port) + "/api/v1/auth/token" + endpoint := dm.scheme() + "://" + decisionMaker.Host + ":" + strconv.Itoa(decisionMaker.Port) + "/api/v1/auth/token" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(jsonBody)) if err != nil { return "", err @@ -169,7 +205,7 @@ func (dm *DecisionMakerClient) DeleteSchedulingIntents(ctx context.Context, deci if err != nil { return err } - endpoint := "http://" + decisionMaker.Host + ":" + strconv.Itoa(decisionMaker.Port) + "/api/v1/intents" + endpoint := dm.scheme() + "://" + decisionMaker.Host + ":" + strconv.Itoa(decisionMaker.Port) + "/api/v1/intents" httpReq, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, bytes.NewBuffer(jsonBody)) if err != nil { return err @@ -196,7 +232,7 @@ func (dm *DecisionMakerClient) DeleteSchedulingIntents(ctx context.Context, deci if err != nil { return err } - endpoint := "http://" + decisionMaker.Host + ":" + strconv.Itoa(decisionMaker.Port) + "/api/v1/intents" + endpoint := dm.scheme() + "://" + decisionMaker.Host + ":" + strconv.Itoa(decisionMaker.Port) + "/api/v1/intents" httpReq, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, bytes.NewBuffer(jsonBody)) if err != nil { return err @@ -222,7 +258,7 @@ func (dm *DecisionMakerClient) GetPodPIDMapping(ctx context.Context, decisionMak return nil, err } - endpoint := "http://" + decisionMaker.Host + ":" + strconv.Itoa(decisionMaker.Port) + "/api/v1/pods/pids" + endpoint := dm.scheme() + "://" + decisionMaker.Host + ":" + strconv.Itoa(decisionMaker.Port) + "/api/v1/pods/pids" req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err diff --git a/manager/client/deicison_maker_test.go b/manager/client/deicison_maker_test.go index 9b5832c..1f3bbf5 100644 --- a/manager/client/deicison_maker_test.go +++ b/manager/client/deicison_maker_test.go @@ -2,14 +2,24 @@ package client import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" "net" "net/http" "net/http/httptest" "net/url" "strconv" "testing" + "time" cache "github.com/Code-Hex/go-generics-cache" + "github.com/Gthulhu/api/config" "github.com/Gthulhu/api/manager/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -89,3 +99,162 @@ func newDecisionMakerPodFromServerURL(t *testing.T, rawURL string) *domain.Decis State: domain.NodeStateOnline, } } + +func TestNewDecisionMakerClientMTLSDisabled(t *testing.T) { + keyConfig := config.KeyConfig{} + mtlsCfg := config.MTLSConfig{Enable: false} + + c, err := NewDecisionMakerClient(keyConfig, mtlsCfg) + require.NoError(t, err) + require.NotNil(t, c) + + dc := c.(*DecisionMakerClient) + assert.False(t, dc.mtlsEnabled) + assert.Equal(t, "http", dc.scheme()) +} + +func TestNewDecisionMakerClientMTLSBadCert(t *testing.T) { + mtlsCfg := config.MTLSConfig{ + Enable: true, + CertPem: "not-valid-pem", + KeyPem: "not-valid-pem", + CAPem: "not-valid-pem", + } + _, err := NewDecisionMakerClient(config.KeyConfig{}, mtlsCfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "load mTLS client certificate") +} + +func TestNewDecisionMakerClientMTLSBadCA(t *testing.T) { + certs := generateTestCerts(t) + + mtlsCfg := config.MTLSConfig{ + Enable: true, + CertPem: config.SecretValue(certs.certPEM), + KeyPem: config.SecretValue(certs.keyPEM), + CAPem: config.SecretValue("not-a-valid-ca-pem"), + } + _, err := NewDecisionMakerClient(config.KeyConfig{}, mtlsCfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse mTLS CA certificate") +} + +func TestDecisionMakerClientMTLSEnabled(t *testing.T) { + certs := generateTestCerts(t) + + mtlsCfg := config.MTLSConfig{ + Enable: true, + CertPem: config.SecretValue(certs.certPEM), + KeyPem: config.SecretValue(certs.keyPEM), + CAPem: config.SecretValue(certs.caPEM), + } + c, err := NewDecisionMakerClient(config.KeyConfig{}, mtlsCfg) + require.NoError(t, err) + require.NotNil(t, c) + + dc := c.(*DecisionMakerClient) + assert.True(t, dc.mtlsEnabled) + assert.Equal(t, "https", dc.scheme()) +} + +func TestDecisionMakerClientMTLSEndToEnd(t *testing.T) { + const cachedToken = "cached-token" + const rootHash = "mtls-root-hash" + + certs := generateTestCerts(t) + + // Build mTLS server (requires client cert signed by the CA) + serverCert, err := tls.X509KeyPair([]byte(certs.certPEM), []byte(certs.keyPEM)) + require.NoError(t, err) + caPool := x509.NewCertPool() + require.True(t, caPool.AppendCertsFromPEM([]byte(certs.caPEM))) + + 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) + _, _ = w.Write([]byte(`{"success":true,"data":{"rootHash":"` + rootHash + `"},"timestamp":"2026-01-01T00:00:00Z"}`)) + })) + server.TLS = serverTLSCfg + server.StartTLS() + defer server.Close() + + // Build mTLS client + mtlsCfg := config.MTLSConfig{ + Enable: true, + CertPem: config.SecretValue(certs.certPEM), + KeyPem: config.SecretValue(certs.keyPEM), + CAPem: config.SecretValue(certs.caPEM), + } + c, err := NewDecisionMakerClient(config.KeyConfig{}, mtlsCfg) + require.NoError(t, err) + + dm := newDecisionMakerPodFromServerURL(t, server.URL) + + // Inject cached token so GetIntentMerkleRoot skips GetToken + dc := c.(*DecisionMakerClient) + dc.tokenCache.Set(dm.NodeID, cachedToken) + + got, err := dc.GetIntentMerkleRoot(context.Background(), dm) + require.NoError(t, err) + assert.Equal(t, rootHash, got) +} + +// testCerts holds PEM-encoded self-signed CA + leaf cert for unit testing. +type testCerts struct { + caPEM string + certPEM string + keyPEM string +} + +// generateTestCerts creates a minimal self-signed CA and a leaf cert/key signed by it. +func generateTestCerts(t *testing.T) testCerts { + t.Helper() + + // Use a fixed time window so tests remain deterministic regardless of when they run. + notBefore := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + notAfter := time.Date(2035, 1, 1, 0, 0, 0, 0, time.UTC) + + // Generate CA key + cert + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, 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) + require.NoError(t, err) + caCert, err := x509.ParseCertificate(caDER) + require.NoError(t, 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) + require.NoError(t, 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) + require.NoError(t, err) + certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER})) + + leafKeyDER, err := x509.MarshalECPrivateKey(leafKey) + require.NoError(t, err) + keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: leafKeyDER})) + + return testCerts{caPEM: caPEM, certPEM: certPEM, keyPEM: keyPEM} +} From 36119a38ba0183ead920e1fa6556c6610b85d0d6 Mon Sep 17 00:00:00 2001 From: Ian Chen Date: Sun, 22 Feb 2026 22:27:18 +0800 Subject: [PATCH 3/7] Update rest_app.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- decisionmaker/app/rest_app.go | 1 + 1 file changed, 1 insertion(+) diff --git a/decisionmaker/app/rest_app.go b/decisionmaker/app/rest_app.go index aeeb456..1f9cd59 100644 --- a/decisionmaker/app/rest_app.go +++ b/decisionmaker/app/rest_app.go @@ -88,6 +88,7 @@ func startTLSServer(ctx context.Context, engine *echo.Echo, addr string, mtlsCfg Certificates: []tls.Certificate{cert}, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: caPool, + MinVersion: tls.VersionTLS12, } ln, err := net.Listen("tcp", addr) From a0dbbb5de076be45a4231d523b17cbce4263d5fe Mon Sep 17 00:00:00 2001 From: Ian Chen Date: Sun, 22 Feb 2026 22:27:46 +0800 Subject: [PATCH 4/7] chore: Update rest_app.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- decisionmaker/app/rest_app.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/decisionmaker/app/rest_app.go b/decisionmaker/app/rest_app.go index 1f9cd59..03ba76f 100644 --- a/decisionmaker/app/rest_app.go +++ b/decisionmaker/app/rest_app.go @@ -80,8 +80,12 @@ func startTLSServer(ctx context.Context, engine *echo.Echo, addr string, mtlsCfg } caPool := x509.NewCertPool() - if !caPool.AppendCertsFromPEM([]byte(mtlsCfg.CAPem.Value())) { - return fmt.Errorf("parse mTLS CA certificate") + caPEM := mtlsCfg.CAPem.Value() + if caPEM == "" { + return fmt.Errorf("mTLS server CA PEM is empty; cannot configure client certificate validation") + } + if !caPool.AppendCertsFromPEM([]byte(caPEM)) { + return fmt.Errorf("no CA certificates found in mTLS server CA PEM; failed to parse CA bundle") } tlsCfg := &tls.Config{ From 4ab7dd3507ac0ad9c7ceb36190855f3cddbcd761 Mon Sep 17 00:00:00 2001 From: Ian Chen Date: Sun, 22 Feb 2026 22:28:01 +0800 Subject: [PATCH 5/7] Update deicison_maker.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- manager/client/deicison_maker.go | 1 + 1 file changed, 1 insertion(+) diff --git a/manager/client/deicison_maker.go b/manager/client/deicison_maker.go index a39ab8a..87ffb69 100644 --- a/manager/client/deicison_maker.go +++ b/manager/client/deicison_maker.go @@ -35,6 +35,7 @@ func NewDecisionMakerClient(keyConfig config.KeyConfig, mtlsCfg config.MTLSConfi tlsCfg := &tls.Config{ Certificates: []tls.Certificate{cert}, RootCAs: caPool, + MinVersion: tls.VersionTLS12, } httpClient = &http.Client{ Transport: &http.Transport{ From a0ba103ce82d1e0f377a0155f0685a4486961976 Mon Sep 17 00:00:00 2001 From: Ian Chen Date: Sun, 22 Feb 2026 22:28:16 +0800 Subject: [PATCH 6/7] chore: Update deicison_maker.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- manager/client/deicison_maker.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/manager/client/deicison_maker.go b/manager/client/deicison_maker.go index 87ffb69..2661366 100644 --- a/manager/client/deicison_maker.go +++ b/manager/client/deicison_maker.go @@ -37,10 +37,16 @@ func NewDecisionMakerClient(keyConfig config.KeyConfig, mtlsCfg config.MTLSConfi 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 + httpClient = &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: tlsCfg, - }, + Transport: mtlsTransport, } } From c423f844dc052b2a36ca307350866169f02ef3eb Mon Sep 17 00:00:00 2001 From: Ian Chen Date: Sun, 22 Feb 2026 22:28:57 +0800 Subject: [PATCH 7/7] fix: update deicison_maker_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- manager/client/deicison_maker_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manager/client/deicison_maker_test.go b/manager/client/deicison_maker_test.go index 1f3bbf5..3368eef 100644 --- a/manager/client/deicison_maker_test.go +++ b/manager/client/deicison_maker_test.go @@ -215,8 +215,8 @@ func generateTestCerts(t *testing.T) testCerts { t.Helper() // Use a fixed time window so tests remain deterministic regardless of when they run. - notBefore := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) - notAfter := time.Date(2035, 1, 1, 0, 0, 0, 0, time.UTC) + 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)