diff --git a/Makefile b/Makefile index 5143474..cab51db 100644 --- a/Makefile +++ b/Makefile @@ -5,11 +5,15 @@ DOCKER_TAG := $(or ${GITHUB_TAG_NAME}, latest) BACKUP_PROVIDER := $(or ${BACKUP_PROVIDER},local) .PHONY: all -all: +all: test go mod tidy go build -ldflags "$(LINKMODE)" -tags 'osusergo netgo static_build' -o bin/backup-restore-sidecar github.com/metal-stack/backup-restore-sidecar/cmd strip bin/backup-restore-sidecar +.PHONY: test +test: + go test -race ./... + .PHONY: proto proto: docker run -it --rm -v ${PWD}/api:/work/api metalstack/builder protoc -I api/ api/v1/*.proto --go_out=plugins=grpc:api diff --git a/README.md b/README.md index ac85c60..8a57aa6 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@ With `--compression-method` you can define how generated backups are compressed - S3 Buckets (tested against Ceph RADOS gateway) - Local +## Encryption + +For all three storage providers are AES encryption is supported, can be enabled with `--encryption-key=`. +The key must be either 16 bytes (AES-128), 24 bytes (AES-192) or 32 bytes (AES-256) long. +The backups are stored at the storage provider with the `.aes` suffix. If the file does not have this suffix, decryption is skipped. + ## How it works ![Sequence Diagram](docs/sequence.png) diff --git a/cmd/internal/backup/backup.go b/cmd/internal/backup/backup.go index 94bbc9f..064b659 100644 --- a/cmd/internal/backup/backup.go +++ b/cmd/internal/backup/backup.go @@ -8,13 +8,14 @@ import ( "github.com/metal-stack/backup-restore-sidecar/cmd/internal/compress" "github.com/metal-stack/backup-restore-sidecar/cmd/internal/constants" "github.com/metal-stack/backup-restore-sidecar/cmd/internal/database" + "github.com/metal-stack/backup-restore-sidecar/cmd/internal/encryption" "github.com/metal-stack/backup-restore-sidecar/cmd/internal/metrics" cron "github.com/robfig/cron/v3" "go.uber.org/zap" ) // Start starts the backup component, which is periodically taking backups of the database -func Start(log *zap.SugaredLogger, backupSchedule string, db database.DatabaseProber, bp backuproviders.BackupProvider, metrics *metrics.Metrics, comp *compress.Compressor, stop <-chan struct{}) error { +func Start(log *zap.SugaredLogger, backupSchedule string, db database.DatabaseProber, bp backuproviders.BackupProvider, metrics *metrics.Metrics, comp *compress.Compressor, encrypter *encryption.Encrypter, stop <-chan struct{}) error { log.Info("database is now available, starting periodic backups") c := cron.New() @@ -45,6 +46,15 @@ func Start(log *zap.SugaredLogger, backupSchedule string, db database.DatabasePr } log.Info("compressed backup") + if encrypter != nil { + filename, err = encrypter.Encrypt(filename) + if err != nil { + metrics.CountError("encrypt") + log.Errorw("unable to encrypt backup", "error", err) + return + } + } + err = bp.UploadBackup(filename) if err != nil { metrics.CountError("upload") diff --git a/cmd/internal/backup/providers/gcp/gcp.go b/cmd/internal/backup/providers/gcp/gcp.go index f6458cb..6c9f593 100644 --- a/cmd/internal/backup/providers/gcp/gcp.go +++ b/cmd/internal/backup/providers/gcp/gcp.go @@ -162,7 +162,6 @@ func (b *BackupProviderGCP) DownloadBackup(version *providers.BackupVersion) err if err != nil { return errors.Wrap(err, "error writing file from gcp to filesystem") } - return nil } diff --git a/cmd/internal/backup/providers/local/local.go b/cmd/internal/backup/providers/local/local.go index 4b0e987..8eb594a 100644 --- a/cmd/internal/backup/providers/local/local.go +++ b/cmd/internal/backup/providers/local/local.go @@ -82,6 +82,7 @@ func (b *BackupProviderLocal) DownloadBackup(version *providers.BackupVersion) e b.log.Infow("download backup called for provider local") source := filepath.Join(b.config.LocalBackupPath, version.Name) destination := filepath.Join(constants.DownloadDir, version.Name) + err := utils.Copy(source, destination) if err != nil { return err diff --git a/cmd/internal/backup/providers/s3/s3.go b/cmd/internal/backup/providers/s3/s3.go index 423d29e..186f035 100644 --- a/cmd/internal/backup/providers/s3/s3.go +++ b/cmd/internal/backup/providers/s3/s3.go @@ -207,7 +207,6 @@ func (b *BackupProviderS3) DownloadBackup(version *providers.BackupVersion) erro if err != nil { return err } - return nil } @@ -237,7 +236,6 @@ func (b *BackupProviderS3) UploadBackup(sourcePath string) error { if err != nil { return err } - return nil } diff --git a/cmd/internal/encryption/encryption.go b/cmd/internal/encryption/encryption.go new file mode 100644 index 0000000..3bbe500 --- /dev/null +++ b/cmd/internal/encryption/encryption.go @@ -0,0 +1,191 @@ +package encryption + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "unicode" + + "go.uber.org/zap" +) + +// Suffix is appended on encryption and removed on decryption from given input +const Suffix = ".aes" + +// Encrypter is used to encrypt/decrypt backups +type Encrypter struct { + key string + log *zap.SugaredLogger +} + +// New creates a new Encrypter with the given key. +// The key should be 16 bytes (AES-128), 24 bytes (AES-192) or +// 32 bytes (AES-256) +func New(log *zap.SugaredLogger, key string) (*Encrypter, error) { + switch len(key) { + case 16, 24, 32: + default: + return nil, fmt.Errorf("key length:%d invalid, must be 16,24 or 32 bytes", len(key)) + } + if !isASCII(key) { + return nil, fmt.Errorf("key must only contain ascii characters") + } + + return &Encrypter{ + key: key, + log: log, + }, nil +} + +// Encrypt input file with key and store the encrypted files with suffix appended +func (e *Encrypter) Encrypt(input string) (string, error) { + output := input + Suffix + e.log.Infow("encrypt", "input", input, "output", output) + infile, err := os.Open(input) + if err != nil { + return "", err + } + defer infile.Close() + + key := []byte(e.key) + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + // Never use more than 2^32 random nonces with a given key + // because of the risk of repeat. + iv := make([]byte, block.BlockSize()) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", err + } + + outfile, err := os.OpenFile(output, os.O_RDWR|os.O_CREATE, 0777) + if err != nil { + return "", err + } + defer outfile.Close() + + // The buffer size must be multiple of 16 bytes + buf := make([]byte, 1024) + stream := cipher.NewCTR(block, iv) + for { + n, err := infile.Read(buf) + if n > 0 { + stream.XORKeyStream(buf, buf[:n]) + // Write into file + _, err = outfile.Write(buf[:n]) + if err != nil { + return "", err + } + } + + if err == io.EOF { + break + } + + if err != nil { + e.log.Infof("Read %d bytes: %v", n, err) + break + } + } + // Append the IV + _, err = outfile.Write(iv) + if err == nil { + err := os.Remove(input) + if err != nil { + e.log.Warnw("unable to remove input", "error", err) + } + } + return output, err +} + +// Decrypt input file with key and store decrypted result with suffix removed +// if input does not end with suffix, it is assumed that the file was not encrypted. +func (e *Encrypter) Decrypt(input string) (string, error) { + output := strings.TrimSuffix(input, Suffix) + e.log.Infow("decrypt", "input", input, "output", output) + extension := filepath.Ext(input) + if extension != Suffix { + return input, fmt.Errorf("input is not encrypted") + } + infile, err := os.Open(input) + if err != nil { + return "", err + } + defer infile.Close() + + key := []byte(e.key) + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + // Never use more than 2^32 random nonces with a given key + // because of the risk of repeat. + fi, err := infile.Stat() + if err != nil { + return "", err + } + + iv := make([]byte, block.BlockSize()) + msgLen := fi.Size() - int64(len(iv)) + _, err = infile.ReadAt(iv, msgLen) + if err != nil { + return "", err + } + + outfile, err := os.OpenFile(output, os.O_RDWR|os.O_CREATE, 0777) + if err != nil { + return "", err + } + defer outfile.Close() + + // The buffer size must be multiple of 16 bytes + buf := make([]byte, 1024) + stream := cipher.NewCTR(block, iv) + for { + n, err := infile.Read(buf) + if n > 0 { + // The last bytes are the IV, don't belong the original message + if n > int(msgLen) { + n = int(msgLen) + } + msgLen -= int64(n) + stream.XORKeyStream(buf, buf[:n]) + // Write into file + _, err = outfile.Write(buf[:n]) + if err != nil { + return "", err + } + } + + if err == io.EOF { + break + } + + if err != nil { + e.log.Infof("Read %d bytes: %v", n, err) + break + } + } + err = os.Remove(input) + if err != nil { + e.log.Warnw("unable to remove input", "error", err) + } + return output, nil +} + +func isASCII(s string) bool { + for _, c := range s { + if c > unicode.MaxASCII { + return false + } + } + return true +} diff --git a/cmd/internal/encryption/encryption_test.go b/cmd/internal/encryption/encryption_test.go new file mode 100644 index 0000000..43f0ae1 --- /dev/null +++ b/cmd/internal/encryption/encryption_test.go @@ -0,0 +1,58 @@ +package encryption + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func TestEncrypter(t *testing.T) { + // Key too short + _, err := New(zap.L().Sugar(), "tooshortkey") + assert.EqualError(t, err, "key length:11 invalid, must be 16,24 or 32 bytes") + + // Key too short + _, err = New(zap.L().Sugar(), "19bytesofencryption") + assert.EqualError(t, err, "key length:19 invalid, must be 16,24 or 32 bytes") + + // Key too long + _, err = New(zap.L().Sugar(), "tooloooonoooooooooooooooooooooooooooongkey") + assert.EqualError(t, err, "key length:42 invalid, must be 16,24 or 32 bytes") + + e, err := New(zap.L().Sugar(), "01234567891234560123456789123456") + assert.NoError(t, err, "") + + input, err := ioutil.TempFile("", "encrypt") + assert.NoError(t, err) + defer os.Remove(input.Name()) + + cleartextInput := []byte("This is the content of the file") + err = ioutil.WriteFile(input.Name(), cleartextInput, 0644) + assert.NoError(t, err) + output, err := e.Encrypt(input.Name()) + assert.NoError(t, err) + + assert.Equal(t, input.Name()+Suffix, output) + + cleartextFile, err := e.Decrypt(output) + assert.NoError(t, err) + cleartext, err := ioutil.ReadFile(cleartextFile) + assert.NoError(t, err) + assert.Equal(t, cleartextInput, cleartext) + + // Test with 100MB file + bigBuff := make([]byte, 100000000) + err = ioutil.WriteFile("bigfile.test", bigBuff, 0666) + assert.NoError(t, err) + + bigEncFile, err := e.Encrypt("bigfile.test") + assert.NoError(t, err) + _, err = e.Decrypt(bigEncFile) + assert.NoError(t, err) + os.Remove("bigfile.test") + os.Remove("bigfile.test.aes") + +} diff --git a/cmd/internal/initializer/initializer.go b/cmd/internal/initializer/initializer.go index ff2e2ce..3f087fc 100644 --- a/cmd/internal/initializer/initializer.go +++ b/cmd/internal/initializer/initializer.go @@ -12,6 +12,7 @@ import ( "github.com/metal-stack/backup-restore-sidecar/cmd/internal/compress" "github.com/metal-stack/backup-restore-sidecar/cmd/internal/constants" "github.com/metal-stack/backup-restore-sidecar/cmd/internal/database" + "github.com/metal-stack/backup-restore-sidecar/cmd/internal/encryption" "github.com/pkg/errors" "go.uber.org/zap" @@ -30,19 +31,21 @@ type Initializer struct { db database.Database bp providers.BackupProvider comp *compress.Compressor + encrypter *encryption.Encrypter } -func New(log *zap.SugaredLogger, addr string, db database.Database, bp providers.BackupProvider, comp *compress.Compressor) *Initializer { +func New(log *zap.SugaredLogger, addr string, db database.Database, bp providers.BackupProvider, comp *compress.Compressor, encrypter *encryption.Encrypter) *Initializer { return &Initializer{ currentStatus: &v1.StatusResponse{ Status: v1.StatusResponse_CHECKING, Message: "starting initializer server", }, - log: log, - addr: addr, - db: db, - bp: bp, - comp: comp, + log: log, + addr: addr, + db: db, + bp: bp, + comp: comp, + encrypter: encrypter, } } @@ -133,7 +136,7 @@ func (i *Initializer) initialize() error { return nil } - err = i.Restore(latestBackup) + err = i.Restore(latestBackup, false) if err != nil { return errors.Wrap(err, "unable to restore database") } @@ -142,7 +145,7 @@ func (i *Initializer) initialize() error { } // Restore restores the database with the given backup version -func (i *Initializer) Restore(version *providers.BackupVersion) error { +func (i *Initializer) Restore(version *providers.BackupVersion, downloadOnly bool) error { i.log.Infow("restoring backup", "version", version.Version, "date", version.Date.String()) i.currentStatus.Status = v1.StatusResponse_RESTORING @@ -172,11 +175,22 @@ func (i *Initializer) Restore(version *providers.BackupVersion) error { return errors.Wrap(err, "unable to download backup") } + if i.encrypter != nil { + backupFilePath, err = i.encrypter.Decrypt(backupFilePath) + if err != nil { + return errors.Wrap(err, "unable to decrypt backup") + } + } + i.currentStatus.Message = "uncompressing backup" err = i.comp.Decompress(backupFilePath) if err != nil { return errors.Wrap(err, "unable to uncompress backup") } + if downloadOnly { + i.log.Info("downloadOnly was specified, skipping database recovery") + return nil + } i.currentStatus.Message = "restoring backup" err = i.db.Recover() diff --git a/cmd/main.go b/cmd/main.go index dcabda1..5fc2347 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,6 +17,7 @@ import ( "github.com/metal-stack/backup-restore-sidecar/cmd/internal/database" "github.com/metal-stack/backup-restore-sidecar/cmd/internal/database/postgres" "github.com/metal-stack/backup-restore-sidecar/cmd/internal/database/rethinkdb" + "github.com/metal-stack/backup-restore-sidecar/cmd/internal/encryption" "github.com/metal-stack/backup-restore-sidecar/cmd/internal/initializer" "github.com/metal-stack/backup-restore-sidecar/cmd/internal/metrics" "github.com/metal-stack/backup-restore-sidecar/cmd/internal/probe" @@ -43,6 +44,7 @@ const ( databaseFlg = "db" databaseDatadirFlg = "db-data-directory" + downloadOnlyFlg = "download-only" postgresUserFlg = "postgres-user" postgresHostFlg = "postgres-host" @@ -71,6 +73,8 @@ const ( s3SecretKeyFlg = "s3-secret-key" compressionMethod = "compression-method" + + encryptionKey = "encryption-key" ) var ( @@ -110,13 +114,21 @@ var startCmd = &cobra.Command{ if err != nil { return err } - initializer.New(logger.Named("initializer"), addr, db, bp, comp).Start(stop) + var encrypter *encryption.Encrypter + key := viper.GetString(encryptionKey) + if key != "" { + encrypter, err = encryption.New(logger.Named("encryption"), key) + if err != nil { + return fmt.Errorf("unable to initialize encryption:%v", err) + } + } + initializer.New(logger.Named("initializer"), addr, db, bp, comp, encrypter).Start(stop) if err := probe.Start(logger.Named("probe"), db, stop); err != nil { return err } metrics := metrics.New() metrics.Start(logger.Named("metrics")) - return backup.Start(logger.Named("backup"), viper.GetString(backupCronScheduleFlg), db, bp, metrics, comp, stop) + return backup.Start(logger.Named("backup"), viper.GetString(backupCronScheduleFlg), db, bp, metrics, comp, encrypter, stop) }, } @@ -142,7 +154,16 @@ var restoreCmd = &cobra.Command{ if err != nil { return err } - return initializer.New(logger.Named("initializer"), "", db, bp, comp).Restore(version) + var encrypter *encryption.Encrypter + key := viper.GetString(encryptionKey) + if key != "" { + encrypter, err = encryption.New(logger.Named("encryption"), key) + if err != nil { + return fmt.Errorf("unable to initialize encryption:%v", err) + } + } + downloadOnly := viper.GetBool(downloadOnlyFlg) + return initializer.New(logger.Named("initializer"), "", db, bp, comp, encrypter).Restore(version, downloadOnly) }, } @@ -226,6 +247,8 @@ func init() { startCmd.Flags().StringP(compressionMethod, "", "targz", "the compression method to use to compress the backups (tar|targz|tarlz4)") + startCmd.Flags().StringP(encryptionKey, "", "", "if given backups will be encrypted with key, must be either 16,24 or 32 byte long") + err = viper.BindPFlags(startCmd.Flags()) if err != nil { fmt.Printf("unable to construct initializer command: %v", err) @@ -241,6 +264,7 @@ func init() { } restoreCmd.AddCommand(restoreListCmd) + restoreCmd.Flags().BoolP(downloadOnlyFlg, "", false, "if set, backups are only downloaded, decompressed and decrypted but no database recovery is made.") } func initConfig() { diff --git a/docs/sequence.drawio b/docs/sequence.drawio index 7a8b920..4c65863 100644 --- a/docs/sequence.drawio +++ b/docs/sequence.drawio @@ -1 +1,278 @@ -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 \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/sequence.png b/docs/sequence.png index 61b177a..28c3ec8 100644 Binary files a/docs/sequence.png and b/docs/sequence.png differ diff --git a/go.mod b/go.mod index fc109ab..6867a7b 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.7.1 + github.com/stretchr/testify v1.6.1 go.opencensus.io v0.22.4 // indirect go.uber.org/zap v1.15.0 golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc // indirect diff --git a/go.sum b/go.sum index 3d1a02b..3549608 100644 --- a/go.sum +++ b/go.sum @@ -384,6 +384,7 @@ github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/y github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk= github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -391,6 +392,8 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= @@ -744,6 +747,8 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=