diff --git a/README.md b/README.md index 09a2d2f..4ec562c 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,12 @@ This project was inspired by the [duplicity project](http://duplicity.nongnu.org - Auth: Set the B2_ACCOUNT_ID and B2_ACCOUNT_KEY environmental variables to the appropiate values - [99.999999999% durability](https://help.backblaze.com/hc/en-us/articles/218485257-B2-Resiliency-Durability-and-Availability) - Using the Reed-Solomon erasure encoding - Local file path (file://[relative|/absolute]/local/path) +- SSH/SFTP (ssh://) + - Auth: username & password, public key or ssh-agent. + - For username & password set the SSH_USERNAME and SSH_PASSWORD environment variables or use the url format: `ssh://username:password@example.org/remote/path`. + - 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. + - ssh-agent auth is activated when SSH_AUTH_SOCK exists. + - 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. ### Compression @@ -224,7 +230,7 @@ Global Flags: - Make PGP cipher configurable. - Refactor - Test Coverage -- Add more backends (e.g. SSH, SCP, etc.) +- Add more backends - Add delete feature - Appease linters - Track intermediary snaps as part of backup jobs diff --git a/backends/backends.go b/backends/backends.go index c3811f1..e7b99e4 100644 --- a/backends/backends.go +++ b/backends/backends.go @@ -84,6 +84,8 @@ func GetBackendForURI(uri string) (Backend, error) { return &AzureBackend{}, nil case B2BackendPrefix: return &B2Backend{}, nil + case SSHBackendPrefix: + return &SSHBackend{}, nil default: return nil, ErrInvalidPrefix } diff --git a/backends/file_backend.go b/backends/file_backend.go index 2a947a9..772de10 100644 --- a/backends/file_backend.go +++ b/backends/file_backend.go @@ -94,7 +94,7 @@ func (f *FileBackend) Upload(ctx context.Context, vol *files.VolumeInfo) error { _, err = io.Copy(w, vol) if err != nil { if closeErr := w.Close(); closeErr != nil { - log.AppLogger.Warningf("file backend: Error closing volume %s - %v", vol.ObjectName, err) + log.AppLogger.Warningf("file backend: Error closing volume %s - %v", vol.ObjectName, closeErr) } if deleteErr := os.Remove(destinationPath); deleteErr != nil { log.AppLogger.Warningf("file backend: Error deleting failed upload file %s - %v", destinationPath, deleteErr) diff --git a/backends/ssh_backend.go b/backends/ssh_backend.go new file mode 100644 index 0000000..847fb5c --- /dev/null +++ b/backends/ssh_backend.go @@ -0,0 +1,293 @@ +package backends + +import ( + "context" + "errors" + "io" + "net" + "net/url" + "os" + "os/user" + "path/filepath" + "strings" + "time" + + "github.com/pkg/sftp" + "github.com/someone1/zfsbackup-go/files" + "github.com/someone1/zfsbackup-go/log" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + "golang.org/x/crypto/ssh/knownhosts" +) + +// SSHBackendPrefix is the URI prefix used for the SSHBackend. +const SSHBackendPrefix = "ssh" + +// SSHBackend provides a ssh/sftp storage option. +type SSHBackend struct { + conf *BackendConfig + sshClient *ssh.Client + sftpClient *sftp.Client + remotePath string +} + +// buildSshSigner reads the private key file at privateKeyPath and transforms it into a ssh.Signer, +// using password to decrypt the key if required. +func buildSshSigner(privateKeyPath string, password string) (ssh.Signer, error) { + privateKey, err := os.ReadFile(privateKeyPath) + if err != nil { + return nil, err + } + + signer, err := ssh.ParsePrivateKey(privateKey) + _, isMissingPassword := err.(*ssh.PassphraseMissingError) + if isMissingPassword && password != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKey, []byte(password)) + } + + return signer, err +} + +// buildAuthMethods builds ssh auth methods based on the provided password and private keys in the the users home directory. +// To use a specific key instead of the default files set the env variable SSH_KEY_FILE. +// buildAuthMethods also adds ssh-agent auth if the env variable SSH_AUTH_SOCK exists. +func buildAuthMethods(userHomeDir string, password string) (sshAuths []ssh.AuthMethod, err error) { + sshAuthSock := os.Getenv("SSH_AUTH_SOCK") + if sshAuthSock != "" { + sshAgent, err := net.Dial("unix", sshAuthSock) + if err != nil { + return nil, err + } + sshAuths = append(sshAuths, ssh.PublicKeysCallback(agent.NewClient(sshAgent).Signers)) + } + + sshKeyFile := os.Getenv("SSH_KEY_FILE") + if sshKeyFile != "" { + signer, err := buildSshSigner(sshKeyFile, password) + if err != nil { + return nil, err + } + sshAuths = append(sshAuths, ssh.PublicKeys(signer)) + } else { + signers := make([]ssh.Signer, 0) + + defaultKeys := []string{ + filepath.Join(userHomeDir, ".ssh/id_rsa"), + filepath.Join(userHomeDir, ".ssh/id_cdsa"), + filepath.Join(userHomeDir, ".ssh/id_ecdsa_sk"), + filepath.Join(userHomeDir, ".ssh/id_ed25519"), + filepath.Join(userHomeDir, ".ssh/id_ed25519_sk"), + filepath.Join(userHomeDir, ".ssh/id_dsa"), + } + + for _, keyPath := range defaultKeys { + signer, err := buildSshSigner(keyPath, password) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + log.AppLogger.Warningf("ssh backend: Failed to use ssh key at %s - %v", keyPath, err) + } + continue + } + signers = append(signers, signer) + } + if len(signers) > 0 { + sshAuths = append(sshAuths, ssh.PublicKeys(signers...)) + } + } + + if password != "" { + sshAuths = append(sshAuths, ssh.Password(password)) + } + + return sshAuths, nil +} + +// buildHostKeyCallback builds a ssh.HostKeyCallback that uses the known_hosts file from the users home directory. +// Or from a custom file specified by the SSH_KNOWN_HOSTS env variable. +// If SSH_KNOWN_HOSTS is set to "ignore" host key checking is disabled. +func buildHostKeyCallback(userHomeDir string) (callback ssh.HostKeyCallback, err error) { + knownHostsFile := os.Getenv("SSH_KNOWN_HOSTS") + if knownHostsFile == "" { + knownHostsFile = filepath.Join(userHomeDir, ".ssh/known_hosts") + } + if knownHostsFile == "ignore" { + callback = ssh.InsecureIgnoreHostKey() + } else { + callback, err = knownhosts.New(knownHostsFile) + } + return callback, err +} + +// Init will initialize the SSHBackend and verify the provided URI is valid and the target directory exists. +func (s *SSHBackend) Init(ctx context.Context, conf *BackendConfig, opts ...Option) (err error) { + s.conf = conf + + if !strings.HasPrefix(s.conf.TargetURI, SSHBackendPrefix+"://") { + return ErrInvalidURI + } + + targetUrl, err := url.Parse(s.conf.TargetURI) + if err != nil { + log.AppLogger.Errorf("ssh backend: Error while parsing target uri %s - %v", s.conf.TargetURI, err) + return err + } + + s.remotePath = strings.TrimSuffix(targetUrl.Path, "/") + if s.remotePath == "" && targetUrl.Path != "/" { // allow root path + log.AppLogger.Errorf("ssh backend: No remote path provided!") + return ErrInvalidURI + } + + username := os.Getenv("SSH_USERNAME") + password := os.Getenv("SSH_PASSWORD") + if targetUrl.User != nil { + urlUsername := targetUrl.User.Username() + if urlUsername != "" { + username = urlUsername + } + urlPassword, _ := targetUrl.User.Password() + if urlPassword != "" { + password = urlPassword + } + } + + userInfo, err := user.Current() + if err != nil { + return err + } + if username == "" { + username = userInfo.Username + } + + sshAuths, err := buildAuthMethods(userInfo.HomeDir, password) + if err != nil { + return err + } + + hostKeyCallback, err := buildHostKeyCallback(userInfo.HomeDir) + if err != nil { + return err + } + + sshConfig := &ssh.ClientConfig{ + User: username, + Auth: sshAuths, + HostKeyCallback: hostKeyCallback, + Timeout: 30 * time.Second, + } + + hostname := targetUrl.Host + if !strings.Contains(hostname, ":") { + hostname = hostname + ":22" + } + s.sshClient, err = ssh.Dial("tcp", hostname, sshConfig) + if err != nil { + return err + } + + s.sftpClient, err = sftp.NewClient(s.sshClient) + if err != nil { + return err + } + + fi, err := s.sftpClient.Stat(s.remotePath) + if err != nil { + log.AppLogger.Errorf("ssh backend: Error while verifying remote path %s - %v", s.remotePath, err) + return err + } + + if !fi.IsDir() { + log.AppLogger.Errorf("ssh backend: Provided remote path is not a directory!") + return ErrInvalidURI + } + + return nil +} + +// Upload will upload the provided VolumeInfo to the remote sftp server. +func (s *SSHBackend) Upload(ctx context.Context, vol *files.VolumeInfo) error { + s.conf.MaxParallelUploadBuffer <- true + defer func() { + <-s.conf.MaxParallelUploadBuffer + }() + + destinationPath := filepath.Join(s.remotePath, vol.ObjectName) + destinationDir := filepath.Dir(destinationPath) + + if err := s.sftpClient.MkdirAll(destinationDir); err != nil { + log.AppLogger.Debugf("ssh backend: Could not create path %s due to error - %v", destinationDir, err) + return err + } + + w, err := s.sftpClient.Create(destinationPath) + if err != nil { + log.AppLogger.Debugf("ssh backend: Could not create file %s due to error - %v", destinationPath, err) + return err + } + + _, err = io.Copy(w, vol) + if err != nil { + if closeErr := w.Close(); closeErr != nil { + log.AppLogger.Warningf("ssh backend: Error closing volume %s - %v", vol.ObjectName, closeErr) + } + if deleteErr := os.Remove(destinationPath); deleteErr != nil { + log.AppLogger.Warningf("ssh backend: Error deleting failed upload file %s - %v", destinationPath, deleteErr) + } + log.AppLogger.Debugf("ssh backend: Error while copying volume %s - %v", vol.ObjectName, err) + return err + } + + return w.Close() +} + +// List will return a list of all files matching the provided prefix. +func (s *SSHBackend) List(ctx context.Context, prefix string) ([]string, error) { + l := make([]string, 0, 1000) + + w := s.sftpClient.Walk(s.remotePath) + for w.Step() { + if err := w.Err(); err != nil { + return l, err + } + + trimmedPath := strings.TrimPrefix(w.Path(), s.remotePath+string(filepath.Separator)) + if !w.Stat().IsDir() && strings.HasPrefix(trimmedPath, prefix) { + l = append(l, trimmedPath) + } + } + + return l, nil +} + +// Close will release any resources used by SSHBackend. +func (s *SSHBackend) Close() (err error) { + if s.sftpClient != nil { + err = s.sftpClient.Close() + s.sftpClient = nil + } + if s.sshClient != nil { + sshErr := s.sshClient.Close() + if sshErr == nil && err == nil { + err = sshErr + } + s.sshClient = nil + } + return err +} + +// PreDownload does nothing on this backend. +func (s *SSHBackend) PreDownload(ctx context.Context, objects []string) error { + return nil +} + +// Download will open the remote file for reading. +func (s *SSHBackend) Download(ctx context.Context, filename string) (io.ReadCloser, error) { + return s.sftpClient.Open(filepath.Join(s.remotePath, filename)) +} + +// Delete will delete the given object from the provided path. +func (s *SSHBackend) Delete(ctx context.Context, filename string) error { + return s.sftpClient.Remove(filepath.Join(s.remotePath, filename)) +} + +var _ Backend = (*SSHBackend)(nil) diff --git a/backends/ssh_backend_test.go b/backends/ssh_backend_test.go new file mode 100644 index 0000000..1479449 --- /dev/null +++ b/backends/ssh_backend_test.go @@ -0,0 +1,166 @@ +package backends + +import ( + "context" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "io" + "net" + "os" + "testing" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ed25519" + "golang.org/x/crypto/ssh" +) + +func generateSSHPrivateKey() (ssh.Signer, error) { + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + + keyBytes, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, err + } + + signer, err := ssh.ParsePrivateKey(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})) + if err != nil { + return nil, err + } + + return signer, nil +} + +// startSftpServer starts a very hacky sftp server based on +// https://github.com/pkg/sftp/blob/v1.13.5/examples/go-sftp-server/main.go +func startSftpServer(t testing.TB, user string, password string) net.Listener { + t.Helper() + + config := &ssh.ServerConfig{ + PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { + if c.User() == user && string(pass) == password { + return nil, nil + } + return nil, fmt.Errorf("password rejected for %q", c.User()) + }, + } + signer, err := generateSSHPrivateKey() + if err != nil { + t.Fatal("failed to generate key", err) + } + config.AddHostKey(signer) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal("failed to listen for connection", err) + } + + fmt.Println("Listening on", listener.Addr()) + + go func() { + for { + acceptedConn, err := listener.Accept() + if err != nil { + if !errors.Is(err, net.ErrClosed) { + t.Fatal("failed to accept incoming connection", err) + } + return + } + + go func(conn net.Conn) { + // Before use, a handshake must be performed on the incoming net.Conn. + _, chans, reqs, err := ssh.NewServerConn(conn, config) + if err != nil { + t.Fatal("failed to handshake", err) + } + fmt.Println("SSH server established") + + // The incoming Request channel must be serviced. + go ssh.DiscardRequests(reqs) + + // Service the incoming Channel channel. + for newChannel := range chans { + // Channels have a type, depending on the application level + // protocol intended. In the case of an SFTP session, this is "subsystem" + // with a payload string of "sftp" + fmt.Println("Incoming channel:", newChannel.ChannelType()) + if newChannel.ChannelType() != "session" { + _ = newChannel.Reject(ssh.UnknownChannelType, "unknown channel type") + fmt.Println("Unknown channel type:", newChannel.ChannelType()) + continue + } + channel, requests, err := newChannel.Accept() + if err != nil { + t.Fatal("could not accept channel.", err) + } + fmt.Println("Channel accepted") + + // Sessions have out-of-band requests such as "shell", + // "pty-req" and "env". Here we handle only the + // "subsystem" request. + go func(in <-chan *ssh.Request) { + for req := range in { + fmt.Println("Request:", req.Type) + ok := false + switch req.Type { + case "subsystem": + fmt.Printf("Subsystem: %s\n", req.Payload[4:]) + if string(req.Payload[4:]) == "sftp" { + ok = true + } + } + fmt.Println(" - accepted:", ok) + _ = req.Reply(ok, nil) + } + }(requests) + + server, err := sftp.NewServer(channel) + if err != nil { + t.Fatal(err) + } + if err := server.Serve(); err == io.EOF { + _ = server.Close() + fmt.Println("sftp client exited session.") + } else if err != nil { + t.Fatal("sftp server completed with error:", err) + } + } + }(acceptedConn) + } + }() + + return listener +} + +func TestSSHBackend(t *testing.T) { + t.Parallel() + + sftpListener := startSftpServer(t, "test", "password") + defer func() { + _ = sftpListener.Close() + }() + + tempPath := t.TempDir() + + backendUriEnd := "test:password@" + sftpListener.Addr().String() + tempPath + + err := os.Setenv("SSH_KNOWN_HOSTS", "ignore") + if err != nil { + t.Fatalf("Failed to disable host key checking: %v", err) + } + + b, err := GetBackendForURI(SSHBackendPrefix + "://" + backendUriEnd) + if err != nil { + t.Fatalf("Error while trying to get backend: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + BackendTest(ctx, SSHBackendPrefix, backendUriEnd, true, b)(t) +} diff --git a/backup/backup.go b/backup/backup.go index 29245d8..fc07f78 100644 --- a/backup/backup.go +++ b/backup/backup.go @@ -416,7 +416,7 @@ func Backup(pctx context.Context, jobInfo *files.JobInfo) error { } else { fmt.Fprintf( config.Stdout, - "Done.\n\tTotal ZFS Stream Bytes: %d (%s)\n\tTotal Bytes Written: %d (%s)\n\tElapsed Time: %v\n\tTotal Files Uploaded: %d", + "Done.\n\tTotal ZFS Stream Bytes: %d (%s)\n\tTotal Bytes Written: %d (%s)\n\tElapsed Time: %v\n\tTotal Files Uploaded: %d\n", jobInfo.ZFSStreamBytes, humanize.IBytes(jobInfo.ZFSStreamBytes), totalWrittenBytes, diff --git a/go.mod b/go.mod index 34acf94..5e3421d 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/nightlyone/lockfile v1.0.0 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 github.com/pkg/errors v0.9.1 + github.com/pkg/sftp v1.13.5 github.com/spf13/cobra v1.5.0 golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0 golang.org/x/sync v0.0.0-20220907140024-f12130a52804 @@ -47,6 +48,7 @@ require ( github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/klauspost/compress v1.15.10 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/mattn/go-ieproxy v0.0.9 // indirect github.com/spf13/pflag v1.0.5 // indirect go.opencensus.io v0.23.0 // indirect diff --git a/go.sum b/go.sum index 4fd8b1e..c928266 100644 --- a/go.sum +++ b/go.sum @@ -257,6 +257,8 @@ github.com/klauspost/compress v1.15.10 h1:Ai8UzuomSCDw90e1qNMtb15msBXsNpH6gzkkEN github.com/klauspost/compress v1.15.10/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE= github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= @@ -277,6 +279,8 @@ github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0C github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.5 h1:a3RLUqkyjYRtBTZJZ1VRrKbN3zhuPLlUc3sphVz81go= +github.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg= 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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -292,6 +296,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -314,6 +319,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0 h1:a5Yg6ylndHHYJqIPrdq0AhvR6KTvDTAvgBtaidhEevY= golang.org/x/crypto v0.0.0-20220919173607-35f4265a4bc0/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -387,6 +393,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= @@ -767,6 +774,7 @@ gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +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=