mirror of
https://github.com/offen/docker-volume-backup.git
synced 2024-11-21 13:00:27 +01:00
Feature: PGP Asymmetric Encryption (#456)
* feat: asym encryption * tests * docs * refactor * logs & errs * comment * Update docs/reference/index.md use correct env var in example Co-authored-by: Frederik Ring <frederik.ring@gmail.com> * Update cmd/backup/encrypt_archive.go use errwarp for initial error msg Co-authored-by: Frederik Ring <frederik.ring@gmail.com> * rm orphaned code in encryption functions * inline readArmoredKeys * naming -GPG_PUBLIC_KEYS- to GPG_PUBLIC_KEY_RING * add eror handling for closing func * use dynamically generated keys for testing * rm explicit gpg-agent start * rm unnecessary private_key export * pass PASSPHRASE correctly to the decryption command * capture defer errors * log & err msg --------- Co-authored-by: Frederik Ring <frederik.ring@gmail.com>
This commit is contained in:
parent
f97ce11734
commit
8a64da4b0b
@ -47,6 +47,7 @@ type Config struct {
|
||||
BackupExcludeRegexp RegexpDecoder `split_words:"true"`
|
||||
BackupSkipBackendsFromPrune []string `split_words:"true"`
|
||||
GpgPassphrase string `split_words:"true"`
|
||||
GpgPublicKeyRing string `split_words:"true"`
|
||||
NotificationURLs []string `envconfig:"NOTIFICATION_URLS"`
|
||||
NotificationLevel string `split_words:"true" default:"error"`
|
||||
EmailNotificationRecipient string `split_words:"true"`
|
||||
|
@ -4,20 +4,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
openpgp "github.com/ProtonMail/go-crypto/openpgp/v2"
|
||||
"github.com/offen/docker-volume-backup/internal/errwrap"
|
||||
)
|
||||
|
||||
// encryptArchive encrypts the backup file using PGP and the configured passphrase.
|
||||
// In case no passphrase is given it returns early, leaving the backup file
|
||||
func (s *script) encryptAsymmetrically(outFile *os.File) (io.WriteCloser, func() error, error) {
|
||||
|
||||
entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(s.c.GpgPublicKeyRing)))
|
||||
if err != nil {
|
||||
return nil, nil, errwrap.Wrap(err, "error parsing armored keyring")
|
||||
}
|
||||
|
||||
armoredWriter, err := armor.Encode(outFile, "PGP MESSAGE", nil)
|
||||
if err != nil {
|
||||
return nil, nil, errwrap.Wrap(err, "error preparing encryption")
|
||||
}
|
||||
|
||||
_, name := path.Split(s.file)
|
||||
dst, err := openpgp.Encrypt(armoredWriter, entityList, nil, nil, &openpgp.FileHints{
|
||||
FileName: name,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return dst, func() error {
|
||||
if err := dst.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return armoredWriter.Close()
|
||||
}, err
|
||||
}
|
||||
|
||||
func (s *script) encryptSymmetrically(outFile *os.File) (io.WriteCloser, func() error, error) {
|
||||
|
||||
_, name := path.Split(s.file)
|
||||
dst, err := openpgp.SymmetricallyEncrypt(outFile, []byte(s.c.GpgPassphrase), &openpgp.FileHints{
|
||||
FileName: name,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return dst, dst.Close, nil
|
||||
}
|
||||
|
||||
// encryptArchive encrypts the backup file using PGP and the configured passphrase or publickey(s).
|
||||
// In case no passphrase or publickey is given it returns early, leaving the backup file
|
||||
// untouched.
|
||||
func (s *script) encryptArchive() error {
|
||||
if s.c.GpgPassphrase == "" {
|
||||
|
||||
var encrypt func(outFile *os.File) (io.WriteCloser, func() error, error)
|
||||
var cleanUpErr error
|
||||
|
||||
switch {
|
||||
case s.c.GpgPassphrase != "" && s.c.GpgPublicKeyRing != "":
|
||||
return errwrap.Wrap(nil, "error in selecting asymmetric and symmetric encryption methods: conflicting env vars are set")
|
||||
case s.c.GpgPassphrase != "":
|
||||
encrypt = s.encryptSymmetrically
|
||||
case s.c.GpgPublicKeyRing != "":
|
||||
encrypt = s.encryptAsymmetrically
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -36,22 +91,31 @@ func (s *script) encryptArchive() error {
|
||||
if err != nil {
|
||||
return errwrap.Wrap(err, "error opening out file")
|
||||
}
|
||||
defer outFile.Close()
|
||||
defer func() {
|
||||
if err := outFile.Close(); err != nil {
|
||||
cleanUpErr = errors.Join(cleanUpErr, errwrap.Wrap(err, "error closing out file"))
|
||||
}
|
||||
}()
|
||||
|
||||
_, name := path.Split(s.file)
|
||||
dst, err := openpgp.SymmetricallyEncrypt(outFile, []byte(s.c.GpgPassphrase), &openpgp.FileHints{
|
||||
FileName: name,
|
||||
}, nil)
|
||||
dst, dstCloseCallback, err := encrypt(outFile)
|
||||
if err != nil {
|
||||
return errwrap.Wrap(err, "error encrypting backup file")
|
||||
}
|
||||
defer dst.Close()
|
||||
defer func() {
|
||||
if err := dstCloseCallback(); err != nil {
|
||||
cleanUpErr = errors.Join(cleanUpErr, errwrap.Wrap(err, "error closing encrypted backup file"))
|
||||
}
|
||||
}()
|
||||
|
||||
src, err := os.Open(s.file)
|
||||
if err != nil {
|
||||
return errwrap.Wrap(err, fmt.Sprintf("error opening backup file `%s`", s.file))
|
||||
}
|
||||
defer src.Close()
|
||||
defer func() {
|
||||
if err := src.Close(); err != nil {
|
||||
cleanUpErr = errors.Join(cleanUpErr, errwrap.Wrap(err, "error closing backup file"))
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return errwrap.Wrap(err, "error writing ciphertext to file")
|
||||
@ -59,7 +123,7 @@ func (s *script) encryptArchive() error {
|
||||
|
||||
s.file = gpgFile
|
||||
s.logger.Info(
|
||||
fmt.Sprintf("Encrypted backup using given passphrase, saving as `%s`.", s.file),
|
||||
fmt.Sprintf("Encrypted backup using gpg, saving as `%s`.", s.file),
|
||||
)
|
||||
return nil
|
||||
return cleanUpErr
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ nav_order: 7
|
||||
# Encrypt backups using GPG
|
||||
|
||||
The image supports encrypting backups using GPG out of the box.
|
||||
In case a `GPG_PASSPHRASE` environment variable is set, the backup archive will be encrypted using the given key and saved as a `.gpg` file instead.
|
||||
In case a `GPG_PASSPHRASE` or `GPG_PUBLIC_KEY_RING` environment variable is set, the backup archive will be encrypted using the given key and saved as a `.gpg` file instead.
|
||||
|
||||
Assuming you have `gpg` installed, you can decrypt such a backup using (your OS will prompt for the passphrase before decryption can happen):
|
||||
|
||||
|
@ -289,7 +289,7 @@ volumes:
|
||||
data:
|
||||
```
|
||||
|
||||
## Encrypting your backups using GPG
|
||||
## Encrypting your backups symmetrically using GPG
|
||||
|
||||
```yml
|
||||
version: '3'
|
||||
@ -311,6 +311,33 @@ volumes:
|
||||
data:
|
||||
```
|
||||
|
||||
## Encrypting your backups asymmetrically using GPG
|
||||
|
||||
```yml
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
# ... define other services using the `data` volume here
|
||||
backup:
|
||||
image: offen/docker-volume-backup:v2
|
||||
environment:
|
||||
AWS_S3_BUCKET_NAME: backup-bucket
|
||||
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
|
||||
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
GPG_PUBLIC_KEY_RING: |
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
D/cIHu6GH/0ghlcUVSbgMg5RRI5QKNNKh04uLAPxr75mKwUg0xPUaWgyyrAChVBi
|
||||
...
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
volumes:
|
||||
- data:/backup/my-app-backup:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
volumes:
|
||||
data:
|
||||
```
|
||||
|
||||
## Using mysqldump to prepare the backup
|
||||
|
||||
```yml
|
||||
|
@ -337,10 +337,19 @@ You can populate below template according to your requirements and use it as you
|
||||
|
||||
########### BACKUP ENCRYPTION
|
||||
|
||||
# Backups can be encrypted using gpg in case a passphrase is given.
|
||||
# Backups can be encrypted symmetrically using gpg in case a passphrase is given.
|
||||
|
||||
# GPG_PASSPHRASE="<xxx>"
|
||||
|
||||
# Backups can be encrypted asymmetrically using gpg in case publickeys are given.
|
||||
|
||||
# GPG_PUBLIC_KEY_RING= |
|
||||
#-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
#
|
||||
#D/cIHu6GH/0ghlcUVSbgMg5RRI5QKNNKh04uLAPxr75mKwUg0xPUaWgyyrAChVBi
|
||||
#...
|
||||
#-----END PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
########### STOPPING CONTAINERS AND SERVICES DURING BACKUP
|
||||
|
||||
# Containers or services can be stopped by applying a
|
||||
|
@ -4,6 +4,7 @@ RUN apk add \
|
||||
coreutils \
|
||||
curl \
|
||||
gpg \
|
||||
gpg-agent \
|
||||
jq \
|
||||
moreutils \
|
||||
tar \
|
||||
|
25
test/gpg-asym/docker-compose.yml
Normal file
25
test/gpg-asym/docker-compose.yml
Normal file
@ -0,0 +1,25 @@
|
||||
services:
|
||||
backup:
|
||||
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
|
||||
restart: always
|
||||
environment:
|
||||
BACKUP_CRON_EXPRESSION: 0 0 5 31 2 ?
|
||||
BACKUP_FILENAME: test.tar.gz
|
||||
BACKUP_LATEST_SYMLINK: test-latest.tar.gz.gpg
|
||||
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7}
|
||||
GPG_PUBLIC_KEY_RING_FILE: /keys/public_key.asc
|
||||
volumes:
|
||||
- ${KEY_DIR:-.}/public_key.asc:/keys/public_key.asc
|
||||
- ${LOCAL_DIR:-./local}:/archive
|
||||
- app_data:/backup/app_data:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
offen:
|
||||
image: offen/offen:latest
|
||||
labels:
|
||||
- docker-volume-backup.stop-during-backup=true
|
||||
volumes:
|
||||
- app_data:/var/opt/offen
|
||||
|
||||
volumes:
|
||||
app_data:
|
49
test/gpg-asym/run.sh
Executable file
49
test/gpg-asym/run.sh
Executable file
@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
. ../util.sh
|
||||
current_test=$(basename $(pwd))
|
||||
|
||||
export LOCAL_DIR=$(mktemp -d)
|
||||
|
||||
export KEY_DIR=$(mktemp -d)
|
||||
|
||||
export PASSPHRASE="test"
|
||||
|
||||
gpg --batch --gen-key <<EOF
|
||||
Key-Type: RSA
|
||||
Key-Length: 4096
|
||||
Name-Real: offen
|
||||
Name-Email: docker-volume-backup@local
|
||||
Expire-Date: 0
|
||||
Passphrase: $PASSPHRASE
|
||||
%commit
|
||||
EOF
|
||||
|
||||
gpg --export --armor --batch --yes --pinentry-mode loopback --passphrase $PASSPHRASE --output $KEY_DIR/public_key.asc
|
||||
|
||||
docker compose up -d --quiet-pull
|
||||
sleep 5
|
||||
|
||||
docker compose exec backup backup
|
||||
|
||||
expect_running_containers "2"
|
||||
|
||||
TMP_DIR=$(mktemp -d)
|
||||
|
||||
gpg -d --pinentry-mode loopback --yes --passphrase $PASSPHRASE "$LOCAL_DIR/test.tar.gz.gpg" > "$LOCAL_DIR/decrypted.tar.gz"
|
||||
|
||||
tar -xf "$LOCAL_DIR/decrypted.tar.gz" -C $TMP_DIR
|
||||
|
||||
if [ ! -f $TMP_DIR/backup/app_data/offen.db ]; then
|
||||
fail "Could not find expected file in untared archive."
|
||||
fi
|
||||
rm "$LOCAL_DIR/decrypted.tar.gz"
|
||||
|
||||
pass "Found relevant files in decrypted and untared local backup."
|
||||
|
||||
if [ ! -L "$LOCAL_DIR/test-latest.tar.gz.gpg" ]; then
|
||||
fail "Could not find local symlink to latest encrypted backup."
|
||||
fi
|
Loading…
Reference in New Issue
Block a user