diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..1dab428 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,30 @@ +# https://github.com/marketplace/actions/go-release-binaries +name: Release + +on: + release: + types: [created] + +jobs: + release-linux-amd64: + name: release linux/amd64 + runs-on: ubuntu-latest + strategy: + matrix: + goos: [linux,windows,darwin] + goarch: [amd64, arm64] + exclude: + - goarch: arm64 + goos: windows + steps: + - uses: actions/checkout@v4 + - uses: wangyoucao577/go-release-action@v1.48 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + goos: ${{ matrix.goos }} + goarch: ${{ matrix.goarch }} + binary_name: vcluster-backup + retry: 10 + sha256sum: true + overwrite: true + pre_command: go mod tidy diff --git a/README.md b/README.md new file mode 100644 index 0000000..bec1651 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# vcluster backup + +A tool to backup periodically your sqlite DB from K3S/vCluster to S3 storage. + +## prerequisites + +- [vCluster](https://www.vcluster.com/docs/getting-started/deployment) deployed in non-HA with K3S and embedded sqlite DB +- S3 compatible storage, using [Minio with security fixes](https://github.com/eumel8/minio/tree/fix/securitycontext/helm/minio) +- bring the tool into the K3S pod + +```bash +tar -cf - vcluster-backup | kubectl -n kunde2 exec --stdin kunde2-vcluster-0 -- sh -c "cat > /tmp/vcluster-backup.tar" +kubectl -n kunde2 exec -it kunde2-vcluster-0 -- sh +cd /tmp +tar xf vcluster-backup.tar +``` + + +## usage + +On a single Kubernetes cluster build with vCluster and K3S there is no mechanism included to backup your cluster or your backend database. Of course, there are hints to use RDS or etcd, in our use case we have the embedded sqlite, which is in fact one file what we want to backup securely and periodically. + + +```bash +./vcluster-backup -h +Usage of ./vcluster-backup: + -accessKey string + S3 accesskey. + -backupFile string + Sqlite database of K3S instance. (default "/data/server/db/state.db") + -backupInterval int + Interval in minutes for backup. (default 2) + -bucketName string + S3 bucket name. (default "k3s-backup") + -decrypt + Decrypt the file + -encKey string + S3 encryption key. + -endpoint string + S3 endpoint. + -region string + S3 region. (default "default") + -secretKey string + S3 secretkey. +``` + +start backup: + +```bash +./vcluster-backup -accessKey vclusterbackup99 -bucketName vclusterbackup99 -endpoint vcluster-backup.minio.io -secretKey xxxxxx -encKey 12345 -backupInterval 1 +# TODO: we need the /data/server/token? +``` + +restore backup: + +```bash +# stop k3s server +# TODO: fetch the file from S3 +rm -rf /data/server/* +mkdir -p /data/server/db +./vcluster-backup -backupFile backup_20240227162707.db.enc -encKey 123455 -decrypt +cp backup_20240227162707.db.enc /data/server/db/state.db +# start k3s server +``` + +## build + +```bash +go mod tidy +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o vcluster-backup vcluster-backup.go +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a1986ac --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module vcluster-backup.go + +go 1.21.0 + +require github.com/minio/minio-go/v7 v7.0.67 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.4 // indirect + github.com/klauspost/cpuid/v2 v2.2.6 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/rs/xid v1.5.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..80e35ef --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= +github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.67 h1:BeBvZWAS+kRJm1vGTMJYVjKUNoo0FoEt/wUWdUtfmh8= +github.com/minio/minio-go/v7 v7.0.67/go.mod h1:+UXocnUeZ3wHvVh5s95gcrA4YjMIbccT6ubB+1m054A= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +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= diff --git a/vcluster-backup.go b/vcluster-backup.go new file mode 100644 index 0000000..3fc5180 --- /dev/null +++ b/vcluster-backup.go @@ -0,0 +1,239 @@ +// vcluster-backup.go +package main + +import ( + "context" + "flag" + "fmt" + "io" + "log" + "os" + "os/signal" + + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "path/filepath" + + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +func encryptFile(filename string, data []byte, passphrase string) error { + block, err := aes.NewCipher([]byte(passphrase)) + if err != nil { + return err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return err + } + ciphertext := gcm.Seal(nonce, nonce, data, nil) + return os.WriteFile(filename, ciphertext, 0777) +} + +func encryptFileAES256(filename string, data []byte, passphrase string) error { + // Generate a 32-byte key from the passphrase + hasher := sha256.New() + hasher.Write([]byte(passphrase)) + key := hasher.Sum(nil) + + block, err := aes.NewCipher(key) + if err != nil { + return err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return err + } + ciphertext := gcm.Seal(nonce, nonce, data, nil) + return os.WriteFile(filename, ciphertext, 0777) +} + +func decryptFileAES256(filename string, ciphertext []byte, passphrase string) ([]byte, error) { + // Generate a 32-byte key from the passphrase + hasher := sha256.New() + hasher.Write([]byte(passphrase)) + key := hasher.Sum(nil) + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, err + } + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + return plaintext, nil +} + +func main() { + + var backupFile, bucketName, accessKey, secretKey, endpoint, region, encKey string + var backupInterval int + var decrypt bool + + // Command-line flags for the backup file, interval, and S3 bucket name + // File to backup, e.g. sqlite database + flag.StringVar(&backupFile, "backupFile", "/data/server/db/state.db", "Sqlite database of K3S instance.") + // Set the interval for backup in minutes + flag.IntVar(&backupInterval, "backupInterval", 2, "Interval in minutes for backup.") + // Set the S3 bucket name and key for storing the backup + flag.StringVar(&bucketName, "bucketName", "k3s-backup", "S3 bucket name.") + flag.StringVar(&accessKey, "accessKey", "", "S3 accesskey.") + flag.StringVar(&secretKey, "secretKey", "", "S3 secretkey.") + flag.StringVar(&endpoint, "endpoint", "", "S3 endpoint.") + flag.StringVar(®ion, "region", "default", "S3 region.") + flag.StringVar(&encKey, "encKey", "", "S3 encryption key.") + /// Calling decrypt function + flag.BoolVar(&decrypt, "decrypt", false, "Decrypt the file") + // Parse the command-line flags + flag.Parse() + + if decrypt { + fmt.Println("Decrypting file ", backupFile) + + ciphertext, err := os.ReadFile(backupFile) + if err != nil { + log.Println("Failed to read file for decrypt:", err) + os.Exit(1) + } + + plaintext, err := decryptFileAES256(backupFile, ciphertext, encKey) + if err != nil { + log.Println("Failed to decrypt file:", err) + os.Exit(1) + } + + restoreFile := backupFile + ".restore" + err = os.WriteFile(restoreFile, plaintext, 0644) + if err != nil { + log.Println("Failed to write decrypted file:", err) + os.Exit(1) + } + os.Exit(0) + } + + // Create a new minio service client + minioClient, err := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(accessKey, secretKey, ""), + Region: region, + Secure: true, + }) + + if err != nil { + log.Fatalln(err) + } + + // Enable tracing. + minioClient.TraceOn(os.Stdout) + + // Create a channel to receive termination signals + signalCh := make(chan os.Signal, 1) + signal.Notify(signalCh, os.Interrupt) + + // Start a goroutine to perform the backup + go func() { + for { + select { + case <-time.After(time.Duration(backupInterval) * time.Minute): + // Open the file to be backed up + file, err := os.Open(backupFile) + if err != nil { + log.Println("Failed to open file:", err) + continue + } + defer file.Close() + + // Create the backup file name with timestamp + backupFileTimestamped := fmt.Sprintf("backup_%s.db", time.Now().Format("20060102150405")) + backupFileTimestampedEnc := backupFileTimestamped + ".enc" + + // Create the backup file in a temporary location + log.Println("Create backup file:", backupFileTimestamped) + backupFilePath := filepath.Join(os.TempDir(), backupFileTimestamped) + toBackupFile, err := os.Create(backupFilePath) + + if err != nil { + log.Println("Failed to create backup file:", err) + continue + } + defer toBackupFile.Close() + + // Copy the contents of the original file to the backup file + log.Println("Start copy content:", backupFileTimestamped) + _, err = io.Copy(toBackupFile, file) + + if err != nil { + log.Println("Failed to copy file contents:", err) + continue + } + + // Encrypt the backup file in minio + log.Println("Encrypt file:", backupFileTimestamped) + backupFilePathEnc := backupFilePath + ".enc" + + // Read the contents of the file into a byte slice + fileContents, err := os.ReadFile(backupFilePath) + + //toBackupFile) + if err != nil { + log.Println("Failed to read file contents:", err) + continue + } + + err = encryptFileAES256(backupFilePathEnc, fileContents, encKey) + + if err != nil { + log.Println("Failed to encrypt file:", err) + continue + } + + // Upload the backup file to S3 + _, err = minioClient.FPutObject(context.Background(), bucketName, backupFileTimestampedEnc, backupFilePathEnc, minio.PutObjectOptions{}) + log.Println("Backup successfully created and uploaded to S3") + + // Remove the temporary backup file + err = os.Remove(backupFilePath) + if err != nil { + log.Println("Failed to remove backup file:", err) + continue + } + err = os.Remove(backupFilePathEnc) + if err != nil { + log.Println("Failed to remove backup file:", err) + continue + } + log.Println("Temporary backup file removed") + + case <-signalCh: + // Terminate the backup process on receiving termination signal + log.Println("Terminating backup process") + return + } + } + }() + + // Wait for termination signal + <-signalCh +} diff --git a/vcluster-backup_test.go b/vcluster-backup_test.go new file mode 100644 index 0000000..9d6d375 --- /dev/null +++ b/vcluster-backup_test.go @@ -0,0 +1,56 @@ +// vcluster-backup_test.go +package main + +import ( + "io/ioutil" + "os" + "testing" +) + +func TestEncryptFile(t *testing.T) { + // Create a temporary file for testing + file, err := ioutil.TempFile("", "testfile") + if err != nil { + t.Fatal(err) + } + defer os.Remove(file.Name()) + + // Write some data to the file + data := []byte("test data") + err = os.WriteFile(file.Name(), data, 0644) + if err != nil { + t.Fatal(err) + } + + // Encrypt the file + err = encryptFile(file.Name(), data, "passphrase") + if err != nil { + t.Fatal(err) + } + + // Read the encrypted file + encryptedData, err := os.ReadFile(file.Name()) + if err != nil { + t.Fatal(err) + } + + // TODO: Add assertions to verify the encryption + + // Decrypt the file + decryptedData, err := decryptFileAES256(file.Name(), encryptedData, "passphrase") + if err != nil { + t.Fatal(err) + } + + // TODO: Add assertions to verify the decryption + + // Compare the decrypted data with the original data + if string(decryptedData) != string(data) { + t.Errorf("Decrypted data does not match original data") + } +} + +func TestMainFunction(t *testing.T) { + // TODO: Write tests for the main function + // You can use the testing package's functionality to simulate command-line arguments and test the behavior of the main function. +}