add ssh user CA support
This commit is contained in:
@ -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
|
run apt-get update && apt-get install -y git
|
||||||
|
|
||||||
|
@ -275,6 +275,10 @@ func (ctx *renderContext) templateFuncs(ctxMap map[string]any) map[string]interf
|
|||||||
return getKeyCert(name, "tls_dir")
|
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) {
|
"ssh_host_keys": func(dir string) (s string) {
|
||||||
return fmt.Sprintf("{{ ssh_host_keys %q %q \"\"}}",
|
return fmt.Sprintf("{{ ssh_host_keys %q %q \"\"}}",
|
||||||
dir, cluster)
|
dir, cluster)
|
||||||
|
@ -69,11 +69,19 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
|
|||||||
cat.AppendBytes(cfgBytes, "config.yaml", 0600)
|
cat.AppendBytes(cfgBytes, "config.yaml", 0600)
|
||||||
|
|
||||||
// ssh keys
|
// 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"} {
|
for _, format := range []string{"rsa", "dsa", "ecdsa", "ed25519"} {
|
||||||
cat.AppendBytes(cfg.FileContent("/etc/ssh/ssh_host_"+format+"_key"), "id_"+format, 0600)
|
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()
|
return cat.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -148,7 +148,7 @@ func (ctx *renderContext) Tag() (string, error) {
|
|||||||
|
|
||||||
enc := yaml.NewEncoder(h)
|
enc := yaml.NewEncoder(h)
|
||||||
|
|
||||||
for _, o := range []interface{}{cfg, ctx} {
|
for _, o := range []any{cfg, ctx} {
|
||||||
if err := enc.Encode(o); err != nil {
|
if err := enc.Encode(o); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@ -157,21 +157,6 @@ func (ctx *renderContext) Tag() (string, error) {
|
|||||||
return hex.EncodeToString(h.Sum(nil)), nil
|
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 {
|
func (ctx *renderContext) TemplateFuncs() map[string]any {
|
||||||
funcs := templateFuncs(ctx.SSLConfig)
|
funcs := templateFuncs(ctx.SSLConfig)
|
||||||
|
|
||||||
@ -187,6 +172,14 @@ func (ctx *renderContext) TemplateFuncs() map[string]any {
|
|||||||
return hex.EncodeToString(ba[:])
|
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) {
|
"ssh_host_keys": func(dir, cluster, host string) (s string, err error) {
|
||||||
if host == "" {
|
if host == "" {
|
||||||
host = ctx.Host.Name
|
host = ctx.Host.Name
|
||||||
|
@ -1,17 +1,19 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/dsa"
|
"bytes"
|
||||||
"crypto/ecdsa"
|
"crypto"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/elliptic"
|
"encoding/pem"
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/asn1"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
var sshHostKeys = KVSecrets[[]SSHKeyPair]{"hosts/ssh-host-keys"}
|
var sshHostKeys = KVSecrets[[]SSHKeyPair]{"hosts/ssh-host-keys"}
|
||||||
@ -104,64 +106,106 @@ genLoop:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func sshKeyGenDSA() (data []byte, pubKey interface{}, err error) {
|
var sshCAKeys = KVSecrets[string]{"ssh-ca-keys"}
|
||||||
privKey := &dsa.PrivateKey{}
|
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = dsa.GenerateKey(privKey, rand.Reader)
|
if caKeyPem == "" {
|
||||||
if err != nil {
|
_, pk, err := ed25519.GenerateKey(nil)
|
||||||
return
|
if err != nil {
|
||||||
}
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
data, err = asn1.Marshal(*privKey)
|
pemBlock, err := ssh.MarshalPrivateKey(crypto.PrivateKey(pk), "")
|
||||||
//data, err = x509.MarshalPKCS8PrivateKey(privKey)
|
if err != nil {
|
||||||
if err != nil {
|
return "", err
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pubKey = privKey.PublicKey
|
caKeyPem = string(pem.EncodeToMemory(pemBlock))
|
||||||
return
|
sshCAKeys.Put(storeKey, caKeyPem)
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
@ -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.AddHeader("Content-Disposition", "attachment; filename="+strconv.Quote(clusterName+"_"+caName+"_"+url.PathEscape(name)+".crt"))
|
||||||
resp.Write(kc.Cert)
|
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)
|
||||||
|
}
|
||||||
|
@ -86,8 +86,9 @@ func registerWS(rest *restful.Container) {
|
|||||||
Doc("Delete a host template instance"))
|
Doc("Delete a host template instance"))
|
||||||
|
|
||||||
const (
|
const (
|
||||||
GET = http.MethodGet
|
GET = http.MethodGet
|
||||||
PUT = http.MethodPut
|
PUT = http.MethodPut
|
||||||
|
POST = http.MethodPost
|
||||||
)
|
)
|
||||||
|
|
||||||
cluster := func(method, subPath string) *restful.RouteBuilder {
|
cluster := func(method, subPath string) *restful.RouteBuilder {
|
||||||
@ -126,6 +127,13 @@ func registerWS(rest *restful.Container) {
|
|||||||
Produces(mime.CERT).
|
Produces(mime.CERT).
|
||||||
Param(ws.QueryParameter("name", "signed reference name").Required(true)).
|
Param(ws.QueryParameter("name", "signed reference name").Required(true)).
|
||||||
Doc("Get cluster's certificate signed by the CA"),
|
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)
|
ws.Route(builder)
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user