Compare commits
21 commits
Author | SHA1 | Date | |
---|---|---|---|
|
ca464b5ffc | ||
|
27b5f1bf59 | ||
|
ae9c540640 | ||
|
ad8ebcc031 | ||
|
c69c27f586 | ||
|
7d20c6b50d | ||
|
9ca136370e | ||
|
29836edf2b | ||
|
0ad6f031e8 | ||
|
a9838d2e6d | ||
|
7a9272ddfc | ||
|
69c3a85ea5 | ||
|
d03020e2b8 | ||
|
89bc50aa81 | ||
|
d27c32e06b | ||
|
53eca2c2bb | ||
|
0e9351e4ad | ||
|
43f468547f | ||
|
c372ca4136 | ||
|
2b328c82bb | ||
|
6321d51a24 |
70 changed files with 840 additions and 1728 deletions
2
.github/workflows/development.yml
vendored
2
.github/workflows/development.yml
vendored
|
@ -2,7 +2,7 @@ name: CI
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [2.1.x]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
|
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
|
@ -4,8 +4,6 @@ on:
|
|||
#schedule:
|
||||
# - cron: '0 4 * * *' # everyday at 4:00 AM UTC
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- v*
|
||||
pull_request:
|
||||
|
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
@ -5,7 +5,7 @@ on:
|
|||
tags: 'v*'
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.16.5
|
||||
GO_VERSION: 1.16.8
|
||||
|
||||
jobs:
|
||||
prepare-sources-with-deps:
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !noportable
|
||||
// +build !noportable
|
||||
|
||||
package cmd
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build noportable
|
||||
// +build noportable
|
||||
|
||||
package cmd
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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{
|
||||
Listener: listener,
|
||||
Policy: policyFunc,
|
||||
}
|
||||
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
|
||||
|
|
|
@ -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++ {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -236,16 +236,26 @@ func (d *memoryDefender) GetHosts() []*DefenderEntry {
|
|||
|
||||
var result []*DefenderEntry
|
||||
for k, v := range d.banned {
|
||||
result = append(result, &DefenderEntry{
|
||||
IP: k,
|
||||
BanTime: v,
|
||||
})
|
||||
if v.After(time.Now()) {
|
||||
result = append(result, &DefenderEntry{
|
||||
IP: k,
|
||||
BanTime: v,
|
||||
})
|
||||
}
|
||||
}
|
||||
for k, v := range d.hosts {
|
||||
result = append(result, &DefenderEntry{
|
||||
IP: k,
|
||||
Score: v.TotalScore,
|
||||
})
|
||||
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: score,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
|
@ -257,17 +267,27 @@ func (d *memoryDefender) GetHost(ip string) (*DefenderEntry, error) {
|
|||
defer d.RUnlock()
|
||||
|
||||
if banTime, ok := d.banned[ip]; ok {
|
||||
return &DefenderEntry{
|
||||
IP: ip,
|
||||
BanTime: banTime,
|
||||
}, nil
|
||||
if banTime.After(time.Now()) {
|
||||
return &DefenderEntry{
|
||||
IP: ip,
|
||||
BanTime: banTime,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if ev, ok := d.hosts[ip]; ok {
|
||||
return &DefenderEntry{
|
||||
IP: ip,
|
||||
Score: ev.TotalScore,
|
||||
}, nil
|
||||
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: score,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if _, ok := d.banned[ip]; ok {
|
||||
return
|
||||
if v, ok := d.banned[ip]; ok {
|
||||
if v.After(time.Now()) {
|
||||
return
|
||||
}
|
||||
delete(d.banned, ip)
|
||||
}
|
||||
|
||||
var score int
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package config
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !linux
|
||||
// +build !linux
|
||||
|
||||
package config
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !nobolt
|
||||
// +build !nobolt
|
||||
|
||||
package dataprovider
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build nobolt
|
||||
// +build nobolt
|
||||
|
||||
package dataprovider
|
||||
|
|
|
@ -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"}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !nomysql
|
||||
// +build !nomysql
|
||||
|
||||
package dataprovider
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build nomysql
|
||||
// +build nomysql
|
||||
|
||||
package dataprovider
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !nopgsql
|
||||
// +build !nopgsql
|
||||
|
||||
package dataprovider
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build nopgsql
|
||||
// +build nopgsql
|
||||
|
||||
package dataprovider
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !nosqlite
|
||||
// +build !nosqlite
|
||||
|
||||
package dataprovider
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build nosqlite
|
||||
// +build nosqlite
|
||||
|
||||
package dataprovider
|
||||
|
|
|
@ -344,17 +344,31 @@ func (u *User) hideConfidentialData() {
|
|||
u.Password = ""
|
||||
switch u.FsConfig.Provider {
|
||||
case vfs.S3FilesystemProvider:
|
||||
u.FsConfig.S3Config.AccessSecret.Hide()
|
||||
if u.FsConfig.S3Config.AccessSecret != nil {
|
||||
u.FsConfig.S3Config.AccessSecret.Hide()
|
||||
}
|
||||
case vfs.GCSFilesystemProvider:
|
||||
u.FsConfig.GCSConfig.Credentials.Hide()
|
||||
if u.FsConfig.GCSConfig.Credentials != nil {
|
||||
u.FsConfig.GCSConfig.Credentials.Hide()
|
||||
}
|
||||
case vfs.AzureBlobFilesystemProvider:
|
||||
u.FsConfig.AzBlobConfig.AccountKey.Hide()
|
||||
u.FsConfig.AzBlobConfig.SASURL.Hide()
|
||||
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:
|
||||
u.FsConfig.CryptConfig.Passphrase.Hide()
|
||||
if u.FsConfig.CryptConfig.Passphrase != nil {
|
||||
u.FsConfig.CryptConfig.Passphrase.Hide()
|
||||
}
|
||||
case vfs.SFTPFilesystemProvider:
|
||||
u.FsConfig.SFTPConfig.Password.Hide()
|
||||
u.FsConfig.SFTPConfig.PrivateKey.Hide()
|
||||
if u.FsConfig.SFTPConfig.Password != nil {
|
||||
u.FsConfig.SFTPConfig.Password.Hide()
|
||||
}
|
||||
if u.FsConfig.SFTPConfig.PrivateKey != nil {
|
||||
u.FsConfig.SFTPConfig.PrivateKey.Hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
73
go.mod
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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, "")
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !noawskms
|
||||
// +build !noawskms
|
||||
|
||||
package kms
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build noawskms
|
||||
// +build noawskms
|
||||
|
||||
package kms
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !nogcpkms
|
||||
// +build !nogcpkms
|
||||
|
||||
package kms
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build nogcpkms
|
||||
// +build nogcpkms
|
||||
|
||||
package kms
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !novaultkms
|
||||
// +build !novaultkms
|
||||
|
||||
package kms
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build novaultkms
|
||||
// +build novaultkms
|
||||
|
||||
package kms
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package logger
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !linux
|
||||
// +build !linux
|
||||
|
||||
package logger
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !nometrics
|
||||
// +build !nometrics
|
||||
|
||||
// Package metrics provides Prometheus metrics support
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !noportable
|
||||
// +build !noportable
|
||||
|
||||
package service
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package service
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package sftpd
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package sftpd
|
||||
|
|
|
@ -241,16 +241,14 @@ 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
|
||||
}
|
||||
listener = proxyListener
|
||||
}
|
||||
|
||||
exitChannel <- c.serve(listener, serverConfig)
|
||||
|
|
1353
static/vendor/datatables/dataTables.checkboxes.css
vendored
1353
static/vendor/datatables/dataTables.checkboxes.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -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">
|
||||
|
|
|
@ -2,7 +2,7 @@ package version
|
|||
|
||||
import "strings"
|
||||
|
||||
const version = "2.1.0-dev"
|
||||
const version = "2.1.2-dev"
|
||||
|
||||
var (
|
||||
commit = ""
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !noazblob
|
||||
// +build !noazblob
|
||||
|
||||
package vfs
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build noazblob
|
||||
// +build noazblob
|
||||
|
||||
package vfs
|
||||
|
|
|
@ -103,17 +103,31 @@ func (v *BaseVirtualFolder) IsLocalOrLocalCrypted() bool {
|
|||
func (v *BaseVirtualFolder) hideConfidentialData() {
|
||||
switch v.FsConfig.Provider {
|
||||
case S3FilesystemProvider:
|
||||
v.FsConfig.S3Config.AccessSecret.Hide()
|
||||
if v.FsConfig.S3Config.AccessSecret != nil {
|
||||
v.FsConfig.S3Config.AccessSecret.Hide()
|
||||
}
|
||||
case GCSFilesystemProvider:
|
||||
v.FsConfig.GCSConfig.Credentials.Hide()
|
||||
if v.FsConfig.GCSConfig.Credentials != nil {
|
||||
v.FsConfig.GCSConfig.Credentials.Hide()
|
||||
}
|
||||
case AzureBlobFilesystemProvider:
|
||||
v.FsConfig.AzBlobConfig.AccountKey.Hide()
|
||||
v.FsConfig.AzBlobConfig.SASURL.Hide()
|
||||
if v.FsConfig.AzBlobConfig.AccountKey != nil {
|
||||
v.FsConfig.AzBlobConfig.AccountKey.Hide()
|
||||
}
|
||||
if v.FsConfig.AzBlobConfig.SASURL != nil {
|
||||
v.FsConfig.AzBlobConfig.SASURL.Hide()
|
||||
}
|
||||
case CryptedFilesystemProvider:
|
||||
v.FsConfig.CryptConfig.Passphrase.Hide()
|
||||
if v.FsConfig.CryptConfig.Passphrase != nil {
|
||||
v.FsConfig.CryptConfig.Passphrase.Hide()
|
||||
}
|
||||
case SFTPFilesystemProvider:
|
||||
v.FsConfig.SFTPConfig.Password.Hide()
|
||||
v.FsConfig.SFTPConfig.PrivateKey.Hide()
|
||||
if v.FsConfig.SFTPConfig.Password != nil {
|
||||
v.FsConfig.SFTPConfig.Password.Hide()
|
||||
}
|
||||
if v.FsConfig.SFTPConfig.PrivateKey != nil {
|
||||
v.FsConfig.SFTPConfig.PrivateKey.Hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
74
vfs/gcsfs.go
74
vfs/gcsfs.go
|
@ -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()
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build nogcs
|
||||
// +build nogcs
|
||||
|
||||
package vfs
|
||||
|
|
34
vfs/s3fs.go
34
vfs/s3fs.go
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build nos3
|
||||
// +build nos3
|
||||
|
||||
package vfs
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !darwin && !linux && !freebsd
|
||||
// +build !darwin,!linux,!freebsd
|
||||
|
||||
package vfs
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package vfs
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build freebsd || darwin
|
||||
// +build freebsd darwin
|
||||
|
||||
package vfs
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package vfs
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue