Compare commits

...

21 commits
main ... 2.1.x

Author SHA1 Message Date
Nicola Murino
ca464b5ffc
sftpfs: map path resolution error to permission denied 2021-10-16 10:52:19 +02:00
Nicola Murino
27b5f1bf59
backport some fixes from main 2021-09-30 10:18:23 +02:00
Nicola Murino
ae9c540640
web client login: redirect to setup page if no admin is found 2021-09-25 14:50:08 +02:00
Nicola Murino
ad8ebcc031
systemd unit: set LimitNOFILE to 8192 2021-09-19 17:38:37 +02:00
Nicola Murino
c69c27f586
httpd: move the check connection middleware before the logger middleware
Fixes #543
2021-09-19 08:22:40 +02:00
Nicola Murino
7d20c6b50d
update version 2021-09-11 18:38:12 +02:00
Nicola Murino
9ca136370e
don't generate defender events for HTTP/WebDAV requests with no auth
it is quite common for HTTP clients to send a first request without
the Authorization header and then send the credentials after receiving
a 401 response. We don't want to generate defender events in this case
2021-09-11 18:20:24 +02:00
Nicola Murino
29836edf2b
fix a possible nil pointer dereference
it can happen by upgrading from very old versions
2021-09-11 12:48:41 +02:00
Nicola Murino
0ad6f031e8
set version to 2.1.1 2021-09-11 06:33:48 +02:00
Nicola Murino
a9838d2e6d
update deps and backport some fixes from main branch 2021-09-09 19:43:00 +02:00
Nicola Murino
7a9272ddfc
admin setup: remove maxlength from HTML form
Fixes #515
2021-09-04 12:13:49 +02:00
Nicola Murino
69c3a85ea5
BaseConnection struct: ensure 64 bit alignment
Fixes #516
2021-08-28 12:23:29 +02:00
Nicola Murino
d03020e2b8
fix folders validation
Fixes #510
2021-08-19 11:33:00 +02:00
Nicola Murino
89bc50aa81
fix loading enabled_ssh_commands config key 2021-07-31 09:47:53 +02:00
Nicola Murino
d27c32e06b
S3: fix Ceph compatibility
This hack will no longer be needed once Ceph tags a new version and vendors
using it update their servers.

This code is taken from rclone, thank you!

Fixes #483
2021-07-23 17:29:58 +02:00
Nicola Murino
53eca2c2bb
S3: add per-chunk download timeout
We hard code 3 minutes here, this is configurable in main
2021-07-16 18:49:28 +02:00
Nicola Murino
0e9351e4ad
GCS: add a trailing / to "directories"
This way SFTPGo should be compatible with Google Cloud console.

This change should be backward compatibile, testing is welcome

Fixes #464
2021-07-16 18:41:56 +02:00
Nicola Murino
43f468547f
update deps 2021-07-16 18:40:56 +02:00
Nicola Murino
c372ca4136
enable CI 2021-06-20 22:01:34 +02:00
Nicola Murino
2b328c82bb
defender: don't return expired hosts/banned ip in GetHost too 2021-06-20 21:55:12 +02:00
Nicola Murino
6321d51a24
defender: don't return expired hosts/banned ip 2021-06-20 21:47:38 +02:00
70 changed files with 840 additions and 1728 deletions

View file

@ -2,7 +2,7 @@ name: CI
on:
push:
branches: [main]
branches: [2.1.x]
pull_request:
jobs:

View file

@ -4,8 +4,6 @@ on:
#schedule:
# - cron: '0 4 * * *' # everyday at 4:00 AM UTC
push:
branches:
- main
tags:
- v*
pull_request:

View file

@ -5,7 +5,7 @@ on:
tags: 'v*'
env:
GO_VERSION: 1.16.5
GO_VERSION: 1.16.8
jobs:
prepare-sources-with-deps:

View file

@ -1,3 +1,4 @@
//go:build !noportable
// +build !noportable
package cmd

View file

@ -1,3 +1,4 @@
//go:build noportable
// +build noportable
package cmd

View file

@ -71,6 +71,7 @@ var (
)
func init() {
rootCmd.CompletionOptions.DisableDefaultCmd = true
rootCmd.Flags().BoolP("version", "v", false, "")
rootCmd.Version = version.GetAsString()
rootCmd.SetVersionTemplate(`{{printf "SFTPGo "}}{{printf "%s" .Version}}

View file

@ -409,9 +409,8 @@ func (c *Configuration) IsAtomicUploadEnabled() bool {
}
// GetProxyListener returns a wrapper for the given listener that supports the
// HAProxy Proxy Protocol or nil if the proxy protocol is not configured
// HAProxy Proxy Protocol
func (c *Configuration) GetProxyListener(listener net.Listener) (*proxyproto.Listener, error) {
var proxyListener *proxyproto.Listener
var err error
if c.ProxyProtocol > 0 {
var policyFunc func(upstream net.Addr) (proxyproto.Policy, error)
@ -433,12 +432,13 @@ func (c *Configuration) GetProxyListener(listener net.Listener) (*proxyproto.Lis
}
}
}
proxyListener = &proxyproto.Listener{
return &proxyproto.Listener{
Listener: listener,
Policy: policyFunc,
ReadHeaderTimeout: 5 * time.Second,
}, nil
}
}
return proxyListener, nil
return nil, errors.New("proxy protocol not configured")
}
// ExecuteStartupHook runs the startup hook if defined

View file

@ -730,6 +730,26 @@ func TestParseAllowedIPAndRanges(t *testing.T) {
assert.False(t, allow[1](net.ParseIP("172.16.1.1")))
}
func TestHideConfidentialData(t *testing.T) {
for _, provider := range []vfs.FilesystemProvider{vfs.S3FilesystemProvider, vfs.GCSFilesystemProvider,
vfs.AzureBlobFilesystemProvider, vfs.CryptedFilesystemProvider, vfs.SFTPFilesystemProvider} {
u := dataprovider.User{
FsConfig: vfs.Filesystem{
Provider: provider,
},
}
u.PrepareForRendering()
f := vfs.BaseVirtualFolder{
FsConfig: vfs.Filesystem{
Provider: provider,
},
}
f.PrepareForRendering()
}
a := dataprovider.Admin{}
a.HideConfidentialData()
}
func BenchmarkBcryptHashing(b *testing.B) {
bcryptPassword := "bcryptpassword"
for i := 0; i < b.N; i++ {

View file

@ -21,8 +21,11 @@ import (
// BaseConnection defines common fields for a connection using any supported protocol
type BaseConnection struct {
// last activity for this connection.
// Since this is accessed atomically we put as first element of the struct achieve 64 bit alignment
// Since this field is accessed atomically we put it as first element of the struct to achieve 64 bit alignment
lastActivity int64
// unique ID for a transfer.
// This field is accessed atomically so we put it at the beginning of the struct to achieve 64 bit alignment
transferID uint64
// Unique identifier for the connection
ID string
// user associated with this connection if any
@ -32,7 +35,6 @@ type BaseConnection struct {
protocol string
remoteAddr string
sync.RWMutex
transferID uint64
activeTransfers []ActiveTransfer
}

View file

@ -236,17 +236,27 @@ func (d *memoryDefender) GetHosts() []*DefenderEntry {
var result []*DefenderEntry
for k, v := range d.banned {
if v.After(time.Now()) {
result = append(result, &DefenderEntry{
IP: k,
BanTime: v,
})
}
}
for k, v := range d.hosts {
score := 0
for _, event := range v.Events {
if event.dateTime.Add(time.Duration(d.config.ObservationTime) * time.Minute).After(time.Now()) {
score += event.score
}
}
if score > 0 {
result = append(result, &DefenderEntry{
IP: k,
Score: v.TotalScore,
Score: score,
})
}
}
return result
}
@ -257,18 +267,28 @@ func (d *memoryDefender) GetHost(ip string) (*DefenderEntry, error) {
defer d.RUnlock()
if banTime, ok := d.banned[ip]; ok {
if banTime.After(time.Now()) {
return &DefenderEntry{
IP: ip,
BanTime: banTime,
}, nil
}
}
if ev, ok := d.hosts[ip]; ok {
if hs, ok := d.hosts[ip]; ok {
score := 0
for _, event := range hs.Events {
if event.dateTime.Add(time.Duration(d.config.ObservationTime) * time.Minute).After(time.Now()) {
score += event.score
}
}
if score > 0 {
return &DefenderEntry{
IP: ip,
Score: ev.TotalScore,
Score: score,
}, nil
}
}
return nil, dataprovider.NewRecordNotFoundError("host not found")
}
@ -339,9 +359,12 @@ func (d *memoryDefender) AddEvent(ip string, event HostEvent) {
}
// ignore events for already banned hosts
if _, ok := d.banned[ip]; ok {
if v, ok := d.banned[ip]; ok {
if v.After(time.Now()) {
return
}
delete(d.banned, ip)
}
var score int

View file

@ -179,6 +179,77 @@ func TestBasicDefender(t *testing.T) {
assert.NoError(t, err)
}
func TestExpiredHostBans(t *testing.T) {
config := &DefenderConfig{
Enabled: true,
BanTime: 10,
BanTimeIncrement: 2,
Threshold: 5,
ScoreInvalid: 2,
ScoreValid: 1,
ScoreLimitExceeded: 3,
ObservationTime: 15,
EntriesSoftLimit: 1,
EntriesHardLimit: 2,
}
d, err := newInMemoryDefender(config)
assert.NoError(t, err)
defender := d.(*memoryDefender)
testIP := "1.2.3.4"
defender.banned[testIP] = time.Now().Add(-24 * time.Hour)
// the ban is expired testIP should not be listed
res := defender.GetHosts()
assert.Len(t, res, 0)
assert.False(t, defender.IsBanned(testIP))
_, err = defender.GetHost(testIP)
assert.Error(t, err)
_, ok := defender.banned[testIP]
assert.True(t, ok)
// now add an event for an expired banned ip, it should be removed
defender.AddEvent(testIP, HostEventLoginFailed)
assert.False(t, defender.IsBanned(testIP))
entry, err := defender.GetHost(testIP)
assert.NoError(t, err)
assert.Equal(t, testIP, entry.IP)
assert.Empty(t, entry.GetBanTime())
assert.Equal(t, 1, entry.Score)
res = defender.GetHosts()
if assert.Len(t, res, 1) {
assert.Equal(t, testIP, res[0].IP)
assert.Empty(t, res[0].GetBanTime())
assert.Equal(t, 1, res[0].Score)
}
events := []hostEvent{
{
dateTime: time.Now().Add(-24 * time.Hour),
score: 2,
},
{
dateTime: time.Now().Add(-24 * time.Hour),
score: 3,
},
}
hs := hostScore{
Events: events,
TotalScore: 5,
}
defender.hosts[testIP] = hs
// the recorded scored are too old
res = defender.GetHosts()
assert.Len(t, res, 0)
_, err = defender.GetHost(testIP)
assert.Error(t, err)
}
func TestLoadHostListFromFile(t *testing.T) {
_, err := loadHostListFromFile(".")
assert.Error(t, err)

View file

@ -147,7 +147,7 @@ func Init() {
MACs: []string{},
TrustedUserCAKeys: []string{},
LoginBannerFile: "",
EnabledSSHCommands: sftpd.GetDefaultSSHCommands(),
EnabledSSHCommands: []string{},
KeyboardInteractiveHook: "",
PasswordAuthentication: true,
},
@ -955,7 +955,7 @@ func setViperDefaults() {
viper.SetDefault("sftpd.macs", globalConf.SFTPD.MACs)
viper.SetDefault("sftpd.trusted_user_ca_keys", globalConf.SFTPD.TrustedUserCAKeys)
viper.SetDefault("sftpd.login_banner_file", globalConf.SFTPD.LoginBannerFile)
viper.SetDefault("sftpd.enabled_ssh_commands", globalConf.SFTPD.EnabledSSHCommands)
viper.SetDefault("sftpd.enabled_ssh_commands", sftpd.GetDefaultSSHCommands())
viper.SetDefault("sftpd.keyboard_interactive_auth_hook", globalConf.SFTPD.KeyboardInteractiveHook)
viper.SetDefault("sftpd.password_authentication", globalConf.SFTPD.PasswordAuthentication)
viper.SetDefault("ftpd.banner", globalConf.FTPD.Banner)

View file

@ -1,3 +1,4 @@
//go:build linux
// +build linux
package config

View file

@ -1,3 +1,4 @@
//go:build !linux
// +build !linux
package config

View file

@ -102,6 +102,35 @@ func TestEmptyBanner(t *testing.T) {
assert.NoError(t, err)
}
func TestEnabledSSHCommands(t *testing.T) {
reset()
configDir := ".."
confName := tempConfigName + ".json"
configFilePath := filepath.Join(configDir, confName)
err := config.LoadConfig(configDir, "")
assert.NoError(t, err)
reset()
sftpdConf := config.GetSFTPDConfig()
sftpdConf.EnabledSSHCommands = []string{"scp"}
c := make(map[string]sftpd.Configuration)
c["sftpd"] = sftpdConf
jsonConf, err := json.Marshal(c)
assert.NoError(t, err)
err = os.WriteFile(configFilePath, jsonConf, os.ModePerm)
assert.NoError(t, err)
err = config.LoadConfig(configDir, confName)
assert.NoError(t, err)
sftpdConf = config.GetSFTPDConfig()
if assert.Len(t, sftpdConf.EnabledSSHCommands, 1) {
assert.Equal(t, "scp", sftpdConf.EnabledSSHCommands[0])
}
err = os.Remove(configFilePath)
assert.NoError(t, err)
}
func TestInvalidUploadMode(t *testing.T) {
reset()

View file

@ -1,3 +1,4 @@
//go:build !nobolt
// +build !nobolt
package dataprovider

View file

@ -1,3 +1,4 @@
//go:build nobolt
// +build nobolt
package dataprovider

View file

@ -1560,6 +1560,7 @@ func createUserPasswordHash(user *User) error {
// ValidateFolder returns an error if the folder is not valid
// FIXME: this should be defined as Folder struct method
func ValidateFolder(folder *vfs.BaseVirtualFolder) error {
folder.FsConfig.SetEmptySecretsIfNil()
if folder.Name == "" {
return &ValidationError{err: "folder name is mandatory"}
}

View file

@ -1,3 +1,4 @@
//go:build !nomysql
// +build !nomysql
package dataprovider

View file

@ -1,3 +1,4 @@
//go:build nomysql
// +build nomysql
package dataprovider

View file

@ -1,3 +1,4 @@
//go:build !nopgsql
// +build !nopgsql
package dataprovider

View file

@ -1,3 +1,4 @@
//go:build nopgsql
// +build nopgsql
package dataprovider

View file

@ -1,3 +1,4 @@
//go:build !nosqlite
// +build !nosqlite
package dataprovider

View file

@ -1,3 +1,4 @@
//go:build nosqlite
// +build nosqlite
package dataprovider

View file

@ -344,18 +344,32 @@ func (u *User) hideConfidentialData() {
u.Password = ""
switch u.FsConfig.Provider {
case vfs.S3FilesystemProvider:
if u.FsConfig.S3Config.AccessSecret != nil {
u.FsConfig.S3Config.AccessSecret.Hide()
}
case vfs.GCSFilesystemProvider:
if u.FsConfig.GCSConfig.Credentials != nil {
u.FsConfig.GCSConfig.Credentials.Hide()
}
case vfs.AzureBlobFilesystemProvider:
if u.FsConfig.AzBlobConfig.AccountKey != nil {
u.FsConfig.AzBlobConfig.AccountKey.Hide()
}
if u.FsConfig.AzBlobConfig.SASURL != nil {
u.FsConfig.AzBlobConfig.SASURL.Hide()
}
case vfs.CryptedFilesystemProvider:
if u.FsConfig.CryptConfig.Passphrase != nil {
u.FsConfig.CryptConfig.Passphrase.Hide()
}
case vfs.SFTPFilesystemProvider:
if u.FsConfig.SFTPConfig.Password != nil {
u.FsConfig.SFTPConfig.Password.Hide()
}
if u.FsConfig.SFTPConfig.PrivateKey != nil {
u.FsConfig.SFTPConfig.PrivateKey.Hide()
}
}
}
// GetSubDirPermissions returns permissions for sub directories

View file

@ -4,10 +4,10 @@ SFTPGo provides an official Docker image, it is available on both [Docker Hub](h
## Supported tags and respective Dockerfile links
- [v2.1.0, v2.1, v2, latest](https://github.com/drakkan/sftpgo/blob/v2.1.0/Dockerfile)
- [v2.1.0-alpine, v2.1-alpine, v2-alpine, alpine](https://github.com/drakkan/sftpgo/blob/v2.1.0/Dockerfile.alpine)
- [v2.1.0-slim, v2.1-slim, v2-slim, slim](https://github.com/drakkan/sftpgo/blob/v2.1.0/Dockerfile)
- [v2.1.0-alpine-slim, v2.1-alpine-slim, v2-alpine-slim, alpine-slim](https://github.com/drakkan/sftpgo/blob/v2.1.0/Dockerfile.alpine)
- [v2.1.2, v2.1, v2, latest](https://github.com/drakkan/sftpgo/blob/v2.1.2/Dockerfile)
- [v2.1.2-alpine, v2.1-alpine, v2-alpine, alpine](https://github.com/drakkan/sftpgo/blob/v2.1.2/Dockerfile.alpine)
- [v2.1.2-slim, v2.1-slim, v2-slim, slim](https://github.com/drakkan/sftpgo/blob/v2.1.2/Dockerfile)
- [v2.1.2-alpine-slim, v2.1-alpine-slim, v2-alpine-slim, alpine-slim](https://github.com/drakkan/sftpgo/blob/v2.1.2/Dockerfile.alpine)
- [edge](../Dockerfile)
- [edge-alpine](../Dockerfile.alpine)
- [edge-slim](../Dockerfile)

View file

@ -2275,13 +2275,13 @@ func TestActiveModeDisabled(t *testing.T) {
if assert.NoError(t, err) {
code, response, err := client.SendCustomCommand("PORT 10,2,0,2,4,31")
assert.NoError(t, err)
assert.Equal(t, ftp.StatusCommandOK, code)
assert.Equal(t, "PORT command successful", response)
assert.Equal(t, ftp.StatusBadArguments, code)
assert.Equal(t, "Your request does not meet the configured security requirements", response)
code, response, err = client.SendCustomCommand("EPRT |1|132.235.1.2|6275|")
assert.NoError(t, err)
assert.Equal(t, ftp.StatusCommandOK, code)
assert.Equal(t, "EPRT command successful", response)
assert.Equal(t, ftp.StatusBadArguments, code)
assert.Equal(t, "Your request does not meet the configured security requirements", response)
err = client.Quit()
assert.NoError(t, err)

View file

@ -12,6 +12,7 @@ import (
"time"
"github.com/eikenb/pipeat"
ftpserver "github.com/fclairamb/ftpserverlib"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -250,6 +251,7 @@ xr5cb9VBRBtB9aOKVfuRhpatAfS2Pzm2Htae9lFn7slGPUmu2hkjDw==
)
type mockFTPClientContext struct {
lastDataChannel ftpserver.DataChannel
}
func (cc mockFTPClientContext) Path() string {
@ -294,6 +296,10 @@ func (cc mockFTPClientContext) GetLastCommand() string {
return ""
}
func (cc mockFTPClientContext) GetLastDataChannel() ftpserver.DataChannel {
return cc.lastDataChannel
}
// MockOsFs mockable OsFs
type MockOsFs struct {
vfs.Fs

View file

@ -94,7 +94,7 @@ func (s *Server) GetSettings() (*ftpserver.Settings, error) {
}
}
var ftpListener net.Listener
if common.Config.ProxyProtocol > 0 && s.binding.ApplyProxyConfig {
if s.binding.HasProxy() {
listener, err := net.Listen("tcp", s.binding.GetAddress())
if err != nil {
logger.Warn(logSender, "", "error starting listener on address %v: %v", s.binding.GetAddress(), err)
@ -105,6 +105,9 @@ func (s *Server) GetSettings() (*ftpserver.Settings, error) {
logger.Warn(logSender, "", "error enabling proxy listener: %v", err)
return nil, err
}
if s.binding.TLSMode == 2 && s.tlsConfig != nil {
ftpListener = tls.NewListener(ftpListener, s.tlsConfig)
}
}
if s.binding.TLSMode < 0 || s.binding.TLSMode > 2 {
@ -197,6 +200,14 @@ func (s *Server) AuthUser(cc ftpserver.ClientContext, username, password string)
return connection, nil
}
// WrapPassiveListener implements the MainDriverExtensionPassiveWrapper interface
func (s *Server) WrapPassiveListener(listener net.Listener) (net.Listener, error) {
if s.binding.HasProxy() {
return common.Config.GetProxyListener(listener)
}
return listener, nil
}
// VerifyConnection checks whether a user should be authenticated using a client certificate without prompting for a password
func (s *Server) VerifyConnection(cc ftpserver.ClientContext, user string, tlsConn *tls.Conn) (ftpserver.ClientDriver, error) {
if !s.binding.isMutualTLSEnabled() {

73
go.mod
View file

@ -3,71 +3,62 @@ module github.com/drakkan/sftpgo
go 1.16
require (
cloud.google.com/go/storage v1.15.0
github.com/Azure/azure-storage-blob-go v0.13.0
cloud.google.com/go/storage v1.16.1
github.com/Azure/azure-storage-blob-go v0.14.0
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
github.com/StackExchange/wmi v0.0.0-20210224194228-fe8f1750fd46 // indirect
github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8
github.com/aws/aws-sdk-go v1.38.61
github.com/aws/aws-sdk-go v1.40.41
github.com/cockroachdb/cockroach-go/v2 v2.1.1
github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
github.com/fclairamb/ftpserverlib v0.13.3-0.20210614220040-27dccea41813
github.com/frankban/quicktest v1.13.0 // indirect
github.com/go-chi/chi/v5 v5.0.3
github.com/fclairamb/ftpserverlib v0.15.1-0.20210910204600-c38788485016
github.com/frankban/quicktest v1.13.1 // indirect
github.com/go-chi/chi/v5 v5.0.4
github.com/go-chi/jwtauth/v5 v5.0.1
github.com/go-chi/render v1.0.1
github.com/go-ole/go-ole v1.2.5 // indirect
github.com/go-sql-driver/mysql v1.6.0
github.com/goccy/go-json v0.7.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/grandcat/zeroconf v1.0.0
github.com/hashicorp/go-retryablehttp v0.7.0
github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
github.com/klauspost/compress v1.13.1
github.com/klauspost/cpuid/v2 v2.0.6 // indirect
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
github.com/lestrrat-go/jwx v1.2.1
github.com/lib/pq v1.10.2
github.com/magiconair/properties v1.8.5 // indirect
github.com/mattn/go-sqlite3 v1.14.7
github.com/miekg/dns v1.1.42 // indirect
github.com/klauspost/compress v1.13.5
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
github.com/lestrrat-go/jwx v1.2.6
github.com/lib/pq v1.10.3
github.com/mattn/go-sqlite3 v1.14.8
github.com/miekg/dns v1.1.43 // indirect
github.com/minio/sio v0.3.0
github.com/otiai10/copy v1.6.0
github.com/pelletier/go-toml v1.9.3 // indirect
github.com/pires/go-proxyproto v0.5.0
github.com/pkg/sftp v1.13.1
github.com/pires/go-proxyproto v0.6.1
github.com/pkg/sftp v1.13.3
github.com/prometheus/client_golang v1.11.0
github.com/prometheus/common v0.29.0 // indirect
github.com/rs/cors v1.7.1-0.20200626170627-8b4a00bd362b
github.com/prometheus/common v0.30.0 // indirect
github.com/rs/cors v1.8.0
github.com/rs/xid v1.3.0
github.com/rs/zerolog v1.23.0
github.com/rs/zerolog v1.25.0
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shirou/gopsutil/v3 v3.21.5
github.com/shirou/gopsutil/v3 v3.21.8
github.com/spf13/afero v1.6.0
github.com/spf13/cast v1.3.1 // indirect
github.com/spf13/cobra v1.1.3
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/viper v1.7.1
github.com/spf13/cobra v1.2.1
github.com/spf13/viper v1.8.1
github.com/stretchr/testify v1.7.0
github.com/studio-b12/gowebdav v0.0.0-20210427212133-86f8378cf140
github.com/studio-b12/gowebdav v0.0.0-20210630100626-7ff61aa87be8
github.com/yl2chen/cidranger v1.0.2
go.etcd.io/bbolt v1.3.6
go.uber.org/automaxprocs v1.4.0
gocloud.dev v0.23.0
gocloud.dev/secrets/hashivault v0.23.0
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a
golang.org/x/net v0.0.0-20210614182718-04defd469f4e
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1
golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6
google.golang.org/api v0.48.0
google.golang.org/genproto v0.0.0-20210614182748-5b3b54cad159 // indirect
gopkg.in/ini.v1 v1.62.0 // indirect
gocloud.dev v0.24.0
gocloud.dev/secrets/hashivault v0.24.0
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/net v0.0.0-20210907225631-ff17edfbf26d
golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
google.golang.org/api v0.56.0
google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)
replace (
github.com/eikenb/pipeat => github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639
github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20210515063737-edf1d3b63536
golang.org/x/net => github.com/drakkan/net v0.0.0-20210615043241-a7f9e02422df
golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20210908103413-a132997f748f
golang.org/x/net => github.com/drakkan/net v0.0.0-20210908102438-2debf45fec0b
)

531
go.sum

File diff suppressed because it is too large Load diff

View file

@ -190,7 +190,10 @@ func Get(url string) (*http.Response, error) {
return nil, err
}
addHeaders(req, url)
return GetHTTPClient().Do(req)
client := GetHTTPClient()
defer client.CloseIdleConnections()
return client.Do(req)
}
// Post issues a POST to the specified URL
@ -201,7 +204,10 @@ func Post(url string, contentType string, body io.Reader) (*http.Response, error
}
req.Header.Set("Content-Type", contentType)
addHeaders(req, url)
return GetHTTPClient().Do(req)
client := GetHTTPClient()
defer client.CloseIdleConnections()
return client.Do(req)
}
// RetryableGet issues a GET to the specified URL using the retryable client
@ -211,7 +217,10 @@ func RetryableGet(url string) (*http.Response, error) {
return nil, err
}
addHeadersToRetryableReq(req, url)
return GetRetraybleHTTPClient().Do(req)
client := GetRetraybleHTTPClient()
defer client.HTTPClient.CloseIdleConnections()
return client.Do(req)
}
// RetryablePost issues a POST to the specified URL using the retryable client
@ -222,7 +231,10 @@ func RetryablePost(url string, contentType string, body io.Reader) (*http.Respon
}
req.Header.Set("Content-Type", contentType)
addHeadersToRetryableReq(req, url)
return GetRetraybleHTTPClient().Do(req)
client := GetRetraybleHTTPClient()
defer client.HTTPClient.CloseIdleConnections()
return client.Do(req)
}
func addHeaders(req *http.Request, url string) {

View file

@ -363,7 +363,7 @@ func parseRangeRequest(bytesRange string, size int64) (int64, int64, error) {
func updateLoginMetrics(user *dataprovider.User, ip string, err error) {
metrics.AddLoginAttempt(dataprovider.LoginMethodPassword)
if err != nil && err != common.ErrInternalFailure {
if err != nil && err != common.ErrInternalFailure && err != common.ErrNoCredentials {
logger.ConnectionFailedLog(user.Username, ip, dataprovider.LoginMethodPassword, common.ProtocolHTTP, err.Error())
event := common.HostEventLoginFailed
if _, ok := err.(*dataprovider.RecordNotFoundError); ok {

View file

@ -5944,6 +5944,11 @@ func TestWebAdminSetupMock(t *testing.T) {
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
assert.Equal(t, webAdminSetupPath, rr.Header().Get("Location"))
req, err = http.NewRequest(http.MethodGet, webClientLoginPath, nil)
assert.NoError(t, err)
rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr)
assert.Equal(t, webAdminSetupPath, rr.Header().Get("Location"))
csrfToken, err := getCSRFToken(httpBaseURL + webAdminSetupPath)
assert.NoError(t, err)

View file

@ -17,7 +17,7 @@ info:
Several storage backends are supported and they are configurable per user, so you can serve a local directory for a user and an S3 bucket (or part of it) for another one.
SFTPGo also supports virtual folders, a virtual folder can use any of the supported storage backends. So you can have, for example, an S3 user that exposes a GCS bucket (or part of it) on a specified path and an encrypted local filesystem on another one.
Virtual folders can be private or shared among multiple users, for shared virtual folders you can define different quota limits for each user.
version: 2.1.0
version: 2.1.2
contact:
name: API support
url: 'https://github.com/drakkan/sftpgo'

View file

@ -530,9 +530,9 @@ func (s *httpdServer) initializeRouter() {
s.router = chi.NewRouter()
s.router.Use(middleware.RequestID)
s.router.Use(s.checkConnection)
s.router.Use(logger.NewStructuredLogger(logger.GetLogger()))
s.router.Use(recoverer)
s.router.Use(s.checkConnection)
s.router.Use(middleware.GetHead)
s.router.Use(middleware.StripSlashes)

View file

@ -253,6 +253,10 @@ func renderCredentialsPage(w http.ResponseWriter, r *http.Request, pwdError stri
}
func handleClientWebLogin(w http.ResponseWriter, r *http.Request) {
if !dataprovider.HasAdmin() {
http.Redirect(w, r, webAdminSetupPath, http.StatusFound)
return
}
renderClientLoginPage(w, "")
}

View file

@ -12,6 +12,7 @@ Environment=SFTPGO_LOG_FILE_PATH=
EnvironmentFile=-/etc/sftpgo/sftpgo.env
ExecStart=/usr/bin/sftpgo serve
ExecReload=/bin/kill -s HUP $MAINPID
LimitNOFILE=8192
KillMode=mixed
PrivateTmp=true
Restart=always

View file

@ -1,3 +1,4 @@
//go:build !noawskms
// +build !noawskms
package kms

View file

@ -1,3 +1,4 @@
//go:build noawskms
// +build noawskms
package kms

View file

@ -1,3 +1,4 @@
//go:build !nogcpkms
// +build !nogcpkms
package kms

View file

@ -1,3 +1,4 @@
//go:build nogcpkms
// +build nogcpkms
package kms

View file

@ -1,3 +1,4 @@
//go:build !novaultkms
// +build !novaultkms
package kms

View file

@ -1,3 +1,4 @@
//go:build novaultkms
// +build novaultkms
package kms

View file

@ -1,3 +1,4 @@
//go:build linux
// +build linux
package logger

View file

@ -1,3 +1,4 @@
//go:build !linux
// +build !linux
package logger

View file

@ -1,3 +1,4 @@
//go:build !nometrics
// +build !nometrics
// Package metrics provides Prometheus metrics support

View file

@ -1,3 +1,4 @@
//go:build !noportable
// +build !noportable
package service

View file

@ -1,3 +1,4 @@
//go:build !windows
// +build !windows
package service

View file

@ -1,3 +1,4 @@
//go:build !windows
// +build !windows
package sftpd

View file

@ -1,3 +1,4 @@
//go:build !windows
// +build !windows
package sftpd

View file

@ -241,17 +241,15 @@ func (c *Configuration) Initialize(configDir string) error {
return
}
if binding.ApplyProxyConfig {
if binding.ApplyProxyConfig && common.Config.ProxyProtocol > 0 {
proxyListener, err := common.Config.GetProxyListener(listener)
if err != nil {
logger.Warn(logSender, "", "error enabling proxy listener: %v", err)
exitChannel <- err
return
}
if proxyListener != nil {
listener = proxyListener
}
}
exitChannel <- c.serve(listener, serverConfig)
}(binding)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -99,15 +99,15 @@
class="user-custom">
<div class="form-group">
<input type="text" class="form-control form-control-user-custom" id="inputUsername"
name="username" placeholder="Username" value="{{.Username}}" maxlength="60" required>
name="username" placeholder="Username" value="{{.Username}}" required>
</div>
<div class="form-group">
<input type="password" class="form-control form-control-user-custom" id="inputPassword"
name="password" placeholder="Password" maxlength="60" required>
name="password" placeholder="Password" required>
</div>
<div class="form-group">
<input type="password" class="form-control form-control-user-custom" id="inputConfirmPassword"
name="confirm_password" placeholder="Repeat password" maxlength="60" required>
name="confirm_password" placeholder="Repeat password" required>
</div>
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
<button type="submit" class="btn btn-primary btn-user-custom btn-block">

View file

@ -2,7 +2,7 @@ package version
import "strings"
const version = "2.1.0-dev"
const version = "2.1.2-dev"
var (
commit = ""

View file

@ -1,3 +1,4 @@
//go:build !noazblob
// +build !noazblob
package vfs

View file

@ -1,3 +1,4 @@
//go:build noazblob
// +build noazblob
package vfs

View file

@ -103,18 +103,32 @@ func (v *BaseVirtualFolder) IsLocalOrLocalCrypted() bool {
func (v *BaseVirtualFolder) hideConfidentialData() {
switch v.FsConfig.Provider {
case S3FilesystemProvider:
if v.FsConfig.S3Config.AccessSecret != nil {
v.FsConfig.S3Config.AccessSecret.Hide()
}
case GCSFilesystemProvider:
if v.FsConfig.GCSConfig.Credentials != nil {
v.FsConfig.GCSConfig.Credentials.Hide()
}
case AzureBlobFilesystemProvider:
if v.FsConfig.AzBlobConfig.AccountKey != nil {
v.FsConfig.AzBlobConfig.AccountKey.Hide()
}
if v.FsConfig.AzBlobConfig.SASURL != nil {
v.FsConfig.AzBlobConfig.SASURL.Hide()
}
case CryptedFilesystemProvider:
if v.FsConfig.CryptConfig.Passphrase != nil {
v.FsConfig.CryptConfig.Passphrase.Hide()
}
case SFTPFilesystemProvider:
if v.FsConfig.SFTPConfig.Password != nil {
v.FsConfig.SFTPConfig.Password.Hide()
}
if v.FsConfig.SFTPConfig.PrivateKey != nil {
v.FsConfig.SFTPConfig.PrivateKey.Hide()
}
}
}
// PrepareForRendering prepares a folder for rendering.

View file

@ -1,3 +1,4 @@
//go:build !nogcs
// +build !nogcs
package vfs
@ -5,7 +6,6 @@ package vfs
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
@ -112,37 +112,18 @@ func (fs *GCSFs) ConnectionID() string {
// Stat returns a FileInfo describing the named file
func (fs *GCSFs) Stat(name string) (os.FileInfo, error) {
var result *FileInfo
var err error
if name == "" || name == "." {
err := fs.checkIfBucketExists()
if err != nil {
return result, err
return nil, err
}
return NewFileInfo(name, true, 0, time.Now(), false), nil
}
if fs.config.KeyPrefix == name+"/" {
return NewFileInfo(name, true, 0, time.Now(), false), nil
}
attrs, err := fs.headObject(name)
if err == nil {
objSize := attrs.Size
objectModTime := attrs.Updated
isDir := attrs.ContentType == dirMimeType || strings.HasSuffix(attrs.Name, "/")
return NewFileInfo(name, isDir, objSize, objectModTime, false), nil
}
if !fs.IsNotExist(err) {
return result, err
}
// now check if this is a prefix (virtual directory)
hasContents, err := fs.hasContents(name)
if err != nil {
return nil, err
}
if hasContents {
return NewFileInfo(name, true, 0, time.Now(), false), nil
}
return nil, errors.New("404 no such file or directory")
_, info, err := fs.getObjectStat(name)
return info, err
}
// Lstat returns a FileInfo describing the named file
@ -229,7 +210,7 @@ func (fs *GCSFs) Rename(source, target string) error {
if source == target {
return nil
}
fi, err := fs.Stat(source)
realSourceName, fi, err := fs.getObjectStat(source)
if err != nil {
return err
}
@ -241,8 +222,11 @@ func (fs *GCSFs) Rename(source, target string) error {
if hasContents {
return fmt.Errorf("cannot rename non empty directory: %#v", source)
}
if !strings.HasSuffix(target, "/") {
target += "/"
}
src := fs.svc.Bucket(fs.config.Bucket).Object(source)
}
src := fs.svc.Bucket(fs.config.Bucket).Object(realSourceName)
dst := fs.svc.Bucket(fs.config.Bucket).Object(target)
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
defer cancelFn()
@ -277,11 +261,18 @@ func (fs *GCSFs) Remove(name string, isDir bool) error {
if hasContents {
return fmt.Errorf("cannot remove non empty directory: %#v", name)
}
if !strings.HasSuffix(name, "/") {
name += "/"
}
}
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
defer cancelFn()
err := fs.svc.Bucket(fs.config.Bucket).Object(name).Delete(ctx)
if fs.IsNotExist(err) && isDir {
// we can have directories without a trailing "/" (created using v2.1.0 and before)
err = fs.svc.Bucket(fs.config.Bucket).Object(strings.TrimSuffix(name, "/")).Delete(ctx)
}
metrics.GCSDeleteObjectCompleted(err)
return err
}
@ -292,6 +283,9 @@ func (fs *GCSFs) Mkdir(name string) error {
if !fs.IsNotExist(err) {
return err
}
if !strings.HasSuffix(name, "/") {
name += "/"
}
_, w, _, err := fs.Create(name, -1)
if err != nil {
return err
@ -613,6 +607,36 @@ func (fs *GCSFs) resolve(name string, prefix string) (string, bool) {
return result, isDir
}
// getObjectStat returns the stat result and the real object name as first value
func (fs *GCSFs) getObjectStat(name string) (string, os.FileInfo, error) {
attrs, err := fs.headObject(name)
if err == nil {
objSize := attrs.Size
objectModTime := attrs.Updated
isDir := attrs.ContentType == dirMimeType || strings.HasSuffix(attrs.Name, "/")
return name, NewFileInfo(name, isDir, objSize, objectModTime, false), nil
}
if !fs.IsNotExist(err) {
return "", nil, err
}
// now check if this is a prefix (virtual directory)
hasContents, err := fs.hasContents(name)
if err != nil {
return "", nil, err
}
if hasContents {
return name, NewFileInfo(name, true, 0, time.Now(), false), nil
}
// finally check if this is an object with a trailing /
attrs, err = fs.headObject(name + "/")
if err != nil {
return "", nil, err
}
objSize := attrs.Size
objectModTime := attrs.Updated
return name + "/", NewFileInfo(name, true, objSize, objectModTime, false), nil
}
func (fs *GCSFs) checkIfBucketExists() error {
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
defer cancelFn()

View file

@ -1,3 +1,4 @@
//go:build nogcs
// +build nogcs
package vfs

View file

@ -1,3 +1,4 @@
//go:build !nos3
// +build !nos3
package vfs
@ -16,6 +17,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
@ -178,6 +180,17 @@ func (fs *S3Fs) Open(name string, offset int64) (File, *pipeat.PipeReaderAt, fun
}
ctx, cancelFn := context.WithCancel(context.Background())
downloader := s3manager.NewDownloaderWithClient(fs.svc)
if offset == 0 {
downloader.RequestOptions = append(downloader.RequestOptions, func(r *request.Request) {
chunkCtx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
r.SetContext(chunkCtx)
go func() {
<-ctx.Done()
cancel()
}()
})
}
var streamRange *string
if offset > 0 {
streamRange = aws.String(fmt.Sprintf("bytes=%v-", offset))
@ -278,11 +291,19 @@ func (fs *S3Fs) Rename(source, target string) error {
defer cancelFn()
_, err = fs.svc.CopyObjectWithContext(ctx, &s3.CopyObjectInput{
Bucket: aws.String(fs.config.Bucket),
CopySource: aws.String(url.PathEscape(copySource)),
CopySource: aws.String(pathEscape(copySource)),
Key: aws.String(target),
StorageClass: utils.NilIfEmpty(fs.config.StorageClass),
ContentType: utils.NilIfEmpty(contentType),
})
if err != nil {
metrics.S3CopyObjectCompleted(err)
return err
}
err = fs.svc.WaitUntilObjectExistsWithContext(ctx, &s3.HeadObjectInput{
Bucket: aws.String(fs.config.Bucket),
Key: aws.String(target),
})
metrics.S3CopyObjectCompleted(err)
if err != nil {
return err
@ -686,3 +707,14 @@ func (*S3Fs) Close() error {
func (*S3Fs) GetAvailableDiskSize(dirName string) (*sftp.StatVFS, error) {
return nil, ErrStorageSizeUnavailable
}
// ideally we should simply use url.PathEscape:
//
// https://github.com/awsdocs/aws-doc-sdk-examples/blob/master/go/example_code/s3/s3_copy_object.go#L65
//
// but this cause issue with some vendors, see #483, the code below is copied from rclone
func pathEscape(in string) string {
var u url.URL
u.Path = in
return strings.ReplaceAll(u.String(), "+", "%2B")
}

View file

@ -1,3 +1,4 @@
//go:build nos3
// +build nos3
package vfs

View file

@ -455,6 +455,9 @@ func (*SFTPFs) IsNotExist(err error) bool {
// IsPermission returns a boolean indicating whether the error is known to
// report that permission is denied.
func (*SFTPFs) IsPermission(err error) bool {
if _, ok := err.(*pathResolutionError); ok {
return true
}
return os.IsPermission(err)
}
@ -612,11 +615,11 @@ func (fs *SFTPFs) isSubDir(name string) error {
}
if len(name) < len(fs.config.Prefix) {
err := fmt.Errorf("path %#v is not inside: %#v", name, fs.config.Prefix)
return err
return &pathResolutionError{err: err.Error()}
}
if !strings.HasPrefix(name, fs.config.Prefix+"/") {
err := fmt.Errorf("path %#v is not inside: %#v", name, fs.config.Prefix)
return err
return &pathResolutionError{err: err.Error()}
}
return nil
}

View file

@ -1,3 +1,4 @@
//go:build !darwin && !linux && !freebsd
// +build !darwin,!linux,!freebsd
package vfs

View file

@ -1,3 +1,4 @@
//go:build linux
// +build linux
package vfs

View file

@ -1,3 +1,4 @@
//go:build freebsd || darwin
// +build freebsd darwin
package vfs

View file

@ -1,3 +1,4 @@
//go:build !windows
// +build !windows
package vfs

View file

@ -367,7 +367,7 @@ func writeLog(r *http.Request, err error) {
func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err error) {
metrics.AddLoginAttempt(loginMethod)
if err != nil && err != common.ErrInternalFailure {
if err != nil && err != common.ErrInternalFailure && err != common.ErrNoCredentials {
logger.ConnectionFailedLog(user.Username, ip, loginMethod, common.ProtocolWebDAV, err.Error())
event := common.HostEventLoginFailed
if _, ok := err.(*dataprovider.RecordNotFoundError); ok {

View file

@ -14,6 +14,7 @@ import (
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
@ -651,6 +652,110 @@ func TestBasicHandlingCryptFs(t *testing.T) {
assert.Len(t, common.Connections.GetStats(), 0)
}
func TestLockAfterDelete(t *testing.T) {
u := getTestUser()
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
client := getWebDavClient(user, false, nil)
assert.NoError(t, checkBasicFunc(client))
testFilePath := filepath.Join(homeBasePath, testFileName)
testFileSize := int64(65535)
err = createTestFile(testFilePath, testFileSize)
assert.NoError(t, err)
err = uploadFile(testFilePath, testFileName, testFileSize, client)
assert.NoError(t, err)
lockBody := `<?xml version="1.0" encoding="utf-8" ?><d:lockinfo xmlns:d="DAV:"><d:lockscope><d:exclusive/></d:lockscope><d:locktype><d:write/></d:locktype></d:lockinfo>`
req, err := http.NewRequest("LOCK", fmt.Sprintf("http://%v/%v", webDavServerAddr, testFileName), bytes.NewReader([]byte(lockBody)))
assert.NoError(t, err)
req.SetBasicAuth(u.Username, u.Password)
httpClient := httpclient.GetHTTPClient()
resp, err := httpClient.Do(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
response, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
re := regexp.MustCompile(`\<D:locktoken><D:href>.*</D:href>`)
lockToken := string(re.Find(response))
lockToken = strings.Replace(lockToken, "<D:locktoken><D:href>", "", 1)
lockToken = strings.Replace(lockToken, "</D:href>", "", 1)
err = resp.Body.Close()
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("http://%v/%v", webDavServerAddr, testFileName), nil)
assert.NoError(t, err)
req.Header.Set("If", fmt.Sprintf("(%v)", lockToken))
req.SetBasicAuth(u.Username, u.Password)
resp, err = httpClient.Do(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
err = resp.Body.Close()
assert.NoError(t, err)
// if we try to lock again it must succeed, the lock must be deleted with the object
req, err = http.NewRequest("LOCK", fmt.Sprintf("http://%v/%v", webDavServerAddr, testFileName), bytes.NewReader([]byte(lockBody)))
assert.NoError(t, err)
req.SetBasicAuth(u.Username, u.Password)
resp, err = httpClient.Do(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
err = resp.Body.Close()
assert.NoError(t, err)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
}
func TestRenameWithLock(t *testing.T) {
u := getTestUser()
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
client := getWebDavClient(user, false, nil)
assert.NoError(t, checkBasicFunc(client))
testFilePath := filepath.Join(homeBasePath, testFileName)
testFileSize := int64(65535)
err = createTestFile(testFilePath, testFileSize)
assert.NoError(t, err)
err = uploadFile(testFilePath, testFileName, testFileSize, client)
assert.NoError(t, err)
lockBody := `<?xml version="1.0" encoding="utf-8" ?><d:lockinfo xmlns:d="DAV:"><d:lockscope><d:exclusive/></d:lockscope><d:locktype><d:write/></d:locktype></d:lockinfo>`
req, err := http.NewRequest("LOCK", fmt.Sprintf("http://%v/%v", webDavServerAddr, testFileName), bytes.NewReader([]byte(lockBody)))
assert.NoError(t, err)
req.SetBasicAuth(u.Username, u.Password)
httpClient := httpclient.GetHTTPClient()
resp, err := httpClient.Do(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
response, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
re := regexp.MustCompile(`\<D:locktoken><D:href>.*</D:href>`)
lockToken := string(re.Find(response))
lockToken = strings.Replace(lockToken, "<D:locktoken><D:href>", "", 1)
lockToken = strings.Replace(lockToken, "</D:href>", "", 1)
err = resp.Body.Close()
assert.NoError(t, err)
// MOVE with a lock should succeeded
req, err = http.NewRequest("MOVE", fmt.Sprintf("http://%v/%v", webDavServerAddr, testFileName), nil)
assert.NoError(t, err)
req.Header.Set("If", fmt.Sprintf("(%v)", lockToken))
req.Header.Set("Overwrite", "T")
req.Header.Set("Destination", path.Join("/", testFileName+"1"))
req.SetBasicAuth(u.Username, u.Password)
resp, err = httpClient.Do(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusCreated, resp.StatusCode)
err = resp.Body.Close()
assert.NoError(t, err)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
err = os.RemoveAll(user.GetHomeDir())
assert.NoError(t, err)
}
func TestPropPatch(t *testing.T) {
u := getTestUser()
u.Username = u.Username + "1"