diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b4b34e62f..1abd8f774 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,23 +25,27 @@ jobs: - job_name: Windows go: 1.22.x os: windows-latest + test_smb: true - job_name: macOS go: 1.22.x os: macOS-latest test_fuse: false + test_smb: true - job_name: Linux go: 1.22.x os: ubuntu-latest test_cloud_backends: true test_fuse: true + test_smb: true check_changelog: true - job_name: Linux (race) go: 1.22.x os: ubuntu-latest test_fuse: true + test_smb: true test_opts: "-race" - job_name: Linux @@ -53,11 +57,13 @@ jobs: go: 1.20.x os: ubuntu-latest test_fuse: true + test_smb: true - job_name: Linux go: 1.19.x os: ubuntu-latest test_fuse: true + test_smb: true name: ${{ matrix.job_name }} Go ${{ matrix.go }} runs-on: ${{ matrix.os }} @@ -96,6 +102,97 @@ jobs: chmod 755 $HOME/bin/rclone rm -rf rclone* + smbuser="smbuser" + smbpass="mGoWwqvgdnwtmh07" + + if [ "$RUNNER_OS" == "macOS" ]; then + smbhome=/Users/$smbuser + echo "Get computer name" + computername=$(sudo -S scutil --get ComputerName) + echo "Create smb user" + sudo dscl . -create $smbhome + sudo dscl . -create $smbhome UserShell /bin/bash + sudo dscl . -create $smbhome RealName $smbuser + LastID=`dscl . -list /Users UniqueID | awk '{print $2}' | sort -n | tail -1` + NextID=$((LastID + 1)) + sudo dscl . -create $smbhome UniqueID $NextID + sudo dscl . -create $smbhome PrimaryGroupID 80 + sudo dscl . -create $smbhome NFSHomeDirectory $smbhome + sudo dscl . -passwd $smbhome $smbpass + sudo dscl . -append /Groups/admin GroupMembership $smbuser + echo "Make home dir" + cd /Users/ + sudo createhomedir -u $smbuser -c + sudo mkdir $smbhome/smbshare + sudo chown -R $smbuser $smbhome + sudo chmod -R 755 $smbhome + echo "Setup smb share" + sudo sharing -a $smbhome/smbshare -S smbuser -n smbuser -s 001 + echo "Enable share for os user" + sudo pwpolicy -u $smbuser -sethashtypes SMB-NT off + sudo pwpolicy -u $smbuser -enableuser + echo "Export domain" + if [[ $computername != *.local ]]; then computername=$computername".local"; fi + export RESTIC_SMB_DOMAIN=$computername + sudo pwpolicy -u $smbuser -sethashtypes SMB-NT on + sudo dscl . -passwd /Users/$smbuser $smbpass + sudo launchctl load -w /System/Library/LaunchDaemons/com.apple.smbd.plist + sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.smb.server.plist EnabledServices -array disk + + else + echo "install samba" + sudo apt-get update + sudo apt-get install samba -y + + echo "Allow Samba in firewall" + sudo ufw allow 'Samba' + + echo "modifying samba config" + echo '' | sudo tee -a /etc/samba/smb.conf + echo ' interfaces = 127.0.0.0/8 eth0' | sudo tee -a /etc/samba/smb.conf + echo ' bind interfaces only = yes' | sudo tee -a /etc/samba/smb.conf + echo '' | sudo tee -a /etc/samba/smb.conf + echo "[$smbuser]" | sudo tee -a /etc/samba/smb.conf + echo ' comment = Samba on Ubuntu' | sudo tee -a /etc/samba/smb.conf + echo " path = /samba/$smbuser" | sudo tee -a /etc/samba/smb.conf + echo ' browseable = yes' | sudo tee -a /etc/samba/smb.conf + echo ' read only = no' | sudo tee -a /etc/samba/smb.conf + echo ' force create mode = 0660' | sudo tee -a /etc/samba/smb.conf + echo ' force directory mode = 2770' | sudo tee -a /etc/samba/smb.conf + echo " valid users = $smbuser" | sudo tee -a /etc/samba/smb.conf + + echo "restart services" + sudo systemctl restart smbd + sudo systemctl restart nmbd + + echo "create samba share directory" + sudo mkdir /samba + + echo "change sambashare group" + sudo chgrp sambashare /samba + + echo "add samba user" + sudo id -u "$smbuser" &>/dev/null || sudo useradd -M -d "/samba/$smbuser" -s /usr/sbin/nologin -G sambashare "$smbuser" + + echo "create samba share user directory" + sudo mkdir "/samba/$smbuser" + + echo "change samba share user directory ownership" + sudo chown "$smbuser":sambashare "/samba/$smbuser" + + echo "modify permissions on samba share user directory" + sudo chmod 2770 "/samba/$smbuser" + + echo "change smb password" + (echo "$smbpass"; echo "$smbpass") | sudo smbpasswd -a "$smbuser" + + echo "enable samba user" + sudo smbpasswd -e "$smbuser" + + echo "restart services" + sudo systemctl restart smbd + sudo systemctl restart nmbd + fi # add $HOME/bin to path ($GOBIN was already added to the path by setup-go@v3) echo $HOME/bin >> $GITHUB_PATH if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' @@ -135,6 +232,26 @@ jobs: Invoke-WebRequest https://github.com/restic/test-assets/raw/master/libiconv-1.8-1-bin.zip -OutFile libiconv.zip unzip libiconv.zip + # Create new smbshare + $smbuser="smbuser" + $smbpass="mGoWwqvgdnwtmh07" + $SecurePassword = $smbpass | ConvertTo-SecureString -AsPlainText -Force + + echo "Create user" + New-LocalUser $smbuser -Password $SecurePassword -FullName "SMB User" -Description "Account used for smb access." + + echo "Making user admin" + Add-LocalGroupMember -Group "Administrators" -Member "$smbuser" + + $path="C:\$smbuser" + mkdir $path + + echo "Create share" + New-SmbShare -Name $smbuser -Path $path -FullAccess "Administrators" -EncryptData $True + + echo "Grant access to share" + Grant-SmbShareAccess -Name $smbuser -AccountName $smbuser -AccessRight Full -Force + # add $USERPROFILE/tar/bin to path echo $Env:USERPROFILE\tar\bin >> $Env:GITHUB_PATH if: matrix.os == 'windows-latest' @@ -157,6 +274,7 @@ jobs: - name: Run local Tests env: RESTIC_TEST_FUSE: ${{ matrix.test_fuse }} + RESTIC_TEST_SMB: ${{ matrix.test_smb }} run: | go test -cover ${{matrix.test_opts}} ./... diff --git a/changelog/unreleased/issue-4185 b/changelog/unreleased/issue-4185 new file mode 100644 index 000000000..dd6364e58 --- /dev/null +++ b/changelog/unreleased/issue-4185 @@ -0,0 +1,7 @@ +Enhancement: SMB backend: Add SMB backend + +Restic now supports SMB/CIFS backend. You can now add a SMB repository as `-r smb://://`. + +You can configure the SMB user name (for NTLM authentication) via the environment variable `RESTIC_SMB_USER`, SMB password via the environment variable `RESTIC_SMB_PASSWORD` and optionally SMB domain via the environment variable `RESTIC_SMB_DOMAIN`(default:'WORKGROUP'). + +https://github.com/restic/restic/issues/4185 diff --git a/cmd/restic/global.go b/cmd/restic/global.go index 6920caa8d..5beddcfd8 100644 --- a/cmd/restic/global.go +++ b/cmd/restic/global.go @@ -26,6 +26,7 @@ import ( "github.com/restic/restic/internal/backend/s3" "github.com/restic/restic/internal/backend/sema" "github.com/restic/restic/internal/backend/sftp" + "github.com/restic/restic/internal/backend/smb" "github.com/restic/restic/internal/backend/swift" "github.com/restic/restic/internal/cache" "github.com/restic/restic/internal/debug" @@ -107,6 +108,7 @@ func init() { backends.Register(s3.NewFactory()) backends.Register(sftp.NewFactory()) backends.Register(swift.NewFactory()) + backends.Register(smb.NewFactory()) globalOptions.backends = backends f := cmdRoot.PersistentFlags() diff --git a/doc/030_preparing_a_new_repo.rst b/doc/030_preparing_a_new_repo.rst index 0c50b65be..732a3856c 100644 --- a/doc/030_preparing_a_new_repo.rst +++ b/doc/030_preparing_a_new_repo.rst @@ -652,6 +652,34 @@ The region, where a bucket should be created, can be specified with the ``-o gs. .. _other-services: +SMB/CIFS +******** + +In order to backup data to SMB/CIFS, you must specify the host (with port if not default port `445`) as the backend. +You must first setup the following environment variables with the SMB credentials and the domain if it is not the default `WORKGROUP`. + +.. code-block:: console + + $ export RESTIC_SMB_USER= + $ export RESTIC_SMB_PASSWORD= + $ export RESTIC_SMB_DOMAIN= + + +Once the server is configured, the setup of the SFTP repository can +simply be achieved by changing the URL scheme in the ``init`` command: + +.. code-block:: console + + $ restic -r smb://user@host:445/sharename/restic-repo init + enter password for new repository: + enter password again: + created restic repository c7s8ffs329 at smb://host:445/sharename/restic-repo + Please note that knowledge of your password is required to access the repository. + Losing your password means that your data is irrecoverably lost. + +Optionally, you can also pass the ``user``, ``password`` and ``domain`` as options. Configurations specified as options take highest precendence. +You can also specify other smb specific optional configurations like ``dialect``, ``client-guid``, ``require-message-signing``, ``idle-timeout`` and ``connections`` as options. + Other Services via rclone ************************* diff --git a/doc/040_backup.rst b/doc/040_backup.rst index d0bd4b2e2..7f87bc9ee 100644 --- a/doc/040_backup.rst +++ b/doc/040_backup.rst @@ -664,6 +664,10 @@ environment variables. The following lists these environment variables: OS_STORAGE_URL Storage URL for token authentication OS_AUTH_TOKEN Auth token for token authentication + RESTIC_SMB_USER SMB user for NTLM authentication + RESTIC_SMB_PASSWORD SMB password for NTLM authentication + RESTIC_SMB_DOMAIN DOMAIN for SMB authentication + RCLONE_BWLIMIT rclone bandwidth limit RESTIC_REST_USERNAME Restic REST Server username diff --git a/go.mod b/go.mod index c928b4a97..4386a1e17 100644 --- a/go.mod +++ b/go.mod @@ -13,8 +13,9 @@ require ( github.com/go-ole/go-ole v1.3.0 github.com/google/go-cmp v0.6.0 github.com/hashicorp/golang-lru/v2 v2.0.7 + github.com/hirochachacha/go-smb2 v1.1.0 github.com/klauspost/compress v1.17.7 - github.com/minio/minio-go/v7 v7.0.66 + github.com/minio/minio-go/v7 v7.0.66 github.com/minio/sha256-simd v1.0.1 github.com/ncw/swift/v2 v2.0.2 github.com/peterbourgon/unixtransport v0.0.4 @@ -48,6 +49,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/fgprof v0.9.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/geoffgarside/ber v1.1.0 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.0 // indirect diff --git a/go.sum b/go.sum index 1a7dc1a79..0586bf86e 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w= +github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -109,6 +111,8 @@ github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/ github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI= +github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -204,6 +208,7 @@ go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnw golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= diff --git a/internal/backend/smb/config.go b/internal/backend/smb/config.go new file mode 100644 index 000000000..0d133c2ba --- /dev/null +++ b/internal/backend/smb/config.go @@ -0,0 +1,143 @@ +package smb + +import ( + "net/url" + "os" + "path" + "strconv" + "strings" + "time" + + "github.com/restic/restic/internal/errors" + "github.com/restic/restic/internal/options" +) + +// Config contains all configuration necessary to connect to an SMB server +type Config struct { + Host string + Port int + ShareName string + Path string + + User string `option:"user" help:"specify the SMB user for NTLM authentication."` + Password options.SecretString `option:"password" help:"specify the SMB password for NTLM authentication."` + Domain string `option:"domain" help:"specify the domain for authentication."` + + Layout string `option:"layout" help:"use this backend directory layout (default: auto-detect)"` + Connections uint `option:"connections" help:"set a limit for the number of concurrent operations (default: 5)"` + IdleTimeout time.Duration `option:"idle-timeout" help:"Max time in seconds before closing idle connections. If no connections have been returned to the connection pool in the time given, the connection pool will be emptied. Set to 0 to keep connections indefinitely.(default: 60)"` + RequireMessageSigning bool `option:"require-message-signing" help:"Mandates message signing otherwise does not allow the connection. If this is false, messaging signing is just enabled and not enforced. (default: false)"` + Dialect uint16 `option:"dialect" help:"Force a specific dialect to be used. For SMB311 use '785', for SMB302 use '770', for SMB300 use '768', for SMB210 use '528', for SMB202 use '514', for SMB2 use '767'. If unspecfied (0), following dialects are tried in order - SMB311, SMB302, SMB300, SMB210, SMB202 (default: 0)"` + ClientGUID string `option:"client-guid" help:"A 16-byte GUID to uniquely identify a client. If not specific a random GUID is used. (default: \"\")"` +} + +const ( + DefaultSmbPort int = 445 // DefaultSmbPort returns the default port for SMB + DefaultDomain string = "WORKGROUP" // DefaultDomain returns the default domain for SMB + DefaultConnections uint = 5 // DefaultConnections returns the number of concurrent connections for SMB. + DefaultIdleTimeout time.Duration = 60 * time.Second // DefaultIdleTimeout returns the default max time before closing idle connections for SMB. +) + +// NewConfig returns a new Config with the default values filled in. +func NewConfig() Config { + return Config{ + Port: DefaultSmbPort, + Domain: DefaultDomain, + IdleTimeout: DefaultIdleTimeout, + Connections: DefaultConnections, + } +} + +func init() { + options.Register("smb", Config{}) +} + +// ParseConfig parses the string s and extracts the s3 config. The +// supported configuration format is smb://[user@]host[:port]/sharename/directory. +// User and port are optional. Default port is 445. +func ParseConfig(s string) (*Config, error) { + var repo string + switch { + case strings.HasPrefix(s, "smb://"): + repo = s + case strings.HasPrefix(s, "smb:"): + repo = "smb://" + s[4:] + default: + return nil, errors.New("smb: invalid format") + } + var user, host, port, dir string + + // parse the "smb://user@host/sharename/directory." url format + url, err := url.Parse(repo) + if err != nil { + return nil, errors.WithStack(err) + } + if url.User != nil { + user = url.User.Username() + //Intentionally not allowing passwords to be set in url as + //it can cause issues when passwords have special characters + //like '@' and it is not recommended to pass passwords in the url. + } + + host = url.Hostname() + if host == "" { + return nil, errors.New("smb: invalid format, host name not found") + } + port = url.Port() + dir = url.Path + if dir == "" { + return nil, errors.Errorf("smb: invalid format, sharename/directory not found") + } + + dir = dir[1:] + + var portNum int + if port == "" { + portNum = DefaultSmbPort + } else { + var err error + portNum, err = strconv.Atoi(port) + if err != nil { + return nil, err + } + } + + sharename, directory, _ := strings.Cut(dir, "/") + + return createConfig(user, host, portNum, sharename, directory) +} + +func createConfig(user string, host string, port int, sharename, directory string) (*Config, error) { + if host == "" { + return nil, errors.New("smb: invalid format, Host not found") + } + + if directory != "" { + directory = path.Clean(directory) + } + + cfg := NewConfig() + cfg.User = user + cfg.Host = host + cfg.Port = port + cfg.ShareName = sharename + cfg.Path = directory + return &cfg, nil +} + +// ApplyEnvironment saves values from the environment to the config. +func (cfg *Config) ApplyEnvironment(prefix string) error { + if cfg.User == "" { + cfg.User = os.Getenv(prefix + "RESTIC_SMB_USER") + } + if cfg.Password.String() == "" { + cfg.Password = options.NewSecretString(os.Getenv(prefix + "RESTIC_SMB_PASSWORD")) + } + if cfg.Domain == "" { + cfg.Domain = os.Getenv(prefix + "RESTIC_SMB_DOMAIN") + } + if cfg.Domain == "" { + cfg.Domain = DefaultDomain + } + return nil +} diff --git a/internal/backend/smb/config_test.go b/internal/backend/smb/config_test.go new file mode 100644 index 000000000..9b3e1dee2 --- /dev/null +++ b/internal/backend/smb/config_test.go @@ -0,0 +1,79 @@ +package smb + +import ( + "strings" + "testing" + + "github.com/restic/restic/internal/backend/test" +) + +var configTests = []test.ConfigTestData[Config]{ + {S: "smb://user@host/sharename/directory", + Cfg: Config{ + Host: "host", + Port: DefaultSmbPort, + User: "user", + Domain: DefaultDomain, + ShareName: "sharename", + Path: "directory", + Connections: DefaultConnections, + IdleTimeout: DefaultIdleTimeout, + }}, + {S: "smb://user@host:456/sharename/directory", + Cfg: Config{ + Host: "host", + Port: 456, + User: "user", + Domain: DefaultDomain, + ShareName: "sharename", + Path: "directory", + Connections: DefaultConnections, + IdleTimeout: DefaultIdleTimeout, + }}, + {S: "smb://host/sharename/directory", + Cfg: Config{ + Host: "host", + Port: DefaultSmbPort, + Domain: DefaultDomain, + ShareName: "sharename", + Path: "directory", + Connections: DefaultConnections, + IdleTimeout: DefaultIdleTimeout, + }}, + {S: "smb://host:446/sharename/directory", + Cfg: Config{ + Host: "host", + Port: 446, + Domain: DefaultDomain, + ShareName: "sharename", + Path: "directory", + Connections: DefaultConnections, + IdleTimeout: DefaultIdleTimeout, + }}, + {S: "smb:user@host:466/sharename/directory", + Cfg: Config{ + Host: "host", + Port: 466, + User: "user", + Domain: DefaultDomain, + ShareName: "sharename", + Path: "directory", + Connections: DefaultConnections, + IdleTimeout: DefaultIdleTimeout, + }}, +} + +func TestParseConfig(t *testing.T) { + test.ParseConfigTester(t, ParseConfig, configTests) +} + +func TestParseError(t *testing.T) { + const prefix = "smb: invalid format," + + for _, s := range []string{"", "/", "//", "host", "user@host", "user@host:445", "/sharename/directory"} { + _, err := ParseConfig("smb://" + s) + if err == nil || !strings.HasPrefix(err.Error(), prefix) { + t.Errorf("expected %q, got %q", prefix, err) + } + } +} diff --git a/internal/backend/smb/conpool.go b/internal/backend/smb/conpool.go new file mode 100644 index 000000000..14379cc0c --- /dev/null +++ b/internal/backend/smb/conpool.go @@ -0,0 +1,238 @@ +package smb + +import ( + "context" + "fmt" + "net" + "strconv" + "sync/atomic" + + "github.com/hirochachacha/go-smb2" + "github.com/restic/restic/internal/debug" +) + +// Parts of this code have been adapted from Rclone (https://github.com/rclone) +// Copyright (C) 2012 by Nick Craig-Wood http://www.craig-wood.com/nick/ + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// conn encapsulates a SMB client and corresponding SMB client +type conn struct { + conn *net.Conn + smbSession *smb2.Session + smbShare *smb2.Share + shareName string +} + +// Closes the connection +func (c *conn) close() (err error) { + if c.smbShare != nil { + err = c.smbShare.Umount() + } + sessionLogoffErr := c.smbSession.Logoff() + if err != nil { + return err + } + return sessionLogoffErr +} + +// True if it's closed +func (c *conn) closed() bool { + var nopErr error + if c.smbShare != nil { + // stat the current directory + _, nopErr = c.smbShare.Stat(".") + } else { + // list the shares + _, nopErr = c.smbSession.ListSharenames() + } + return nopErr == nil +} + +// Show that we are using a SMB session +// +// Call removeSession() when done +func (b *Backend) addSession() { + atomic.AddInt32(&b.sessions, 1) +} + +// Show the SMB session is no longer in use +func (b *Backend) removeSession() { + atomic.AddInt32(&b.sessions, -1) +} + +// getSessions shows whether there are any sessions in use +func (b *Backend) getSessions() int32 { + return atomic.LoadInt32(&b.sessions) +} + +// dial starts a client connection to the given SMB server. It is a +// convenience function that connects to the given network address, +// initiates the SMB handshake, and then returns a session for SMB communication. +func (b *Backend) dial(ctx context.Context, network, addr string) (*conn, error) { + dialer := net.Dialer{} + tconn, err := dialer.Dial(network, addr) + if err != nil { + return nil, err + } + var clientID [16]byte + if b.ClientGUID != "" { + copy(clientID[:], []byte(b.ClientGUID)) + } + + d := &smb2.Dialer{ + Negotiator: smb2.Negotiator{ + RequireMessageSigning: b.RequireMessageSigning, + SpecifiedDialect: b.Dialect, + ClientGuid: clientID, + }, + Initiator: &smb2.NTLMInitiator{ + User: b.User, + Password: b.Password.Unwrap(), + Domain: b.Domain, + }, + } + + session, err := d.DialContext(ctx, tconn) + if err != nil { + return nil, err + } + + return &conn{ + smbSession: session, + conn: &tconn, + }, nil +} + +// Open a new connection to the SMB server. +func (b *Backend) newConnection(share string) (c *conn, err error) { + // As we are pooling these connections we need to decouple + // them from the current context + ctx := context.Background() + + c, err = b.dial(ctx, "tcp", b.Host+":"+strconv.Itoa(b.Port)) + if err != nil { + return nil, fmt.Errorf("couldn't connect SMB: %w", err) + } + + if share != "" { + // mount the specified share as well if user requested + c.smbShare, err = c.smbSession.Mount(share) + if err != nil { + _ = c.smbSession.Logoff() + return nil, fmt.Errorf("couldn't initialize SMB: %w", err) + } + c.smbShare = c.smbShare.WithContext(ctx) + } + + return c, nil +} + +// Ensure the specified share is mounted or the session is unmounted +func (c *conn) mountShare(share string) (err error) { + if c.shareName == share { + return nil + } + if c.smbShare != nil { + err = c.smbShare.Umount() + c.smbShare = nil + } + if err != nil { + return + } + if share != "" { + c.smbShare, err = c.smbSession.Mount(share) + if err != nil { + return + } + } + c.shareName = share + return nil +} + +// Get a SMB connection from the pool, or open a new one +func (b *Backend) getConnection(_ context.Context, share string) (c *conn, err error) { + b.poolMu.Lock() + for len(b.pool) > 0 { + c = b.pool[0] + b.pool = b.pool[1:] + err = c.mountShare(share) + if err == nil { + break + } + debug.Log("Discarding unusable SMB connection: %v", err) + c = nil + } + b.poolMu.Unlock() + if c != nil { + return c, nil + } + c, err = b.newConnection(share) + return c, err +} + +// Return a SMB connection to the pool +func (b *Backend) putConnection(c *conn) { + var nopErr error + if c.smbShare != nil { + // stat the current directory + _, nopErr = c.smbShare.Stat(".") + } else { + // list the shares + _, nopErr = c.smbSession.ListSharenames() + } + if nopErr != nil { + debug.Log("Connection failed, closing: %v", nopErr) + _ = c.close() + return + } + + b.poolMu.Lock() + b.pool = append(b.pool, c) + b.drain.Reset(b.Config.IdleTimeout) // nudge on the pool emptying timer + b.poolMu.Unlock() +} + +// Drain the pool of any connections +func (b *Backend) drainPool() (err error) { + b.poolMu.Lock() + defer b.poolMu.Unlock() + if sessions := b.getSessions(); sessions != 0 { + debug.Log("Not closing %d unused connections as %d sessions active", len(b.pool), sessions) + b.drain.Reset(b.Config.IdleTimeout) // nudge on the pool emptying timer + return nil + } + if b.Config.IdleTimeout > 0 { + b.drain.Stop() + } + if len(b.pool) != 0 { + debug.Log("Closing %d unused connections", len(b.pool)) + } + for i, c := range b.pool { + if !c.closed() { + cErr := c.close() + if cErr != nil { + err = cErr + } + } + b.pool[i] = nil + } + b.pool = nil + return err +} diff --git a/internal/backend/smb/smb.go b/internal/backend/smb/smb.go new file mode 100644 index 000000000..367069c59 --- /dev/null +++ b/internal/backend/smb/smb.go @@ -0,0 +1,464 @@ +package smb + +import ( + "context" + "crypto/rand" + "encoding/hex" + "hash" + "io" + "os" + "path" + "path/filepath" + "sync" + "syscall" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/hirochachacha/go-smb2" + "github.com/restic/restic/internal/backend" + "github.com/restic/restic/internal/backend/layout" + "github.com/restic/restic/internal/backend/limiter" + "github.com/restic/restic/internal/backend/location" + "github.com/restic/restic/internal/backend/util" + "github.com/restic/restic/internal/debug" + "github.com/restic/restic/internal/errors" + "github.com/restic/restic/internal/restic" +) + +// Parts of this code have been adapted from Rclone (https://github.com/rclone) +// Copyright (C) 2012 by Nick Craig-Wood http://www.craig-wood.com/nick/ + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// Backend stores data on an SMB endpoint. +type Backend struct { + Config + layout.Layout + util.Modes + + sessions int32 + poolMu sync.Mutex + pool []*conn + drain *time.Timer // used to drain the pool when we stop using the connections +} + +// make sure that *Backend implements backend.Backend +var _ backend.Backend = &Backend{} + +func NewFactory() location.Factory { + return location.NewLimitedBackendFactory("smb", ParseConfig, location.NoPassword, limiter.WrapBackendConstructor(Create), limiter.WrapBackendConstructor(Open)) +} + +const ( + defaultLayout = "default" +) + +func open(ctx context.Context, cfg Config) (*Backend, error) { + + l, err := layout.ParseLayout(ctx, &layout.LocalFilesystem{}, cfg.Layout, defaultLayout, cfg.Path) + if err != nil { + return nil, err + } + + b := &Backend{ + Config: cfg, + Layout: l, + } + + debug.Log("open, config %#v", cfg) + + // set the pool drainer timer going + if b.Config.IdleTimeout > 0 { + b.drain = time.AfterFunc(b.Config.IdleTimeout, func() { _ = b.drainPool() }) + } + + cn, err := b.getConnection(ctx, b.ShareName) + if err != nil { + return nil, err + } + defer b.putConnection(cn) + + stat, err := cn.smbShare.Stat(l.Filename(backend.Handle{Type: restic.ConfigFile})) + m := util.DeriveModesFromFileInfo(stat, err) + debug.Log("using (%03O file, %03O dir) permissions", m.File, m.Dir) + + b.Modes = m + + return b, nil +} + +// Open opens the local backend as specified by config. +func Open(ctx context.Context, cfg Config) (*Backend, error) { + debug.Log("open local backend at %v (layout %q)", cfg.Path, cfg.Layout) + return open(ctx, cfg) +} + +// Create creates all the necessary files and directories for a new local +// backend at dir. Afterwards a new config blob should be created. +func Create(ctx context.Context, cfg Config) (*Backend, error) { + debug.Log("create local backend at %v (layout %q)", cfg.Path, cfg.Layout) + + b, err := open(ctx, cfg) + if err != nil { + return nil, err + } + + cn, err := b.getConnection(ctx, cfg.ShareName) + if err != nil { + return b, err + } + defer b.putConnection(cn) + + // test if config file already exists + _, err = cn.smbShare.Lstat(b.Filename(backend.Handle{Type: restic.ConfigFile})) + if err == nil { + return nil, errors.New("config file already exists") + } + + // create paths for data and refs + for _, d := range b.Paths() { + err := cn.smbShare.MkdirAll(d, b.Modes.Dir) + if err != nil { + return nil, errors.WithStack(err) + } + } + + return b, nil +} + +func (b *Backend) Connections() uint { + return b.Config.Connections +} + +// Location returns this backend's location (the directory name). +func (b *Backend) Location() string { + return b.Join(b.ShareName, b.Path) +} + +// Hasher may return a hash function for calculating a content hash for the backend +func (b *Backend) Hasher() hash.Hash { + return nil +} + +// HasAtomicReplace returns whether Save() can atomically replace files +func (b *Backend) HasAtomicReplace() bool { + return true +} + +// IsNotExist returns true if the error is caused by a non existing file. +func (b *Backend) IsNotExist(err error) bool { + return errors.Is(err, os.ErrNotExist) +} + +// Join combines path components with slashes. +func (b *Backend) Join(p ...string) string { + return path.Join(p...) +} + +// Save stores data in the backend at the handle. +func (b *Backend) Save(ctx context.Context, h backend.Handle, rd backend.RewindReader) (err error) { + filename := b.Filename(h) + tmpFilename := filename + "-restic-temp-" + tempSuffix() + dir := filepath.Dir(tmpFilename) + + defer func() { + // Mark non-retriable errors as such + if errors.Is(err, syscall.ENOSPC) || os.IsPermission(err) { + err = backoff.Permanent(err) + } + }() + + b.addSession() // Show session in use + defer b.removeSession() + + cn, err := b.getConnection(ctx, b.ShareName) + if err != nil { + return err + } + defer b.putConnection(cn) + + // create new file + f, err := cn.smbShare.OpenFile(tmpFilename, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + + if b.IsNotExist(err) { + debug.Log("error %v: creating dir", err) + + // error is caused by a missing directory, try to create it + mkdirErr := cn.smbShare.MkdirAll(dir, b.Modes.Dir) + if mkdirErr != nil { + debug.Log("error creating dir %v: %v", dir, mkdirErr) + } else { + // try again + f, err = cn.smbShare.OpenFile(tmpFilename, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + } + } + + if err != nil { + return errors.WithStack(err) + } + + defer func(f *smb2.File) { + if err != nil { + _ = f.Close() // Double Close is harmless. + // Remove after Rename is harmless: we embed the final name in the + // temporary's name and no other goroutine will get the same data to + // Save, so the temporary name should never be reused by another + // goroutine. + _ = cn.smbShare.Remove(f.Name()) + } + }(f) + + // save data, then sync + wbytes, err := io.Copy(f, rd) + if err != nil { + return errors.WithStack(err) + } + // sanity check + if wbytes != rd.Length() { + return errors.Errorf("wrote %d bytes instead of the expected %d bytes", wbytes, rd.Length()) + } + + // Ignore error if filesystem does not support fsync. + // In this case the sync call is on the smb client's file. + err = f.Sync() + syncNotSup := err != nil && (errors.Is(err, syscall.ENOTSUP)) + if err != nil && !syncNotSup { + return errors.WithStack(err) + } + + // Close, then rename. Windows doesn't like the reverse order. + if err = f.Close(); err != nil { + return errors.WithStack(err) + } + if err = cn.smbShare.Rename(f.Name(), filename); err != nil { + return errors.WithStack(err) + } + + // try to mark file as read-only to avoid accidential modifications + // ignore if the operation fails as some filesystems don't allow the chmod call + // e.g. exfat and network file systems with certain mount options + err = cn.setFileReadonly(filename, b.Modes.File) + if err != nil && !os.IsPermission(err) { + return errors.WithStack(err) + } + + return nil +} + +// set file to readonly +func (cn *conn) setFileReadonly(f string, mode os.FileMode) error { + return cn.smbShare.Chmod(f, mode&^0222) +} + +// Load runs fn with a reader that yields the contents of the file at h at the +// given offset. +func (b *Backend) Load(ctx context.Context, h backend.Handle, length int, offset int64, fn func(rd io.Reader) error) error { + return util.DefaultLoad(ctx, h, length, offset, b.openReader, fn) +} + +func (b *Backend) openReader(ctx context.Context, h backend.Handle, length int, offset int64) (io.ReadCloser, error) { + b.addSession() // Show session in use + defer b.removeSession() + cn, err := b.getConnection(ctx, b.ShareName) + if err != nil { + return nil, err + } + defer b.putConnection(cn) + + f, err := cn.smbShare.Open(b.Filename(h)) + if err != nil { + return nil, err + } + + if offset > 0 { + _, err = f.Seek(offset, 0) + if err != nil { + _ = f.Close() + return nil, err + } + } + + if length > 0 { + return backend.LimitReadCloser(f, int64(length)), nil + } + + return f, nil +} + +// Stat returns information about a blob. +func (b *Backend) Stat(ctx context.Context, h backend.Handle) (backend.FileInfo, error) { + cn, err := b.getConnection(ctx, b.ShareName) + if err != nil { + return backend.FileInfo{}, err + } + defer b.putConnection(cn) + + fi, err := cn.smbShare.Stat(b.Filename(h)) + if err != nil { + return backend.FileInfo{}, errors.WithStack(err) + } + + return backend.FileInfo{Size: fi.Size(), Name: h.Name}, nil +} + +// Remove removes the blob with the given name and type. +func (b *Backend) Remove(ctx context.Context, h backend.Handle) error { + fn := b.Filename(h) + + cn, err := b.getConnection(ctx, b.ShareName) + if err != nil { + return err + } + defer b.putConnection(cn) + + // reset read-only flag + err = cn.smbShare.Chmod(fn, 0666) + if err != nil && !os.IsPermission(err) { + return errors.WithStack(err) + } + + return cn.smbShare.Remove(fn) +} + +// List runs fn for each file in the backend which has the type t. When an +// error occurs (or fn returns an error), List stops and returns it. +func (b *Backend) List(ctx context.Context, t restic.FileType, fn func(backend.FileInfo) error) (err error) { + cn, err := b.getConnection(ctx, b.ShareName) + if err != nil { + return err + } + defer b.putConnection(cn) + + basedir, subdirs := b.Basedir(t) + if subdirs { + err = b.visitDirs(ctx, cn, basedir, fn) + } else { + err = b.visitFiles(ctx, cn, basedir, fn, false) + } + + if b.IsNotExist(err) { + debug.Log("ignoring non-existing directory") + return nil + } + + return err +} + +// The following two functions are like filepath.Walk, but visit only one or +// two levels of directory structure (including dir itself as the first level). +// Also, visitDirs assumes it sees a directory full of directories, while +// visitFiles wants a directory full or regular files. +func (b *Backend) visitDirs(ctx context.Context, cn *conn, dir string, fn func(backend.FileInfo) error) error { + d, err := cn.smbShare.Open(dir) + if err != nil { + return err + } + + sub, err := d.Readdirnames(-1) + if err != nil { + // ignore subsequent errors + _ = d.Close() + return err + } + + err = d.Close() + if err != nil { + return err + } + + for _, f := range sub { + err = b.visitFiles(ctx, cn, filepath.Join(dir, f), fn, true) + if err != nil { + return err + } + } + return ctx.Err() +} + +func (b *Backend) visitFiles(ctx context.Context, cn *conn, dir string, fn func(backend.FileInfo) error, ignoreNotADirectory bool) error { + d, err := cn.smbShare.Open(dir) + if err != nil { + return err + } + + if ignoreNotADirectory { + fi, err := d.Stat() + if err != nil || !fi.IsDir() { + // ignore subsequent errors + _ = d.Close() + return err + } + } + + sub, err := d.Readdir(-1) + if err != nil { + // ignore subsequent errors + _ = d.Close() + return err + } + + err = d.Close() + if err != nil { + return err + } + + for _, fi := range sub { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + err := fn(backend.FileInfo{ + Name: fi.Name(), + Size: fi.Size(), + }) + if err != nil { + return err + } + } + return nil +} + +// Delete removes the repository and all files. +func (b *Backend) Delete(ctx context.Context) error { + cn, err := b.getConnection(ctx, b.ShareName) + if err != nil { + return err + } + defer b.putConnection(cn) + return cn.smbShare.RemoveAll(b.Location()) +} + +// Close closes all open files. +func (b *Backend) Close() error { + err := b.drainPool() + return err +} + +// tempSuffix generates a random string suffix that should be sufficiently long +// to avoid accidental conflicts. +func tempSuffix() string { + var nonce [16]byte + _, err := rand.Read(nonce[:]) + if err != nil { + panic(err) + } + return hex.EncodeToString(nonce[:]) +} diff --git a/internal/backend/smb/smb_test.go b/internal/backend/smb/smb_test.go new file mode 100644 index 000000000..ca2625e90 --- /dev/null +++ b/internal/backend/smb/smb_test.go @@ -0,0 +1,58 @@ +package smb_test + +import ( + "os" + "testing" + + "github.com/google/uuid" + "github.com/restic/restic/internal/backend/smb" + "github.com/restic/restic/internal/backend/test" + "github.com/restic/restic/internal/options" + rtest "github.com/restic/restic/internal/test" +) + +func newTestSuite(t testing.TB) *test.Suite[smb.Config] { + return &test.Suite[smb.Config]{ + // NewConfig returns a config for a new temporary backend that will be used in tests. + NewConfig: func() (*smb.Config, error) { + + cfg := smb.NewConfig() + cfg.Host = "127.0.0.1" + cfg.User = "smbuser" + cfg.ShareName = cfg.User + cfg.Path = "Repo-" + uuid.New().String() + cfg.Password = options.NewSecretString("mGoWwqvgdnwtmh07") + cfg.Connections = smb.DefaultConnections + timeout := smb.DefaultIdleTimeout + cfg.IdleTimeout = timeout + domain := os.Getenv("RESTIC_SMB_DOMAIN") + if domain == "" { + cfg.Domain = smb.DefaultDomain + } + + t.Logf("create new backend at %v", cfg.Host+"/"+cfg.ShareName) + + return &cfg, nil + }, + + Factory: smb.NewFactory(), + } +} + +func TestBackendSMB(t *testing.T) { + if !rtest.RunSMBTest { + t.Skip("Skipping smb tests") + } + t.Logf("run tests") + + newTestSuite(t).RunTests(t) +} + +func BenchmarkBackendSMB(t *testing.B) { + if !rtest.RunSMBTest { + t.Skip("Skipping smb tests") + } + t.Logf("run benchmarks") + + newTestSuite(t).RunBenchmarks(t) +} diff --git a/internal/options/options.go b/internal/options/options.go index 7490ac430..36fa7e306 100644 --- a/internal/options/options.go +++ b/internal/options/options.go @@ -211,6 +211,11 @@ func (o Options) Apply(ns string, dst interface{}) error { v.Field(i).SetInt(int64(d)) + case "SecretString": + ss := NewSecretString(value) + + v.Field(i).Set(reflect.ValueOf(ss)) + default: panic("type " + v.Type().Field(i).Type.Name() + " not handled") } diff --git a/internal/test/vars.go b/internal/test/vars.go index b6b76541e..e2b2497ef 100644 --- a/internal/test/vars.go +++ b/internal/test/vars.go @@ -13,6 +13,7 @@ var ( TestTempDir = getStringVar("RESTIC_TEST_TMPDIR", "") RunIntegrationTest = getBoolVar("RESTIC_TEST_INTEGRATION", true) RunFuseTest = getBoolVar("RESTIC_TEST_FUSE", true) + RunSMBTest = getBoolVar("RESTIC_TEST_SMB", true) TestSFTPPath = getStringVar("RESTIC_TEST_SFTPPATH", "/usr/lib/ssh:/usr/lib/openssh:/usr/libexec") TestWalkerPath = getStringVar("RESTIC_TEST_PATH", ".") BenchArchiveDirectory = getStringVar("RESTIC_BENCH_DIR", ".")