diff --git a/internal/app/wwctl/configure/keys/main.go b/internal/app/wwctl/configure/keys/main.go new file mode 100644 index 00000000..78144783 --- /dev/null +++ b/internal/app/wwctl/configure/keys/main.go @@ -0,0 +1,127 @@ +package keys + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/configure" + "github.com/warewulf/warewulf/internal/pkg/util" +) + +func CobraRunE(cmd *cobra.Command, args []string) error { + conf := config.Get() + keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys") + + if err := os.MkdirAll(keystore, 0755); err != nil { + return fmt.Errorf("could not create keystore directory: %w", err) + } + + keyFile := path.Join(keystore, "warewulf.key") + certFile := path.Join(keystore, "warewulf.crt") + + if importPath != "" { + info, err := os.Stat(importPath) + if err != nil { + return fmt.Errorf("could not access import path: %w", err) + } + + var sourceKey, sourceCert string + if info.IsDir() { + sourceKey = path.Join(importPath, "warewulf.key") + sourceCert = path.Join(importPath, "warewulf.crt") + } else { + return fmt.Errorf("import path must be a directory containing warewulf.key and warewulf.crt") + } + + if err := util.CopyFile(sourceKey, keyFile); err != nil { + return fmt.Errorf("failed to import key: %w", err) + } + if err := util.CopyFile(sourceCert, certFile); err != nil { + return fmt.Errorf("failed to import cert: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Imported keys from %s\n", importPath) + + if err := configure.WAREWULFD(); err != nil { + return fmt.Errorf("failed to restart warewulfd: %w", err) + } + + return nil + } + + if exportPath != "" { + if err := os.MkdirAll(exportPath, 0755); err != nil { + return fmt.Errorf("could not create export directory: %w", err) + } + if err := util.CopyFile(keyFile, path.Join(exportPath, "warewulf.key")); err != nil { + return fmt.Errorf("failed to export key: %w", err) + } + if err := util.CopyFile(certFile, path.Join(exportPath, "warewulf.crt")); err != nil { + return fmt.Errorf("failed to export cert: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Exported keys to %s\n", exportPath) + return nil + } + + if util.IsFile(keyFile) && util.IsFile(certFile) && !force { + if create { + fmt.Fprintf(cmd.OutOrStdout(), "Keys already exist in %s\n", keystore) + } + } else { + if create { + if err := configure.GenKeys(); err != nil { + return err + } + if err := configure.WAREWULFD(); err != nil { + return fmt.Errorf("failed to restart warewulfd: %w", err) + } + } else { + return fmt.Errorf("keys not found in: %s", keystore) + } + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + + fmt.Fprintf(w, "Private Key:\t%s\n", keyFile) + fmt.Fprintf(w, "Certificate:\t%s\n", certFile) + + keyBytes, err := os.ReadFile(keyFile) + if err == nil { + block, _ := pem.Decode(keyBytes) + if block != nil { + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + fmt.Fprintf(w, "Key Size:\t%d bits\n", key.N.BitLen()) + } + } + } + + certBytes, err := os.ReadFile(certFile) + if err == nil { + block, _ := pem.Decode(certBytes) + if block != nil { + if cert, err := x509.ParseCertificate(block.Bytes); err == nil { + fmt.Fprintf(w, "Issuer:\t%s\n", cert.Issuer) + fmt.Fprintf(w, "Subject:\t%s\n", cert.Subject) + fmt.Fprintf(w, "Valid From:\t%s\n", cert.NotBefore) + fmt.Fprintf(w, "Valid Until:\t%s\n", cert.NotAfter) + fmt.Fprintf(w, "Serial Nr:\t%s\n", cert.SerialNumber) + + var ipStrings []string + for _, ip := range cert.IPAddresses { + ipStrings = append(ipStrings, ip.String()) + } + fmt.Fprintf(w, "IP Addresses:\t%s\n", strings.Join(ipStrings, ", ")) + fmt.Fprintf(w, "DNS Names:\t%s\n", strings.Join(cert.DNSNames, ", ")) + } + } + } + w.Flush() + + return nil +} diff --git a/internal/app/wwctl/configure/keys/main_test.go b/internal/app/wwctl/configure/keys/main_test.go new file mode 100644 index 00000000..ae7d6e85 --- /dev/null +++ b/internal/app/wwctl/configure/keys/main_test.go @@ -0,0 +1,108 @@ +package keys + +import ( + "bytes" + "path" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/warewulf/warewulf/internal/pkg/testenv" + "github.com/warewulf/warewulf/internal/pkg/wwlog" +) + +func Test_Keys(t *testing.T) { + env := testenv.New(t) + defer env.RemoveAll() + + // Define a keystore path within the test environment + keystoreRelPath := "etc/warewulf/keys" + keystorePath := env.GetPath(keystoreRelPath) + + // Reload configuration to pick up the change + env.Configure() + + t.Run("keys create", func(t *testing.T) { + baseCmd := GetCommand() + // Reset flags + create = true + importPath = "" + exportPath = "" + + baseCmd.SetArgs([]string{"--create"}) + buf := new(bytes.Buffer) + baseCmd.SetOut(buf) + baseCmd.SetErr(buf) + wwlog.SetLogWriter(buf) + + err := baseCmd.Execute() + assert.NoError(t, err) + assert.FileExists(t, path.Join(keystorePath, "warewulf.key")) + assert.FileExists(t, path.Join(keystorePath, "warewulf.crt")) + assert.FileExists(t, path.Join(keystorePath, "warewulf.rsa.pub")) + }) + + t.Run("keys exist check", func(t *testing.T) { + baseCmd := GetCommand() + // Reset flags + create = true // Even with create, it should say they exist + importPath = "" + exportPath = "" + + baseCmd.SetArgs([]string{"--create"}) + buf := new(bytes.Buffer) + baseCmd.SetOut(buf) + baseCmd.SetErr(buf) + wwlog.SetLogWriter(buf) + + err := baseCmd.Execute() + assert.NoError(t, err) + assert.Contains(t, buf.String(), "Keys already exist") + }) + + t.Run("keys display", func(t *testing.T) { + baseCmd := GetCommand() + // Reset flags + create = false + importPath = "" + exportPath = "" + + baseCmd.SetArgs([]string{}) + buf := new(bytes.Buffer) + baseCmd.SetOut(buf) + baseCmd.SetErr(buf) + wwlog.SetLogWriter(buf) + + err := baseCmd.Execute() + assert.NoError(t, err) + assert.Contains(t, buf.String(), "Private Key:") + assert.Contains(t, buf.String(), "Certificate:") + assert.Contains(t, buf.String(), "Key Size:") + assert.Contains(t, buf.String(), "Issuer:") + assert.Contains(t, buf.String(), "Subject:") + assert.Contains(t, buf.String(), "Valid From:") + assert.Contains(t, buf.String(), "Valid Until:") + }) + + t.Run("keys export", func(t *testing.T) { + exportRelPath := "exported_keys" + exportFullPath := env.GetPath(exportRelPath) + + baseCmd := GetCommand() + // Reset flags + create = false + importPath = "" + exportPath = exportFullPath + + baseCmd.SetArgs([]string{"--export", exportFullPath}) + buf := new(bytes.Buffer) + baseCmd.SetOut(buf) + baseCmd.SetErr(buf) + wwlog.SetLogWriter(buf) + + err := baseCmd.Execute() + assert.NoError(t, err) + assert.FileExists(t, path.Join(exportFullPath, "warewulf.key")) + assert.FileExists(t, path.Join(exportFullPath, "warewulf.crt")) + assert.Contains(t, buf.String(), "Exported keys to") + }) +} diff --git a/internal/app/wwctl/configure/keys/root.go b/internal/app/wwctl/configure/keys/root.go new file mode 100644 index 00000000..2614e3e5 --- /dev/null +++ b/internal/app/wwctl/configure/keys/root.go @@ -0,0 +1,35 @@ +package keys + +import ( + "github.com/spf13/cobra" + "github.com/warewulf/warewulf/internal/app/wwctl/completions" +) + +var ( + baseCmd = &cobra.Command{ + DisableFlagsInUseLine: true, + Use: "keys [OPTIONS]", + Short: "Manage and initialize x509 keys", + Long: "This application allows you to manage the x509 keys for Warewulf\n" + + "based on the configuration in the warewulf.conf file.", + RunE: CobraRunE, + Args: cobra.NoArgs, + ValidArgsFunction: completions.None, + } + importPath string + exportPath string + create bool + force bool +) + +func init() { + baseCmd.PersistentFlags().StringVar(&importPath, "import", "", "Import keys from directory") + baseCmd.PersistentFlags().StringVar(&exportPath, "export", "", "Export keys to directory") + baseCmd.PersistentFlags().BoolVar(&create, "create", false, "Create keys if they do not exist") + baseCmd.PersistentFlags().BoolVarP(&force, "force", "f", false, "Enforce creation of keys even if they exist") +} + +// GetCommand returns the root cobra.Command for the application. +func GetCommand() *cobra.Command { + return baseCmd +} diff --git a/internal/app/wwctl/configure/main.go b/internal/app/wwctl/configure/main.go index 366a7930..2db70ec8 100644 --- a/internal/app/wwctl/configure/main.go +++ b/internal/app/wwctl/configure/main.go @@ -1,11 +1,14 @@ package configure import ( + "fmt" "os" + "path" "github.com/spf13/cobra" warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" "github.com/warewulf/warewulf/internal/pkg/configure" + "github.com/warewulf/warewulf/internal/pkg/util" "github.com/warewulf/warewulf/internal/pkg/wwlog" ) @@ -20,6 +23,19 @@ func CobraRunE(cmd *cobra.Command, args []string) error { } if allFunctions { + keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys") + keyFile := path.Join(keystore, "warewulf.key") + certFile := path.Join(keystore, "warewulf.crt") + + if !util.IsFile(keyFile) || !util.IsFile(certFile) { + err = configure.GenKeys() + if err != nil { + return err + } + } else { + fmt.Printf("Keys already exist in %s\n", keystore) + } + err = configure.WAREWULFD() if err != nil { return err diff --git a/internal/app/wwctl/configure/root.go b/internal/app/wwctl/configure/root.go index cdacd64d..3649a095 100644 --- a/internal/app/wwctl/configure/root.go +++ b/internal/app/wwctl/configure/root.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/warewulf/warewulf/internal/app/wwctl/configure/dhcp" "github.com/warewulf/warewulf/internal/app/wwctl/configure/hostfile" + "github.com/warewulf/warewulf/internal/app/wwctl/configure/keys" "github.com/warewulf/warewulf/internal/app/wwctl/configure/nfs" "github.com/warewulf/warewulf/internal/app/wwctl/configure/ssh" "github.com/warewulf/warewulf/internal/app/wwctl/configure/tftp" @@ -29,6 +30,7 @@ func init() { baseCmd.AddCommand(ssh.GetCommand()) baseCmd.AddCommand(nfs.GetCommand()) baseCmd.AddCommand(hostfile.GetCommand()) + baseCmd.AddCommand(keys.GetCommand()) baseCmd.AddCommand(warewulfd.GetCommand()) baseCmd.Flags().BoolVarP(&allFunctions, "all", "a", false, "Configure all services") diff --git a/internal/pkg/config/buildconfig.go.in b/internal/pkg/config/buildconfig.go.in index a0ff26f9..5ce7aad8 100644 --- a/internal/pkg/config/buildconfig.go.in +++ b/internal/pkg/config/buildconfig.go.in @@ -42,7 +42,9 @@ func (conf TFTPConf) Enabled() bool { // BaseConf. type WarewulfConf struct { Port int `yaml:"port,omitempty" default:"9873"` + SecurePort int `yaml:"secure port,omitempty" default:"9874"` SecureP *bool `yaml:"secure,omitempty" default:"true"` + EnableHttpsP *bool `yaml:"enable https,omitempty" default:"false"` UpdateInterval int `yaml:"update interval,omitempty" default:"60"` AutobuildOverlaysP *bool `yaml:"autobuild overlays,omitempty" default:"true"` EnableHostOverlayP *bool `yaml:"host overlay,omitempty" default:"true"` @@ -54,6 +56,10 @@ func (conf WarewulfConf) Secure() bool { return util.BoolP(conf.SecureP) } +func (conf WarewulfConf) EnableHttps() bool { + return util.BoolP(conf.EnableHttpsP) +} + func (conf WarewulfConf) AutobuildOverlays() bool { return util.BoolP(conf.AutobuildOverlaysP) } diff --git a/internal/pkg/config/root_test.go b/internal/pkg/config/root_test.go index bbbdc016..5f49f253 100644 --- a/internal/pkg/config/root_test.go +++ b/internal/pkg/config/root_test.go @@ -17,10 +17,12 @@ func TestParse(t *testing.T) { result: ` warewulf: autobuild overlays: true + enable https: false grubboot: false host overlay: true port: 9873 secure: true + secure port: 9874 update interval: 60 nfs: enabled: true @@ -63,10 +65,12 @@ network: 192.168.0.0 netmask: 255.255.255.0 warewulf: autobuild overlays: true + enable https: false grubboot: false host overlay: true port: 9873 secure: true + secure port: 9874 update interval: 60 nfs: enabled: true @@ -111,10 +115,12 @@ network: 192.168.0.0 netmask: 255.255.0.0 warewulf: autobuild overlays: true + enable https: false grubboot: false host overlay: true port: 9873 secure: true + secure port: 9874 update interval: 60 nfs: enabled: true @@ -156,10 +162,12 @@ ipaddr6: "2001:db8::1" prefixlen6: "64" warewulf: autobuild overlays: true + enable https: false grubboot: false host overlay: true port: 9873 secure: true + secure port: 9874 update interval: 60 nfs: enabled: true @@ -230,10 +238,12 @@ netmask: 255.255.255.0 network: 192.168.200.0 warewulf: autobuild overlays: true + enable https: false grubboot: false host overlay: true port: 9873 secure: false + secure port: 9874 update interval: 60 nfs: enabled: true diff --git a/internal/pkg/configure/keys.go b/internal/pkg/configure/keys.go new file mode 100644 index 00000000..c54b55ea --- /dev/null +++ b/internal/pkg/configure/keys.go @@ -0,0 +1,104 @@ +package configure + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "path" + "time" + + warewulfconf "github.com/warewulf/warewulf/internal/pkg/config" + "github.com/warewulf/warewulf/internal/pkg/wwlog" +) + +/* +GenKeys checks for existence of x509 keys and creates them if they don't exist. +*/ +func GenKeys() error { + conf := warewulfconf.Get() + keystore := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys") + + keyFile := path.Join(keystore, "warewulf.key") + certFile := path.Join(keystore, "warewulf.crt") + pubFile := path.Join(keystore, "warewulf.rsa.pub") + wwlog.Verbose("Generating new x509 keys in %s", keystore) + priv, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return fmt.Errorf("failed to generate rsa key: %w", err) + } + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return fmt.Errorf("failed to generate serial number: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: "Warewulf Server", + Organization: []string{"Warewulf"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * 365 * 10), + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + if ip := net.ParseIP(conf.Ipaddr); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } + if ip := net.ParseIP(conf.Ipaddr6); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } + if conf.Fqdn != "" { + template.DNSNames = append(template.DNSNames, conf.Fqdn) + } + template.DNSNames = append(template.DNSNames, "warewulf") + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return fmt.Errorf("failed to create certificate: %w", err) + } + + keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to open key file for writing: %w", err) + } + defer keyOut.Close() + if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil { + return fmt.Errorf("failed to write data to key file: %w", err) + } + + certOut, err := os.OpenFile(certFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to open cert file for writing: %w", err) + } + defer certOut.Close() + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + return fmt.Errorf("failed to write data to cert file: %w", err) + } + + pubBytes, err := x509.MarshalPKIXPublicKey(&priv.PublicKey) + if err != nil { + return fmt.Errorf("failed to marshal public key: %w", err) + } + pubOut, err := os.OpenFile(pubFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to open public key file for writing: %w", err) + } + defer pubOut.Close() + if err := pem.Encode(pubOut, &pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}); err != nil { + return fmt.Errorf("failed to write data to public key file: %w", err) + } + + return nil +} diff --git a/internal/pkg/warewulfd/provision.go b/internal/pkg/warewulfd/provision.go index afe842ab..dffc4699 100644 --- a/internal/pkg/warewulfd/provision.go +++ b/internal/pkg/warewulfd/provision.go @@ -39,6 +39,7 @@ type templateVars struct { KernelArgs string KernelVersion string Root string + Https bool Tags map[string]string NetDevs map[string]*node.NetDev } @@ -247,6 +248,7 @@ func ProvisionSend(w http.ResponseWriter, req *http.Request) { Ipaddr: conf.Ipaddr, Ipaddr6: ipaddr6, Port: strconv.Itoa(conf.Warewulf.Port), + Https: conf.Warewulf.EnableHttps(), Authority: authority, Hostname: remoteNode.Id(), Hwaddr: rinfo.hwaddr, diff --git a/internal/pkg/warewulfd/server/server.go b/internal/pkg/warewulfd/server/server.go index 26bac7f8..3587c553 100644 --- a/internal/pkg/warewulfd/server/server.go +++ b/internal/pkg/warewulfd/server/server.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "os/signal" + "path" "strconv" "strings" "syscall" @@ -82,6 +83,24 @@ func RunServer() error { defaultHandler.ServeHTTP(w, r) } }) + + if conf.Warewulf.EnableHttps() { + key := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys", "warewulf.key") + crt := path.Join(conf.Paths.Sysconfdir, "warewulf", "keys", "warewulf.crt") + + if !util.IsFile(key) || !util.IsFile(crt) { + wwlog.Error("HTTPS enabled but keys not found in %s", path.Join(conf.Paths.Sysconfdir, "warewulf", "keys")) + } else { + go func() { + wwlog.Info("Starting HTTPS service on port %d", conf.Warewulf.SecurePort) + if err := http.ListenAndServeTLS(":"+strconv.Itoa(conf.Warewulf.SecurePort), crt, key, dispatchHandler); err != nil { + wwlog.Error("Could not start HTTPS service: %s", err) + } + }() + } + } + + wwlog.Info("Starting HTTP service on port %d", daemonPort) if err := http.ListenAndServe(":"+strconv.Itoa(daemonPort), dispatchHandler); err != nil { return fmt.Errorf("could not start listening service: %w", err) }