Import
This commit is contained in:
259
cert.go
Normal file
259
cert.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CertConfig holds paths and settings for certificate and kubeconfig generation.
|
||||
type CertConfig struct {
|
||||
ServerURL string // written as the cluster.server in the returned kubeconfig
|
||||
ServerCACert string // path — embedded in the kubeconfig so kubectl can verify the API server
|
||||
ClientCACert string // path — signs the per-user client certificates
|
||||
ClientCAKey string // path
|
||||
Duration time.Duration // validity period for generated client certs
|
||||
ClusterName string // name used for the cluster/context in generated kubeconfigs
|
||||
}
|
||||
|
||||
// KubeconfigGenerator loads the k3s CAs once and issues per-user kubeconfigs on demand.
|
||||
type KubeconfigGenerator struct {
|
||||
cfg *CertConfig
|
||||
caCert *x509.Certificate
|
||||
caKey crypto.PrivateKey
|
||||
serverCA string // base64-encoded PEM of the server CA, ready to paste into kubeconfig
|
||||
}
|
||||
|
||||
// NewKubeconfigGenerator reads the k3s CA files and prepares the generator.
|
||||
func NewKubeconfigGenerator(cfg *CertConfig) (*KubeconfigGenerator, error) {
|
||||
caCert, caKey, err := loadCA(cfg.ClientCACert, cfg.ClientCAKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading client CA: %w", err)
|
||||
}
|
||||
|
||||
serverCAPEM, err := os.ReadFile(cfg.ServerCACert)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading server CA %s: %w", cfg.ServerCACert, err)
|
||||
}
|
||||
|
||||
return &KubeconfigGenerator{
|
||||
cfg: cfg,
|
||||
caCert: caCert,
|
||||
caKey: caKey,
|
||||
serverCA: base64.StdEncoding.EncodeToString(serverCAPEM),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Credential holds the raw PEM blobs and expiry for a generated client certificate.
|
||||
// Used both for kubeconfig generation and the /credential exec-plugin endpoint.
|
||||
type Credential struct {
|
||||
CertPEM []byte
|
||||
KeyPEM []byte
|
||||
Expiry time.Time
|
||||
}
|
||||
|
||||
// GenerateCredential signs a fresh client certificate and returns the raw PEM data.
|
||||
// Use this when you need the cert material directly (e.g. the exec credential plugin).
|
||||
func (g *KubeconfigGenerator) GenerateCredential(username string, groups []string) (*Credential, error) {
|
||||
certPEM, keyPEM, err := g.signClientCert(username, groups)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("signing cert for %s: %w", username, err)
|
||||
}
|
||||
block, _ := pem.Decode(certPEM)
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing signed cert: %w", err)
|
||||
}
|
||||
return &Credential{CertPEM: certPEM, KeyPEM: keyPEM, Expiry: cert.NotAfter}, nil
|
||||
}
|
||||
|
||||
// Generate produces a kubeconfig with a freshly signed client certificate for username.
|
||||
// groups are embedded as the certificate's Organisation field, which Kubernetes reads
|
||||
// as RBAC group memberships.
|
||||
func (g *KubeconfigGenerator) Generate(username string, groups []string) ([]byte, error) {
|
||||
cred, err := g.GenerateCredential(username, groups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return renderKubeconfig(
|
||||
g.cfg.ServerURL,
|
||||
g.serverCA,
|
||||
g.cfg.ClusterName,
|
||||
username,
|
||||
base64.StdEncoding.EncodeToString(cred.CertPEM),
|
||||
base64.StdEncoding.EncodeToString(cred.KeyPEM),
|
||||
)
|
||||
}
|
||||
|
||||
// signClientCert issues an ECDSA P-256 client certificate signed by the k3s client CA.
|
||||
func (g *KubeconfigGenerator) signClientCert(username string, groups []string) (certPEM, keyPEM []byte, err error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generating key: %w", err)
|
||||
}
|
||||
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generating serial number: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: username,
|
||||
Organization: groups, // Kubernetes maps these to RBAC groups
|
||||
},
|
||||
NotBefore: now.Add(-5 * time.Minute), // tolerate minor clock skew
|
||||
NotAfter: now.Add(g.cfg.Duration),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, g.caCert, priv.Public(), g.caKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("signing certificate: %w", err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
|
||||
privDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshaling private key: %w", err)
|
||||
}
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER})
|
||||
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// loadCA reads a PEM certificate and its corresponding private key from disk.
|
||||
// It handles EC, RSA, and PKCS#8 key formats.
|
||||
func loadCA(certFile, keyFile string) (*x509.Certificate, crypto.PrivateKey, error) {
|
||||
certPEM, err := os.ReadFile(certFile)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("reading %s: %w", certFile, err)
|
||||
}
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
return nil, nil, fmt.Errorf("no PEM block in %s", certFile)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parsing certificate in %s: %w", certFile, err)
|
||||
}
|
||||
|
||||
keyPEM, err := os.ReadFile(keyFile)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("reading %s: %w", keyFile, err)
|
||||
}
|
||||
keyBlock, _ := pem.Decode(keyPEM)
|
||||
if keyBlock == nil {
|
||||
return nil, nil, fmt.Errorf("no PEM block in %s", keyFile)
|
||||
}
|
||||
|
||||
var key crypto.PrivateKey
|
||||
switch keyBlock.Type {
|
||||
case "EC PRIVATE KEY":
|
||||
key, err = x509.ParseECPrivateKey(keyBlock.Bytes)
|
||||
case "RSA PRIVATE KEY":
|
||||
key, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
|
||||
case "PRIVATE KEY":
|
||||
key, err = x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported key type %q in %s", keyBlock.Type, keyFile)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parsing key in %s: %w", keyFile, err)
|
||||
}
|
||||
return cert, key, nil
|
||||
}
|
||||
|
||||
// GenerateBootstrap returns a kubeconfig that contains no user credentials —
|
||||
// just the cluster endpoint, server CA, and an exec plugin stanza that will
|
||||
// invoke "ward credential --server=<wardURL>" on demand.
|
||||
// Distribute this file to users instead of manually constructing it.
|
||||
func (g *KubeconfigGenerator) GenerateBootstrap(wardURL, username string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
err := bootstrapTmpl.Execute(&buf, map[string]string{
|
||||
"Server": g.cfg.ServerURL,
|
||||
"ServerCA": g.serverCA,
|
||||
"WardURL": wardURL,
|
||||
"Username": username,
|
||||
"Cluster": g.cfg.ClusterName,
|
||||
})
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
|
||||
var bootstrapTmpl = template.Must(template.New("bootstrap").Parse(
|
||||
`apiVersion: v1
|
||||
kind: Config
|
||||
preferences: {}
|
||||
clusters:
|
||||
- cluster:
|
||||
certificate-authority-data: {{.ServerCA}}
|
||||
server: {{.Server}}
|
||||
name: {{.Cluster}}
|
||||
contexts:
|
||||
- context:
|
||||
cluster: {{.Cluster}}
|
||||
user: {{.Username}}
|
||||
name: {{.Cluster}}
|
||||
current-context: {{.Cluster}}
|
||||
users:
|
||||
- name: {{.Username}}
|
||||
user:
|
||||
exec:
|
||||
apiVersion: client.authentication.k8s.io/v1
|
||||
command: ward
|
||||
args:
|
||||
- credential
|
||||
- --server={{.WardURL}}
|
||||
interactiveMode: IfAvailable
|
||||
provideClusterInfo: false
|
||||
`))
|
||||
|
||||
var kubeconfigTmpl = template.Must(template.New("kubeconfig").Parse(
|
||||
`apiVersion: v1
|
||||
kind: Config
|
||||
preferences: {}
|
||||
clusters:
|
||||
- cluster:
|
||||
certificate-authority-data: {{.ServerCA}}
|
||||
server: {{.Server}}
|
||||
name: {{.Cluster}}
|
||||
contexts:
|
||||
- context:
|
||||
cluster: {{.Cluster}}
|
||||
user: {{.Username}}
|
||||
name: {{.Cluster}}
|
||||
current-context: {{.Cluster}}
|
||||
users:
|
||||
- name: {{.Username}}
|
||||
user:
|
||||
client-certificate-data: {{.ClientCert}}
|
||||
client-key-data: {{.ClientKey}}
|
||||
`))
|
||||
|
||||
func renderKubeconfig(server, serverCA, cluster, username, clientCert, clientKey string) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
err := kubeconfigTmpl.Execute(&buf, map[string]string{
|
||||
"Server": server,
|
||||
"ServerCA": serverCA,
|
||||
"Cluster": cluster,
|
||||
"Username": username,
|
||||
"ClientCert": clientCert,
|
||||
"ClientKey": clientKey,
|
||||
})
|
||||
return buf.Bytes(), err
|
||||
}
|
||||
Reference in New Issue
Block a user