add kube CSR access

This commit is contained in:
Mikaël Cluseau
2025-07-02 21:47:08 +02:00
parent 9ad7715a29
commit 20b6769cbb
4 changed files with 242 additions and 13 deletions

View File

@ -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 <nil>
// 0001-01-01 00:00:00 +0000 UTC invalid duration: "hi!"
// 2020-04-26 09:30:00 +0000 UTC <nil>
// 2020-04-30 15:30:00 +0000 UTC <nil>
// 2021-04-28 12:29:59 +0000 UTC <nil>
}

View File

@ -1,10 +1,18 @@
package main package main
import ( import (
"errors"
"fmt"
"log" "log"
"net/url" "net/url"
"regexp"
"strconv" "strconv"
"strings"
"time"
"github.com/cloudflare/cfssl/config"
"github.com/cloudflare/cfssl/csr"
"github.com/cloudflare/cfssl/signer"
restful "github.com/emicklei/go-restful" restful "github.com/emicklei/go-restful"
"novit.tech/direktil/local-server/pkg/mime" "novit.tech/direktil/local-server/pkg/mime"
@ -151,7 +159,25 @@ func wsClusterSSHUserCASign(req *restful.Request, resp *restful.Response) {
return 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 { if err != nil {
wsError(resp, err) wsError(resp, err)
return return
@ -159,3 +185,147 @@ func wsClusterSSHUserCASign(req *restful.Request, resp *restful.Response) {
resp.Write(cert) 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
}

View File

@ -134,6 +134,9 @@ func registerWS(rest *restful.Container) {
cluster(POST, "/ssh/user-ca/sign").To(wsClusterSSHUserCASign). cluster(POST, "/ssh/user-ca/sign").To(wsClusterSSHUserCASign).
Produces(mime.OCTET). Produces(mime.OCTET).
Doc("Sign a user's SSH public key for this cluster"), 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) ws.Route(builder)
} }

View File

@ -7,12 +7,18 @@ export default {
props: [ 'cluster', 'token', 'state' ], props: [ 'cluster', 'token', 'state' ],
data() { data() {
return { return {
signReqValidity: "1d",
sshSignReq: { sshSignReq: {
PubKey: "", PubKey: "",
Principal: "root", Principal: "root",
Validity: "+1d",
}, },
sshUserCert: null, sshUserCert: null,
kubeSignReq: {
CSR: "",
User: "anonymous",
Group: "",
},
kubeUserCert: null,
}; };
}, },
methods: { methods: {
@ -20,12 +26,22 @@ export default {
event.preventDefault(); event.preventDefault();
fetch(`/clusters/${this.cluster.Name}/ssh/user-ca/sign`, { fetch(`/clusters/${this.cluster.Name}/ssh/user-ca/sign`, {
method: 'POST', method: 'POST',
body: JSON.stringify(this.sshSignReq), body: JSON.stringify({ ...this.sshSignReq, Validity: this.signReqValidity }),
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' }, headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
}).then((resp) => resp.blob()) }).then((resp) => resp.blob())
.then((cert) => { this.sshUserCert = URL.createObjectURL(cert) }) .then((cert) => { this.sshUserCert = URL.createObjectURL(cert) })
.catch((e) => { alert('failed to sign: '+e); }) .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: ` template: `
<h3>Tokens</h3> <h3>Tokens</h3>
@ -52,17 +68,34 @@ export default {
</template></td> </template></td>
</tr></table> </tr></table>
<h3>SSH</h3> <h3>Access</h3>
<form @submit="sshCASign()" action="">
<p>User public key (OpenSSH format):<br/> <p>Allow cluster access from a public key</p>
<textarea v-model="sshSignReq.PubKey"></textarea> <p>Certificate time validity: <input type="text" v-model="signReqValidity"/> <small>ie: -5m:1w, 5m, 1M, 1y, 1d-1s, etc.</p>
<h4>Grant SSH access</h4>
<p>Public key (OpenSSH format):<br/>
<textarea v-model="sshSignReq.PubKey" style="width:64em;height:2lh"></textarea>
</p> </p>
<p>Principal: <input type="text" v-model="sshSignReq.Principal"/></p> <p>Principal: <input type="text" v-model="sshSignReq.Principal"/></p>
<p>Validity: <input type="text" v-model="sshSignReq.Validity"/></p>
<input type="submit" value="Sign key" /> <p><button @click="sshCASign">Sign SSH access request</button></p>
</form>
<p v-if="sshUserCert"> <p v-if="sshUserCert">
<a :href="sshUserCert" download="ssh-cert.pub">Get user SSH certificate</a> <a :href="sshUserCert" download="ssh-cert.pub">Get certificate</a>
</p>
<h4>Grant Kubernetes API access</h4>
<p>Certificate signing request (PEM format):<br/>
<textarea v-model="kubeSignReq.CSR" style="width:64em;height:7lh;"></textarea>
</p>
<p>User: <input type="text" v-model="kubeSignReq.User"/></p>
<p>Group: <input type="text" v-model="kubeSignReq.Group"/></p>
<p><button @click="kubeCASign">Sign Kubernetes API access request</button></p>
<p v-if="kubeUserCert">
<a :href="kubeUserCert" download="kube-cert.pub">Get certificate</a>
</p> </p>
` `
} }