From a7264370f547ac925e0c4bc4d9168760c7bc1875 Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Sat, 4 Feb 2023 11:40:07 +0400 Subject: [PATCH] Introduce "controller init" and generate self-signed X.509 certificate (#17) --- internal/command/controller/controller.go | 8 +- internal/command/controller/init.go | 141 ++++++++++++++++++++++ internal/command/controller/run.go | 42 ++++++- internal/command/dev/dev.go | 19 +-- internal/command/worker/run.go | 2 +- internal/command/worker/worker.go | 6 +- internal/controller/controller.go | 6 +- internal/controller/datadir.go | 92 +++++++++++++- internal/controller/option.go | 2 +- internal/worker/option.go | 4 +- internal/worker/worker.go | 12 +- 11 files changed, 301 insertions(+), 33 deletions(-) create mode 100644 internal/command/controller/init.go diff --git a/internal/command/controller/controller.go b/internal/command/controller/controller.go index d64ec90..61e2051 100644 --- a/internal/command/controller/controller.go +++ b/internal/command/controller/controller.go @@ -7,7 +7,7 @@ import ( "path/filepath" ) -var dataDir string +var dataDirPath string func NewCommand() *cobra.Command { command := &cobra.Command{ @@ -15,15 +15,15 @@ func NewCommand() *cobra.Command { Short: "Initialize and run a controller on the local machine", } - command.AddCommand(newRunCommand()) + command.AddCommand(newInitCommand(), newRunCommand()) orchardHome, err := orchardhome.Path() if err != nil { log.Fatal(err) } - command.PersistentFlags().StringVar(&dataDir, "data-dir", filepath.Join(orchardHome, "controller"), - "path to the data directory") + command.PersistentFlags().StringVar(&dataDirPath, "data-dir", filepath.Join(orchardHome, "controller"), + "path to the data controller's directory") return command } diff --git a/internal/command/controller/init.go b/internal/command/controller/init.go new file mode 100644 index 0000000..ac1403a --- /dev/null +++ b/internal/command/controller/init.go @@ -0,0 +1,141 @@ +package controller + +import ( + "crypto/ecdsa" + "crypto/elliptic" + cryptorand "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "github.com/cirruslabs/orchard/internal/controller" + "github.com/spf13/cobra" + "math/big" + "time" +) + +var ErrInitFailed = errors.New("controller initialization failed") + +var controllerCertPath string +var controllerKeyPath string +var force bool + +func newInitCommand() *cobra.Command { + command := &cobra.Command{ + Use: "init", + Short: "Initialize the controller", + RunE: runInit, + } + + command.PersistentFlags().StringVar(&controllerCertPath, "controller-cert", "", + "do not auto-generate the controller certificate, import it from the specified path instead"+ + " (requires --controller-key)") + command.PersistentFlags().StringVar(&controllerKeyPath, "controller-key", "", + "do not auto-generate the controller certificate key, import it from the specified path instead"+ + " (requires --controller-cert)") + command.PersistentFlags().BoolVar(&force, "force", false, + "force re-initialization if the controller is already initialized") + + return command +} + +func runInit(cmd *cobra.Command, args []string) (err error) { + var controllerCert tls.Certificate + + dataDir, err := controller.NewDataDir(dataDirPath) + if err != nil { + return err + } + + initialized, err := dataDir.Initialized() + if err != nil { + return err + } + + if initialized && !force { + return fmt.Errorf("%w: controller is already initialized, preventing overwrite; "+ + "please specify \"--force\" to re-initialize", ErrInitFailed) + } + + if controllerCertPath != "" || controllerKeyPath != "" { + if err := checkBothCertAndKeyAreSpecified(); err != nil { + return err + } + + controllerCert, err = tls.LoadX509KeyPair(controllerCertPath, controllerCertPath) + if err != nil { + return err + } + } else { + controllerCert, err = generateSelfSignedControllerCertificate() + if err != nil { + return err + } + } + + if err := dataDir.SetControllerCertificate(controllerCert); err != nil { + return err + } + + return nil +} + +func checkBothCertAndKeyAreSpecified() error { + if controllerCertPath == "" { + return fmt.Errorf("%w: when --controller-key is specified, --controller-cert must be specified too", + ErrInitFailed) + } + + if controllerKeyPath == "" { + return fmt.Errorf("%w: when --controller-cert is specified, --controller-key must be specified too", + ErrInitFailed) + } + + return nil +} + +func generateSelfSignedControllerCertificate() (tls.Certificate, error) { + privateKey, err := ecdsa.GenerateKey(elliptic.P384(), cryptorand.Reader) + if err != nil { + return tls.Certificate{}, err + } + + privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return tls.Certificate{}, err + } + + now := time.Now() + + cert := &x509.Certificate{ + SerialNumber: big.NewInt(0), + Subject: pkix.Name{ + CommonName: "Orchard Controller", + }, + NotBefore: now, + NotAfter: now.AddDate(10, 0, 0), + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"orchard-controller"}, + } + + certBytes, err := x509.CreateCertificate(cryptorand.Reader, cert, cert, privateKey.Public(), privateKey) + if err != nil { + return tls.Certificate{}, err + } + + certPEMBytes := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + + privateKeyPEMBytes := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: privateKeyBytes, + }) + + return tls.X509KeyPair(certPEMBytes, privateKeyPEMBytes) +} diff --git a/internal/command/controller/run.go b/internal/command/controller/run.go index db6f0ac..bce492b 100644 --- a/internal/command/controller/run.go +++ b/internal/command/controller/run.go @@ -1,15 +1,21 @@ package controller import ( + "crypto/tls" + "errors" + "fmt" "github.com/cirruslabs/orchard/internal/controller" "github.com/spf13/cobra" "go.uber.org/zap" ) +var ErrRunFailed = errors.New("failed to run controller") + func newRunCommand() *cobra.Command { return &cobra.Command{ - Use: "run", - RunE: runController, + Use: "run", + Short: "Run the controller", + RunE: runController, } } @@ -25,7 +31,37 @@ func runController(cmd *cobra.Command, args []string) (err error) { } }() - controller, err := controller.New(controller.WithDataDir(dataDir), controller.WithLogger(logger)) + // Instantiate a data directory and ensure it's initialized + dataDir, err := controller.NewDataDir(dataDirPath) + if err != nil { + return err + } + + initialized, err := dataDir.Initialized() + if err != nil { + return err + } + + if !initialized { + return fmt.Errorf("%w: data directory is not initialized, please run \"orchard controller init\" first", + ErrRunFailed) + } + + controllerCert, err := dataDir.ControllerCertificate() + if err != nil { + return err + } + + controller, err := controller.New( + controller.WithDataDir(dataDir), + controller.WithLogger(logger), + controller.WithTLSConfig(&tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{ + controllerCert, + }, + }), + ) if err != nil { return err } diff --git a/internal/command/dev/dev.go b/internal/command/dev/dev.go index 148bedd..c094d0a 100644 --- a/internal/command/dev/dev.go +++ b/internal/command/dev/dev.go @@ -19,11 +19,6 @@ func NewCommand() *cobra.Command { } func runDev(cmd *cobra.Command, args []string) error { - tempDir, err := os.MkdirTemp("", "") - if err != nil { - return err - } - // Initialize the logger logger, err := zap.NewDevelopment() if err != nil { @@ -35,12 +30,22 @@ func runDev(cmd *cobra.Command, args []string) error { } }() - controller, err := controller.New(controller.WithDataDir(tempDir), controller.WithLogger(logger)) + tempDir, err := os.MkdirTemp("", "") if err != nil { return err } - worker, err := worker.New(worker.WithDataDir(tempDir), worker.WithLogger(logger)) + dataDir, err := controller.NewDataDir(tempDir) + if err != nil { + return err + } + + controller, err := controller.New(controller.WithDataDir(dataDir), controller.WithLogger(logger)) + if err != nil { + return err + } + + worker, err := worker.New(worker.WithDataDirPath(tempDir), worker.WithLogger(logger)) if err != nil { return err } diff --git a/internal/command/worker/run.go b/internal/command/worker/run.go index eec98f1..8eba75e 100644 --- a/internal/command/worker/run.go +++ b/internal/command/worker/run.go @@ -25,7 +25,7 @@ func runWorker(cmd *cobra.Command, args []string) (err error) { } }() - worker, err := worker.New(worker.WithDataDir(dataDir), worker.WithLogger(logger)) + worker, err := worker.New(worker.WithDataDirPath(dataDirPath), worker.WithLogger(logger)) if err != nil { return err } diff --git a/internal/command/worker/worker.go b/internal/command/worker/worker.go index 512a0b9..0870ca4 100644 --- a/internal/command/worker/worker.go +++ b/internal/command/worker/worker.go @@ -7,7 +7,7 @@ import ( "path/filepath" ) -var dataDir string +var dataDirPath string func NewCommand() *cobra.Command { command := &cobra.Command{ @@ -22,8 +22,8 @@ func NewCommand() *cobra.Command { log.Fatal(err) } - command.PersistentFlags().StringVar(&dataDir, "data-dir", filepath.Join(orchardHome, "worker"), - "path to the data directory") + command.PersistentFlags().StringVar(&dataDirPath, "data-dir", filepath.Join(orchardHome, "worker"), + "path to the worker's data directory") return command } diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 087a673..d2acab6 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -15,7 +15,7 @@ import ( var ErrInitFailed = errors.New("controller initialization failed") type Controller struct { - dataDir string + dataDir *DataDir listenAddr string tlsConfig *tls.Config listener net.Listener @@ -33,7 +33,7 @@ func New(opts ...Option) (*Controller, error) { } // Apply defaults - if controller.dataDir == "" { + if controller.dataDir == nil { return nil, fmt.Errorf("%w: please specify the data directory path with WithDataDir()", ErrInitFailed) } @@ -45,7 +45,7 @@ func New(opts ...Option) (*Controller, error) { } // Instantiate controller - store, err := storepkg.New(controller.dbPath()) + store, err := storepkg.New(controller.dataDir.DBPath()) if err != nil { return nil, err } diff --git a/internal/controller/datadir.go b/internal/controller/datadir.go index 06aefb4..00890ca 100644 --- a/internal/controller/datadir.go +++ b/internal/controller/datadir.go @@ -1,7 +1,93 @@ package controller -import "path/filepath" +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" +) -func (controller *Controller) dbPath() string { - return filepath.Join(controller.dataDir, "db") +var ErrDataDirError = errors.New("controller's data directory operation error") + +type DataDir struct { + path string +} + +func NewDataDir(path string) (*DataDir, error) { + if err := os.MkdirAll(path, 0700); err != nil { + return nil, fmt.Errorf("%w: failed to create data directory at path %s: %v", + ErrDataDirError, path, err) + } + + return &DataDir{ + path: path, + }, nil +} + +func (dataDir *DataDir) ControllerCertificate() (tls.Certificate, error) { + cert, err := tls.LoadX509KeyPair(dataDir.ControllerCertificatePath(), dataDir.ControllerKeyPath()) + if err != nil { + return tls.Certificate{}, fmt.Errorf("%w: failed to load controller's certificate and key: %v", + ErrDataDirError, err) + } + + return cert, nil +} + +func (dataDir *DataDir) SetControllerCertificate(certificate tls.Certificate) error { + certPEMBytes := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certificate.Certificate[0], + }) + + privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(certificate.PrivateKey) + if err != nil { + return fmt.Errorf("%w: failed to set controller's certificate: PKCS #8 marshalling failed: %v", + ErrDataDirError, err) + } + + privateKeyPEMBytes := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: privateKeyBytes, + }) + + err = os.WriteFile(dataDir.ControllerCertificatePath(), certPEMBytes, 0600) + if err != nil { + return fmt.Errorf("%w: failed to write controller's certificate: %v", ErrDataDirError, err) + } + err = os.WriteFile(dataDir.ControllerKeyPath(), privateKeyPEMBytes, 0600) + if err != nil { + return fmt.Errorf("%w: failed to write controller's key: %v", ErrDataDirError, err) + } + + return nil +} + +func (dataDir *DataDir) DBPath() string { + return filepath.Join(dataDir.path, "db") +} + +func (dataDir *DataDir) ControllerCertificatePath() string { + return filepath.Join(dataDir.path, "controller.crt") +} + +func (dataDir *DataDir) ControllerKeyPath() string { + return filepath.Join(dataDir.path, "controller.key") +} + +func (dataDir *DataDir) Initialized() (bool, error) { + dataDirEntries, err := os.ReadDir(dataDir.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + + return false, fmt.Errorf("%w: failed to read data directory contents at path %s: %v", + ErrDataDirError, dataDir.path, err) + } + + return len(dataDirEntries) != 0, nil } diff --git a/internal/controller/option.go b/internal/controller/option.go index 86569f6..942a3b7 100644 --- a/internal/controller/option.go +++ b/internal/controller/option.go @@ -7,7 +7,7 @@ import ( type Option func(*Controller) -func WithDataDir(dataDir string) Option { +func WithDataDir(dataDir *DataDir) Option { return func(controller *Controller) { controller.dataDir = dataDir } diff --git a/internal/worker/option.go b/internal/worker/option.go index 67dbce7..ea288ba 100644 --- a/internal/worker/option.go +++ b/internal/worker/option.go @@ -4,9 +4,9 @@ import "go.uber.org/zap" type Option func(*Worker) -func WithDataDir(dataDir string) Option { +func WithDataDirPath(dataDir string) Option { return func(worker *Worker) { - worker.dataDir = dataDir + worker.dataDirPath = dataDir } } diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 6c3b821..92a9a03 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -17,12 +17,12 @@ const pollInterval = 5 * time.Second var ErrPollFailed = errors.New("failed to poll controller") type Worker struct { - dataDir string - name string - uid string - vmm *vmmanager.VMManager - client *client.Client - logger *zap.SugaredLogger + dataDirPath string + name string + uid string + vmm *vmmanager.VMManager + client *client.Client + logger *zap.SugaredLogger } func New(opts ...Option) (*Worker, error) {