Skip to content

Commit 0ac389e

Browse files
authoredFeb 13, 2023
Add SSH/SFTP backend (#658)
* fix: file backend logging wrong error * feat: add ssh/sftp storage backend * fix: add new line after backup stats * chore: add ssh docs & test
1 parent a30f1a4 commit 0ac389e

File tree

8 files changed

+480
-3
lines changed

8 files changed

+480
-3
lines changed
 

‎README.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ This project was inspired by the [duplicity project](http://duplicity.nongnu.org
3737
- Auth: Set the B2_ACCOUNT_ID and B2_ACCOUNT_KEY environmental variables to the appropiate values
3838
- [99.999999999% durability](https://help.backblaze.com/hc/en-us/articles/218485257-B2-Resiliency-Durability-and-Availability) - Using the Reed-Solomon erasure encoding
3939
- Local file path (file://[relative|/absolute]/local/path)
40+
- SSH/SFTP (ssh://)
41+
- Auth: username & password, public key or ssh-agent.
42+
- For username & password set the SSH_USERNAME and SSH_PASSWORD environment variables or use the url format: `ssh://username:password@example.org/remote/path`.
43+
- For public key auth set the SSH_KEY_FILE environment variable. By default zfsbackup tries to use common key names from the users home directory.
44+
- ssh-agent auth is activated when SSH_AUTH_SOCK exists.
45+
- By default zfsbackup also uses the known hosts file from the users home directory. To disable host key checking set SSH_KNOWN_HOSTS to `ignore`. You can also specify the path to your own known hosts file.
4046

4147
### Compression
4248

@@ -224,7 +230,7 @@ Global Flags:
224230
- Make PGP cipher configurable.
225231
- Refactor
226232
- Test Coverage
227-
- Add more backends (e.g. SSH, SCP, etc.)
233+
- Add more backends
228234
- Add delete feature
229235
- Appease linters
230236
- Track intermediary snaps as part of backup jobs

‎backends/backends.go

+2
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ func GetBackendForURI(uri string) (Backend, error) {
8484
return &AzureBackend{}, nil
8585
case B2BackendPrefix:
8686
return &B2Backend{}, nil
87+
case SSHBackendPrefix:
88+
return &SSHBackend{}, nil
8789
default:
8890
return nil, ErrInvalidPrefix
8991
}

‎backends/file_backend.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func (f *FileBackend) Upload(ctx context.Context, vol *files.VolumeInfo) error {
9494
_, err = io.Copy(w, vol)
9595
if err != nil {
9696
if closeErr := w.Close(); closeErr != nil {
97-
log.AppLogger.Warningf("file backend: Error closing volume %s - %v", vol.ObjectName, err)
97+
log.AppLogger.Warningf("file backend: Error closing volume %s - %v", vol.ObjectName, closeErr)
9898
}
9999
if deleteErr := os.Remove(destinationPath); deleteErr != nil {
100100
log.AppLogger.Warningf("file backend: Error deleting failed upload file %s - %v", destinationPath, deleteErr)

‎backends/ssh_backend.go

+293
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
package backends
2+
3+
import (
4+
"context"
5+
"errors"
6+
"io"
7+
"net"
8+
"net/url"
9+
"os"
10+
"os/user"
11+
"path/filepath"
12+
"strings"
13+
"time"
14+
15+
"github.com/pkg/sftp"
16+
"github.com/someone1/zfsbackup-go/files"
17+
"github.com/someone1/zfsbackup-go/log"
18+
"golang.org/x/crypto/ssh"
19+
"golang.org/x/crypto/ssh/agent"
20+
"golang.org/x/crypto/ssh/knownhosts"
21+
)
22+
23+
// SSHBackendPrefix is the URI prefix used for the SSHBackend.
24+
const SSHBackendPrefix = "ssh"
25+
26+
// SSHBackend provides a ssh/sftp storage option.
27+
type SSHBackend struct {
28+
conf *BackendConfig
29+
sshClient *ssh.Client
30+
sftpClient *sftp.Client
31+
remotePath string
32+
}
33+
34+
// buildSshSigner reads the private key file at privateKeyPath and transforms it into a ssh.Signer,
35+
// using password to decrypt the key if required.
36+
func buildSshSigner(privateKeyPath string, password string) (ssh.Signer, error) {
37+
privateKey, err := os.ReadFile(privateKeyPath)
38+
if err != nil {
39+
return nil, err
40+
}
41+
42+
signer, err := ssh.ParsePrivateKey(privateKey)
43+
_, isMissingPassword := err.(*ssh.PassphraseMissingError)
44+
if isMissingPassword && password != "" {
45+
signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(password))
46+
}
47+
48+
return signer, err
49+
}
50+
51+
// buildAuthMethods builds ssh auth methods based on the provided password and private keys in the the users home directory.
52+
// To use a specific key instead of the default files set the env variable SSH_KEY_FILE.
53+
// buildAuthMethods also adds ssh-agent auth if the env variable SSH_AUTH_SOCK exists.
54+
func buildAuthMethods(userHomeDir string, password string) (sshAuths []ssh.AuthMethod, err error) {
55+
sshAuthSock := os.Getenv("SSH_AUTH_SOCK")
56+
if sshAuthSock != "" {
57+
sshAgent, err := net.Dial("unix", sshAuthSock)
58+
if err != nil {
59+
return nil, err
60+
}
61+
sshAuths = append(sshAuths, ssh.PublicKeysCallback(agent.NewClient(sshAgent).Signers))
62+
}
63+
64+
sshKeyFile := os.Getenv("SSH_KEY_FILE")
65+
if sshKeyFile != "" {
66+
signer, err := buildSshSigner(sshKeyFile, password)
67+
if err != nil {
68+
return nil, err
69+
}
70+
sshAuths = append(sshAuths, ssh.PublicKeys(signer))
71+
} else {
72+
signers := make([]ssh.Signer, 0)
73+
74+
defaultKeys := []string{
75+
filepath.Join(userHomeDir, ".ssh/id_rsa"),
76+
filepath.Join(userHomeDir, ".ssh/id_cdsa"),
77+
filepath.Join(userHomeDir, ".ssh/id_ecdsa_sk"),
78+
filepath.Join(userHomeDir, ".ssh/id_ed25519"),
79+
filepath.Join(userHomeDir, ".ssh/id_ed25519_sk"),
80+
filepath.Join(userHomeDir, ".ssh/id_dsa"),
81+
}
82+
83+
for _, keyPath := range defaultKeys {
84+
signer, err := buildSshSigner(keyPath, password)
85+
if err != nil {
86+
if !errors.Is(err, os.ErrNotExist) {
87+
log.AppLogger.Warningf("ssh backend: Failed to use ssh key at %s - %v", keyPath, err)
88+
}
89+
continue
90+
}
91+
signers = append(signers, signer)
92+
}
93+
if len(signers) > 0 {
94+
sshAuths = append(sshAuths, ssh.PublicKeys(signers...))
95+
}
96+
}
97+
98+
if password != "" {
99+
sshAuths = append(sshAuths, ssh.Password(password))
100+
}
101+
102+
return sshAuths, nil
103+
}
104+
105+
// buildHostKeyCallback builds a ssh.HostKeyCallback that uses the known_hosts file from the users home directory.
106+
// Or from a custom file specified by the SSH_KNOWN_HOSTS env variable.
107+
// If SSH_KNOWN_HOSTS is set to "ignore" host key checking is disabled.
108+
func buildHostKeyCallback(userHomeDir string) (callback ssh.HostKeyCallback, err error) {
109+
knownHostsFile := os.Getenv("SSH_KNOWN_HOSTS")
110+
if knownHostsFile == "" {
111+
knownHostsFile = filepath.Join(userHomeDir, ".ssh/known_hosts")
112+
}
113+
if knownHostsFile == "ignore" {
114+
callback = ssh.InsecureIgnoreHostKey()
115+
} else {
116+
callback, err = knownhosts.New(knownHostsFile)
117+
}
118+
return callback, err
119+
}
120+
121+
// Init will initialize the SSHBackend and verify the provided URI is valid and the target directory exists.
122+
func (s *SSHBackend) Init(ctx context.Context, conf *BackendConfig, opts ...Option) (err error) {
123+
s.conf = conf
124+
125+
if !strings.HasPrefix(s.conf.TargetURI, SSHBackendPrefix+"://") {
126+
return ErrInvalidURI
127+
}
128+
129+
targetUrl, err := url.Parse(s.conf.TargetURI)
130+
if err != nil {
131+
log.AppLogger.Errorf("ssh backend: Error while parsing target uri %s - %v", s.conf.TargetURI, err)
132+
return err
133+
}
134+
135+
s.remotePath = strings.TrimSuffix(targetUrl.Path, "/")
136+
if s.remotePath == "" && targetUrl.Path != "/" { // allow root path
137+
log.AppLogger.Errorf("ssh backend: No remote path provided!")
138+
return ErrInvalidURI
139+
}
140+
141+
username := os.Getenv("SSH_USERNAME")
142+
password := os.Getenv("SSH_PASSWORD")
143+
if targetUrl.User != nil {
144+
urlUsername := targetUrl.User.Username()
145+
if urlUsername != "" {
146+
username = urlUsername
147+
}
148+
urlPassword, _ := targetUrl.User.Password()
149+
if urlPassword != "" {
150+
password = urlPassword
151+
}
152+
}
153+
154+
userInfo, err := user.Current()
155+
if err != nil {
156+
return err
157+
}
158+
if username == "" {
159+
username = userInfo.Username
160+
}
161+
162+
sshAuths, err := buildAuthMethods(userInfo.HomeDir, password)
163+
if err != nil {
164+
return err
165+
}
166+
167+
hostKeyCallback, err := buildHostKeyCallback(userInfo.HomeDir)
168+
if err != nil {
169+
return err
170+
}
171+
172+
sshConfig := &ssh.ClientConfig{
173+
User: username,
174+
Auth: sshAuths,
175+
HostKeyCallback: hostKeyCallback,
176+
Timeout: 30 * time.Second,
177+
}
178+
179+
hostname := targetUrl.Host
180+
if !strings.Contains(hostname, ":") {
181+
hostname = hostname + ":22"
182+
}
183+
s.sshClient, err = ssh.Dial("tcp", hostname, sshConfig)
184+
if err != nil {
185+
return err
186+
}
187+
188+
s.sftpClient, err = sftp.NewClient(s.sshClient)
189+
if err != nil {
190+
return err
191+
}
192+
193+
fi, err := s.sftpClient.Stat(s.remotePath)
194+
if err != nil {
195+
log.AppLogger.Errorf("ssh backend: Error while verifying remote path %s - %v", s.remotePath, err)
196+
return err
197+
}
198+
199+
if !fi.IsDir() {
200+
log.AppLogger.Errorf("ssh backend: Provided remote path is not a directory!")
201+
return ErrInvalidURI
202+
}
203+
204+
return nil
205+
}
206+
207+
// Upload will upload the provided VolumeInfo to the remote sftp server.
208+
func (s *SSHBackend) Upload(ctx context.Context, vol *files.VolumeInfo) error {
209+
s.conf.MaxParallelUploadBuffer <- true
210+
defer func() {
211+
<-s.conf.MaxParallelUploadBuffer
212+
}()
213+
214+
destinationPath := filepath.Join(s.remotePath, vol.ObjectName)
215+
destinationDir := filepath.Dir(destinationPath)
216+
217+
if err := s.sftpClient.MkdirAll(destinationDir); err != nil {
218+
log.AppLogger.Debugf("ssh backend: Could not create path %s due to error - %v", destinationDir, err)
219+
return err
220+
}
221+
222+
w, err := s.sftpClient.Create(destinationPath)
223+
if err != nil {
224+
log.AppLogger.Debugf("ssh backend: Could not create file %s due to error - %v", destinationPath, err)
225+
return err
226+
}
227+
228+
_, err = io.Copy(w, vol)
229+
if err != nil {
230+
if closeErr := w.Close(); closeErr != nil {
231+
log.AppLogger.Warningf("ssh backend: Error closing volume %s - %v", vol.ObjectName, closeErr)
232+
}
233+
if deleteErr := os.Remove(destinationPath); deleteErr != nil {
234+
log.AppLogger.Warningf("ssh backend: Error deleting failed upload file %s - %v", destinationPath, deleteErr)
235+
}
236+
log.AppLogger.Debugf("ssh backend: Error while copying volume %s - %v", vol.ObjectName, err)
237+
return err
238+
}
239+
240+
return w.Close()
241+
}
242+
243+
// List will return a list of all files matching the provided prefix.
244+
func (s *SSHBackend) List(ctx context.Context, prefix string) ([]string, error) {
245+
l := make([]string, 0, 1000)
246+
247+
w := s.sftpClient.Walk(s.remotePath)
248+
for w.Step() {
249+
if err := w.Err(); err != nil {
250+
return l, err
251+
}
252+
253+
trimmedPath := strings.TrimPrefix(w.Path(), s.remotePath+string(filepath.Separator))
254+
if !w.Stat().IsDir() && strings.HasPrefix(trimmedPath, prefix) {
255+
l = append(l, trimmedPath)
256+
}
257+
}
258+
259+
return l, nil
260+
}
261+
262+
// Close will release any resources used by SSHBackend.
263+
func (s *SSHBackend) Close() (err error) {
264+
if s.sftpClient != nil {
265+
err = s.sftpClient.Close()
266+
s.sftpClient = nil
267+
}
268+
if s.sshClient != nil {
269+
sshErr := s.sshClient.Close()
270+
if sshErr == nil && err == nil {
271+
err = sshErr
272+
}
273+
s.sshClient = nil
274+
}
275+
return err
276+
}
277+
278+
// PreDownload does nothing on this backend.
279+
func (s *SSHBackend) PreDownload(ctx context.Context, objects []string) error {
280+
return nil
281+
}
282+
283+
// Download will open the remote file for reading.
284+
func (s *SSHBackend) Download(ctx context.Context, filename string) (io.ReadCloser, error) {
285+
return s.sftpClient.Open(filepath.Join(s.remotePath, filename))
286+
}
287+
288+
// Delete will delete the given object from the provided path.
289+
func (s *SSHBackend) Delete(ctx context.Context, filename string) error {
290+
return s.sftpClient.Remove(filepath.Join(s.remotePath, filename))
291+
}
292+
293+
var _ Backend = (*SSHBackend)(nil)

0 commit comments

Comments
 (0)
Please sign in to comment.