Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cmd/backup/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,14 @@ type Config struct {
WebdavPassword string `split_words:"true"`
SSHHostName string `split_words:"true"`
SSHPort string `split_words:"true" default:"22"`
SSHHostPublicKey string `split_words:"true"`
SSHKnownHostsFile string `split_words:"true"`
SSHUser string `split_words:"true"`
SSHPassword string `split_words:"true"`
SSHIdentityFile string `split_words:"true" default:"/root/.ssh/id_rsa"`
SSHIdentityPassphrase string `split_words:"true"`
SSHRemotePath string `split_words:"true"`
SSHInsecureIgnoreHostKey bool `split_words:"true"`
ExecLabel string `split_words:"true"`
ExecForwardOutput bool `split_words:"true"`
LockTimeout time.Duration `split_words:"true" default:"60m"`
Expand Down Expand Up @@ -364,6 +367,11 @@ func (c *Config) resolve() (reset func() error, warnings []string, err error) {
}
c.AzureStorageEndpoint = fmt.Sprintf("%s/", strings.TrimSuffix(ep.String(), "/"))
}

if c.SSHHostName != "" && !c.SSHInsecureIgnoreHostKey && (c.SSHHostPublicKey == "" || c.SSHKnownHostsFile != "") {
err = errwrap.Wrap(nil, "SSH hostname given without a public key or known_hosts, refer to the documentation for guidance")
return
}
return
}

Expand Down
17 changes: 10 additions & 7 deletions cmd/backup/script.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,13 +186,16 @@ func (s *script) init() error {

if s.c.SSHHostName != "" {
sshConfig := ssh.Config{
HostName: s.c.SSHHostName,
Port: s.c.SSHPort,
User: s.c.SSHUser,
Password: s.c.SSHPassword,
IdentityFile: s.c.SSHIdentityFile,
IdentityPassphrase: s.c.SSHIdentityPassphrase,
RemotePath: s.c.SSHRemotePath,
HostName: s.c.SSHHostName,
Port: s.c.SSHPort,
User: s.c.SSHUser,
Password: s.c.SSHPassword,
IdentityFile: s.c.SSHIdentityFile,
IdentityPassphrase: s.c.SSHIdentityPassphrase,
RemotePath: s.c.SSHRemotePath,
InsecureIgnoreHostKey: s.c.SSHInsecureIgnoreHostKey,
HostPublicKey: s.c.SSHHostPublicKey,
KnownHostsFile: s.c.SSHKnownHostsFile,
}

sshBackend, closeSSHConnection, err := ssh.NewStorageBackend(sshConfig, logFunc)
Expand Down
20 changes: 20 additions & 0 deletions docs/how-tos/provide-ssh-host-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
title: Provide public host key or known hosts when using the SSH backend
layout: default
parent: How Tos
nav_order: 21
---

# Provide public host key or known hosts when using the SSH backend

Up to version 2.49.1 the image would not check the SSH host's identity, which is unsafe.
This potentially leaks credentials or backup content to external attackers.

Starting with version 2.49.2, this is prevented, requiring you to either:

- provide the host's public key by setting either `SSH_HOST_PUBLIC_KEY` (the key's content) or `SSH_HOST_PUBLIC_KEY_FILE` (the fs location of the key in a file)
- provide the location of a valid known_hosts file by setting `SSH_KNOWN_HOSTS_FILE`
- dangerously set `SSH_INSECURE_IGNORE_HOST_KEY` to ignore host key checks

{: .warning }
Only set `SSH_INSECURE_IGNORE_HOST_KEY` as a last resort or in entirely airgapped environments.
2 changes: 2 additions & 0 deletions docs/recipes/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,15 @@ services:
image: offen/docker-volume-backup:v2
environment:
SSH_HOST_NAME: server.local
SSH_HOST_PUBLIC_KEY_FILE: /config/host_key_ecdsa.pub
SSH_PORT: 2222
SSH_USER: user
SSH_REMOTE_PATH: /data
volumes:
- data:/backup/my-app-backup:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- /path/to/private_key:/root/.ssh/id_rsa
- /path/to/host_key:/config/host_key_ecdsa.pub

volumes:
data:
Expand Down
27 changes: 27 additions & 0 deletions docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,24 @@ If you need to confirm what the container actually loaded, see [Show loaded conf

# ---

# The host's public SSH key
# If you want to mount a file containing the key instead, provide `SSH_HOST_PUBLIC_KEY_FILE`
# Alternatively, provide the location of a known hosts file as shown below.
# If you do not want to provide the key, set `SSH_IGNORE_HOST_KEY` at your own risk

# SSH_HOST_PUBLIC_KEY=""

# ---

# The location of a known_hosts file used when checking the identity of the remote server.
# Alternatively, provide the server's public key as shown above.
# If both a public key and a known_hosts file are set, the key takes precedence.
# If you do not want to provide such a file, set `SSH_IGNORE_HOST_KEY` at your own risk

# SSH_KNOWN_HOSTS_FILE=""

# ---

# The port of the remote SSH server

# SSH_PORT="22"
Expand Down Expand Up @@ -334,6 +352,15 @@ If you need to confirm what the container actually loaded, see [Show loaded conf

# SSH_IDENTITY_PASSPHRASE=""

# ---

# Ignore all host key checks. This should be used as a last resort or in
# airgapped environments only. Instead, provide the host's public key as
# described above. Backup integrity and credential confindentiality are at
# risk if using this option.

# SSH_IGNORE_HOST_KEY="false"

########### AZURE BLOB STORAGE

# The credential's account name when using Azure Blob Storage. This has to be
Expand Down
40 changes: 30 additions & 10 deletions internal/storage/ssh/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/offen/docker-volume-backup/internal/storage"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)

type sshStorage struct {
Expand All @@ -27,13 +28,16 @@ type sshStorage struct {

// Config allows to configure a SSH backend.
type Config struct {
HostName string
Port string
User string
Password string
IdentityFile string
IdentityPassphrase string
RemotePath string
HostName string
HostPublicKey string
Port string
User string
Password string
IdentityFile string
IdentityPassphrase string
RemotePath string
KnownHostsFile string
InsecureIgnoreHostKey bool
}

var noop = func() error { return nil }
Expand Down Expand Up @@ -69,10 +73,26 @@ func NewStorageBackend(opts Config, logFunc storage.Log) (storage.Backend, func(
}

sshClientConfig := &ssh.ClientConfig{
User: opts.User,
Auth: authMethods,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
User: opts.User,
Auth: authMethods,
}

if opts.InsecureIgnoreHostKey {
sshClientConfig.HostKeyCallback = ssh.InsecureIgnoreHostKey()
} else if opts.HostPublicKey != "" {
cb, _, _, _, err := ssh.ParseAuthorizedKey([]byte(opts.HostPublicKey))
if err != nil {
return nil, noop, errwrap.Wrap(err, "error parsing public host key")
}
sshClientConfig.HostKeyCallback = ssh.FixedHostKey(cb)
} else if opts.KnownHostsFile != "" {
cb, err := knownhosts.New(opts.KnownHostsFile)
if err != nil {
return nil, noop, errwrap.Wrap(err, "error parsing knownhosts file")
}
sshClientConfig.HostKeyCallback = cb
}

sshClient, err := ssh.Dial("tcp", fmt.Sprintf("%s:%s", opts.HostName, opts.Port), sshClientConfig)
if err != nil || sshClient == nil {
return nil, noop, errwrap.Wrap(err, "error creating ssh client")
Expand Down
5 changes: 4 additions & 1 deletion test/ssh/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ services:
volumes:
- ${KEY_DIR:-.}/id_rsa.pub:/config/.ssh/authorized_keys
- ssh_backup_data:/tmp
- ssh_config_data:/config

backup:
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
Expand All @@ -23,13 +24,15 @@ services:
BACKUP_PRUNING_LEEWAY: 5s
BACKUP_PRUNING_PREFIX: test
SSH_HOST_NAME: ssh
SSH_HOST_PUBLIC_KEY_FILE: /ssh_config_data/ssh_host_keys/ssh_host_ecdsa_key.pub
SSH_PORT: 2222
SSH_USER: test
SSH_REMOTE_PATH: /tmp
SSH_IDENTITY_PASSPHRASE: test1234
volumes:
- ${KEY_DIR:-.}/id_rsa:/root/.ssh/id_rsa
- app_data:/backup/app_data:ro
- ssh_config_data:/ssh_config_data
- /var/run/docker.sock:/var/run/docker.sock:ro

offen:
Expand All @@ -41,5 +44,5 @@ services:

volumes:
ssh_backup_data:
name: ssh_backup_data
app_data:
ssh_config_data:
8 changes: 4 additions & 4 deletions test/ssh/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ sleep 5
expect_running_containers 3

docker run --rm \
-v ssh_backup_data:/ssh_data \
-v ssh_ssh_backup_data:/ssh_data \
alpine \
ash -c 'tar -xvf /ssh_data/test-hostnametoken.tar.gz -C /tmp && test -f /tmp/backup/app_data/offen.db'

Expand All @@ -34,7 +34,7 @@ sleep 5
docker compose exec backup backup

docker run --rm \
-v ssh_backup_data:/ssh_data \
-v ssh_ssh_backup_data:/ssh_data \
alpine \
ash -c '[ $(find /ssh_data/ -type f | wc -l) = "1" ]'

Expand All @@ -51,7 +51,7 @@ docker compose exec backup backup

# Set the modification date of the old backup to 14 days ago
docker run --rm \
-v ssh_backup_data:/ssh_data \
-v ssh_ssh_backup_data:/ssh_data \
--user 1000 \
alpine \
ash -c 'touch -d@$(( $(date +%s) - 1209600 )) /ssh_data/test-hostnametoken-old.tar.gz'
Expand All @@ -60,7 +60,7 @@ info "Create second backup and prune"
docker compose exec backup backup

docker run --rm \
-v ssh_backup_data:/ssh_data \
-v ssh_ssh_backup_data:/ssh_data \
alpine \
ash -c 'test ! -f /ssh_data/test-hostnametoken-old.tar.gz && test -f /ssh_data/test-hostnametoken.tar.gz'

Expand Down
Loading