From 521163c945e1ac2d28f02ba597ed2499822b0ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 10 Jun 2026 19:22:49 +0200 Subject: [PATCH 1/8] feat: embed cluster CA in kubeconfig via TLS probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kubeconfig for GitHub Actions previously hardcoded insecure-skip-tls-verify, skipping TLS verification entirely. A new backend endpoint (GET /api/cluster/ca) reads the service account CA bundle, probes the API server's TLS certificate, and returns the CA only when the bundle actually verifies the handshake. If the bundle does not work (e.g. a mismatched intermediate after a Let's Encrypt rotation), it is omitted and the runner's system trust store handles verification instead. Signed-off-by: Matej Vašek Co-Authored-By: Claude --- backend/main.go | 81 +++++++ backend/main_test.go | 198 ++++++++++++++++++ .../cluster/OcpClusterService.test.ts | 22 +- .../services/cluster/OcpClusterService.ts | 30 ++- 4 files changed, 322 insertions(+), 9 deletions(-) create mode 100644 backend/main_test.go diff --git a/backend/main.go b/backend/main.go index ac09335..5d89bfe 100644 --- a/backend/main.go +++ b/backend/main.go @@ -3,8 +3,12 @@ package main import ( "context" "crypto/tls" + "crypto/x509" "embed" + "encoding/base64" "encoding/json" + "encoding/pem" + "errors" "flag" "fmt" "io" @@ -12,10 +16,12 @@ import ( "log" "net" "net/http" + "net/url" "os" "path/filepath" "regexp" "strings" + "time" cigithub "knative.dev/func/pkg/ci/github" "knative.dev/func/pkg/functions" @@ -38,6 +44,7 @@ func main() { mux := http.NewServeMux() mux.HandleFunc("POST /api/function/create", handleFuncCreate) + mux.HandleFunc("GET /api/cluster/ca", handleClusterCA) mux.Handle("/", http.FileServer(http.FS(static))) handler := loggingMiddleware(mux) @@ -218,6 +225,80 @@ func handleFuncCreate(w http.ResponseWriter, r *http.Request) { } } +// saCAPath is the path to the service account CA certificate. +// It is a variable so tests can override it. +var saCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + +func handleClusterCA(w http.ResponseWriter, r *http.Request) { + serverParam := r.URL.Query().Get("server") + if serverParam == "" { + jsonError(w, "missing required query parameter: server", http.StatusBadRequest) + return + } + + parsed, err := url.Parse(serverParam) + if err != nil || parsed.Scheme != "https" { + jsonError(w, "server must be an HTTPS URL", http.StatusBadRequest) + return + } + + host := parsed.Host + if parsed.Port() == "" { + host = host + ":443" + } + + caPEM, err := os.ReadFile(saCAPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ca": nil}) + return + } + jsonError(w, "failed to read CA file: "+err.Error(), http.StatusInternalServerError) + return + } + + pool := x509.NewCertPool() + rest := caPEM + var found bool + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type == "CERTIFICATE" { + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + jsonError(w, "failed to parse CA certificate: "+err.Error(), http.StatusInternalServerError) + return + } + pool.AddCert(cert) + found = true + } + } + if !found { + jsonError(w, "no valid certificates found in CA file", http.StatusInternalServerError) + return + } + + dialer := &net.Dialer{Timeout: 5 * time.Second} + conn, err := tls.DialWithDialer(dialer, "tcp", host, &tls.Config{ + RootCAs: pool, + }) + if err != nil { + // TLS verification failed, the CA does not match the server cert. + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ca": nil}) + return + } + conn.Close() + + encoded := base64.StdEncoding.EncodeToString(caPEM) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"ca": encoded}) +} + const ocpInternalRegistry = "image-registry.openshift-image-registry.svc:5000/" func generateCIWorkflow(root, runtime, branch, registry string) error { diff --git a/backend/main_test.go b/backend/main_test.go new file mode 100644 index 0000000..9df69fb --- /dev/null +++ b/backend/main_test.go @@ -0,0 +1,198 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +// newTestCA generates a self-signed CA certificate and returns the PEM bytes, +// the parsed certificate, and the private key. +func newTestCA(t *testing.T) ([]byte, *x509.Certificate, *ecdsa.PrivateKey) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + } + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(certDER) + if err != nil { + t.Fatal(err) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + return pemBytes, cert, key +} + +// newTestLeafCert creates a leaf certificate signed by the given CA. +func newTestLeafCert(t *testing.T, ca *x509.Certificate, caKey *ecdsa.PrivateKey) tls.Certificate { + t.Helper() + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "localhost"}, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, ca, &leafKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + return tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: leafKey, + } +} + +// writeCAFile writes PEM data to a temp file and sets saCAPath to it. +// It restores the original saCAPath on cleanup. +func writeCAFile(t *testing.T, pemData []byte) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "ca.crt") + if err := os.WriteFile(path, pemData, 0644); err != nil { + t.Fatal(err) + } + orig := saCAPath + saCAPath = path + t.Cleanup(func() { saCAPath = orig }) +} + +func TestHandleClusterCA_MissingServerParam(t *testing.T) { + req := httptest.NewRequest("GET", "/api/cluster/ca", nil) + w := httptest.NewRecorder() + + handleClusterCA(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + var body map[string]string + json.NewDecoder(w.Body).Decode(&body) + if body["message"] == "" { + t.Error("expected error message in response") + } +} + +func TestHandleClusterCA_NonHTTPS(t *testing.T) { + req := httptest.NewRequest("GET", "/api/cluster/ca?server=http://example.com", nil) + w := httptest.NewRecorder() + + handleClusterCA(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestHandleClusterCA_MissingCAFile(t *testing.T) { + orig := saCAPath + saCAPath = "/nonexistent/path/ca.crt" + t.Cleanup(func() { saCAPath = orig }) + + req := httptest.NewRequest("GET", "/api/cluster/ca?server=https://example.com", nil) + w := httptest.NewRecorder() + + handleClusterCA(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + json.NewDecoder(w.Body).Decode(&body) + if body["ca"] != nil { + t.Errorf("expected ca to be null, got %v", body["ca"]) + } +} + +func TestHandleClusterCA_CAMatchesServer(t *testing.T) { + caPEM, caCert, caKey := newTestCA(t) + leafCert := newTestLeafCert(t, caCert, caKey) + + // Start a TLS server using the leaf cert signed by our CA. + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + ts.TLS = &tls.Config{Certificates: []tls.Certificate{leafCert}} + ts.StartTLS() + defer ts.Close() + + writeCAFile(t, caPEM) + + req := httptest.NewRequest("GET", "/api/cluster/ca?server="+ts.URL, nil) + w := httptest.NewRecorder() + + handleClusterCA(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var body map[string]interface{} + json.NewDecoder(w.Body).Decode(&body) + if body["ca"] == nil { + t.Fatal("expected ca to be a base64 string, got null") + } + caVal, ok := body["ca"].(string) + if !ok || caVal == "" { + t.Fatalf("expected non-empty ca string, got %v", body["ca"]) + } +} + +func TestHandleClusterCA_CADoesNotMatchServer(t *testing.T) { + // Create one CA for the server cert. + _, serverCACert, serverCAKey := newTestCA(t) + leafCert := newTestLeafCert(t, serverCACert, serverCAKey) + + // Create a different CA that we'll give to the handler. + differentCAPEM, _, _ := newTestCA(t) + + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + ts.TLS = &tls.Config{Certificates: []tls.Certificate{leafCert}} + ts.StartTLS() + defer ts.Close() + + writeCAFile(t, differentCAPEM) + + req := httptest.NewRequest("GET", "/api/cluster/ca?server="+ts.URL, nil) + w := httptest.NewRecorder() + + handleClusterCA(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var body map[string]interface{} + json.NewDecoder(w.Body).Decode(&body) + if body["ca"] != nil { + t.Errorf("expected ca to be null when CA doesn't match, got %v", body["ca"]) + } +} diff --git a/src/common/services/cluster/OcpClusterService.test.ts b/src/common/services/cluster/OcpClusterService.test.ts index 769fd45..401666d 100644 --- a/src/common/services/cluster/OcpClusterService.test.ts +++ b/src/common/services/cluster/OcpClusterService.test.ts @@ -16,6 +16,8 @@ describe('OcpClusterService', () => { (window as unknown as Record).SERVER_FLAGS = { kubeAPIServerURL: 'https://api.cluster.example.com:6443', }; + // GET call: cluster CA endpoint + mockGet.mockResolvedValueOnce({ ca: 'dGVzdC1jYQ==' }); // POST calls: SA, Role, RoleBinding, ImageBuilderBinding, TokenRequest mockPost .mockResolvedValueOnce({}) @@ -79,19 +81,36 @@ describe('OcpClusterService', () => { }), ); + // Verify CA endpoint was called + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('/api/cluster/ca?server=')); + // Verify kubeconfig structure const parsed = JSON.parse(kubeconfig); expect(parsed.apiVersion).toBe('v1'); expect(parsed.kind).toBe('Config'); expect(parsed.clusters[0].cluster.server).toBe('https://api.cluster.example.com:6443'); - expect(parsed.clusters[0].cluster['insecure-skip-tls-verify']).toBe(true); + expect(parsed.clusters[0].cluster['certificate-authority-data']).toBe('dGVzdC1jYQ=='); + expect(parsed.clusters[0].cluster['insecure-skip-tls-verify']).toBeUndefined(); expect(parsed.users[0].user.token).toBe('sa-token-value'); expect(parsed.contexts[0].context.namespace).toBe(namespace); }); + it('omits CA fields when cluster uses a publicly trusted certificate', async () => { + mockGet.mockReset().mockResolvedValueOnce({ ca: null }); + + const svc = new OcpClusterService(); + const kubeconfig = await svc.generateKubeconfig(namespace); + + const parsed = JSON.parse(kubeconfig); + expect(parsed.clusters[0].cluster['certificate-authority-data']).toBeUndefined(); + expect(parsed.clusters[0].cluster['insecure-skip-tls-verify']).toBeUndefined(); + expect(parsed.clusters[0].cluster.server).toBe('https://api.cluster.example.com:6443'); + }); + it('treats 409 Conflict (response.status) on SA/Role/RoleBinding as success', async () => { const conflict = Object.assign(new Error('Conflict'), { response: { status: 409 } }); + mockGet.mockReset().mockResolvedValueOnce({ ca: 'dGVzdC1jYQ==' }); mockPost .mockReset() .mockRejectedValueOnce(conflict) // SA already exists @@ -109,6 +128,7 @@ describe('OcpClusterService', () => { it('treats K8s Status object with code 409 as success', async () => { const k8sConflict = { code: 409, reason: 'AlreadyExists', message: 'already exists' }; + mockGet.mockReset().mockResolvedValueOnce({ ca: 'dGVzdC1jYQ==' }); mockPost .mockReset() .mockRejectedValueOnce(k8sConflict) diff --git a/src/common/services/cluster/OcpClusterService.ts b/src/common/services/cluster/OcpClusterService.ts index fc19961..06755cb 100644 --- a/src/common/services/cluster/OcpClusterService.ts +++ b/src/common/services/cluster/OcpClusterService.ts @@ -3,6 +3,7 @@ import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk'; const SA_NAME = 'func-github'; const ROLE_NAME = 'func-github-deployer'; const TOKEN_EXPIRY_SECONDS = 31536000; // 1 year; token rotation is not yet implemented +const PROXY_BASE = '/api/proxy/plugin/console-functions-plugin/backend'; export class OcpClusterService { /** @@ -11,8 +12,11 @@ export class OcpClusterService { * necessary ServiceAccount, Role, and RoleBindings in the target * namespace if they do not already exist. * - * The kubeconfig uses insecure-skip-tls-verify so it works regardless - * of whether the API server uses a publicly trusted or self-signed CA. + * The kubeconfig embeds the cluster CA certificate when the service + * account CA matches the API server's TLS certificate. When it does + * not match (e.g. publicly trusted CA like Let's Encrypt), neither + * certificate-authority-data nor insecure-skip-tls-verify is set, + * letting the runner's system trust store handle verification. */ async generateKubeconfig(namespace: string): Promise { await this.#createServiceAccount(namespace); @@ -22,8 +26,9 @@ export class OcpClusterService { const token = await this.#requestToken(namespace); const apiServerURL = this.#getApiServerURL(); + const ca = await this.#fetchClusterCA(apiServerURL); - return this.#buildKubeconfig(apiServerURL, token, namespace); + return this.#buildKubeconfig(apiServerURL, token, namespace, ca); } async #createServiceAccount(namespace: string): Promise { @@ -116,6 +121,13 @@ export class OcpClusterService { return result.status.token; } + async #fetchClusterCA(server: string): Promise { + const result = await consoleFetchJSON( + `${PROXY_BASE}/api/cluster/ca?server=${encodeURIComponent(server)}`, + ); + return (result as { ca: string | null }).ca; + } + #getApiServerURL(): string { const serverFlags = (window as unknown as Record).SERVER_FLAGS as | { kubeAPIServerURL?: string } @@ -126,16 +138,18 @@ export class OcpClusterService { return serverFlags.kubeAPIServerURL; } - #buildKubeconfig(server: string, token: string, namespace: string): string { + #buildKubeconfig(server: string, token: string, namespace: string, ca: string | null): string { + const clusterEntry: Record = { server }; + if (ca) { + clusterEntry['certificate-authority-data'] = ca; + } + return JSON.stringify({ apiVersion: 'v1', kind: 'Config', clusters: [ { - cluster: { - server, - 'insecure-skip-tls-verify': true, - }, + cluster: clusterEntry, name: 'cluster', }, ], From 51bb40e7903bf1d3a8ead3f8cb5ddcaa42b5b2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 10 Jun 2026 20:21:40 +0200 Subject: [PATCH 2/8] fix: skip public CAs in cluster CA probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a system roots probe before the SA bundle probe. If the API server's cert is already publicly trusted, the CA is not embedded in the kubeconfig. This avoids shipping public CAs that may break after intermediate rotation (e.g. Let's Encrypt) while still embedding private ones that a GitHub Actions runner would not otherwise trust. Signed-off-by: Matej Vašek Co-Authored-By: Claude fixup: correct JSDoc on generateKubeconfig function Co-Authored-By: Claude Signed-off-by: Matej Vašek fix: copy CA trust bundle into ubi9-micro runtime image ubi9-micro ships with no CA certificates, so the system roots TLS probe always failed and every cluster appeared to use a private CA. Copy the RHEL CA bundle from the go-toolset build stage so probe 1 can correctly identify publicly trusted certs. Signed-off-by: Matej Vašek Co-Authored-By: Claude --- Dockerfile | 1 + backend/main.go | 22 ++++++++- backend/main_test.go | 49 +++++++++++++++++-- .../services/cluster/OcpClusterService.ts | 9 ++-- 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6b9c593..474b502 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,7 @@ RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w" -o FROM registry.access.redhat.com/ubi9-micro:latest +COPY --from=go-build /etc/pki/tls/certs/ca-bundle.crt /etc/pki/tls/certs/ca-bundle.crt COPY --from=go-build /opt/app-root/src/backend/plugin-backend /usr/bin/plugin-backend USER 1001 diff --git a/backend/main.go b/backend/main.go index 5d89bfe..d6571fd 100644 --- a/backend/main.go +++ b/backend/main.go @@ -229,6 +229,13 @@ func handleFuncCreate(w http.ResponseWriter, r *http.Request) { // It is a variable so tests can override it. var saCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" +// systemTLSConfig returns the TLS config for the system roots probe. +// In production this returns an empty config (system trust store). +// Tests can override it to inject a custom cert pool. +var systemTLSConfig = func() *tls.Config { + return &tls.Config{} +} + func handleClusterCA(w http.ResponseWriter, r *http.Request) { serverParam := r.URL.Query().Get("server") if serverParam == "" { @@ -247,6 +254,18 @@ func handleClusterCA(w http.ResponseWriter, r *http.Request) { host = host + ":443" } + // Probe 1: try system trust store. If the server's cert is publicly + // trusted, there is no need to embed a CA in the kubeconfig. + dialer := &net.Dialer{Timeout: 5 * time.Second} + if conn, err := tls.DialWithDialer(dialer, "tcp", host, systemTLSConfig()); err == nil { + conn.Close() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ca": nil}) + return + } + + // Probe 2: try the service account CA bundle. If it verifies the + // server, the cert is privately signed and the runner will need it. caPEM, err := os.ReadFile(saCAPath) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -282,12 +301,11 @@ func handleClusterCA(w http.ResponseWriter, r *http.Request) { return } - dialer := &net.Dialer{Timeout: 5 * time.Second} conn, err := tls.DialWithDialer(dialer, "tcp", host, &tls.Config{ RootCAs: pool, }) if err != nil { - // TLS verification failed, the CA does not match the server cert. + // Neither system roots nor the SA bundle can verify the server. w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ca": nil}) return diff --git a/backend/main_test.go b/backend/main_test.go index 9df69fb..5cc3e0f 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -136,11 +136,52 @@ func TestHandleClusterCA_MissingCAFile(t *testing.T) { } } -func TestHandleClusterCA_CAMatchesServer(t *testing.T) { +// TestHandleClusterCA_PublicCA verifies that when the server's cert is trusted +// by system roots (probe 1), null is returned even if a CA file is present. +func TestHandleClusterCA_PublicCA(t *testing.T) { + caPEM, caCert, caKey := newTestCA(t) + leafCert := newTestLeafCert(t, caCert, caKey) + + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + ts.TLS = &tls.Config{Certificates: []tls.Certificate{leafCert}} + ts.StartTLS() + defer ts.Close() + + // Inject the CA into "system roots" so probe 1 succeeds. + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(caPEM) + orig := systemTLSConfig + systemTLSConfig = func() *tls.Config { + return &tls.Config{RootCAs: pool} + } + t.Cleanup(func() { systemTLSConfig = orig }) + + // Write the same CA to the SA file. If probe 1 works correctly, + // the handler returns null without ever reaching probe 2. + writeCAFile(t, caPEM) + + req := httptest.NewRequest("GET", "/api/cluster/ca?server="+ts.URL, nil) + w := httptest.NewRecorder() + + handleClusterCA(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var body map[string]interface{} + json.NewDecoder(w.Body).Decode(&body) + if body["ca"] != nil { + t.Errorf("expected ca to be null for publicly trusted cert, got %v", body["ca"]) + } +} + +// TestHandleClusterCA_PrivateCA verifies the two-probe logic for a private CA: +// probe 1 (system roots) fails because the test CA is self-signed, then +// probe 2 (SA bundle) succeeds because the CA matches the server cert. +func TestHandleClusterCA_PrivateCA(t *testing.T) { caPEM, caCert, caKey := newTestCA(t) leafCert := newTestLeafCert(t, caCert, caKey) - // Start a TLS server using the leaf cert signed by our CA. ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) ts.TLS = &tls.Config{Certificates: []tls.Certificate{leafCert}} ts.StartTLS() @@ -167,7 +208,9 @@ func TestHandleClusterCA_CAMatchesServer(t *testing.T) { } } -func TestHandleClusterCA_CADoesNotMatchServer(t *testing.T) { +// TestHandleClusterCA_BundleMismatch verifies that when neither system roots +// nor the SA bundle can verify the server, null is returned. +func TestHandleClusterCA_BundleMismatch(t *testing.T) { // Create one CA for the server cert. _, serverCACert, serverCAKey := newTestCA(t) leafCert := newTestLeafCert(t, serverCACert, serverCAKey) diff --git a/src/common/services/cluster/OcpClusterService.ts b/src/common/services/cluster/OcpClusterService.ts index 06755cb..e458bed 100644 --- a/src/common/services/cluster/OcpClusterService.ts +++ b/src/common/services/cluster/OcpClusterService.ts @@ -12,11 +12,10 @@ export class OcpClusterService { * necessary ServiceAccount, Role, and RoleBindings in the target * namespace if they do not already exist. * - * The kubeconfig embeds the cluster CA certificate when the service - * account CA matches the API server's TLS certificate. When it does - * not match (e.g. publicly trusted CA like Let's Encrypt), neither - * certificate-authority-data nor insecure-skip-tls-verify is set, - * letting the runner's system trust store handle verification. + * The kubeconfig embeds the cluster CA certificate when the API + * server uses a private CA (not trusted by the system trust store). + * When the cert is publicly trusted, the CA is omitted and the + * runner's system trust store handles verification. */ async generateKubeconfig(namespace: string): Promise { await this.#createServiceAccount(namespace); From 1f6b0366b86c350e34a5797e3dab1bc6cdbf3c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 10 Jun 2026 20:29:44 +0200 Subject: [PATCH 3/8] refactor: use handler struct for cluster CA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the global saCAPath variable and systemTLSConfig function with a clusterCAHandler struct that holds CAPath and SystemTLS as fields. Tests construct their own handler instances instead of mutating and restoring globals. Signed-off-by: Matej Vašek Co-Authored-By: Claude --- backend/main.go | 30 +++++++++++++-------- backend/main_test.go | 64 ++++++++++++++++++++------------------------ 2 files changed, 48 insertions(+), 46 deletions(-) diff --git a/backend/main.go b/backend/main.go index d6571fd..df62120 100644 --- a/backend/main.go +++ b/backend/main.go @@ -44,7 +44,7 @@ func main() { mux := http.NewServeMux() mux.HandleFunc("POST /api/function/create", handleFuncCreate) - mux.HandleFunc("GET /api/cluster/ca", handleClusterCA) + mux.Handle("GET /api/cluster/ca", &clusterCAHandler{CAPath: defaultCAPath}) mux.Handle("/", http.FileServer(http.FS(static))) handler := loggingMiddleware(mux) @@ -225,18 +225,26 @@ func handleFuncCreate(w http.ResponseWriter, r *http.Request) { } } -// saCAPath is the path to the service account CA certificate. -// It is a variable so tests can override it. -var saCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" +const defaultCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" -// systemTLSConfig returns the TLS config for the system roots probe. -// In production this returns an empty config (system trust store). -// Tests can override it to inject a custom cert pool. -var systemTLSConfig = func() *tls.Config { +// clusterCAHandler probes the API server's TLS certificate to decide whether +// to return the service account CA bundle for embedding in a kubeconfig. +type clusterCAHandler struct { + // CAPath is the path to the service account CA certificate file. + CAPath string + // SystemTLS returns the TLS config used for the system roots probe. + // When nil, an empty tls.Config (system trust store) is used. + SystemTLS func() *tls.Config +} + +func (h *clusterCAHandler) systemTLSConfig() *tls.Config { + if h.SystemTLS != nil { + return h.SystemTLS() + } return &tls.Config{} } -func handleClusterCA(w http.ResponseWriter, r *http.Request) { +func (h *clusterCAHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { serverParam := r.URL.Query().Get("server") if serverParam == "" { jsonError(w, "missing required query parameter: server", http.StatusBadRequest) @@ -257,7 +265,7 @@ func handleClusterCA(w http.ResponseWriter, r *http.Request) { // Probe 1: try system trust store. If the server's cert is publicly // trusted, there is no need to embed a CA in the kubeconfig. dialer := &net.Dialer{Timeout: 5 * time.Second} - if conn, err := tls.DialWithDialer(dialer, "tcp", host, systemTLSConfig()); err == nil { + if conn, err := tls.DialWithDialer(dialer, "tcp", host, h.systemTLSConfig()); err == nil { conn.Close() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ca": nil}) @@ -266,7 +274,7 @@ func handleClusterCA(w http.ResponseWriter, r *http.Request) { // Probe 2: try the service account CA bundle. If it verifies the // server, the cert is privately signed and the runner will need it. - caPEM, err := os.ReadFile(saCAPath) + caPEM, err := os.ReadFile(h.CAPath) if err != nil { if errors.Is(err, os.ErrNotExist) { w.Header().Set("Content-Type", "application/json") diff --git a/backend/main_test.go b/backend/main_test.go index 5cc3e0f..83098d0 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -75,25 +75,23 @@ func newTestLeafCert(t *testing.T, ca *x509.Certificate, caKey *ecdsa.PrivateKey } } -// writeCAFile writes PEM data to a temp file and sets saCAPath to it. -// It restores the original saCAPath on cleanup. -func writeCAFile(t *testing.T, pemData []byte) { +// writeCAFile writes PEM data to a temp file and returns its path. +func writeCAFile(t *testing.T, pemData []byte) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "ca.crt") if err := os.WriteFile(path, pemData, 0644); err != nil { t.Fatal(err) } - orig := saCAPath - saCAPath = path - t.Cleanup(func() { saCAPath = orig }) + return path } -func TestHandleClusterCA_MissingServerParam(t *testing.T) { +func TestClusterCAHandler_MissingServerParam(t *testing.T) { + h := &clusterCAHandler{CAPath: "/nonexistent"} req := httptest.NewRequest("GET", "/api/cluster/ca", nil) w := httptest.NewRecorder() - handleClusterCA(w, req) + h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) @@ -105,26 +103,24 @@ func TestHandleClusterCA_MissingServerParam(t *testing.T) { } } -func TestHandleClusterCA_NonHTTPS(t *testing.T) { +func TestClusterCAHandler_NonHTTPS(t *testing.T) { + h := &clusterCAHandler{CAPath: "/nonexistent"} req := httptest.NewRequest("GET", "/api/cluster/ca?server=http://example.com", nil) w := httptest.NewRecorder() - handleClusterCA(w, req) + h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d", w.Code) } } -func TestHandleClusterCA_MissingCAFile(t *testing.T) { - orig := saCAPath - saCAPath = "/nonexistent/path/ca.crt" - t.Cleanup(func() { saCAPath = orig }) - +func TestClusterCAHandler_MissingCAFile(t *testing.T) { + h := &clusterCAHandler{CAPath: "/nonexistent/path/ca.crt"} req := httptest.NewRequest("GET", "/api/cluster/ca?server=https://example.com", nil) w := httptest.NewRecorder() - handleClusterCA(w, req) + h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("expected 200, got %d", w.Code) @@ -136,9 +132,9 @@ func TestHandleClusterCA_MissingCAFile(t *testing.T) { } } -// TestHandleClusterCA_PublicCA verifies that when the server's cert is trusted +// TestClusterCAHandler_PublicCA verifies that when the server's cert is trusted // by system roots (probe 1), null is returned even if a CA file is present. -func TestHandleClusterCA_PublicCA(t *testing.T) { +func TestClusterCAHandler_PublicCA(t *testing.T) { caPEM, caCert, caKey := newTestCA(t) leafCert := newTestLeafCert(t, caCert, caKey) @@ -150,20 +146,18 @@ func TestHandleClusterCA_PublicCA(t *testing.T) { // Inject the CA into "system roots" so probe 1 succeeds. pool := x509.NewCertPool() pool.AppendCertsFromPEM(caPEM) - orig := systemTLSConfig - systemTLSConfig = func() *tls.Config { - return &tls.Config{RootCAs: pool} - } - t.Cleanup(func() { systemTLSConfig = orig }) - // Write the same CA to the SA file. If probe 1 works correctly, - // the handler returns null without ever reaching probe 2. - writeCAFile(t, caPEM) + h := &clusterCAHandler{ + CAPath: writeCAFile(t, caPEM), + SystemTLS: func() *tls.Config { + return &tls.Config{RootCAs: pool} + }, + } req := httptest.NewRequest("GET", "/api/cluster/ca?server="+ts.URL, nil) w := httptest.NewRecorder() - handleClusterCA(w, req) + h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) @@ -175,10 +169,10 @@ func TestHandleClusterCA_PublicCA(t *testing.T) { } } -// TestHandleClusterCA_PrivateCA verifies the two-probe logic for a private CA: +// TestClusterCAHandler_PrivateCA verifies the two-probe logic for a private CA: // probe 1 (system roots) fails because the test CA is self-signed, then // probe 2 (SA bundle) succeeds because the CA matches the server cert. -func TestHandleClusterCA_PrivateCA(t *testing.T) { +func TestClusterCAHandler_PrivateCA(t *testing.T) { caPEM, caCert, caKey := newTestCA(t) leafCert := newTestLeafCert(t, caCert, caKey) @@ -187,12 +181,12 @@ func TestHandleClusterCA_PrivateCA(t *testing.T) { ts.StartTLS() defer ts.Close() - writeCAFile(t, caPEM) + h := &clusterCAHandler{CAPath: writeCAFile(t, caPEM)} req := httptest.NewRequest("GET", "/api/cluster/ca?server="+ts.URL, nil) w := httptest.NewRecorder() - handleClusterCA(w, req) + h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) @@ -208,9 +202,9 @@ func TestHandleClusterCA_PrivateCA(t *testing.T) { } } -// TestHandleClusterCA_BundleMismatch verifies that when neither system roots +// TestClusterCAHandler_BundleMismatch verifies that when neither system roots // nor the SA bundle can verify the server, null is returned. -func TestHandleClusterCA_BundleMismatch(t *testing.T) { +func TestClusterCAHandler_BundleMismatch(t *testing.T) { // Create one CA for the server cert. _, serverCACert, serverCAKey := newTestCA(t) leafCert := newTestLeafCert(t, serverCACert, serverCAKey) @@ -223,12 +217,12 @@ func TestHandleClusterCA_BundleMismatch(t *testing.T) { ts.StartTLS() defer ts.Close() - writeCAFile(t, differentCAPEM) + h := &clusterCAHandler{CAPath: writeCAFile(t, differentCAPEM)} req := httptest.NewRequest("GET", "/api/cluster/ca?server="+ts.URL, nil) w := httptest.NewRecorder() - handleClusterCA(w, req) + h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) From f13e46688a537b848d00a174c3ddc5a480e99c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 10 Jun 2026 20:49:58 +0200 Subject: [PATCH 4/8] fix: return 500 when CA file is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A missing service account CA file now returns an error instead of silently returning null. The MissingCAFile test is fixed to use a local TLS server so probe 1 always fails, ensuring the test actually exercises the file-read path. Signed-off-by: Matej Vašek Co-Authored-By: Claude --- backend/main.go | 6 ------ backend/main_test.go | 23 +++++++++++++++++------ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/backend/main.go b/backend/main.go index df62120..1d1d946 100644 --- a/backend/main.go +++ b/backend/main.go @@ -8,7 +8,6 @@ import ( "encoding/base64" "encoding/json" "encoding/pem" - "errors" "flag" "fmt" "io" @@ -276,11 +275,6 @@ func (h *clusterCAHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // server, the cert is privately signed and the runner will need it. caPEM, err := os.ReadFile(h.CAPath) if err != nil { - if errors.Is(err, os.ErrNotExist) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ca": nil}) - return - } jsonError(w, "failed to read CA file: "+err.Error(), http.StatusInternalServerError) return } diff --git a/backend/main_test.go b/backend/main_test.go index 83098d0..6cd914a 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -15,6 +15,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" ) @@ -116,19 +117,29 @@ func TestClusterCAHandler_NonHTTPS(t *testing.T) { } func TestClusterCAHandler_MissingCAFile(t *testing.T) { + // Use a self-signed TLS server so probe 1 (system roots) always fails, + // ensuring the handler reaches the CA file read path. + _, caCert, caKey := newTestCA(t) + leafCert := newTestLeafCert(t, caCert, caKey) + + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + ts.TLS = &tls.Config{Certificates: []tls.Certificate{leafCert}} + ts.StartTLS() + defer ts.Close() + h := &clusterCAHandler{CAPath: "/nonexistent/path/ca.crt"} - req := httptest.NewRequest("GET", "/api/cluster/ca?server=https://example.com", nil) + req := httptest.NewRequest("GET", "/api/cluster/ca?server="+ts.URL, nil) w := httptest.NewRecorder() h.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Errorf("expected 200, got %d", w.Code) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) } - var body map[string]interface{} + var body map[string]string json.NewDecoder(w.Body).Decode(&body) - if body["ca"] != nil { - t.Errorf("expected ca to be null, got %v", body["ca"]) + if !strings.Contains(body["message"], "failed to read CA file") { + t.Errorf("expected 'failed to read CA file' in message, got %q", body["message"]) } } From d4eeec91df5b84a1627ae4ba03d8e102cd1028d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 10 Jun 2026 21:15:07 +0200 Subject: [PATCH 5/8] feat: add --kube-root-ca-path flag for local dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a CLI flag to override the default CA path so init.sh can extract the cluster CA via oc and pass it to the backend. This lets the /api/cluster/ca endpoint work outside a pod where the service account mount is not available. Signed-off-by: Matej Vašek Co-Authored-By: Claude Signed-off-by: Matej Vašek --- backend/main.go | 3 ++- init.sh | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/main.go b/backend/main.go index 1d1d946..11db0ce 100644 --- a/backend/main.go +++ b/backend/main.go @@ -34,6 +34,7 @@ func main() { httpsPort := flag.Int("https-port", 8443, "HTTPS server port") certFile := flag.String("cert", "/var/cert/tls.crt", "TLS certificate file") keyFile := flag.String("key", "/var/cert/tls.key", "TLS key file") + caPath := flag.String("kube-root-ca-path", defaultCAPath, "path to CA certificate for cluster TLS probe") flag.Parse() static, err := fs.Sub(staticFiles, "static") @@ -43,7 +44,7 @@ func main() { mux := http.NewServeMux() mux.HandleFunc("POST /api/function/create", handleFuncCreate) - mux.Handle("GET /api/cluster/ca", &clusterCAHandler{CAPath: defaultCAPath}) + mux.Handle("GET /api/cluster/ca", &clusterCAHandler{CAPath: *caPath}) mux.Handle("/", http.FileServer(http.FS(static))) handler := loggingMiddleware(mux) diff --git a/init.sh b/init.sh index 0209f50..3fd6f67 100755 --- a/init.sh +++ b/init.sh @@ -75,12 +75,18 @@ write_dev_env() { EOF } +extract_cluster_ca() { + echo "Extracting cluster CA certificate..." + CA_FILE=$(mktemp --suffix=.crt) + oc get cm kube-root-ca.crt -n default -o jsonpath='{.data.ca\.crt}' > "$CA_FILE" +} + start_backend() { echo "Building Go backend..." (cd backend && go build -buildvcs=false -o ../bin/backend .) (cd backend && go build -buildvcs=false -o ../bin/errserver ./cmd/errserver) echo "Starting Go backend..." - ./bin/backend --http-port "$BACKEND_PORT" >>"$LOG_DIR/backend.log" 2>&1 & + ./bin/backend --http-port "$BACKEND_PORT" --kube-root-ca-path "$CA_FILE" >>"$LOG_DIR/backend.log" 2>&1 & echo $! > "$PID_DIR/backend.pid" } @@ -111,7 +117,7 @@ start_backend_watcher() { if $build_ok; then mv bin/backend-tmp bin/backend - ./bin/backend --http-port "$BACKEND_PORT" >>"$LOG_DIR/backend.log" 2>&1 & + ./bin/backend --http-port "$BACKEND_PORT" --kube-root-ca-path "$CA_FILE" >>"$LOG_DIR/backend.log" 2>&1 & echo $! > "$PID_DIR/backend.pid" echo "[watcher] Backend restarted (PID $!)." else @@ -217,6 +223,7 @@ main() { install_dependencies stop_dev write_dev_env + extract_cluster_ca start_backend wait_for_port "$BACKEND_PORT" "Go backend" start_backend_watcher From d1f0ff0b09f449e99e5b2d411e3512d999f3327d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 10 Jun 2026 21:58:21 +0200 Subject: [PATCH 6/8] test: add tests for empty and malformed CA files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the two previously untested error paths in clusterCAHandler: an empty CA file with no PEM blocks, and a CA file with a valid PEM envelope but corrupt DER content. Co-Authored-By: Claude Signed-off-by: Matej Vašek --- backend/main_test.go | 56 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/backend/main_test.go b/backend/main_test.go index 6cd914a..5aee66d 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -213,6 +213,62 @@ func TestClusterCAHandler_PrivateCA(t *testing.T) { } } +// TestClusterCAHandler_EmptyCAFile verifies that an empty CA file returns 500. +func TestClusterCAHandler_EmptyCAFile(t *testing.T) { + _, caCert, caKey := newTestCA(t) + leafCert := newTestLeafCert(t, caCert, caKey) + + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + ts.TLS = &tls.Config{Certificates: []tls.Certificate{leafCert}} + ts.StartTLS() + defer ts.Close() + + h := &clusterCAHandler{CAPath: writeCAFile(t, []byte{})} + req := httptest.NewRequest("GET", "/api/cluster/ca?server="+ts.URL, nil) + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } + var body map[string]string + json.NewDecoder(w.Body).Decode(&body) + if !strings.Contains(body["message"], "no valid certificates found") { + t.Errorf("expected 'no valid certificates found' in message, got %q", body["message"]) + } +} + +// TestClusterCAHandler_MalformedCAFile verifies that a CA file with corrupt +// certificate data returns 500. +func TestClusterCAHandler_MalformedCAFile(t *testing.T) { + _, caCert, caKey := newTestCA(t) + leafCert := newTestLeafCert(t, caCert, caKey) + + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + ts.TLS = &tls.Config{Certificates: []tls.Certificate{leafCert}} + ts.StartTLS() + defer ts.Close() + + // PEM block with type CERTIFICATE but garbage DER content. + badPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("not valid DER")}) + + h := &clusterCAHandler{CAPath: writeCAFile(t, badPEM)} + req := httptest.NewRequest("GET", "/api/cluster/ca?server="+ts.URL, nil) + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } + var body map[string]string + json.NewDecoder(w.Body).Decode(&body) + if !strings.Contains(body["message"], "failed to parse CA certificate") { + t.Errorf("expected 'failed to parse CA certificate' in message, got %q", body["message"]) + } +} + // TestClusterCAHandler_BundleMismatch verifies that when neither system roots // nor the SA bundle can verify the server, null is returned. func TestClusterCAHandler_BundleMismatch(t *testing.T) { From db5c7a08af1fa0fcd0311e656d3897feb00c56a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 10 Jun 2026 22:27:16 +0200 Subject: [PATCH 7/8] docs: update progress log for kubeconfig CA session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Matej Vašek Co-Authored-By: Claude --- docs/claude-progress.txt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/claude-progress.txt b/docs/claude-progress.txt index 1591bd8..c9beec1 100644 --- a/docs/claude-progress.txt +++ b/docs/claude-progress.txt @@ -1,6 +1,23 @@ # Claude Progress Log # Newest entries first. Agents: append your entry at the top after the header. +--- +## 2026-06-10 | Session: Kubeconfig CA cert via backend TLS probe +Worked on: Replace insecure-skip-tls-verify in generated kubeconfigs with proper CA handling +Completed: +- New backend endpoint GET /api/cluster/ca with two-probe TLS logic: system roots first (public CA detection), then SA CA bundle (private CA embedding) +- clusterCAHandler struct with injectable SystemTLS for testability +- Frontend OcpClusterService updated: #fetchClusterCA, #buildKubeconfig with ca parameter, removed insecure-skip-tls-verify +- 8 Go tests covering all handler paths (missing param, non-HTTPS, missing file, public CA, private CA, bundle mismatch, empty file, malformed file) +- Frontend tests updated for CA-present and CA-null cases +- --kube-root-ca-path CLI flag for local dev, init.sh extracts CA via oc get cm kube-root-ca.crt +- Investigated Option B (Kubernetes API for server URL and ConfigMap) but rejected due to RBAC requirements +- Confirmed OCP dynamic plugin proxy does not pass cluster metadata to backends +- Dockerfile: copy RHEL CA bundle from go-toolset build stage into ubi9-micro (confirmed ubi9-micro has zero CA infrastructure, no /etc/pki/ or /etc/ssl/ at all) +- All CI green: 8 Go tests, 14 TS suites / 127 tests, build clean +Left off: Branch kubeconfig-ca ready for squash/merge. CA_FILE temp file cleanup in init.sh not yet addressed (minor). +Blockers: None + --- ## 2026-05-21 | Session: Page co-location restructure (continued) Worked on: Follow-up fixes from review, test cleanup, docs From c59d1da643ed5393d6940d12fe85978568659e73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Fri, 12 Jun 2026 14:38:03 +0200 Subject: [PATCH 8/8] refactor: split backend handlers into separate files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move func create handler to func_create.go and cluster CA handler to cluster_ca.go. Extract jsonOK helper to deduplicate the JSON response pattern. Use guard clause in the PEM parsing loop to reduce nesting. Signed-off-by: Matej Vašek Co-Authored-By: Claude --- backend/cluster_ca.go | 107 +++++++++++++++++ backend/func_create.go | 163 ++++++++++++++++++++++++++ backend/main.go | 253 +---------------------------------------- 3 files changed, 272 insertions(+), 251 deletions(-) create mode 100644 backend/cluster_ca.go create mode 100644 backend/func_create.go diff --git a/backend/cluster_ca.go b/backend/cluster_ca.go new file mode 100644 index 0000000..5059ed1 --- /dev/null +++ b/backend/cluster_ca.go @@ -0,0 +1,107 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "net" + "net/http" + "net/url" + "os" + "time" +) + +const defaultCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + +// clusterCAHandler probes the API server's TLS certificate to decide whether +// to return the service account CA bundle for embedding in a kubeconfig. +type clusterCAHandler struct { + // CAPath is the path to the service account CA certificate file. + CAPath string + // SystemTLS returns the TLS config used for the system roots probe. + // When nil, an empty tls.Config (system trust store) is used. + SystemTLS func() *tls.Config +} + +func (h *clusterCAHandler) systemTLSConfig() *tls.Config { + if h.SystemTLS != nil { + return h.SystemTLS() + } + return &tls.Config{} +} + +func (h *clusterCAHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + serverParam := r.URL.Query().Get("server") + if serverParam == "" { + jsonError(w, "missing required query parameter: server", http.StatusBadRequest) + return + } + + parsed, err := url.Parse(serverParam) + if err != nil || parsed.Scheme != "https" { + jsonError(w, "server must be an HTTPS URL", http.StatusBadRequest) + return + } + + host := parsed.Host + if parsed.Port() == "" { + host = host + ":443" + } + + // Probe 1: try system trust store. If the server's cert is publicly + // trusted, there is no need to embed a CA in the kubeconfig. + dialer := &net.Dialer{Timeout: 5 * time.Second} + if conn, err := tls.DialWithDialer(dialer, "tcp", host, h.systemTLSConfig()); err == nil { + conn.Close() + jsonOK(w, map[string]interface{}{"ca": nil}) + return + } + + // Probe 2: try the service account CA bundle. If it verifies the + // server, the cert is privately signed and the runner will need it. + caPEM, err := os.ReadFile(h.CAPath) + if err != nil { + jsonError(w, "failed to read CA file: "+err.Error(), http.StatusInternalServerError) + return + } + + pool := x509.NewCertPool() + rest := caPEM + var found bool + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + jsonError(w, "failed to parse CA certificate: "+err.Error(), http.StatusInternalServerError) + return + } + pool.AddCert(cert) + found = true + } + if !found { + jsonError(w, "no valid certificates found in CA file", http.StatusInternalServerError) + return + } + + conn, err := tls.DialWithDialer(dialer, "tcp", host, &tls.Config{ + RootCAs: pool, + }) + if err != nil { + // Neither system roots nor the SA bundle can verify the server. + jsonOK(w, map[string]interface{}{"ca": nil}) + return + } + conn.Close() + + encoded := base64.StdEncoding.EncodeToString(caPEM) + jsonOK(w, map[string]string{"ca": encoded}) +} diff --git a/backend/func_create.go b/backend/func_create.go new file mode 100644 index 0000000..6b71c4d --- /dev/null +++ b/backend/func_create.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + cigithub "knative.dev/func/pkg/ci/github" + "knative.dev/func/pkg/functions" +) + +type funcCreateRequest struct { + Name string `json:"name"` + Runtime string `json:"runtime"` + Registry string `json:"registry"` + Namespace string `json:"namespace"` + Branch string `json:"branch"` +} + +// validName restricts function names to lowercase DNS-label characters. +var validName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) + +// validRuntimes is the set of supported function runtimes. +var validRuntimes = map[string]bool{ + "node": true, "python": true, "go": true, "quarkus": true, +} + +// validBranch restricts branch names to safe git ref characters. +var validBranch = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9._/-]*[a-zA-Z0-9])?$`) + +// validNamespace restricts namespaces to valid Kubernetes names. +var validNamespace = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) + +type fileEntry struct { + Path string `json:"path"` + Mode string `json:"mode"` + Content string `json:"content"` + Type string `json:"type"` +} + +func handleFuncCreate(w http.ResponseWriter, r *http.Request) { + var cfg funcCreateRequest + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB limit + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + jsonError(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + return + } + + if !validName.MatchString(cfg.Name) { + jsonError(w, "invalid function name: must contain only lowercase alphanumeric characters and hyphens", http.StatusBadRequest) + return + } + if !validRuntimes[cfg.Runtime] { + jsonError(w, "invalid runtime: must be one of node, python, go, quarkus", http.StatusBadRequest) + return + } + if !validBranch.MatchString(cfg.Branch) { + jsonError(w, "invalid branch name", http.StatusBadRequest) + return + } + if !validNamespace.MatchString(cfg.Namespace) { + jsonError(w, "invalid namespace: must contain only lowercase alphanumeric characters and hyphens", http.StatusBadRequest) + return + } + + tmpDir, err := os.MkdirTemp("", "func-create-*") + if err != nil { + jsonError(w, "failed to create temp dir: "+err.Error(), http.StatusInternalServerError) + return + } + defer os.RemoveAll(tmpDir) + + root := filepath.Join(tmpDir, cfg.Name) + + client := functions.New() + _, err = client.Init(functions.Function{ + Name: cfg.Name, + Root: root, + Runtime: cfg.Runtime, + Registry: cfg.Registry, + Namespace: cfg.Namespace, + Template: "http", + }) + if err != nil { + jsonError(w, "failed to initialize function: "+err.Error(), http.StatusInternalServerError) + return + } + + if err := generateCIWorkflow(root, cfg.Runtime, cfg.Branch, cfg.Registry); err != nil { + jsonError(w, "failed to generate CI workflow: "+err.Error(), http.StatusInternalServerError) + return + } + + var files []fileEntry + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + relPath, err := filepath.Rel(root, path) + if err != nil { + return err + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + mode := "100644" + info, err := d.Info() + if err != nil { + return err + } + if info.Mode()&0111 != 0 { + mode = "100755" + } + if info.Mode()&os.ModeSymlink != 0 { + mode = "120000" + } + files = append(files, fileEntry{ + Path: relPath, + Mode: mode, + Content: string(content), + Type: "blob", + }) + return nil + }) + if err != nil { + jsonError(w, "failed to read generated files: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(files); err != nil { + log.Printf("failed to encode response: %v", err) + } +} + +const ocpInternalRegistry = "image-registry.openshift-image-registry.svc:5000/" + +func generateCIWorkflow(root, runtime, branch, registry string) error { + gen := cigithub.NewWorkflowGenerator( + cigithub.WithWorkflowConfig(cigithub.WorkflowConfig{ + Branch: branch, + RegistryLogin: !strings.HasPrefix(registry, ocpInternalRegistry), + TestStep: cigithub.DefaultTestStep, + }), + cigithub.WithMessageWriter(io.Discard), + ) + + return gen.Generate(context.Background(), functions.Function{ + Root: root, + Runtime: runtime, + }) +} diff --git a/backend/main.go b/backend/main.go index 11db0ce..5967457 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,29 +1,16 @@ package main import ( - "context" "crypto/tls" - "crypto/x509" "embed" - "encoding/base64" "encoding/json" - "encoding/pem" "flag" "fmt" - "io" "io/fs" "log" "net" "net/http" - "net/url" "os" - "path/filepath" - "regexp" - "strings" - "time" - - cigithub "knative.dev/func/pkg/ci/github" - "knative.dev/func/pkg/functions" ) //go:embed static/* @@ -91,249 +78,13 @@ func loggingMiddleware(next http.Handler) http.Handler { }) } -type funcCreateRequest struct { - Name string `json:"name"` - Runtime string `json:"runtime"` - Registry string `json:"registry"` - Namespace string `json:"namespace"` - Branch string `json:"branch"` -} - -// validName restricts function names to lowercase DNS-label characters. -var validName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) - -// validRuntimes is the set of supported function runtimes. -var validRuntimes = map[string]bool{ - "node": true, "python": true, "go": true, "quarkus": true, -} - -// validBranch restricts branch names to safe git ref characters. -var validBranch = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9._/-]*[a-zA-Z0-9])?$`) - -// validNamespace restricts namespaces to valid Kubernetes names. -var validNamespace = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) - -type fileEntry struct { - Path string `json:"path"` - Mode string `json:"mode"` - Content string `json:"content"` - Type string `json:"type"` -} - func jsonError(w http.ResponseWriter, msg string, code int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"message": msg}) } -func handleFuncCreate(w http.ResponseWriter, r *http.Request) { - var cfg funcCreateRequest - r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB limit - if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { - jsonError(w, "invalid request body: "+err.Error(), http.StatusBadRequest) - return - } - - if !validName.MatchString(cfg.Name) { - jsonError(w, "invalid function name: must contain only lowercase alphanumeric characters and hyphens", http.StatusBadRequest) - return - } - if !validRuntimes[cfg.Runtime] { - jsonError(w, "invalid runtime: must be one of node, python, go, quarkus", http.StatusBadRequest) - return - } - if !validBranch.MatchString(cfg.Branch) { - jsonError(w, "invalid branch name", http.StatusBadRequest) - return - } - if !validNamespace.MatchString(cfg.Namespace) { - jsonError(w, "invalid namespace: must contain only lowercase alphanumeric characters and hyphens", http.StatusBadRequest) - return - } - - tmpDir, err := os.MkdirTemp("", "func-create-*") - if err != nil { - jsonError(w, "failed to create temp dir: "+err.Error(), http.StatusInternalServerError) - return - } - defer os.RemoveAll(tmpDir) - - root := filepath.Join(tmpDir, cfg.Name) - - client := functions.New() - _, err = client.Init(functions.Function{ - Name: cfg.Name, - Root: root, - Runtime: cfg.Runtime, - Registry: cfg.Registry, - Namespace: cfg.Namespace, - Template: "http", - }) - if err != nil { - jsonError(w, "failed to initialize function: "+err.Error(), http.StatusInternalServerError) - return - } - - if err := generateCIWorkflow(root, cfg.Runtime, cfg.Branch, cfg.Registry); err != nil { - jsonError(w, "failed to generate CI workflow: "+err.Error(), http.StatusInternalServerError) - return - } - - var files []fileEntry - err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - relPath, err := filepath.Rel(root, path) - if err != nil { - return err - } - content, err := os.ReadFile(path) - if err != nil { - return err - } - mode := "100644" - info, err := d.Info() - if err != nil { - return err - } - if info.Mode()&0111 != 0 { - mode = "100755" - } - if info.Mode()&os.ModeSymlink != 0 { - mode = "120000" - } - files = append(files, fileEntry{ - Path: relPath, - Mode: mode, - Content: string(content), - Type: "blob", - }) - return nil - }) - if err != nil { - jsonError(w, "failed to read generated files: "+err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(files); err != nil { - log.Printf("failed to encode response: %v", err) - } -} - -const defaultCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" - -// clusterCAHandler probes the API server's TLS certificate to decide whether -// to return the service account CA bundle for embedding in a kubeconfig. -type clusterCAHandler struct { - // CAPath is the path to the service account CA certificate file. - CAPath string - // SystemTLS returns the TLS config used for the system roots probe. - // When nil, an empty tls.Config (system trust store) is used. - SystemTLS func() *tls.Config -} - -func (h *clusterCAHandler) systemTLSConfig() *tls.Config { - if h.SystemTLS != nil { - return h.SystemTLS() - } - return &tls.Config{} -} - -func (h *clusterCAHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - serverParam := r.URL.Query().Get("server") - if serverParam == "" { - jsonError(w, "missing required query parameter: server", http.StatusBadRequest) - return - } - - parsed, err := url.Parse(serverParam) - if err != nil || parsed.Scheme != "https" { - jsonError(w, "server must be an HTTPS URL", http.StatusBadRequest) - return - } - - host := parsed.Host - if parsed.Port() == "" { - host = host + ":443" - } - - // Probe 1: try system trust store. If the server's cert is publicly - // trusted, there is no need to embed a CA in the kubeconfig. - dialer := &net.Dialer{Timeout: 5 * time.Second} - if conn, err := tls.DialWithDialer(dialer, "tcp", host, h.systemTLSConfig()); err == nil { - conn.Close() - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ca": nil}) - return - } - - // Probe 2: try the service account CA bundle. If it verifies the - // server, the cert is privately signed and the runner will need it. - caPEM, err := os.ReadFile(h.CAPath) - if err != nil { - jsonError(w, "failed to read CA file: "+err.Error(), http.StatusInternalServerError) - return - } - - pool := x509.NewCertPool() - rest := caPEM - var found bool - for { - var block *pem.Block - block, rest = pem.Decode(rest) - if block == nil { - break - } - if block.Type == "CERTIFICATE" { - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - jsonError(w, "failed to parse CA certificate: "+err.Error(), http.StatusInternalServerError) - return - } - pool.AddCert(cert) - found = true - } - } - if !found { - jsonError(w, "no valid certificates found in CA file", http.StatusInternalServerError) - return - } - - conn, err := tls.DialWithDialer(dialer, "tcp", host, &tls.Config{ - RootCAs: pool, - }) - if err != nil { - // Neither system roots nor the SA bundle can verify the server. - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ca": nil}) - return - } - conn.Close() - - encoded := base64.StdEncoding.EncodeToString(caPEM) +func jsonOK(w http.ResponseWriter, v interface{}) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"ca": encoded}) -} - -const ocpInternalRegistry = "image-registry.openshift-image-registry.svc:5000/" - -func generateCIWorkflow(root, runtime, branch, registry string) error { - gen := cigithub.NewWorkflowGenerator( - cigithub.WithWorkflowConfig(cigithub.WorkflowConfig{ - Branch: branch, - RegistryLogin: !strings.HasPrefix(registry, ocpInternalRegistry), - TestStep: cigithub.DefaultTestStep, - }), - cigithub.WithMessageWriter(io.Discard), - ) - - return gen.Generate(context.Background(), functions.Function{ - Root: root, - Runtime: runtime, - }) + json.NewEncoder(w).Encode(v) }