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: on:
push: push:
branches: [main] branches: [2.1.x]
pull_request: pull_request:
jobs: jobs:

View file

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

View file

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

View file

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

View file

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

View file

@ -71,6 +71,7 @@ var (
) )
func init() { func init() {
rootCmd.CompletionOptions.DisableDefaultCmd = true
rootCmd.Flags().BoolP("version", "v", false, "") rootCmd.Flags().BoolP("version", "v", false, "")
rootCmd.Version = version.GetAsString() rootCmd.Version = version.GetAsString()
rootCmd.SetVersionTemplate(`{{printf "SFTPGo "}}{{printf "%s" .Version}} 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 // 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) { func (c *Configuration) GetProxyListener(listener net.Listener) (*proxyproto.Listener, error) {
var proxyListener *proxyproto.Listener
var err error var err error
if c.ProxyProtocol > 0 { if c.ProxyProtocol > 0 {
var policyFunc func(upstream net.Addr) (proxyproto.Policy, error) 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, Listener: listener,
Policy: policyFunc, 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 // 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"))) 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) { func BenchmarkBcryptHashing(b *testing.B) {
bcryptPassword := "bcryptpassword" bcryptPassword := "bcryptpassword"
for i := 0; i < b.N; i++ { 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 // BaseConnection defines common fields for a connection using any supported protocol
type BaseConnection struct { type BaseConnection struct {
// last activity for this connection. // 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 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 // Unique identifier for the connection
ID string ID string
// user associated with this connection if any // user associated with this connection if any
@ -32,7 +35,6 @@ type BaseConnection struct {
protocol string protocol string
remoteAddr string remoteAddr string
sync.RWMutex sync.RWMutex
transferID uint64
activeTransfers []ActiveTransfer activeTransfers []ActiveTransfer
} }

View file

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

View file

@ -179,6 +179,77 @@ func TestBasicDefender(t *testing.T) {
assert.NoError(t, err) 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) { func TestLoadHostListFromFile(t *testing.T) {
_, err := loadHostListFromFile(".") _, err := loadHostListFromFile(".")
assert.Error(t, err) assert.Error(t, err)

View file

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

View file

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

View file

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

View file

@ -102,6 +102,35 @@ func TestEmptyBanner(t *testing.T) {
assert.NoError(t, err) 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) { func TestInvalidUploadMode(t *testing.T) {
reset() reset()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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 ## 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.2, v2.1, v2, latest](https://github.com/drakkan/sftpgo/blob/v2.1.2/Dockerfile)
- [v2.1.0-alpine, v2.1-alpine, v2-alpine, alpine](https://github.com/drakkan/sftpgo/blob/v2.1.0/Dockerfile.alpine) - [v2.1.2-alpine, v2.1-alpine, v2-alpine, alpine](https://github.com/drakkan/sftpgo/blob/v2.1.2/Dockerfile.alpine)
- [v2.1.0-slim, v2.1-slim, v2-slim, slim](https://github.com/drakkan/sftpgo/blob/v2.1.0/Dockerfile) - [v2.1.2-slim, v2.1-slim, v2-slim, slim](https://github.com/drakkan/sftpgo/blob/v2.1.2/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-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](../Dockerfile)
- [edge-alpine](../Dockerfile.alpine) - [edge-alpine](../Dockerfile.alpine)
- [edge-slim](../Dockerfile) - [edge-slim](../Dockerfile)

View file

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

View file

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

View file

@ -94,7 +94,7 @@ func (s *Server) GetSettings() (*ftpserver.Settings, error) {
} }
} }
var ftpListener net.Listener var ftpListener net.Listener
if common.Config.ProxyProtocol > 0 && s.binding.ApplyProxyConfig { if s.binding.HasProxy() {
listener, err := net.Listen("tcp", s.binding.GetAddress()) listener, err := net.Listen("tcp", s.binding.GetAddress())
if err != nil { if err != nil {
logger.Warn(logSender, "", "error starting listener on address %v: %v", s.binding.GetAddress(), err) 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) logger.Warn(logSender, "", "error enabling proxy listener: %v", err)
return nil, 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 { 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 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 // 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) { func (s *Server) VerifyConnection(cc ftpserver.ClientContext, user string, tlsConn *tls.Conn) (ftpserver.ClientDriver, error) {
if !s.binding.isMutualTLSEnabled() { if !s.binding.isMutualTLSEnabled() {

73
go.mod
View file

@ -3,71 +3,62 @@ module github.com/drakkan/sftpgo
go 1.16 go 1.16
require ( require (
cloud.google.com/go/storage v1.15.0 cloud.google.com/go/storage v1.16.1
github.com/Azure/azure-storage-blob-go v0.13.0 github.com/Azure/azure-storage-blob-go v0.14.0
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 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/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/cockroachdb/cockroach-go/v2 v2.1.1
github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
github.com/fclairamb/ftpserverlib v0.13.3-0.20210614220040-27dccea41813 github.com/fclairamb/ftpserverlib v0.15.1-0.20210910204600-c38788485016
github.com/frankban/quicktest v1.13.0 // indirect github.com/frankban/quicktest v1.13.1 // indirect
github.com/go-chi/chi/v5 v5.0.3 github.com/go-chi/chi/v5 v5.0.4
github.com/go-chi/jwtauth/v5 v5.0.1 github.com/go-chi/jwtauth/v5 v5.0.1
github.com/go-chi/render v1.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/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/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/grandcat/zeroconf v1.0.0 github.com/grandcat/zeroconf v1.0.0
github.com/hashicorp/go-retryablehttp v0.7.0 github.com/hashicorp/go-retryablehttp v0.7.0
github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126 github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
github.com/klauspost/compress v1.13.1 github.com/klauspost/compress v1.13.5
github.com/klauspost/cpuid/v2 v2.0.6 // indirect github.com/klauspost/cpuid/v2 v2.0.9 // indirect
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect github.com/lestrrat-go/jwx v1.2.6
github.com/lestrrat-go/jwx v1.2.1 github.com/lib/pq v1.10.3
github.com/lib/pq v1.10.2 github.com/mattn/go-sqlite3 v1.14.8
github.com/magiconair/properties v1.8.5 // indirect github.com/miekg/dns v1.1.43 // indirect
github.com/mattn/go-sqlite3 v1.14.7
github.com/miekg/dns v1.1.42 // indirect
github.com/minio/sio v0.3.0 github.com/minio/sio v0.3.0
github.com/otiai10/copy v1.6.0 github.com/otiai10/copy v1.6.0
github.com/pelletier/go-toml v1.9.3 // indirect github.com/pires/go-proxyproto v0.6.1
github.com/pires/go-proxyproto v0.5.0 github.com/pkg/sftp v1.13.3
github.com/pkg/sftp v1.13.1
github.com/prometheus/client_golang v1.11.0 github.com/prometheus/client_golang v1.11.0
github.com/prometheus/common v0.29.0 // indirect github.com/prometheus/common v0.30.0 // indirect
github.com/rs/cors v1.7.1-0.20200626170627-8b4a00bd362b github.com/rs/cors v1.8.0
github.com/rs/xid v1.3.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/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/afero v1.6.0
github.com/spf13/cast v1.3.1 // indirect github.com/spf13/cobra v1.2.1
github.com/spf13/cobra v1.1.3 github.com/spf13/viper v1.8.1
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/viper v1.7.1
github.com/stretchr/testify v1.7.0 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 github.com/yl2chen/cidranger v1.0.2
go.etcd.io/bbolt v1.3.6 go.etcd.io/bbolt v1.3.6
go.uber.org/automaxprocs v1.4.0 go.uber.org/automaxprocs v1.4.0
gocloud.dev v0.23.0 gocloud.dev v0.24.0
gocloud.dev/secrets/hashivault v0.23.0 gocloud.dev/secrets/hashivault v0.24.0
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/net v0.0.0-20210614182718-04defd469f4e golang.org/x/net v0.0.0-20210907225631-ff17edfbf26d
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0
golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6 golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
google.golang.org/api v0.48.0 google.golang.org/api v0.56.0
google.golang.org/genproto v0.0.0-20210614182748-5b3b54cad159 // indirect google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af // indirect
gopkg.in/ini.v1 v1.62.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 gopkg.in/natefinch/lumberjack.v2 v2.0.0
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
) )
replace ( 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 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/crypto => github.com/drakkan/crypto v0.0.0-20210908103413-a132997f748f
golang.org/x/net => github.com/drakkan/net v0.0.0-20210615043241-a7f9e02422df 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 return nil, err
} }
addHeaders(req, url) addHeaders(req, url)
return GetHTTPClient().Do(req) client := GetHTTPClient()
defer client.CloseIdleConnections()
return client.Do(req)
} }
// Post issues a POST to the specified URL // 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) req.Header.Set("Content-Type", contentType)
addHeaders(req, url) 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 // 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 return nil, err
} }
addHeadersToRetryableReq(req, url) 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 // 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) req.Header.Set("Content-Type", contentType)
addHeadersToRetryableReq(req, url) addHeadersToRetryableReq(req, url)
return GetRetraybleHTTPClient().Do(req) client := GetRetraybleHTTPClient()
defer client.HTTPClient.CloseIdleConnections()
return client.Do(req)
} }
func addHeaders(req *http.Request, url string) { 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) { func updateLoginMetrics(user *dataprovider.User, ip string, err error) {
metrics.AddLoginAttempt(dataprovider.LoginMethodPassword) 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()) logger.ConnectionFailedLog(user.Username, ip, dataprovider.LoginMethodPassword, common.ProtocolHTTP, err.Error())
event := common.HostEventLoginFailed event := common.HostEventLoginFailed
if _, ok := err.(*dataprovider.RecordNotFoundError); ok { if _, ok := err.(*dataprovider.RecordNotFoundError); ok {

View file

@ -5944,6 +5944,11 @@ func TestWebAdminSetupMock(t *testing.T) {
rr = executeRequest(req) rr = executeRequest(req)
checkResponseCode(t, http.StatusFound, rr) checkResponseCode(t, http.StatusFound, rr)
assert.Equal(t, webAdminSetupPath, rr.Header().Get("Location")) 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) csrfToken, err := getCSRFToken(httpBaseURL + webAdminSetupPath)
assert.NoError(t, err) 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. 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. 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. 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: contact:
name: API support name: API support
url: 'https://github.com/drakkan/sftpgo' url: 'https://github.com/drakkan/sftpgo'

View file

@ -530,9 +530,9 @@ func (s *httpdServer) initializeRouter() {
s.router = chi.NewRouter() s.router = chi.NewRouter()
s.router.Use(middleware.RequestID) s.router.Use(middleware.RequestID)
s.router.Use(s.checkConnection)
s.router.Use(logger.NewStructuredLogger(logger.GetLogger())) s.router.Use(logger.NewStructuredLogger(logger.GetLogger()))
s.router.Use(recoverer) s.router.Use(recoverer)
s.router.Use(s.checkConnection)
s.router.Use(middleware.GetHead) s.router.Use(middleware.GetHead)
s.router.Use(middleware.StripSlashes) 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) { func handleClientWebLogin(w http.ResponseWriter, r *http.Request) {
if !dataprovider.HasAdmin() {
http.Redirect(w, r, webAdminSetupPath, http.StatusFound)
return
}
renderClientLoginPage(w, "") renderClientLoginPage(w, "")
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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"> class="user-custom">
<div class="form-group"> <div class="form-group">
<input type="text" class="form-control form-control-user-custom" id="inputUsername" <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>
<div class="form-group"> <div class="form-group">
<input type="password" class="form-control form-control-user-custom" id="inputPassword" <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>
<div class="form-group"> <div class="form-group">
<input type="password" class="form-control form-control-user-custom" id="inputConfirmPassword" <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> </div>
<input type="hidden" name="_form_token" value="{{.CSRFToken}}"> <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
<button type="submit" class="btn btn-primary btn-user-custom btn-block"> <button type="submit" class="btn btn-primary btn-user-custom btn-block">

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
//go:build !nogcs
// +build !nogcs // +build !nogcs
package vfs package vfs
@ -5,7 +6,6 @@ package vfs
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"mime" "mime"
@ -112,37 +112,18 @@ func (fs *GCSFs) ConnectionID() string {
// Stat returns a FileInfo describing the named file // Stat returns a FileInfo describing the named file
func (fs *GCSFs) Stat(name string) (os.FileInfo, error) { func (fs *GCSFs) Stat(name string) (os.FileInfo, error) {
var result *FileInfo
var err error
if name == "" || name == "." { if name == "" || name == "." {
err := fs.checkIfBucketExists() err := fs.checkIfBucketExists()
if err != nil { if err != nil {
return result, err return nil, err
} }
return NewFileInfo(name, true, 0, time.Now(), false), nil return NewFileInfo(name, true, 0, time.Now(), false), nil
} }
if fs.config.KeyPrefix == name+"/" { if fs.config.KeyPrefix == name+"/" {
return NewFileInfo(name, true, 0, time.Now(), false), nil return NewFileInfo(name, true, 0, time.Now(), false), nil
} }
attrs, err := fs.headObject(name) _, info, err := fs.getObjectStat(name)
if err == nil { return info, err
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")
} }
// Lstat returns a FileInfo describing the named file // Lstat returns a FileInfo describing the named file
@ -229,7 +210,7 @@ func (fs *GCSFs) Rename(source, target string) error {
if source == target { if source == target {
return nil return nil
} }
fi, err := fs.Stat(source) realSourceName, fi, err := fs.getObjectStat(source)
if err != nil { if err != nil {
return err return err
} }
@ -241,8 +222,11 @@ func (fs *GCSFs) Rename(source, target string) error {
if hasContents { if hasContents {
return fmt.Errorf("cannot rename non empty directory: %#v", source) 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) dst := fs.svc.Bucket(fs.config.Bucket).Object(target)
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout)) ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
defer cancelFn() defer cancelFn()
@ -277,11 +261,18 @@ func (fs *GCSFs) Remove(name string, isDir bool) error {
if hasContents { if hasContents {
return fmt.Errorf("cannot remove non empty directory: %#v", name) 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)) ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
defer cancelFn() defer cancelFn()
err := fs.svc.Bucket(fs.config.Bucket).Object(name).Delete(ctx) 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) metrics.GCSDeleteObjectCompleted(err)
return err return err
} }
@ -292,6 +283,9 @@ func (fs *GCSFs) Mkdir(name string) error {
if !fs.IsNotExist(err) { if !fs.IsNotExist(err) {
return err return err
} }
if !strings.HasSuffix(name, "/") {
name += "/"
}
_, w, _, err := fs.Create(name, -1) _, w, _, err := fs.Create(name, -1)
if err != nil { if err != nil {
return err return err
@ -613,6 +607,36 @@ func (fs *GCSFs) resolve(name string, prefix string) (string, bool) {
return result, isDir 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 { func (fs *GCSFs) checkIfBucketExists() error {
ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout)) ctx, cancelFn := context.WithDeadline(context.Background(), time.Now().Add(fs.ctxTimeout))
defer cancelFn() defer cancelFn()

View file

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

View file

@ -1,3 +1,4 @@
//go:build !nos3
// +build !nos3 // +build !nos3
package vfs package vfs
@ -16,6 +17,7 @@ import (
"github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials" "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/aws/session"
"github.com/aws/aws-sdk-go/service/s3" "github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager" "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()) ctx, cancelFn := context.WithCancel(context.Background())
downloader := s3manager.NewDownloaderWithClient(fs.svc) 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 var streamRange *string
if offset > 0 { if offset > 0 {
streamRange = aws.String(fmt.Sprintf("bytes=%v-", offset)) streamRange = aws.String(fmt.Sprintf("bytes=%v-", offset))
@ -278,11 +291,19 @@ func (fs *S3Fs) Rename(source, target string) error {
defer cancelFn() defer cancelFn()
_, err = fs.svc.CopyObjectWithContext(ctx, &s3.CopyObjectInput{ _, err = fs.svc.CopyObjectWithContext(ctx, &s3.CopyObjectInput{
Bucket: aws.String(fs.config.Bucket), Bucket: aws.String(fs.config.Bucket),
CopySource: aws.String(url.PathEscape(copySource)), CopySource: aws.String(pathEscape(copySource)),
Key: aws.String(target), Key: aws.String(target),
StorageClass: utils.NilIfEmpty(fs.config.StorageClass), StorageClass: utils.NilIfEmpty(fs.config.StorageClass),
ContentType: utils.NilIfEmpty(contentType), 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) metrics.S3CopyObjectCompleted(err)
if err != nil { if err != nil {
return err return err
@ -686,3 +707,14 @@ func (*S3Fs) Close() error {
func (*S3Fs) GetAvailableDiskSize(dirName string) (*sftp.StatVFS, error) { func (*S3Fs) GetAvailableDiskSize(dirName string) (*sftp.StatVFS, error) {
return nil, ErrStorageSizeUnavailable 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 // +build nos3
package vfs 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 // IsPermission returns a boolean indicating whether the error is known to
// report that permission is denied. // report that permission is denied.
func (*SFTPFs) IsPermission(err error) bool { func (*SFTPFs) IsPermission(err error) bool {
if _, ok := err.(*pathResolutionError); ok {
return true
}
return os.IsPermission(err) return os.IsPermission(err)
} }
@ -612,11 +615,11 @@ func (fs *SFTPFs) isSubDir(name string) error {
} }
if len(name) < len(fs.config.Prefix) { if len(name) < len(fs.config.Prefix) {
err := fmt.Errorf("path %#v is not inside: %#v", name, 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+"/") { if !strings.HasPrefix(name, fs.config.Prefix+"/") {
err := fmt.Errorf("path %#v is not inside: %#v", 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 return nil
} }

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
//go:build !windows
// +build !windows // +build !windows
package vfs 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) { func updateLoginMetrics(user *dataprovider.User, ip, loginMethod string, err error) {
metrics.AddLoginAttempt(loginMethod) 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()) logger.ConnectionFailedLog(user.Username, ip, loginMethod, common.ProtocolWebDAV, err.Error())
event := common.HostEventLoginFailed event := common.HostEventLoginFailed
if _, ok := err.(*dataprovider.RecordNotFoundError); ok { if _, ok := err.(*dataprovider.RecordNotFoundError); ok {

View file

@ -14,6 +14,7 @@ import (
"os/exec" "os/exec"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"runtime" "runtime"
"strings" "strings"
"sync" "sync"
@ -651,6 +652,110 @@ func TestBasicHandlingCryptFs(t *testing.T) {
assert.Len(t, common.Connections.GetStats(), 0) 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) { func TestPropPatch(t *testing.T) {
u := getTestUser() u := getTestUser()
u.Username = u.Username + "1" u.Username = u.Username + "1"