Skip to content
This repository was archived by the owner on Jun 26, 2026. It is now read-only.

Commit df9744c

Browse files
matejvasekclaude
andauthored
Embed cluster CA in kubeconfig if necessary (#37)
* feat: embed cluster CA in kubeconfig via TLS probe 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 <matejvasek@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com> * fix: skip public CAs in cluster CA probe 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 <matejvasek@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com> fixup: correct JSDoc on generateKubeconfig function Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Matej Vašek <matejvasek@gmail.com> 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 <matejvasek@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com> * refactor: use handler struct for cluster CA 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 <matejvasek@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com> * fix: return 500 when CA file is missing 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 <matejvasek@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com> * feat: add --kube-root-ca-path flag for local dev 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 <matejvasek@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com> * test: add tests for empty and malformed CA files 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 <noreply@anthropic.com> Signed-off-by: Matej Vašek <matejvasek@gmail.com> * docs: update progress log for kubeconfig CA session Signed-off-by: Matej Vašek <matejvasek@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com> * refactor: split backend handlers into separate files 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 <matejvasek@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com> * perf: cache CA file read with sync.Once The CA file was read and parsed on every request that reached probe 2. Use sync.Once to load it once on first access. The loadCA method stores parsed results on the struct and is called via h.caOnce.Do(h.loadCA). Signed-off-by: Matej Vašek <matejvasek@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com> --------- Signed-off-by: Matej Vašek <matejvasek@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8d2d994 commit df9744c

9 files changed

Lines changed: 662 additions & 162 deletions

File tree

‎Dockerfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w" -o
2727

2828
FROM registry.access.redhat.com/ubi9-micro:latest
2929

30+
COPY --from=go-build /etc/pki/tls/certs/ca-bundle.crt /etc/pki/tls/certs/ca-bundle.crt
3031
COPY --from=go-build /opt/app-root/src/backend/plugin-backend /usr/bin/plugin-backend
3132
USER 1001
3233

‎backend/cluster_ca.go‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package main
2+
3+
import (
4+
"crypto/tls"
5+
"crypto/x509"
6+
"encoding/base64"
7+
"encoding/pem"
8+
"net"
9+
"net/http"
10+
"net/url"
11+
"os"
12+
"sync"
13+
"time"
14+
)
15+
16+
const defaultCAPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
17+
18+
// clusterCAHandler probes the API server's TLS certificate to decide whether
19+
// to return the service account CA bundle for embedding in a kubeconfig.
20+
type clusterCAHandler struct {
21+
// CAPath is the path to the service account CA certificate file.
22+
CAPath string
23+
// SystemTLS returns the TLS config used for the system roots probe.
24+
// When nil, an empty tls.Config (system trust store) is used.
25+
SystemTLS func() *tls.Config
26+
27+
caOnce sync.Once
28+
caPEM []byte
29+
caPool *x509.CertPool
30+
caErr string
31+
}
32+
33+
func (h *clusterCAHandler) systemTLSConfig() *tls.Config {
34+
if h.SystemTLS != nil {
35+
return h.SystemTLS()
36+
}
37+
return &tls.Config{}
38+
}
39+
40+
// loadCA reads and parses the CA file, storing results on the struct.
41+
func (h *clusterCAHandler) loadCA() {
42+
data, err := os.ReadFile(h.CAPath)
43+
if err != nil {
44+
h.caErr = "failed to read CA file: " + err.Error()
45+
return
46+
}
47+
48+
pool := x509.NewCertPool()
49+
rest := data
50+
var found bool
51+
for {
52+
var block *pem.Block
53+
block, rest = pem.Decode(rest)
54+
if block == nil {
55+
break
56+
}
57+
if block.Type != "CERTIFICATE" {
58+
continue
59+
}
60+
61+
cert, err := x509.ParseCertificate(block.Bytes)
62+
if err != nil {
63+
h.caErr = "failed to parse CA certificate: " + err.Error()
64+
return
65+
}
66+
pool.AddCert(cert)
67+
found = true
68+
}
69+
if !found {
70+
h.caErr = "no valid certificates found in CA file"
71+
return
72+
}
73+
74+
h.caPEM = data
75+
h.caPool = pool
76+
}
77+
78+
func (h *clusterCAHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
79+
serverParam := r.URL.Query().Get("server")
80+
if serverParam == "" {
81+
jsonError(w, "missing required query parameter: server", http.StatusBadRequest)
82+
return
83+
}
84+
85+
parsed, err := url.Parse(serverParam)
86+
if err != nil || parsed.Scheme != "https" {
87+
jsonError(w, "server must be an HTTPS URL", http.StatusBadRequest)
88+
return
89+
}
90+
91+
host := parsed.Host
92+
if parsed.Port() == "" {
93+
host = host + ":443"
94+
}
95+
96+
// Probe 1: try system trust store. If the server's cert is publicly
97+
// trusted, there is no need to embed a CA in the kubeconfig.
98+
dialer := &net.Dialer{Timeout: 5 * time.Second}
99+
if conn, err := tls.DialWithDialer(dialer, "tcp", host, h.systemTLSConfig()); err == nil {
100+
conn.Close()
101+
jsonOK(w, map[string]interface{}{"ca": nil})
102+
return
103+
}
104+
105+
// Probe 2: try the service account CA bundle. If it verifies the
106+
// server, the cert is privately signed and the runner will need it.
107+
h.caOnce.Do(h.loadCA)
108+
if h.caErr != "" {
109+
jsonError(w, h.caErr, http.StatusInternalServerError)
110+
return
111+
}
112+
113+
conn, err := tls.DialWithDialer(dialer, "tcp", host, &tls.Config{
114+
RootCAs: h.caPool,
115+
})
116+
if err != nil {
117+
// Neither system roots nor the SA bundle can verify the server.
118+
jsonOK(w, map[string]interface{}{"ca": nil})
119+
return
120+
}
121+
conn.Close()
122+
123+
encoded := base64.StdEncoding.EncodeToString(h.caPEM)
124+
jsonOK(w, map[string]string{"ca": encoded})
125+
}

‎backend/func_create.go‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"io/fs"
8+
"log"
9+
"net/http"
10+
"os"
11+
"path/filepath"
12+
"regexp"
13+
"strings"
14+
15+
cigithub "knative.dev/func/pkg/ci/github"
16+
"knative.dev/func/pkg/functions"
17+
)
18+
19+
type funcCreateRequest struct {
20+
Name string `json:"name"`
21+
Runtime string `json:"runtime"`
22+
Registry string `json:"registry"`
23+
Namespace string `json:"namespace"`
24+
Branch string `json:"branch"`
25+
}
26+
27+
// validName restricts function names to lowercase DNS-label characters.
28+
var validName = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
29+
30+
// validRuntimes is the set of supported function runtimes.
31+
var validRuntimes = map[string]bool{
32+
"node": true, "python": true, "go": true, "quarkus": true,
33+
}
34+
35+
// validBranch restricts branch names to safe git ref characters.
36+
var validBranch = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9._/-]*[a-zA-Z0-9])?$`)
37+
38+
// validNamespace restricts namespaces to valid Kubernetes names.
39+
var validNamespace = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
40+
41+
type fileEntry struct {
42+
Path string `json:"path"`
43+
Mode string `json:"mode"`
44+
Content string `json:"content"`
45+
Type string `json:"type"`
46+
}
47+
48+
func handleFuncCreate(w http.ResponseWriter, r *http.Request) {
49+
var cfg funcCreateRequest
50+
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB limit
51+
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
52+
jsonError(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
53+
return
54+
}
55+
56+
if !validName.MatchString(cfg.Name) {
57+
jsonError(w, "invalid function name: must contain only lowercase alphanumeric characters and hyphens", http.StatusBadRequest)
58+
return
59+
}
60+
if !validRuntimes[cfg.Runtime] {
61+
jsonError(w, "invalid runtime: must be one of node, python, go, quarkus", http.StatusBadRequest)
62+
return
63+
}
64+
if !validBranch.MatchString(cfg.Branch) {
65+
jsonError(w, "invalid branch name", http.StatusBadRequest)
66+
return
67+
}
68+
if !validNamespace.MatchString(cfg.Namespace) {
69+
jsonError(w, "invalid namespace: must contain only lowercase alphanumeric characters and hyphens", http.StatusBadRequest)
70+
return
71+
}
72+
73+
tmpDir, err := os.MkdirTemp("", "func-create-*")
74+
if err != nil {
75+
jsonError(w, "failed to create temp dir: "+err.Error(), http.StatusInternalServerError)
76+
return
77+
}
78+
defer os.RemoveAll(tmpDir)
79+
80+
root := filepath.Join(tmpDir, cfg.Name)
81+
82+
client := functions.New()
83+
_, err = client.Init(functions.Function{
84+
Name: cfg.Name,
85+
Root: root,
86+
Runtime: cfg.Runtime,
87+
Registry: cfg.Registry,
88+
Namespace: cfg.Namespace,
89+
Template: "http",
90+
})
91+
if err != nil {
92+
jsonError(w, "failed to initialize function: "+err.Error(), http.StatusInternalServerError)
93+
return
94+
}
95+
96+
if err := generateCIWorkflow(root, cfg.Runtime, cfg.Branch, cfg.Registry); err != nil {
97+
jsonError(w, "failed to generate CI workflow: "+err.Error(), http.StatusInternalServerError)
98+
return
99+
}
100+
101+
var files []fileEntry
102+
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
103+
if err != nil {
104+
return err
105+
}
106+
if d.IsDir() {
107+
return nil
108+
}
109+
relPath, err := filepath.Rel(root, path)
110+
if err != nil {
111+
return err
112+
}
113+
content, err := os.ReadFile(path)
114+
if err != nil {
115+
return err
116+
}
117+
mode := "100644"
118+
info, err := d.Info()
119+
if err != nil {
120+
return err
121+
}
122+
if info.Mode()&0111 != 0 {
123+
mode = "100755"
124+
}
125+
if info.Mode()&os.ModeSymlink != 0 {
126+
mode = "120000"
127+
}
128+
files = append(files, fileEntry{
129+
Path: relPath,
130+
Mode: mode,
131+
Content: string(content),
132+
Type: "blob",
133+
})
134+
return nil
135+
})
136+
if err != nil {
137+
jsonError(w, "failed to read generated files: "+err.Error(), http.StatusInternalServerError)
138+
return
139+
}
140+
141+
w.Header().Set("Content-Type", "application/json")
142+
if err := json.NewEncoder(w).Encode(files); err != nil {
143+
log.Printf("failed to encode response: %v", err)
144+
}
145+
}
146+
147+
const ocpInternalRegistry = "image-registry.openshift-image-registry.svc:5000/"
148+
149+
func generateCIWorkflow(root, runtime, branch, registry string) error {
150+
gen := cigithub.NewWorkflowGenerator(
151+
cigithub.WithWorkflowConfig(cigithub.WorkflowConfig{
152+
Branch: branch,
153+
RegistryLogin: !strings.HasPrefix(registry, ocpInternalRegistry),
154+
TestStep: cigithub.DefaultTestStep,
155+
}),
156+
cigithub.WithMessageWriter(io.Discard),
157+
)
158+
159+
return gen.Generate(context.Background(), functions.Function{
160+
Root: root,
161+
Runtime: runtime,
162+
})
163+
}

0 commit comments

Comments
 (0)