diff --git a/Dockerfile b/Dockerfile index 2ce7c94..90a10b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ------------------------------------------------------------------------ -from golang:1.24.3-bookworm as build +from golang:1.24.4-bookworm as build run apt-get update && apt-get install -y git diff --git a/cmd/dkl-dir2config/render-context.go b/cmd/dkl-dir2config/render-context.go index bc0ff3a..481ee42 100644 --- a/cmd/dkl-dir2config/render-context.go +++ b/cmd/dkl-dir2config/render-context.go @@ -275,6 +275,10 @@ func (ctx *renderContext) templateFuncs(ctxMap map[string]any) map[string]interf return getKeyCert(name, "tls_dir") }, + "ssh_user_ca": func(path string) (s string) { + return fmt.Sprintf("{{ ssh_user_ca %q %q}}", + path, cluster) + }, "ssh_host_keys": func(dir string) (s string) { return fmt.Sprintf("{{ ssh_host_keys %q %q \"\"}}", dir, cluster) diff --git a/cmd/dkl-local-server/bootv2.go b/cmd/dkl-local-server/bootv2.go index 836f207..3420a0c 100644 --- a/cmd/dkl-local-server/bootv2.go +++ b/cmd/dkl-local-server/bootv2.go @@ -69,11 +69,19 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) { cat.AppendBytes(cfgBytes, "config.yaml", 0600) // ssh keys - // FIXME we want a bootstrap-stage key instead of the real host key + // XXX do we want a bootstrap-stage key instead of the real host key? for _, format := range []string{"rsa", "dsa", "ecdsa", "ed25519"} { cat.AppendBytes(cfg.FileContent("/etc/ssh/ssh_host_"+format+"_key"), "id_"+format, 0600) } + // ssh user CA + userCA, err := sshCAPubKey(ctx.Host.ClusterName) + if err != nil { + return fmt.Errorf("failed to get SSH user CA: %w", err) + } + + cat.AppendBytes(userCA, "user_ca.pub", 0600) + return cat.Close() } diff --git a/cmd/dkl-local-server/render-context.go b/cmd/dkl-local-server/render-context.go index 23aec16..b897365 100644 --- a/cmd/dkl-local-server/render-context.go +++ b/cmd/dkl-local-server/render-context.go @@ -148,7 +148,7 @@ func (ctx *renderContext) Tag() (string, error) { enc := yaml.NewEncoder(h) - for _, o := range []interface{}{cfg, ctx} { + for _, o := range []any{cfg, ctx} { if err := enc.Encode(o); err != nil { return "", err } @@ -157,21 +157,6 @@ func (ctx *renderContext) Tag() (string, error) { return hex.EncodeToString(h.Sum(nil)), nil } -func asMap(v interface{}) map[string]interface{} { - ba, err := yaml.Marshal(v) - if err != nil { - panic(err) // shouldn't happen - } - - result := make(map[string]interface{}) - - if err := yaml.Unmarshal(ba, result); err != nil { - panic(err) // shouldn't happen - } - - return result -} - func (ctx *renderContext) TemplateFuncs() map[string]any { funcs := templateFuncs(ctx.SSLConfig) @@ -187,6 +172,14 @@ func (ctx *renderContext) TemplateFuncs() map[string]any { return hex.EncodeToString(ba[:]) }, + "ssh_user_ca": func(path, cluster string) (s string, err error) { + userCA, err := sshCAPubKey(cluster) + return asYaml([]config.FileDef{{ + Path: path, + Mode: 0644, + Content: string(userCA), + }}) + }, "ssh_host_keys": func(dir, cluster, host string) (s string, err error) { if host == "" { host = ctx.Host.Name diff --git a/cmd/dkl-local-server/ssh-secrets.go b/cmd/dkl-local-server/ssh-secrets.go index 4353754..3c1e3ac 100644 --- a/cmd/dkl-local-server/ssh-secrets.go +++ b/cmd/dkl-local-server/ssh-secrets.go @@ -1,17 +1,19 @@ package main import ( - "crypto/dsa" - "crypto/ecdsa" + "bytes" + "crypto" "crypto/ed25519" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/asn1" + "encoding/pem" "fmt" + "io" "os" "os/exec" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/ssh" ) var sshHostKeys = KVSecrets[[]SSHKeyPair]{"hosts/ssh-host-keys"} @@ -104,64 +106,106 @@ genLoop: return } -func sshKeyGenDSA() (data []byte, pubKey interface{}, err error) { - privKey := &dsa.PrivateKey{} +var sshCAKeys = KVSecrets[string]{"ssh-ca-keys"} - err = dsa.GenerateParameters(&privKey.Parameters, rand.Reader, dsa.L1024N160) +func sshCAKey(cluster string) (caKeyPem string, err error) { + storeKey := "clusters/" + cluster + caKeyPem, _, err = sshCAKeys.Get(storeKey) if err != nil { return } - err = dsa.GenerateKey(privKey, rand.Reader) - if err != nil { - return - } + if caKeyPem == "" { + _, pk, err := ed25519.GenerateKey(nil) + if err != nil { + return "", err + } - data, err = asn1.Marshal(*privKey) - //data, err = x509.MarshalPKCS8PrivateKey(privKey) - if err != nil { - return - } + pemBlock, err := ssh.MarshalPrivateKey(crypto.PrivateKey(pk), "") + if err != nil { + return "", err + } - pubKey = privKey.PublicKey - return -} - -func sshKeyGenRSA() (data []byte, pubKey interface{}, err error) { - privKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return - } - - data = x509.MarshalPKCS1PrivateKey(privKey) - pubKey = privKey.Public() - - return -} - -func sshKeyGenECDSA() (data []byte, pubKey interface{}, err error) { - privKey, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) - if err != nil { - return - } - - data, err = x509.MarshalPKCS8PrivateKey(privKey) - if err != nil { - return - } - - pubKey = privKey.Public() - - return -} - -func sshKeyGenED25519() (data []byte, pubKey interface{}, err error) { - pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) - - data, err = x509.MarshalPKCS8PrivateKey(privKey) - if err != nil { - return + caKeyPem = string(pem.EncodeToMemory(pemBlock)) + sshCAKeys.Put(storeKey, caKeyPem) } return } + +func sshCAPubKey(cluster string) (pubKey []byte, err error) { + keyPem, err := sshCAKey(cluster) + if err != nil { + return + } + + k, err := ssh.ParsePrivateKey([]byte(keyPem)) + if err != nil { + return + } + + pubKey = ssh.MarshalAuthorizedKey(k.PublicKey()) + return +} + +// principal: user (login) to allow (ie: "root") +// validity: ssh-keygen validity string (ie: "+1h", "202506280811:202506281011", ""=forever) +// options: ssh-keygen options (ie: "force-command=/bin/date +\"%F %T\"", "source-address=192.168.1.0/24,192.168.42.0/24" +func sshCASign(cluster string, userPubKey []byte, principal, validity string, options ...string) (cert []byte, err error) { + caKey, err := sshCAKey(cluster) + if err != nil { + return + } + + _, identity, _, _, err := ssh.ParseAuthorizedKey(userPubKey) + if err != nil { + return + } + + userPubKeyFile, err := os.CreateTemp("/tmp", "user.pub") + if err != nil { + return + } + defer os.Remove(userPubKeyFile.Name()) + + _, err = io.Copy(userPubKeyFile, bytes.NewBuffer(userPubKey)) + userPubKeyFile.Close() + if err != nil { + return + } + + err = os.WriteFile(userPubKeyFile.Name(), userPubKey, 0600) + if err != nil { + return + } + + serial := strconv.FormatInt(time.Now().Unix(), 10) + cmd := exec.Command("ssh-keygen", "-q", "-s", "/dev/stdin", "-I", identity, "-z", serial, "-n", principal) + + if validity != "" { + cmd.Args = append(cmd.Args, "-V", validity) + } + + for _, opt := range options { + cmd.Args = append(cmd.Args, "-O", opt) + } + + cmd.Args = append(cmd.Args, userPubKeyFile.Name()) + + stderr := new(bytes.Buffer) + cmd.Stdin = bytes.NewBuffer([]byte(caKey)) + cmd.Stderr = stderr + + err = cmd.Run() + if err != nil { + err = fmt.Errorf("ssh-keygen sign failed: %s", strings.TrimSpace(stderr.String())) + return + } + + certFile := userPubKeyFile.Name() + "-cert.pub" + cert, err = os.ReadFile(certFile) + + os.Remove(certFile) + + return +} diff --git a/cmd/dkl-local-server/ws-clusters.go b/cmd/dkl-local-server/ws-clusters.go index b380763..52e2d8f 100644 --- a/cmd/dkl-local-server/ws-clusters.go +++ b/cmd/dkl-local-server/ws-clusters.go @@ -121,3 +121,41 @@ func wsClusterSignedCert(req *restful.Request, resp *restful.Response) { resp.AddHeader("Content-Disposition", "attachment; filename="+strconv.Quote(clusterName+"_"+caName+"_"+url.PathEscape(name)+".crt")) resp.Write(kc.Cert) } + +type SSHSignReq struct { + PubKey string + Principal string + Validity string + Options []string +} + +func wsClusterSSHUserCAPubKey(req *restful.Request, resp *restful.Response) { + clusterName := req.PathParameter("cluster-name") + + pubkey, err := sshCAPubKey(clusterName) + if err != nil { + wsError(resp, err) + return + } + + resp.Write(pubkey) +} + +func wsClusterSSHUserCASign(req *restful.Request, resp *restful.Response) { + clusterName := req.PathParameter("cluster-name") + + signReq := SSHSignReq{} + err := req.ReadEntity(&signReq) + if err != nil { + wsError(resp, err) + return + } + + cert, err := sshCASign(clusterName, []byte(signReq.PubKey), signReq.Principal, signReq.Validity, signReq.Options...) + if err != nil { + wsError(resp, err) + return + } + + resp.Write(cert) +} diff --git a/cmd/dkl-local-server/ws.go b/cmd/dkl-local-server/ws.go index aed5f4e..204d112 100644 --- a/cmd/dkl-local-server/ws.go +++ b/cmd/dkl-local-server/ws.go @@ -86,8 +86,9 @@ func registerWS(rest *restful.Container) { Doc("Delete a host template instance")) const ( - GET = http.MethodGet - PUT = http.MethodPut + GET = http.MethodGet + PUT = http.MethodPut + POST = http.MethodPost ) cluster := func(method, subPath string) *restful.RouteBuilder { @@ -126,6 +127,13 @@ func registerWS(rest *restful.Container) { Produces(mime.CERT). Param(ws.QueryParameter("name", "signed reference name").Required(true)). Doc("Get cluster's certificate signed by the CA"), + + cluster(GET, "/ssh/user-ca").To(wsClusterSSHUserCAPubKey). + Produces(mime.OCTET). + Doc("User CA public key for this cluster"), + cluster(POST, "/ssh/user-ca/sign").To(wsClusterSSHUserCASign). + Produces(mime.OCTET). + Doc("Sign a user's SSH public key for this cluster"), } { ws.Route(builder) }