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/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 ac09335..5967457 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,24 +1,16 @@ package main import ( - "context" "crypto/tls" "embed" "encoding/json" "flag" "fmt" - "io" "io/fs" "log" "net" "net/http" "os" - "path/filepath" - "regexp" - "strings" - - cigithub "knative.dev/func/pkg/ci/github" - "knative.dev/func/pkg/functions" ) //go:embed static/* @@ -29,6 +21,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") @@ -38,6 +31,7 @@ func main() { mux := http.NewServeMux() mux.HandleFunc("POST /api/function/create", handleFuncCreate) + mux.Handle("GET /api/cluster/ca", &clusterCAHandler{CAPath: *caPath}) mux.Handle("/", http.FileServer(http.FS(static))) handler := loggingMiddleware(mux) @@ -84,154 +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 - } - +func jsonOK(w http.ResponseWriter, v interface{}) { 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, - }) + json.NewEncoder(w).Encode(v) } diff --git a/backend/main_test.go b/backend/main_test.go new file mode 100644 index 0000000..5aee66d --- /dev/null +++ b/backend/main_test.go @@ -0,0 +1,302 @@ +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" + "strings" + "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 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) + } + return path +} + +func TestClusterCAHandler_MissingServerParam(t *testing.T) { + h := &clusterCAHandler{CAPath: "/nonexistent"} + req := httptest.NewRequest("GET", "/api/cluster/ca", nil) + w := httptest.NewRecorder() + + h.ServeHTTP(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 TestClusterCAHandler_NonHTTPS(t *testing.T) { + h := &clusterCAHandler{CAPath: "/nonexistent"} + req := httptest.NewRequest("GET", "/api/cluster/ca?server=http://example.com", nil) + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +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="+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 read CA file") { + t.Errorf("expected 'failed to read CA file' in message, got %q", body["message"]) + } +} + +// 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 TestClusterCAHandler_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) + + 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() + + h.ServeHTTP(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"]) + } +} + +// 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 TestClusterCAHandler_PrivateCA(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() + + h := &clusterCAHandler{CAPath: writeCAFile(t, caPEM)} + + req := httptest.NewRequest("GET", "/api/cluster/ca?server="+ts.URL, nil) + w := httptest.NewRecorder() + + h.ServeHTTP(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"]) + } +} + +// 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) { + // 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() + + h := &clusterCAHandler{CAPath: writeCAFile(t, differentCAPEM)} + + req := httptest.NewRequest("GET", "/api/cluster/ca?server="+ts.URL, nil) + w := httptest.NewRecorder() + + h.ServeHTTP(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/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 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 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..e458bed 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,10 @@ 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 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); @@ -22,8 +25,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 +120,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 +137,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', }, ],