From 6e985740823efe6557f19502024df3b7214926c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=C3=ABl=20Cluseau?= Date: Wed, 2 Jul 2025 21:47:08 +0200 Subject: [PATCH] add kube CSR access --- cmd/dkl-local-server/parsers_test.go | 23 ++++ cmd/dkl-local-server/ws-clusters.go | 172 ++++++++++++++++++++++++++- cmd/dkl-local-server/ws.go | 3 + html/ui/js/Cluster.js | 57 +++++++-- 4 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 cmd/dkl-local-server/parsers_test.go diff --git a/cmd/dkl-local-server/parsers_test.go b/cmd/dkl-local-server/parsers_test.go new file mode 100644 index 0000000..bf7f594 --- /dev/null +++ b/cmd/dkl-local-server/parsers_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "time" +) + +func Example_parseCertDuration() { + now := time.Date(2020, time.April, 28, 12, 30, 0, 0, time.UTC) + + fmt.Println(parseCertDuration("", now)) + fmt.Println(parseCertDuration("hi!", now)) + fmt.Println(parseCertDuration("-2d3h", now)) + fmt.Println(parseCertDuration("2d3h", now)) + fmt.Println(parseCertDuration("+1y-1s", now)) + + // output: + // 0001-01-01 00:00:00 +0000 UTC + // 0001-01-01 00:00:00 +0000 UTC invalid duration: "hi!" + // 2020-04-26 09:30:00 +0000 UTC + // 2020-04-30 15:30:00 +0000 UTC + // 2021-04-28 12:29:59 +0000 UTC +} diff --git a/cmd/dkl-local-server/ws-clusters.go b/cmd/dkl-local-server/ws-clusters.go index 52e2d8f..9891307 100644 --- a/cmd/dkl-local-server/ws-clusters.go +++ b/cmd/dkl-local-server/ws-clusters.go @@ -1,10 +1,18 @@ package main import ( + "errors" + "fmt" "log" "net/url" + "regexp" "strconv" + "strings" + "time" + "github.com/cloudflare/cfssl/config" + "github.com/cloudflare/cfssl/csr" + "github.com/cloudflare/cfssl/signer" restful "github.com/emicklei/go-restful" "novit.tech/direktil/local-server/pkg/mime" @@ -151,7 +159,25 @@ func wsClusterSSHUserCASign(req *restful.Request, resp *restful.Response) { return } - cert, err := sshCASign(clusterName, []byte(signReq.PubKey), signReq.Principal, signReq.Validity, signReq.Options...) + now := time.Now().Truncate(time.Second) + notBefore, notAfter, err := parseCertDurationRange(signReq.Validity, now) + if err != nil { + wsError(resp, fmt.Errorf("invalid validity: %w", err)) + return + } + + const sshTimestamp = "20060102150405Z" + + validity := notBefore.Format(sshTimestamp) + ":" + if notAfter.IsZero() { + validity += "forever" + } else { + validity += notAfter.Format(sshTimestamp) + } + + log.Printf("sign ssh public key, validity %s -> %s", signReq.Validity, validity) + + cert, err := sshCASign(clusterName, []byte(signReq.PubKey), signReq.Principal, validity, signReq.Options...) if err != nil { wsError(resp, err) return @@ -159,3 +185,147 @@ func wsClusterSSHUserCASign(req *restful.Request, resp *restful.Response) { resp.Write(cert) } + +type KubeSignReq struct { + CSR string + User string + Group string + Validity string +} + +func wsClusterKubeCASign(req *restful.Request, resp *restful.Response) { + clusterName := req.PathParameter("cluster-name") + + signReq := KubeSignReq{} + err := req.ReadEntity(&signReq) + if err != nil { + wsError(resp, err) + return + } + + now := time.Now().Truncate(time.Second) + notBefore, notAfter, err := parseCertDurationRange(signReq.Validity, now) + if err != nil { + wsError(resp, fmt.Errorf("invalid validity: %w", err)) + return + } + + var names []csr.Name + if signReq.Group != "" { + names = []csr.Name{{O: signReq.Group}} + } + + ca, err := getUsableClusterCA(clusterName, "cluster") + if err != nil { + wsError(resp, fmt.Errorf("get cluster CA failed: %w", err)) + return + } + + caSigner, err := ca.Signer(&config.Signing{ + Default: &config.SigningProfile{ + Usage: []string{"client auth"}, + Expiry: notAfter.Sub(now), + }, + }) + if err != nil { + wsError(resp, err) + return + } + + csr := signer.SignRequest{ + Request: signReq.CSR, + Subject: &signer.Subject{ + CN: signReq.User, + Names: names, + }, + NotBefore: notBefore, + NotAfter: notAfter, + } + + cert, err := caSigner.Sign(csr) + if err != nil { + wsError(resp, err) + return + } + + resp.Write(cert) +} + +func parseCertDurationRange(d string, now time.Time) (notBefore, notAfter time.Time, err error) { + if d == "" { + return + } + + d1, d2, ok := strings.Cut(d, ":") + + if ok { + notBefore, err = parseCertDuration(d1, now) + if err != nil { + return + } + notAfter, err = parseCertDuration(d2, now) + } else { + notAfter, err = parseCertDuration(d, now) + } + + if err != nil { + return + } + + if notBefore.IsZero() { + notBefore = now.Add(-5 * time.Minute) + } + + return +} + +var durRegex = regexp.MustCompile("^([+-]?)([0-9]+)([yMdwhms])") + +func parseCertDuration(d string, now time.Time) (t time.Time, err error) { + if d == "" { + return + } + + direction := 1 + t = now + + for d != "" { + match := durRegex.FindStringSubmatch(d) + if match == nil { + t = time.Time{} + err = errors.New("invalid duration: " + strconv.Quote(d)) + return + } + + d = d[len(match[0]):] + + switch match[1] { + case "+": + direction = 1 + case "-": + direction = -1 + } + + qty, _ := strconv.Atoi(match[2]) + unit := match[3] + + switch unit { + case "y": + t = t.AddDate(qty*direction, 0, 0) + case "M": + t = t.AddDate(0, qty*direction, 0) + case "d": + t = t.AddDate(0, 0, qty*direction) + case "w": + t = t.AddDate(0, 0, 7*qty*direction) + case "h": + t = t.Add(time.Duration(qty*direction) * time.Hour) + case "m": + t = t.Add(time.Duration(qty*direction) * time.Minute) + case "s": + t = t.Add(time.Duration(qty*direction) * time.Second) + } + } + + return +} diff --git a/cmd/dkl-local-server/ws.go b/cmd/dkl-local-server/ws.go index 204d112..87f32ca 100644 --- a/cmd/dkl-local-server/ws.go +++ b/cmd/dkl-local-server/ws.go @@ -134,6 +134,9 @@ func registerWS(rest *restful.Container) { cluster(POST, "/ssh/user-ca/sign").To(wsClusterSSHUserCASign). Produces(mime.OCTET). Doc("Sign a user's SSH public key for this cluster"), + cluster(POST, "/kube/sign").To(wsClusterKubeCASign). + Produces(mime.OCTET). + Doc("Sign a user's public key for this cluster's Kubernetes API server"), } { ws.Route(builder) } diff --git a/html/ui/js/Cluster.js b/html/ui/js/Cluster.js index 3289cee..a9f97c6 100644 --- a/html/ui/js/Cluster.js +++ b/html/ui/js/Cluster.js @@ -7,12 +7,18 @@ export default { props: [ 'cluster', 'token', 'state' ], data() { return { + signReqValidity: "+1d", sshSignReq: { PubKey: "", Principal: "root", - Validity: "+1d", }, sshUserCert: null, + kubeSignReq: { + CSR: "", + User: "anonymous", + Group: "", + }, + kubeUserCert: null, }; }, methods: { @@ -20,12 +26,22 @@ export default { event.preventDefault(); fetch(`/clusters/${this.cluster.Name}/ssh/user-ca/sign`, { method: 'POST', - body: JSON.stringify(this.sshSignReq), + body: JSON.stringify({ ...this.sshSignReq, Validity: this.signReqValidity }), headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' }, }).then((resp) => resp.blob()) .then((cert) => { this.sshUserCert = URL.createObjectURL(cert) }) .catch((e) => { alert('failed to sign: '+e); }) }, + kubeCASign() { + event.preventDefault(); + fetch(`/clusters/${this.cluster.Name}/kube/sign`, { + method: 'POST', + body: JSON.stringify({ ...this.kubeSignReq, Validity: this.signReqValidity }), + headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' }, + }).then((resp) => resp.blob()) + .then((cert) => { this.kubeUserCert = URL.createObjectURL(cert) }) + .catch((e) => { alert('failed to sign: '+e); }) + }, }, template: `

Tokens

@@ -52,17 +68,34 @@ export default { -

SSH

-
-

User public key (OpenSSH format):
- -

-

Principal:

-

Validity:

- -
+

Access

+ +

Allow cluster access from a public key

+

Certificate time validity: ie: -5m:1w, 5m, 1M, 1y, 1d-1s, etc.

+ +

Grant SSH access

+ +

Public key (OpenSSH format):
+ +

+

Principal:

+ +

- Get user SSH certificate + Get certificate +

+ +

Grant Kubernetes API access

+ +

Certificate signing request (PEM format):
+ +

+

User:

+

User:

+ +

+

+ Get certificate

` }